diff --git a/README.md b/README.md index 6623a96..c0cc7f1 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,19 @@ Makefile requires installed dependecies: ```shell $ make -Usage: make [command] +Usage: make [command] [ENV=staging|production] 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 @@ -36,24 +40,16 @@ Commands: ## HTTP Server -```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). +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 -# Expose env vars before and start server -$ ./bin/http-server +# 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 from the file and start server -$ ./bin/http-server --env-path ./config/env/.env +# Start with the production config +$ ENV_MODE=production ./bin/http-server ``` ## API Docs @@ -124,7 +120,7 @@ Handler → Service → Processor → Repository ## API Endpoints ### Health Check -- `GET /api/v1/health` - Health check endpoint +- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`) ### Organizations - `POST /api/v1/organizations` - Create organization @@ -157,73 +153,139 @@ Handler → Service → Processor → Repository - `PUT /api/v1/order-items/{id}` - Update order item - `DELETE /api/v1/order-items/{id}` - Remove order item -## Installation +## Running the Application -1. **Clone the repository** - ```bash - git clone - cd apskel-pos-backend - ``` +### Prerequisites -2. **Install dependencies** - ```bash - go mod tidy - ``` +| 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) | -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 +### 1. Clone & install dependencies ```bash -# 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 +git clone +cd apskel-pos-backend +go mod download ``` -### Using Make Commands +### 2. Configuration + +Configuration is **not** read from `.env` files — it is loaded from YAML files in [infra/](infra/) +by [config/configs.go](config/configs.go) using viper. + +The file is selected by the `ENV_MODE` environment variable: + +| `ENV_MODE` | Config file loaded | +|------------|--------------------| +| `local` | `infra/local.yaml` | +| `development` | `infra/development.yaml` | +| `staging` *(default)* | `infra/staging.yaml` | +| `production` | `infra/production.yaml` | + +Any other/unset value falls back to `staging`. Only `staging.yaml` and `production.yaml` are +committed — for `local`/`development` copy one of them first: ```bash -# 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 +cp infra/staging.yaml infra/local.yaml ``` +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:8080/api/v1/organizations \ +curl -X POST http://localhost:4000/api/v1/organizations \ -H "Content-Type: application/json" \ -d '{ "name": "My Restaurant", @@ -233,7 +295,7 @@ curl -X POST http://localhost:8080/api/v1/organizations \ ### Create User ```bash -curl -X POST http://localhost:8080/api/v1/users \ +curl -X POST http://localhost:4000/api/v1/users \ -H "Content-Type: application/json" \ -d '{ "organization_id": "uuid-here", @@ -247,7 +309,7 @@ curl -X POST http://localhost:8080/api/v1/users \ ### Create Order with Items ```bash -curl -X POST http://localhost:8080/api/v1/orders \ +curl -X POST http://localhost:4000/api/v1/orders \ -H "Content-Type: application/json" \ -d '{ "outlet_id": "uuid-here", diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md new file mode 100644 index 0000000..1d9d658 --- /dev/null +++ b/docs/integration-weight-based-products.md @@ -0,0 +1,279 @@ +# Integrasi Produk Timbangan — POS Mobile & Backoffice + +**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh + +Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien. +Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md). + +--- + +## 1. Konsep inti + +**Satu penimbangan = satu baris order.** + +Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua +baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri. + +| Bukan begini | Melainkan begini | +|---|---| +| `quantity: 9.8` | `quantity: 1, weight: 4.2`
`quantity: 1, weight: 5.6` | +| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah | + +Empat aturan yang berlaku di seluruh dokumen ini: + +1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database + menolak nilai lain lewat constraint `chk_order_items_weight_single_line`. +2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram — + apa pun yang dipilih saat setup). +3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`. + `unit_price` tetap berarti harga per satu satuan (per ons). +4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski + produknya sama. + +Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap +cacah seperti sekarang. + +--- + +## 2. Backoffice — setup produk + +### 2.1 Pastikan satuannya ada + +Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan. + +`POST /api/v1/units` + +```json +{ + "name": "Ons", + "abbreviation": "ons", + "is_active": true +} +``` + +`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk +pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`. + +### 2.2 Buat produk sebagai produk timbangan + +`POST /api/v1/products` + +```json +{ + "category_id": "…", + "name": "Ikan Tude", + "price": 4500, + "cost": 3000, + "sell_by": "weight", + "unit_id": "" +} +``` + +| Field | Tipe | Keterangan | +|---|---|---| +| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. | +| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. | +| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. | + +Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya +sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak +adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan. + +Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan +ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`, +`/products/:id`). + +### 2.3 Catatan UI + +- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit` + ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per + cacah — tapi akan membingungkan pengguna yang melihat riwayatnya. +- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend + juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan. +- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*. +- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum + dikonsumsi apa pun di POS. + +--- + +## 3. POS Mobile — transaksi + +### 3.1 Bentuk input mengikuti `sell_by` + +| `sell_by` | Input di POS | Yang dikirim | +|---|---|---| +| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` | +| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` | + +### 3.2 Mengirim order + +`POST /api/v1/orders` + +```json +{ + "outlet_id": "…", + "user_id": "…", + "order_type": "dine_in", + "order_items": [ + { "product_id": "", "quantity": 1, "weight": 4.2 }, + { "product_id": "", "quantity": 1, "weight": 5.6 }, + { "product_id": "", "quantity": 2 } + ] +} +``` + +Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan +mandiri `POST /api/v1/self-order/orders`. + +**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di +bawah `0.001` membulat ke nol dan ditolak. + +**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend +dari master produk. Jangan mengirim harga hasil hitungan sendiri. + +--- + +## 4. Menampilkan baris + +Setiap `order_items[]` di response membawa empat field tambahan: + +```json +{ + "product_name": "Ikan Tude", + "quantity": 1, + "weight": 4.2, + "unit_id": "…", + "unit_name": "Ons", + "unit_abbreviation": "ons", + "unit_price": 4500, + "total_price": 18900 +} +``` + +Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`: + +| Kondisi | Tampilkan | +|---|---| +| `weight == null` | `2 × Rp 25.000` | +| `weight != null` | `4,2 ons × Rp 4.500` | + +**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan +terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma +desimal sesuai format Indonesia. + +--- + +## 5. Void, refund, split bill + +Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena +`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai +rupiahnya dari `weight`. + +| Aksi | Endpoint | Field untuk baris berbobot | +|---|---|---| +| Void per item | `POST /orders/void` | `items[].quantity: 1` | +| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) | +| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh | + +```json +{ + "order_id": "…", + "reason": "Salah timbang", + "type": "ITEM", + "items": [ + { "order_item_id": "", "quantity": 1 } + ] +} +``` + +Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak +ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan +tombol pilih baris. + +**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons — +tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang, +sehingga jejak auditnya tetap jujur. + +--- + +## 6. Referensi error + +Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`: + +```json +{ + "success": false, + "data": null, + "errors": [ + { "code": "900", "entity": "ORDER", + "cause": "product Ikan Tude is sold by weight and requires a weight" } + ] +} +``` + +| Pesan (`cause`) | Penyebab | Perbaikan di klien | +|---|---|---| +| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang | +| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` | +| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad | +| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah | + +Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir. + +### Setup produk (Backoffice) + +| Pesan (`cause`) | Penyebab | Perbaikan di klien | +|---|---|---| +| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih | +| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` | +| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` | + +--- + +## 7. Kompatibilitas mundur + +- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya. +- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk + produk satuan. +- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi + parser lama tidak terganggu. +- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe. + +Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri — +permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam. + +--- + +## 8. Batasan yang diketahui + +- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`, + bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu + diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat). +- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun + produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan + sisa stok yang mengandaikan itu berjalan. + +--- + +## 9. Checklist per tim + +**Backoffice Website** + +- [ ] CRUD satuan tersedia di menu master data +- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan +- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih +- [ ] Label harga ikut satuan — "Harga per ons" +- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi +- [ ] Daftar produk menandai mana yang dijual per timbangan +- [ ] Laporan harian menampilkan kolom Berat + +**POS Mobile** + +- [ ] Menu membaca `sell_by` tiap produk +- [ ] Papan angka desimal untuk produk timbangan +- [ ] Kirim `quantity: 1` + `weight` +- [ ] Dua penimbangan menjadi dua baris, tidak digabung +- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500` +- [ ] Void & refund baris berbobot bersifat utuh +- [ ] Split bill: pilih baris, bukan stepper jumlah +- [ ] Pesan error validasi ditampilkan ke kasir diff --git a/docs/rfc-weight-based-products.md b/docs/rfc-weight-based-products.md new file mode 100644 index 0000000..2a0f299 --- /dev/null +++ b/docs/rfc-weight-based-products.md @@ -0,0 +1,362 @@ +# RFC: Produk Timbangan (Weight-Based Products) + +**Status:** Diimplementasikan (migrasi `000089`) +**Tanggal:** 2026-09-05, diperbarui 2026-09-06 +**Scope:** Product, Order, Void/Refund, Report +**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8) + +--- + +## 1. Masalah + +Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity` +bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung +`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill. + +Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons, +lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang +terpisah** — bukan satu baris berisi 9,8. + +Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya. + +> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk +> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau +> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang +> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya. + +--- + +## 2. Keputusan Inti + +**Satu penimbangan = satu baris `order_items`.** + +| | Baris 1 | Baris 2 | +|---|---|---| +| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | | +| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` | + +`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke +kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua +ikan yang berbeda. + +### Kenapa bukan `quantity = 4.2` + +Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi +`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan: + +1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan + informasi bahwa ada dua ikan. Tidak bisa direkonstruksi. +2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan + "total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang + bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun + yang berubah di dunia nyata. +3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai + di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan + `4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang + sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer. + +Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan +epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan. + +--- + +## 3. Prinsip + +**P1 — Baris transaksi adalah snapshot yang beku.** +`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join +ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh +mengubah arti transaksi yang sudah terjadi. + +**P2 — Perhitungan harga baris hanya ada di satu tempat.** +Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh +ada perkalian harga yang tersebar; semuanya memanggil satu fungsi. + +**P3 — Harga tetap otoritas backend.** +Klien tidak pernah mengirim harga. Backend membacanya dari `products` / +`product_outlet_prices` seperti sekarang. + +**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.** +`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan +satuan berbeda tidak bermakna. + +--- + +## 4. Perubahan Skema + +```sql +-- Products: cara jual +ALTER TABLE products + ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit' + CHECK (sell_by IN ('unit', 'weight')); + +-- Order items: berat + snapshot satuan +ALTER TABLE order_items + ADD COLUMN weight DECIMAL(12,3), + ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT; + +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_weight_positive + CHECK (weight IS NULL OR weight > 0), + ADD CONSTRAINT chk_order_items_weight_single_line + CHECK (weight IS NULL OR quantity = 1); +``` + +**Catatan:** + +- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid + tanpa backfill, dan perilakunya tidak berubah sama sekali. +- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database: + baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak + ambigu dan membuat void otomatis bersifat utuh (§6). +- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap + berlaku. +- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai + presisi sama. +- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan + "ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi. + +--- + +## 5. Perhitungan Harga + +Satu-satunya tempat yang boleh mengalikan harga (P2): + +```go +// BillableQuantity mengembalikan pengali harga untuk baris ini: +// berat bila produk dijual per timbangan, jumlah bila dijual per satuan. +// Baris berbobot dijamin quantity = 1 oleh constraint DB. +func (oi *OrderItem) BillableQuantity() float64 { + if oi.Weight != nil { + return *oi.Weight + } + return float64(oi.Quantity) +} + +func (oi *OrderItem) CalculateTotalPrice() { + oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice) +} + +func (oi *OrderItem) CalculateTotalCost() { + oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost) +} +``` + +`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor +konversi yang menyelinap ke perhitungan uang. + +### Titik yang harus diganti + +Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan +menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang, +bukan salah tampilan**, dan tidak memicu error apa pun. + +| Lokasi | Sekarang | +|---|---| +| `processor/order_processor.go:197-198` | buat order | +| `processor/order_processor.go:330-331` | tambah item ke order | +| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void | +| `processor/order_processor.go:723` | jumlah refund | +| `processor/split_bill_processor.go:143` | hitung jumlah split | +| `processor/split_bill_processor.go:189` | catat pembayaran | +| `processor/split_bill_processor.go:231` | metadata pembayaran | +| `repository/order_item_repository.go:113` | jumlah void penuh | + +Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur +inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis: + +| Lokasi | Status | +|---|---| +| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki | +| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** | +| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** | +| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki | +| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** | + +Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan +dihitung sebagai 1 satuan resep. + +### Verifikasi + +```bash +grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \ + | grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity +``` + +Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman: + +- `mappers/inventory_movement_mapper.go:129` dan + `processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan + baris order. +- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian, + yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`. + +Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu. + +--- + +## 6. Void, Refund, Split Bill + +**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap +integer. + +**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada +`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan +`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang +pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa +berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5). + +**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di +`order_processor.go:723` yang perlu memakai `BillableQuantity()`. + +**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot +nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di +`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi +float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang +berubah. + +**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons +dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan, +koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya +adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat. +Ini menjaga jejak audit tetap jujur. + +--- + +## 7. Validasi & Tampilan + +### 7.1 Aturan validasi + +Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat: + +| `products.sell_by` | Aturan | +|---|---| +| `unit` | `weight` harus kosong. Bila dikirim → tolak. | +| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. | + +`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) — +bukan dibaca lewat join saat ditampilkan. + +Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan +nilai yang divalidasi. + +### 7.2 Tampilan + +`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris +berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`. + +Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat +menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`. + +--- + +## 8. Report + +**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti +"berapa banyak barang": + +- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas + produk — 2 ikan tetap dihitung 2, bukan 9,8 ons. +- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan. +- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per + ekor", yang tetap merupakan angka bermakna. + +**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk** +(P4): + +```sql +COALESCE(SUM(oi.weight), 0) AS weight_sold +``` + +Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang +berbeda. + +--- + +## 9. Di Luar Scope + +**Pengurangan stok otomatis.** `adjustInventoryWithTransaction` +(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction` +(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** — +sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat +penjualan tidak tersambung. + +Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi +apa pun. + +Catatan untuk nanti bila jalur stok disambungkan: + +- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau + tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1. +- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol + sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan. +- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan. + Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa + diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam. + +Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan +menggantung — komentar di dalamnya ditulis seolah-olah aktif. + +--- + +## 10. Temuan Sampingan: `unit_price` pada request diabaikan + +`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar +*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak +pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di +`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari +`products` / `product_outlet_prices`. + +Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang +menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract, +atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database. + +Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka +berhasil, padahal diabaikan diam-diam. + +--- + +## 11. Urutan Implementasi + +1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama + tidak tersentuh. +2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5). +3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi. +4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada + response. +5. **Validasi `sell_by`** (§7.1). +6. **Template & tampilan struk** (§7.2). +7. *(Opsional)* `weight_sold` pada laporan per produk (§8). + +Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5 +mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar. + +--- + +## 12. Risiko + +| Risiko | Dampak | Mitigasi | +|---|---|---| +| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) | +| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB | +| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB | +| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan | +| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 | + +--- + +## 13. Pertanyaan Terbuka + +1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney` + membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah + dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`. + + Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan** + pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau + kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat, + dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan + baru akan mengikuti aturan berbeda. +2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input + harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi, + tambahkan `products.min_weight_increment`. +3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila + tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini. diff --git a/internal/app/app.go b/internal/app/app.go index 6a0f4d1..ace698f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -140,6 +140,8 @@ func (a *App) Initialize(cfg *config.Config) error { selfOrderHandler, services.expenseService, validators.expenseValidator, + services.cashAdvanceService, + validators.cashAdvanceValidator, a.redisClient, ) @@ -244,6 +246,7 @@ type repositories struct { notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl expenseRepo *repository.ExpenseRepositoryImpl + cashAdvanceRepo *repository.CashAdvanceRepositoryImpl } func (a *App) initRepositories() *repositories { @@ -298,6 +301,7 @@ 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), } } @@ -345,6 +349,7 @@ 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 { @@ -372,7 +377,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), - purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.cashAdvanceRepo, 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), @@ -396,7 +401,8 @@ 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), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), + cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), } } @@ -438,6 +444,7 @@ type services struct { notificationService service.NotificationService productOutletPriceService service.ProductOutletPriceService expenseService *service.ExpenseServiceImpl + cashAdvanceService *service.CashAdvanceServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -518,6 +525,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con notificationService: notificationService, productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), expenseService: service.NewExpenseService(processors.expenseProcessor), + cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), } } @@ -562,6 +570,7 @@ type validators struct { notificationValidator *validator.NotificationValidatorImpl productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl expenseValidator *validator.ExpenseValidatorImpl + cashAdvanceValidator *validator.CashAdvanceValidatorImpl } func (a *App) initValidators() *validators { @@ -594,6 +603,7 @@ func (a *App) initValidators() *validators { notificationValidator: validator.NewNotificationValidator(), productOutletPriceValidator: validator.NewProductOutletPriceValidator(), expenseValidator: validator.NewExpenseValidator(), + cashAdvanceValidator: validator.NewCashAdvanceValidator(), } } diff --git a/internal/constants/cash_advance.go b/internal/constants/cash_advance.go new file mode 100644 index 0000000..d964406 --- /dev/null +++ b/internal/constants/cash_advance.go @@ -0,0 +1,62 @@ +package constants + +// A cash advance is money handed to a team so it can go shopping. Its status is +// about the document only — whether the money may leave the drawer. How much of it +// has been accounted for is a separate axis, derived from the spending charged to +// the advance rather than stored, so the two never have to be kept in step. +const ( + CashAdvanceStatusDraft = "draft" + CashAdvanceStatusApproved = "approved" + CashAdvanceStatusRejected = "rejected" + CashAdvanceStatusCancelled = "cancelled" +) + +// Settlement states come out of the amount, the spending charged to the advance, and +// the cash handed back. An advance the team overspent still counts as settled: the +// shortfall is owed back to the team and shows up as a negative remaining amount. +const ( + CashAdvanceSettlementOpen = "open" + CashAdvanceSettlementPartial = "partial" + CashAdvanceSettlementSettled = "settled" +) + +func GetAllCashAdvanceStatuses() []string { + return []string{ + CashAdvanceStatusDraft, + CashAdvanceStatusApproved, + CashAdvanceStatusRejected, + CashAdvanceStatusCancelled, + } +} + +func IsValidCashAdvanceStatus(status string) bool { + for _, valid := range GetAllCashAdvanceStatuses() { + if status == valid { + return true + } + } + return false +} + +func GetAllCashAdvanceSettlementStatuses() []string { + return []string{ + CashAdvanceSettlementOpen, + CashAdvanceSettlementPartial, + CashAdvanceSettlementSettled, + } +} + +func IsValidCashAdvanceSettlementStatus(status string) bool { + for _, valid := range GetAllCashAdvanceSettlementStatuses() { + if status == valid { + return true + } + } + return false +} + +// Settlement entries name where a piece of spending was recorded. +const ( + CashAdvanceSettlementTypePurchaseOrder = "purchase_order" + CashAdvanceSettlementTypeExpense = "expense" +) diff --git a/internal/constants/error.go b/internal/constants/error.go index af56665..c2cd49b 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -62,6 +62,7 @@ const ( NotificationHandlerEntity = "notification_handler" ProductOutletPriceServiceEntity = "product_outlet_price_service" ExpenseServiceEntity = "expense_service" + CashAdvanceServiceEntity = "cash_advance_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/constants/product.go b/internal/constants/product.go new file mode 100644 index 0000000..cb79eaf --- /dev/null +++ b/internal/constants/product.go @@ -0,0 +1,17 @@ +package constants + +// SellBy describes how a product's quantity is expressed on an order line. +type SellBy = string + +const ( + // SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles). + SellByUnit SellBy = "unit" + // SellByWeight means the line carries a weighed amount. One weighing is one line, + // so quantity stays 1 and the measured value lives in order_items.weight. + SellByWeight SellBy = "weight" +) + +// IsValidSellBy reports whether s is a supported sell_by value. +func IsValidSellBy(s string) bool { + return s == SellByUnit || s == SellByWeight +} diff --git a/internal/constants/purchase_team.go b/internal/constants/purchase_team.go new file mode 100644 index 0000000..e066381 --- /dev/null +++ b/internal/constants/purchase_team.go @@ -0,0 +1,19 @@ +package constants + +// A purchase order is charged to a team. Teams come from the parent product +// categories, plus Pusat for spending that belongs to no single team. +const ( + PurchaseTeamScopeCategory = "category" + PurchaseTeamScopeCentral = "central" + + // PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no + // row of its own, so the name lives here rather than in the database. + PurchaseTeamCentralName = "Pusat" + + // PurchaseTeamNone is the value the list filter takes to ask for purchases + // that have not been charged to any team yet. + PurchaseTeamNone = "none" + + // PurchaseTeamNoneName labels those purchases in the reports. + PurchaseTeamNoneName = "Tanpa Team" +) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index b49fd58..1d2045f 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -88,15 +88,19 @@ type SalesAnalyticsData struct { type PurchasingAnalyticsRequest struct { OrganizationID uuid.UUID OutletID *string `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + // Team narrows the report to one team: a parent category id, "central" for + // Pusat, or "none" for purchases charged to no team. Empty covers all teams. + Team string `form:"team,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -104,6 +108,21 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID +// are exactly what the team filter takes, so a row doubles as a drill-down link. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -117,6 +136,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { @@ -178,6 +198,7 @@ type ProductAnalyticsData struct { CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` @@ -304,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct { ProductSku string `json:"product_sku"` ProductPrice float64 `json:"product_price"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` diff --git a/internal/contract/cash_advance_contract.go b/internal/contract/cash_advance_contract.go new file mode 100644 index 0000000..47bf20a --- /dev/null +++ b/internal/contract/cash_advance_contract.go @@ -0,0 +1,92 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateCashAdvanceRequest struct { + // OutletID falls back to the caller's outlet when omitted; an advance is cash out + // of one drawer, so one of the two has to be known. + OutletID *uuid.UUID `json:"outlet_id,omitempty" validate:"omitempty"` + CodeNumber string `json:"code_number" validate:"required,min=1,max=50"` + TeamScope string `json:"team_scope" validate:"required,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + Amount float64 `json:"amount" validate:"required,gt=0"` + IssuedDate string `json:"issued_date" validate:"required"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"` + Description *string `json:"description,omitempty" validate:"omitempty"` +} + +type UpdateCashAdvanceRequest struct { + CodeNumber *string `json:"code_number,omitempty" validate:"omitempty,min=1,max=50"` + // An advance always belongs to a team, so team_scope can be moved but not cleared. + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + Amount *float64 `json:"amount,omitempty" validate:"omitempty,gt=0"` + ReturnedAmount *float64 `json:"returned_amount,omitempty" validate:"omitempty,gte=0"` + IssuedDate *string `json:"issued_date,omitempty" validate:"omitempty"` + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"` + Description *string `json:"description,omitempty" validate:"omitempty"` +} + +type CashAdvanceResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + Amount float64 `json:"amount"` + SettledAmount float64 `json:"settled_amount"` + ReturnedAmount float64 `json:"returned_amount"` + RemainingAmount float64 `json:"remaining_amount"` + SettlementStatus string `json:"settlement_status"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date"` + Status string `json:"status"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeamResponse `json:"team,omitempty"` + Settlements []CashAdvanceSettlementResponse `json:"settlements,omitempty"` +} + +// CashAdvanceSettlementResponse is one purchase order or expense paid out of the +// advance. +type CashAdvanceSettlementResponse struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Status string `json:"status"` +} + +type ListCashAdvancesRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"` + // SettlementStatus filters on how much of the cash has been accounted for, + // which is derived from the spending charged to the advance rather than stored. + SettlementStatus string `json:"settlement_status,omitempty" validate:"omitempty,oneof=open partial settled"` + // Team is the single-value form of the two filters below, so the team picker can + // send back what it was given: a parent category id or "central" for Pusat. + Team string `json:"team,omitempty"` + TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +type ListCashAdvancesResponse struct { + CashAdvances []CashAdvanceResponse `json:"cash_advances"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index 348cbd3..9f2ae25 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -7,15 +7,18 @@ 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"` - 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"` + // CashAdvanceID marks the expense as paid out of cash advanced to a team, which is + // what accounts for that advance. + CashAdvanceID *string `json:"cash_advance_id,omitempty"` + Items []CreateExpenseItemRequest `json:"items" validate:"required"` } type CreateExpenseItemRequest struct { @@ -27,16 +30,18 @@ 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"` - 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"` + // An empty string unlinks the cash advance; omitting the field leaves it untouched. + CashAdvanceID *string `json:"cash_advance_id,omitempty"` + Items []UpdateExpenseItemRequest `json:"items,omitempty"` } type UpdateExpenseItemRequest struct { @@ -59,6 +64,7 @@ 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"` diff --git a/internal/contract/ingredient_unit_converter_contract.go b/internal/contract/ingredient_unit_converter_contract.go index 9741814..9c42d6e 100644 --- a/internal/contract/ingredient_unit_converter_contract.go +++ b/internal/contract/ingredient_unit_converter_contract.go @@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 237874f..80ea44f 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -40,13 +40,16 @@ type UpdateOrderRequest struct { } type CreateOrderItemRequest struct { - ProductID uuid.UUID `json:"product_id" validate:"required"` - ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"` - Quantity int `json:"quantity" validate:"required,min=1"` - UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided - Modifiers []map[string]interface{} `json:"modifiers,omitempty"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ProductID uuid.UUID `json:"product_id" validate:"required"` + ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"` + Quantity int `json:"quantity" validate:"required,min=1"` + // Weight is required for products with sell_by = "weight" and must be absent otherwise. + // One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price. + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"` + UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database + Modifiers []map[string]interface{} `json:"modifiers,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type UpdateOrderItemRequest struct { @@ -92,26 +95,31 @@ type OrderResponse struct { } type OrderItemResponse struct { - ID uuid.UUID `json:"id"` - OrderID uuid.UUID `json:"order_id"` - ProductID uuid.UUID `json:"product_id"` - ProductName string `json:"product_name"` - ProductVariantID *uuid.UUID `json:"product_variant_id"` - ProductVariantName *string `json:"product_variant_name,omitempty"` - CategoryID *uuid.UUID `json:"category_id,omitempty"` - CategoryName *string `json:"category_name,omitempty"` - Quantity int `json:"quantity"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Modifiers []map[string]interface{} `json:"modifiers"` - Notes *string `json:"notes,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - PrinterType string `json:"printer_type"` - PrintToChecker bool `json:"print_to_checker"` - PaidQuantity int `json:"paid_quantity"` + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductVariantID *uuid.UUID `json:"product_variant_id"` + ProductVariantName *string `json:"product_variant_name,omitempty"` + CategoryID *uuid.UUID `json:"category_id,omitempty"` + CategoryName *string `json:"category_name,omitempty"` + Quantity int `json:"quantity"` + // Weight is set only for products sold by weight; the line is priced weight * unit_price. + Weight *float64 `json:"weight,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + UnitName *string `json:"unit_name,omitempty"` + UnitAbbreviation *string `json:"unit_abbreviation,omitempty"` + UnitPrice float64 `json:"unit_price"` + TotalPrice float64 `json:"total_price"` + Modifiers []map[string]interface{} `json:"modifiers"` + Notes *string `json:"notes,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PrinterType string `json:"printer_type"` + PrintToChecker bool `json:"print_to_checker"` + PaidQuantity int `json:"paid_quantity"` } type ListOrdersQuery struct { diff --git a/internal/contract/product_contract.go b/internal/contract/product_contract.go index 61c9e90..90a90ea 100644 --- a/internal/contract/product_contract.go +++ b/internal/contract/product_contract.go @@ -18,6 +18,8 @@ type CreateProductRequest struct { ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` PrintToChecker *bool `json:"print_to_checker,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"` Metadata map[string]interface{} `json:"metadata,omitempty"` IsActive *bool `json:"is_active,omitempty"` Variants []CreateProductVariantRequest `json:"variants,omitempty"` @@ -38,6 +40,8 @@ type UpdateProductRequest struct { ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` PrintToChecker *bool `json:"print_to_checker,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"` Metadata map[string]interface{} `json:"metadata,omitempty"` IsActive *bool `json:"is_active,omitempty"` ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` @@ -73,6 +77,8 @@ type ProductResponse struct { BusinessType string `json:"business_type"` ImageURL *string `json:"image_url"` PrinterType string `json:"printer_type"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + SellBy string `json:"sell_by"` PrintToChecker bool `json:"print_to_checker"` Metadata map[string]interface{} `json:"metadata"` IsActive bool `json:"is_active"` diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go index cc73de2..8806601 100644 --- a/internal/contract/product_recipe_contract.go +++ b/internal/contract/product_recipe_contract.go @@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 7038b4b..a9bd480 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -7,13 +7,18 @@ 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"` + 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"` Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -28,13 +33,18 @@ type CreatePurchaseOrderItemRequest struct { } type UpdatePurchaseOrderRequest struct { - VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` - PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"` - TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - Message *string `json:"message,omitempty" validate:"omitempty"` + VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` + PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"` + TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + Message *string `json:"message,omitempty" validate:"omitempty"` + // An empty string clears the team; omitting the field leaves it untouched. + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + // An all-zero uuid unlinks the cash advance; omitting the field leaves it untouched. + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"` Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -61,13 +71,30 @@ type PurchaseOrderResponse struct { Status string `json:"status"` Message *string `json:"message"` TotalAmount float64 `json:"total_amount"` + TeamScope *string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeamResponse `json:"team,omitempty"` Vendor *VendorResponse `json:"vendor,omitempty"` Items []PurchaseOrderItemResponse `json:"items,omitempty"` Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"` } +// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent +// product categories; Pusat is the extra entry that has no category behind it, so +// its CategoryID is null. +type PurchaseTeamResponse struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` +} + +type ListPurchaseTeamsResponse struct { + Teams []PurchaseTeamResponse `json:"teams"` +} + type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` @@ -93,13 +120,20 @@ type PurchaseOrderAttachmentResponse struct { } type ListPurchaseOrdersRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - VendorID *uuid.UUID `json:"vendor_id,omitempty"` - StartDate *time.Time `json:"start_date,omitempty"` - EndDate *time.Time `json:"end_date,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + VendorID *uuid.UUID `json:"vendor_id,omitempty"` + // Team is the single-value form of the two filters below, so the team picker + // can send back what it was given: a parent category id, "central" for Pusat, + // or "none" for purchases with no team yet. It replaces them rather than + // narrowing alongside them. + Team string `json:"team,omitempty"` + TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` } type ListPurchaseOrdersResponse struct { diff --git a/internal/contract/self_order_contract.go b/internal/contract/self_order_contract.go index 8a4e7fd..7e97c7c 100644 --- a/internal/contract/self_order_contract.go +++ b/internal/contract/self_order_contract.go @@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct { ProductID uuid.UUID `json:"product_id" validate:"required"` ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"` Quantity int `json:"quantity" validate:"required,min=1"` + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"` Notes *string `json:"notes,omitempty"` } diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 4ea0933..87b9644 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -27,6 +27,14 @@ type SalesAnalytics struct { NetSales float64 `json:"net_sales"` } +// PurchaseTeamFilter narrows purchasing figures to a single team: a parent +// category, Pusat, or the purchases that carry no team at all. A nil filter +// leaves the figures spanning every team. +type PurchaseTeamFilter struct { + Scope string + CategoryID *uuid.UUID +} + // PurchasingAnalytics represents purchasing analytics data type PurchasingAnalytics struct { OutletName *string `json:"outlet_name,omitempty"` @@ -34,6 +42,22 @@ type PurchasingAnalytics struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases: a parent category, +// Pusat, or the purchases charged to no team at all. Scope and CategoryID are +// what the team filter takes back, so a row can be clicked straight through. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -47,6 +71,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { @@ -89,6 +114,7 @@ type ProductAnalytics struct { CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` diff --git a/internal/entities/cash_advance.go b/internal/entities/cash_advance.go new file mode 100644 index 0000000..fc9f66a --- /dev/null +++ b/internal/entities/cash_advance.go @@ -0,0 +1,63 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + + "gorm.io/gorm" +) + +// CashAdvance is money handed to a team up front so it can go shopping — kasbon in +// the Indonesian UI. While the cash is out it is still the outlet's, not a cost, so +// nothing about what the team bought lives here: that is read back from the purchase +// orders and expenses charged to the advance. +type CashAdvance struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"` + CodeNumber string `gorm:"not null;size:50" json:"code_number"` + // An advance is always handed to a team, so unlike a purchase order it has no + // "not chosen yet" state. TeamCategoryID is set only when the scope is category. + TeamScope string `gorm:"not null;size:20;index" json:"team_scope"` + TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id"` + Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` + ReturnedAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"returned_amount"` + IssuedDate time.Time `gorm:"type:date;not null" json:"issued_date"` + DueDate *time.Time `gorm:"type:date" json:"due_date"` + Status string `gorm:"not null;size:20;default:'draft'" json:"status"` + Description *string `gorm:"type:text" json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // SettledAmount is filled in by the read queries from the spending charged to + // this advance. It has no column of its own, so it can never drift out of step + // with the purchases behind it; the arrow tag keeps writes from touching it. + SettledAmount float64 `gorm:"->;-:migration" json:"settled_amount"` + + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"` +} + +func (k *CashAdvance) BeforeCreate(tx *gorm.DB) error { + if k.ID == uuid.Nil { + k.ID = uuid.New() + } + return nil +} + +func (CashAdvance) TableName() string { + return "cash_advances" +} + +// CashAdvanceSettlement is one piece of spending charged to an advance. It is read +// out of purchase_orders and expenses, so it has no table of its own. +type CashAdvanceSettlement struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Status string `json:"status"` +} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index c95a5c6..a5d6973 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -43,6 +43,7 @@ func GetAllEntities() []interface{} { &NotificationDelivery{}, &ProductOutletPrice{}, &Expense{}, + &CashAdvance{}, } } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 9f391fe..0cdb08d 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -20,11 +20,14 @@ 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"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // CashAdvanceID is set when the expense was paid out of cash advanced to a team. + CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"` Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"` } diff --git a/internal/entities/ingredient.go b/internal/entities/ingredient.go index d8e7f91..4db67ca 100644 --- a/internal/entities/ingredient.go +++ b/internal/entities/ingredient.go @@ -11,7 +11,7 @@ type Ingredient struct { OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` Name string `gorm:"not null;size:255" json:"name"` - UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` diff --git a/internal/entities/order_item.go b/internal/entities/order_item.go index 72cbf58..189bcc3 100644 --- a/internal/entities/order_item.go +++ b/internal/entities/order_item.go @@ -4,6 +4,7 @@ import ( "database/sql/driver" "encoding/json" "errors" + "math" "time" "github.com/google/uuid" @@ -47,6 +48,8 @@ type OrderItem struct { ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"` ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"` Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"` + Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"` UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"` TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"` UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"` @@ -68,6 +71,7 @@ type OrderItem struct { Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"` + Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` } func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error { @@ -81,8 +85,57 @@ func (OrderItem) TableName() string { return "order_items" } +// IsWeighed reports whether this line was sold by weight rather than by count. +func (oi *OrderItem) IsWeighed() bool { + return oi.Weight != nil +} + +// BillableQuantity is the multiplier for unit price and unit cost on this line: +// the weighed amount for weight-based products, the item count otherwise. +// +// This is the ONLY place that decides between the two. Every price or cost +// calculation must go through it — multiplying by Quantity directly would bill a +// 4.2 ons fish as a single ons. +// +// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line, +// so there is no ambiguity between counting and weighing. +func (oi *OrderItem) BillableQuantity() float64 { + if oi.Weight != nil { + return *oi.Weight + } + return float64(oi.Quantity) +} + +// BillableQuantityFor is BillableQuantity for a partial action on this line, such as +// voiding or refunding `units` of it. +// +// Weighed lines are always acted on in full — quantity is 1, so any void or refund +// covers the whole weighing — hence the full weight is returned regardless of units. +func (oi *OrderItem) BillableQuantityFor(units int) float64 { + if oi.Weight != nil { + return *oi.Weight + } + return float64(units) +} + +// RoundMoney rounds to the 2 decimals that every monetary column stores, so the +// value written back always matches what the database keeps. +func RoundMoney(amount float64) float64 { + return math.Round(amount*100) / 100 +} + +// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value +// is identical to the stored one. +func RoundWeight(weight float64) float64 { + return math.Round(weight*1000) / 1000 +} + func (oi *OrderItem) CalculateTotalPrice() { - oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice + oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice) +} + +func (oi *OrderItem) CalculateTotalCost() { + oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost) } func (oi *OrderItem) CanBeModified() bool { diff --git a/internal/entities/order_item_test.go b/internal/entities/order_item_test.go new file mode 100644 index 0000000..0292693 --- /dev/null +++ b/internal/entities/order_item_test.go @@ -0,0 +1,104 @@ +package entities + +import "testing" + +func weightPtr(v float64) *float64 { return &v } + +func TestBillableQuantity(t *testing.T) { + tests := []struct { + name string + item OrderItem + want float64 + }{ + {"unit line counts items", OrderItem{Quantity: 3}, 3}, + {"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2}, + {"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.item.BillableQuantity(); got != tt.want { + t.Errorf("BillableQuantity() = %v, want %v", got, tt.want) + } + }) + } +} + +// A weighed line is voided or refunded whole, so the units asked for must not shrink +// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons. +func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) { + weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)} + if got := weighed.BillableQuantityFor(1); got != 5.6 { + t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got) + } + + counted := OrderItem{Quantity: 3} + if got := counted.BillableQuantityFor(2); got != 2 { + t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got) + } +} + +// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons +// must not be billed as a single ons. +func TestCalculateTotalPriceForWeighedLine(t *testing.T) { + item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000} + item.CalculateTotalPrice() + item.CalculateTotalCost() + + if item.TotalPrice != 18900 { + t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice) + } + if item.TotalCost != 12600 { + t.Errorf("TotalCost = %v, want 12600", item.TotalCost) + } +} + +func TestCalculateTotalPriceForUnitLine(t *testing.T) { + item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000} + item.CalculateTotalPrice() + item.CalculateTotalCost() + + if item.TotalPrice != 75000 { + t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice) + } + if item.TotalCost != 30000 { + t.Errorf("TotalCost = %v, want 30000", item.TotalCost) + } +} + +// Sub-rupiah residue must not survive into a decimal(10,2) column. +func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) { + item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500} + item.CalculateTotalPrice() + + if item.TotalPrice != 19066.5 { + t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice) + } +} + +func TestIsWeighed(t *testing.T) { + if (&OrderItem{Quantity: 1}).IsWeighed() { + t.Error("unit line reported as weighed") + } + if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() { + t.Error("weighed line reported as unit") + } +} + +func TestRoundWeight(t *testing.T) { + tests := []struct { + in float64 + want float64 + }{ + {4.2, 4.2}, + {4.2374, 4.237}, + {4.2375, 4.238}, + {0.0004, 0}, + } + + for _, tt := range tests { + if got := RoundWeight(tt.in); got != tt.want { + t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want) + } + } +} diff --git a/internal/entities/product.go b/internal/entities/product.go index dc8c524..22d3f6d 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -20,6 +20,7 @@ type Product struct { ImageURL *string `gorm:"size:500" json:"image_url"` PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"` UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` + SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"` HasIngredients bool `gorm:"default:false" json:"has_ingredients"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` IsActive bool `gorm:"default:true" json:"is_active"` diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index ba5209e..251ab63 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -20,12 +20,21 @@ type PurchaseOrder struct { Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"` Message *string `gorm:"type:text" json:"message" validate:"omitempty"` TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // TeamScope is 'category' when the purchase is charged to a parent category, or + // 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat. + TeamScope *string `gorm:"size:20;index" json:"team_scope" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id" validate:"omitempty"` + // CashAdvanceID is set when the purchase was paid out of cash advanced to the team. + // It is what accounts for that advance, so the cash advance holds no copy of the items. + CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id" validate:"omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` + TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"` + CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` } diff --git a/internal/handler/cash_advance_handler.go b/internal/handler/cash_advance_handler.go new file mode 100644 index 0000000..52b0a9a --- /dev/null +++ b/internal/handler/cash_advance_handler.go @@ -0,0 +1,232 @@ +package handler + +import ( + "strconv" + "time" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type CashAdvanceHandler struct { + cashAdvanceService service.CashAdvanceService + cashAdvanceValidator validator.CashAdvanceValidator +} + +func NewCashAdvanceHandler(cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator) *CashAdvanceHandler { + return &CashAdvanceHandler{ + cashAdvanceService: cashAdvanceService, + cashAdvanceValidator: cashAdvanceValidator, + } +} + +func (h *CashAdvanceHandler) CreateCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.CreateCashAdvanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::CreateCashAdvance -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance") + return + } + + validationError, validationErrorCode := h.cashAdvanceValidator.ValidateCreateCashAdvanceRequest(&req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance") + return + } + + response := h.cashAdvanceService.CreateCashAdvance(ctx, contextInfo, &req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::CreateCashAdvance -> Failed to create cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::CreateCashAdvance") +} + +func (h *CashAdvanceHandler) UpdateCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance") + return + } + + var req contract.UpdateCashAdvanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance") + return + } + + validationError, validationErrorCode := h.cashAdvanceValidator.ValidateUpdateCashAdvanceRequest(&req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance") + return + } + + response := h.cashAdvanceService.UpdateCashAdvance(ctx, contextInfo, cashAdvanceID, &req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvance -> Failed to update cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvance") +} + +func (h *CashAdvanceHandler) DeleteCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::DeleteCashAdvance -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::DeleteCashAdvance") + return + } + + response := h.cashAdvanceService.DeleteCashAdvance(ctx, contextInfo, cashAdvanceID) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::DeleteCashAdvance -> Failed to delete cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::DeleteCashAdvance") +} + +func (h *CashAdvanceHandler) GetCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::GetCashAdvance -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::GetCashAdvance") + return + } + + response := h.cashAdvanceService.GetCashAdvanceByID(ctx, contextInfo, cashAdvanceID) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::GetCashAdvance -> Failed to get cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::GetCashAdvance") +} + +func (h *CashAdvanceHandler) ListCashAdvances(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + req := &contract.ListCashAdvancesRequest{ + Page: 1, + Limit: 10, + } + + if pageStr := c.Query("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil { + req.Page = page + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + req.Limit = limit + } + } + + req.Search = c.Query("search") + req.Status = c.Query("status") + req.SettlementStatus = c.Query("settlement_status") + req.Team = c.Query("team") + req.TeamScope = c.Query("team_scope") + + if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" { + if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil { + req.TeamCategoryID = &teamCategoryID + } + } + + if startDateStr := c.Query("start_date"); startDateStr != "" { + if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil { + req.StartDate = &startDate + } + } + + if endDateStr := c.Query("end_date"); endDateStr != "" { + if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil { + req.EndDate = &endDate + } + } + + validationError, validationErrorCode := h.cashAdvanceValidator.ValidateListCashAdvancesRequest(req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::ListCashAdvances") + return + } + + response := h.cashAdvanceService.ListCashAdvances(ctx, contextInfo, req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvances -> Failed to list cash advances from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvances") +} + +func (h *CashAdvanceHandler) UpdateCashAdvanceStatus(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus") + return + } + + status := c.Param("status") + if status == "" { + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Status parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus") + return + } + + response := h.cashAdvanceService.UpdateCashAdvanceStatus(ctx, contextInfo, cashAdvanceID, status) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Failed to update cash advance status from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvanceStatus") +} + +// ListCashAdvanceTeams serves the team picker for the advance form: the parent +// categories of the caller's outlet, plus Pusat. Same list the purchase form uses. +func (h *CashAdvanceHandler) ListCashAdvanceTeams(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + response := h.cashAdvanceService.ListCashAdvanceTeams(ctx, contextInfo) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvanceTeams -> Failed to list cash advance teams from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvanceTeams") +} diff --git a/internal/handler/purchase_order_handler.go b/internal/handler/purchase_order_handler.go index 6be1cff..f1d1012 100644 --- a/internal/handler/purchase_order_handler.go +++ b/internal/handler/purchase_order_handler.go @@ -176,6 +176,20 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) { } } + if team := c.Query("team"); team != "" { + req.Team = team + } + + if teamScope := c.Query("team_scope"); teamScope != "" { + req.TeamScope = teamScope + } + + if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" { + if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil { + req.TeamCategoryID = &teamCategoryID + } + } + if startDateStr := c.Query("start_date"); startDateStr != "" { if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil { req.StartDate = &startDate @@ -224,6 +238,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus") } +// ListPurchaseTeams serves the team picker for the purchase form: the parent +// categories of the caller's outlet, plus Pusat. +func (h *PurchaseOrderHandler) ListPurchaseTeams(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + teamsResponse := h.purchaseOrderService.ListPurchaseTeams(ctx, contextInfo) + if teamsResponse.HasErrors() { + errorResp := teamsResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("PurchaseOrderHandler::ListPurchaseTeams -> Failed to list purchase teams from service") + } + + util.HandleResponse(c.Writer, c.Request, teamsResponse, "PurchaseOrderHandler::ListPurchaseTeams") +} + func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) diff --git a/internal/handler/self_order_handler.go b/internal/handler/self_order_handler.go index ebbfdcb..1d7c576 100644 --- a/internal/handler/self_order_handler.go +++ b/internal/handler/self_order_handler.go @@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) { ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, Notes: item.Notes, }) } diff --git a/internal/mappers/cash_advance_mapper.go b/internal/mappers/cash_advance_mapper.go new file mode 100644 index 0000000..9b5a94c --- /dev/null +++ b/internal/mappers/cash_advance_mapper.go @@ -0,0 +1,128 @@ +package mappers + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// Amounts are money rounded to two decimals, so anything under half a cent apart +// is the same figure. Comparing them directly would leave an advance a hundredth of +// a rupiah short of settled. +const cashAdvanceAmountEpsilon = 0.005 + +// cashAdvanceTeamFromEntity renders the team an advance was handed to. Unlike a +// purchase order there is always one, so this never returns nil. The category name +// is only filled in when TeamCategory was preloaded. +func cashAdvanceTeamFromEntity(entity *entities.CashAdvance) *models.PurchaseTeam { + team := &models.PurchaseTeam{Scope: entity.TeamScope} + + switch entity.TeamScope { + case constants.PurchaseTeamScopeCentral: + team.Name = constants.PurchaseTeamCentralName + case constants.PurchaseTeamScopeCategory: + team.CategoryID = entity.TeamCategoryID + if entity.TeamCategory != nil { + team.Name = entity.TeamCategory.Name + } + } + + return team +} + +// cashAdvanceSettlementStatus reads the money rather than any stored flag: cash is +// accounted for by spending charged to the advance plus what was handed back. A +// team that overspent still counts as settled — the excess is owed back to them. +func cashAdvanceSettlementStatus(amount, settled, returned float64) string { + accounted := settled + returned + + switch { + case accounted <= cashAdvanceAmountEpsilon: + return constants.CashAdvanceSettlementOpen + case amount-accounted > cashAdvanceAmountEpsilon: + return constants.CashAdvanceSettlementPartial + default: + return constants.CashAdvanceSettlementSettled + } +} + +func CashAdvanceEntityToModel(entity *entities.CashAdvance) *models.CashAdvance { + if entity == nil { + return nil + } + + return &models.CashAdvance{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + CodeNumber: entity.CodeNumber, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, + Amount: entity.Amount, + ReturnedAmount: entity.ReturnedAmount, + IssuedDate: entity.IssuedDate, + DueDate: entity.DueDate, + Status: entity.Status, + Description: entity.Description, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func CashAdvanceEntityToResponse(entity *entities.CashAdvance) *models.CashAdvanceResponse { + if entity == nil { + return nil + } + + return &models.CashAdvanceResponse{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + CodeNumber: entity.CodeNumber, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, + Amount: entity.Amount, + SettledAmount: entity.SettledAmount, + ReturnedAmount: entity.ReturnedAmount, + RemainingAmount: entity.Amount - entity.SettledAmount - entity.ReturnedAmount, + SettlementStatus: cashAdvanceSettlementStatus(entity.Amount, entity.SettledAmount, entity.ReturnedAmount), + IssuedDate: entity.IssuedDate, + DueDate: entity.DueDate, + Status: entity.Status, + Description: entity.Description, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + Team: cashAdvanceTeamFromEntity(entity), + } +} + +func CashAdvanceEntitiesToResponses(entities []*entities.CashAdvance) []*models.CashAdvanceResponse { + if entities == nil { + return nil + } + + responses := make([]*models.CashAdvanceResponse, len(entities)) + for i, entity := range entities { + responses[i] = CashAdvanceEntityToResponse(entity) + } + return responses +} + +func CashAdvanceSettlementEntitiesToModels(settlements []*entities.CashAdvanceSettlement) []models.CashAdvanceSettlement { + if settlements == nil { + return nil + } + + result := make([]models.CashAdvanceSettlement, len(settlements)) + for i, settlement := range settlements { + result[i] = models.CashAdvanceSettlement{ + Type: settlement.Type, + ID: settlement.ID, + Number: settlement.Number, + Date: settlement.Date, + Amount: settlement.Amount, + Status: settlement.Status, + } + } + return result +} diff --git a/internal/mappers/cash_advance_mapper_test.go b/internal/mappers/cash_advance_mapper_test.go new file mode 100644 index 0000000..801f06b --- /dev/null +++ b/internal/mappers/cash_advance_mapper_test.go @@ -0,0 +1,101 @@ +package mappers + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCashAdvanceSettlementStatusFollowsTheMoney(t *testing.T) { + tests := []struct { + name string + amount float64 + settled float64 + returned float64 + expectedStatus string + expectedRemaining float64 + }{ + { + name: "nothing spent or returned is still open", + amount: 500000, + expectedStatus: constants.CashAdvanceSettlementOpen, + expectedRemaining: 500000, + }, + { + name: "some spending leaves it partial", + amount: 500000, + settled: 200000, + expectedStatus: constants.CashAdvanceSettlementPartial, + expectedRemaining: 300000, + }, + { + name: "cash handed back counts the same as spending", + amount: 500000, + settled: 300000, + returned: 100000, + expectedStatus: constants.CashAdvanceSettlementPartial, + expectedRemaining: 100000, + }, + { + name: "spending plus cash back covering the advance settles it", + amount: 500000, + settled: 420000, + returned: 80000, + expectedStatus: constants.CashAdvanceSettlementSettled, + expectedRemaining: 0, + }, + { + // The team paid the difference out of pocket, so the outlet owes them. + name: "overspending settles the cash advance and goes negative", + amount: 500000, + settled: 620000, + expectedStatus: constants.CashAdvanceSettlementSettled, + expectedRemaining: -120000, + }, + { + // Two decimals of money should not leave a cash advance a fraction short. + name: "a rounding crumb short still settles", + amount: 100000, + settled: 99999.999, + expectedStatus: constants.CashAdvanceSettlementSettled, + expectedRemaining: 0.001, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response := CashAdvanceEntityToResponse(&entities.CashAdvance{ + ID: uuid.New(), + TeamScope: constants.PurchaseTeamScopeCentral, + Amount: tt.amount, + SettledAmount: tt.settled, + ReturnedAmount: tt.returned, + }) + + require.Equal(t, tt.expectedStatus, response.SettlementStatus) + require.InDelta(t, tt.expectedRemaining, response.RemainingAmount, 0.0001) + }) + } +} + +func TestCashAdvanceTeamAlwaysRendered(t *testing.T) { + categoryID := uuid.New() + + central := CashAdvanceEntityToResponse(&entities.CashAdvance{TeamScope: constants.PurchaseTeamScopeCentral}) + require.NotNil(t, central.Team) + require.Equal(t, constants.PurchaseTeamCentralName, central.Team.Name) + require.Nil(t, central.Team.CategoryID) + + category := CashAdvanceEntityToResponse(&entities.CashAdvance{ + TeamScope: constants.PurchaseTeamScopeCategory, + TeamCategoryID: &categoryID, + TeamCategory: &entities.Category{ID: categoryID, Name: "Dapur"}, + }) + require.NotNil(t, category.Team) + require.Equal(t, "Dapur", category.Team.Name) + require.Equal(t, &categoryID, category.Team.CategoryID) +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index c3b1e43..4d5f06a 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -66,6 +66,7 @@ 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, } diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index b4c84da..5f68ce2 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo } } + // Unit is only meaningful for weighed lines, but carry it whenever it was captured. + var unitName, unitAbbreviation *string + if item.Unit != nil { + unitName = &item.Unit.Name + unitAbbreviation = item.Unit.Abbreviation + } + response := &models.OrderItemResponse{ ID: item.ID, OrderID: item.OrderID, ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, + UnitID: item.UnitID, + UnitName: unitName, + UnitAbbreviation: unitAbbreviation, UnitPrice: item.UnitPrice, TotalPrice: item.TotalPrice, UnitCost: item.UnitCost, diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go index e91c3ef..c9d1aa4 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product { BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, PrinterType: entity.PrinterType, + UnitID: entity.UnitID, + SellBy: entity.SellBy, + HasIngredients: entity.HasIngredients, Metadata: map[string]interface{}(entity.Metadata), IsActive: entity.IsActive, CreatedAt: entity.CreatedAt, @@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product { BusinessType: string(model.BusinessType), ImageURL: model.ImageURL, PrinterType: model.PrinterType, + UnitID: model.UnitID, + SellBy: model.SellBy, + HasIngredients: model.HasIngredients, Metadata: entities.Metadata(model.Metadata), IsActive: model.IsActive, CreatedAt: model.CreatedAt, @@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr printerType = *req.PrinterType } + sellBy := constants.SellByUnit + if constants.IsValidSellBy(req.SellBy) { + sellBy = req.SellBy + } + metadata := entities.Metadata{} if req.Metadata != nil { metadata = entities.Metadata(req.Metadata) @@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr BusinessType: businessType, ImageURL: req.ImageURL, PrinterType: printerType, + UnitID: req.UnitID, + SellBy: sellBy, Metadata: metadata, IsActive: true, // Default to active } @@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse { BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, PrinterType: entity.PrinterType, + UnitID: entity.UnitID, + SellBy: entity.SellBy, Metadata: map[string]interface{}(entity.Metadata), IsActive: entity.IsActive, CreatedAt: entity.CreatedAt, @@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update entity.PrinterType = *req.PrinterType } + if req.UnitID != nil { + entity.UnitID = req.UnitID + } + + if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) { + entity.SellBy = *req.SellBy + } + if req.Metadata != nil { if entity.Metadata == nil { entity.Metadata = make(entities.Metadata) diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index 5be72d2..60a9df8 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -1,10 +1,33 @@ package mappers import ( + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" ) +// purchaseTeamFromEntity renders the team a purchase order is charged to. It returns +// nil when no team was chosen, which is distinct from a purchase charged to Pusat. +// The category name is only filled in when TeamCategory was preloaded. +func purchaseTeamFromEntity(entity *entities.PurchaseOrder) *models.PurchaseTeam { + if entity.TeamScope == nil { + return nil + } + + team := &models.PurchaseTeam{Scope: *entity.TeamScope} + switch *entity.TeamScope { + case constants.PurchaseTeamScopeCentral: + team.Name = constants.PurchaseTeamCentralName + case constants.PurchaseTeamScopeCategory: + team.CategoryID = entity.TeamCategoryID + if entity.TeamCategory != nil { + team.Name = entity.TeamCategory.Name + } + } + + return team +} + func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder { if entity == nil { return nil @@ -22,6 +45,9 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase Status: entity.Status, Message: entity.Message, TotalAmount: entity.TotalAmount, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, + CashAdvanceID: entity.CashAdvanceID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, } @@ -44,6 +70,9 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO Status: model.Status, Message: model.Message, TotalAmount: model.TotalAmount, + TeamScope: model.TeamScope, + TeamCategoryID: model.TeamCategoryID, + CashAdvanceID: model.CashAdvanceID, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, } @@ -66,8 +95,12 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch Status: entity.Status, Message: entity.Message, TotalAmount: entity.TotalAmount, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, + CashAdvanceID: entity.CashAdvanceID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, + Team: purchaseTeamFromEntity(entity), } // Map vendor if present diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 60b511d..109a65b 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -1,8 +1,12 @@ package models import ( + "fmt" "time" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "github.com/google/uuid" ) @@ -93,9 +97,34 @@ type SalesAnalyticsData struct { type PurchasingAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID `validate:"omitempty"` - DateFrom time.Time `validate:"required"` - DateTo time.Time `validate:"required"` - GroupBy string `validate:"omitempty,oneof=day hour week month"` + // Team is the raw value the team picker sends: a parent category id, + // "central" for Pusat, "none" for purchases with no team, or empty for all. + Team string + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` +} + +// ParsePurchaseTeamFilter turns the team value the picker sends into the scope and +// category the purchasing queries filter on. An empty value spans every team; an +// unknown one is an error rather than a report that quietly ignores the filter. +func ParsePurchaseTeamFilter(team string) (*entities.PurchaseTeamFilter, error) { + switch team { + case "": + return nil, nil + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return &entities.PurchaseTeamFilter{Scope: team}, nil + } + + categoryID, err := uuid.Parse(team) + if err != nil || categoryID == uuid.Nil { + return nil, fmt.Errorf("team must be one of: central, none, or a category id") + } + + return &entities.PurchaseTeamFilter{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + }, nil } // PurchasingAnalyticsResponse represents the response for purchasing analytics @@ -103,6 +132,7 @@ type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -110,6 +140,20 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData represents purchasing analytics for a single team +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } // PurchasingSummary represents the summary of purchasing analytics @@ -124,6 +168,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } // PurchasingAnalyticsData represents purchasing analytics by time period @@ -188,6 +233,7 @@ type ProductAnalyticsData struct { CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` @@ -314,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct { ProductSku string `json:"product_sku"` ProductPrice float64 `json:"product_price"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` diff --git a/internal/models/cash_advance.go b/internal/models/cash_advance.go new file mode 100644 index 0000000..462d952 --- /dev/null +++ b/internal/models/cash_advance.go @@ -0,0 +1,104 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type CashAdvance struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + Amount float64 `json:"amount"` + ReturnedAmount float64 `json:"returned_amount"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date"` + Status string `json:"status"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CashAdvanceResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + Amount float64 `json:"amount"` + // SettledAmount is the spending charged to this advance, ReturnedAmount the cash + // handed back, and RemainingAmount what is still out with the team. A negative + // remaining amount means the team overspent and is owed the difference. + SettledAmount float64 `json:"settled_amount"` + ReturnedAmount float64 `json:"returned_amount"` + RemainingAmount float64 `json:"remaining_amount"` + SettlementStatus string `json:"settlement_status"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date"` + Status string `json:"status"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeam `json:"team,omitempty"` + Settlements []CashAdvanceSettlement `json:"settlements,omitempty"` +} + +// CashAdvanceSettlement is one purchase order or expense paid out of the advance. +type CashAdvanceSettlement struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Status string `json:"status"` +} + +type CreateCashAdvanceRequest struct { + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + Amount float64 `json:"amount"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date,omitempty"` + Status *string `json:"status,omitempty"` + Description *string `json:"description,omitempty"` +} + +type UpdateCashAdvanceRequest struct { + CodeNumber *string `json:"code_number,omitempty"` + TeamScope *string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + Amount *float64 `json:"amount,omitempty"` + ReturnedAmount *float64 `json:"returned_amount,omitempty"` + IssuedDate *time.Time `json:"issued_date,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + Status *string `json:"status,omitempty"` + Description *string `json:"description,omitempty"` +} + +type ListCashAdvancesRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty"` + SettlementStatus string `json:"settlement_status,omitempty"` + Team string `json:"team,omitempty"` + TeamScope string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +type ListCashAdvancesResponse struct { + CashAdvances []CashAdvanceResponse `json:"cash_advances"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/models/expense.go b/internal/models/expense.go index 859ed69..0ce88c6 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -46,6 +46,7 @@ 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"` @@ -76,6 +77,7 @@ 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"` } @@ -97,6 +99,7 @@ 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"` } diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go index 7a3ac3d..e4293e6 100644 --- a/internal/models/ingredient.go +++ b/internal/models/ingredient.go @@ -12,7 +12,7 @@ type Ingredient struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` @@ -29,7 +29,7 @@ type CreateIngredientRequest struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -48,7 +48,7 @@ type CompositionItemRequest struct { type UpdateIngredientRequest struct { OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -61,7 +61,7 @@ type IngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/models/ingredient_unit_converter.go b/internal/models/ingredient_unit_converter.go index af14c0c..5290284 100644 --- a/internal/models/ingredient_unit_converter.go +++ b/internal/models/ingredient_unit_converter.go @@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/models/order.go b/internal/models/order.go index 313c496..d18e2a9 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -44,6 +44,8 @@ type OrderItem struct { ProductID uuid.UUID ProductVariantID *uuid.UUID Quantity int + Weight *float64 + UnitID *uuid.UUID UnitPrice float64 TotalPrice float64 UnitCost float64 @@ -99,7 +101,8 @@ type CreateOrderItemRequest struct { ProductID uuid.UUID `validate:"required"` ProductVariantID *uuid.UUID `validate:"omitempty"` Quantity int `validate:"required,min=1"` - UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided + Weight *float64 `validate:"omitempty,gt=0"` + UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database Modifiers []map[string]interface{} `validate:"omitempty"` Notes *string `validate:"omitempty,max=500"` Metadata map[string]interface{} `validate:"omitempty"` @@ -191,6 +194,10 @@ type OrderItemResponse struct { CategoryID *uuid.UUID CategoryName *string Quantity int + Weight *float64 + UnitID *uuid.UUID + UnitName *string + UnitAbbreviation *string UnitPrice float64 TotalPrice float64 UnitCost float64 diff --git a/internal/models/product.go b/internal/models/product.go index 0e0b6a1..1d2142b 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -19,6 +19,7 @@ type Product struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + SellBy string UnitID *uuid.UUID HasIngredients bool Metadata map[string]interface{} @@ -52,6 +53,7 @@ type CreateProductRequest struct { PrinterType *string `validate:"omitempty,max=50"` PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` + SellBy string `validate:"omitempty,oneof=unit weight"` HasIngredients bool `validate:"omitempty"` Metadata map[string]interface{} Variants []CreateProductVariantRequest `validate:"omitempty,dive"` @@ -73,6 +75,7 @@ type UpdateProductRequest struct { PrinterType *string `validate:"omitempty,max=50"` PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` + SellBy *string `validate:"omitempty,oneof=unit weight"` HasIngredients *bool `validate:"omitempty"` Metadata map[string]interface{} IsActive *bool @@ -110,6 +113,7 @@ type ProductResponse struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + SellBy string PrintToChecker bool UnitID *uuid.UUID HasIngredients bool diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 7122ed8..4836f34 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -18,10 +18,21 @@ type PurchaseOrder struct { Status string `json:"status"` Message *string `json:"message"` TotalAmount float64 `json:"total_amount"` + TeamScope *string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// PurchaseTeam is one entry of the team picker: either a parent category or Pusat. +// Pusat carries no CategoryID because it has no category of its own. +type PurchaseTeam struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` +} + type PurchaseOrderItem struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` @@ -54,8 +65,12 @@ type PurchaseOrderResponse struct { Status string `json:"status"` Message *string `json:"message"` TotalAmount float64 `json:"total_amount"` + TeamScope *string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeam `json:"team,omitempty"` Vendor *VendorResponse `json:"vendor,omitempty"` Items []PurchaseOrderItemResponse `json:"items,omitempty"` Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"` @@ -94,6 +109,9 @@ type CreatePurchaseOrderRequest struct { Reference *string `json:"reference,omitempty"` Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` + TeamScope *string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"` Items []CreatePurchaseOrderItemRequest `json:"items"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -115,6 +133,9 @@ type UpdatePurchaseOrderRequest struct { Reference *string `json:"reference,omitempty"` Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` + TeamScope *string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"` Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -130,13 +151,20 @@ type UpdatePurchaseOrderItemRequest struct { } type ListPurchaseOrdersRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` - Status string `json:"status,omitempty"` - VendorID *uuid.UUID `json:"vendor_id,omitempty"` - StartDate *time.Time `json:"start_date,omitempty"` - EndDate *time.Time `json:"end_date,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty"` + VendorID *uuid.UUID `json:"vendor_id,omitempty"` + Team string `json:"team,omitempty"` + TeamScope string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +type ListPurchaseTeamsResponse struct { + Teams []PurchaseTeam `json:"teams"` } type ListPurchaseOrdersResponse struct { diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 2560124..1297e90 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -200,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req req.GroupBy = "day" } - result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + teamFilter, err := models.ParsePurchaseTeamFilter(req.Team) + if err != nil { + return nil, err + } + + result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) } @@ -245,10 +250,26 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req } } + teamData := make([]models.PurchasingTeamData, len(result.TeamData)) + for i, item := range result.TeamData { + teamData[i] = models.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &models.PurchasingAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: result.OutletName, + Team: req.Team, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, @@ -263,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, TotalVendors: result.Summary.TotalVendors, + TotalTeams: result.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } @@ -299,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m CategoryName: data.CategoryName, CategoryOrder: data.CategoryOrder, QuantitySold: data.QuantitySold, + WeightSold: data.WeightSold, Revenue: data.Revenue, AveragePrice: data.AveragePrice, OrderCount: data.OrderCount, @@ -424,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co ProductSku: product.ProductSku, ProductPrice: product.ProductPrice, QuantitySold: product.QuantitySold, + WeightSold: product.WeightSold, Revenue: product.Revenue, AveragePrice: product.AveragePrice, OrderCount: product.OrderCount, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 6cb4722..7ae2d0e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + purchasingTeam *entities.PurchaseTeamFilter budgetCutOffWeeks []*entities.BudgetCutOffWeek profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics @@ -32,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu return nil, nil } -func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) { +func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) { + s.purchasingTeam = team return s.purchasingResult, nil } @@ -158,6 +161,110 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + team string + want *entities.PurchaseTeamFilter + }{ + {name: "all teams", team: "", want: nil}, + {name: "pusat", team: constants.PurchaseTeamScopeCentral, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCentral}}, + {name: "no team", team: constants.PurchaseTeamNone, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamNone}}, + { + name: "category team", + team: categoryID.String(), + want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCategory, CategoryID: &categoryID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: tt.team, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, tt.team, result.Team) + require.Equal(t, tt.want, repo.purchasingTeam) + }) + } +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + purchasingResult: &entities.PurchasingAnalytics{ + Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []entities.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, float64(150), result.TeamData[0].RawMaterialPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsRejectsUnknownTeam(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: "marketing", + DateFrom: now, + DateTo: now, + }) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "team must be one of") +} + func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { productID := uuid.New() categoryID := uuid.New() diff --git a/internal/processor/cash_advance_processor.go b/internal/processor/cash_advance_processor.go new file mode 100644 index 0000000..058d279 --- /dev/null +++ b/internal/processor/cash_advance_processor.go @@ -0,0 +1,286 @@ +package processor + +import ( + "context" + "fmt" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type CashAdvanceProcessor interface { + CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) + UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) + DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error + GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) + ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) + UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) + ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) +} + +type CashAdvanceProcessorImpl struct { + cashAdvanceRepo CashAdvanceRepository + categoryRepo CategoryRepository +} + +func NewCashAdvanceProcessorImpl(cashAdvanceRepo CashAdvanceRepository, categoryRepo CategoryRepository) *CashAdvanceProcessorImpl { + return &CashAdvanceProcessorImpl{ + cashAdvanceRepo: cashAdvanceRepo, + categoryRepo: categoryRepo, + } +} + +func (p *CashAdvanceProcessorImpl) CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) { + // The cash leaves one drawer, so the outlet has to be known: either the caller + // named it or it comes from the outlet they are signed in to. + resolvedOutletID := req.OutletID + if resolvedOutletID == nil { + resolvedOutletID = outletID + } + if resolvedOutletID == nil || *resolvedOutletID == uuid.Nil { + return nil, fmt.Errorf("outlet_id is required") + } + + teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, resolvedOutletID, &req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + + existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, req.CodeNumber, organizationID) + if err == nil && existing != nil { + return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", req.CodeNumber) + } + + status := constants.CashAdvanceStatusDraft + if req.Status != nil { + status = *req.Status + } + + cashAdvance := &entities.CashAdvance{ + OrganizationID: organizationID, + OutletID: *resolvedOutletID, + CodeNumber: req.CodeNumber, + TeamScope: teamScope, + TeamCategoryID: teamCategoryID, + Amount: req.Amount, + IssuedDate: req.IssuedDate, + DueDate: req.DueDate, + Status: status, + Description: req.Description, + } + + if err := p.cashAdvanceRepo.Create(ctx, cashAdvance); err != nil { + return nil, fmt.Errorf("failed to create cash advance: %w", err) + } + + created, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) + if err != nil { + return nil, fmt.Errorf("failed to get created cash advance: %w", err) + } + + return mappers.CashAdvanceEntityToResponse(created), nil +} + +func (p *CashAdvanceProcessorImpl) UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) { + cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + if req.CodeNumber != nil && *req.CodeNumber != cashAdvance.CodeNumber { + existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, *req.CodeNumber, organizationID) + if err == nil && existing != nil { + return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", *req.CodeNumber) + } + cashAdvance.CodeNumber = *req.CodeNumber + } + + if req.TeamScope != nil { + teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, &cashAdvance.OutletID, req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + cashAdvance.TeamScope = teamScope + cashAdvance.TeamCategoryID = teamCategoryID + } + + if req.Amount != nil { + cashAdvance.Amount = *req.Amount + } + if req.ReturnedAmount != nil { + cashAdvance.ReturnedAmount = *req.ReturnedAmount + } + if req.IssuedDate != nil { + cashAdvance.IssuedDate = *req.IssuedDate + } + if req.DueDate != nil { + cashAdvance.DueDate = req.DueDate + } + if req.Status != nil { + if err := p.guardStatusChange(ctx, cashAdvance, *req.Status); err != nil { + return nil, err + } + cashAdvance.Status = *req.Status + } + if req.Description != nil { + cashAdvance.Description = req.Description + } + + // Cash handed back can only ever be part of the cash handed out. + if cashAdvance.ReturnedAmount > cashAdvance.Amount { + return nil, fmt.Errorf("returned_amount cannot be greater than the cash advance amount") + } + + if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil { + return nil, fmt.Errorf("failed to update cash advance: %w", err) + } + + updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) + if err != nil { + return nil, fmt.Errorf("failed to get updated cash advance: %w", err) + } + + return mappers.CashAdvanceEntityToResponse(updated), nil +} + +func (p *CashAdvanceProcessorImpl) DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error { + if _, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID); err != nil { + return fmt.Errorf("cash advance not found: %w", err) + } + + // The foreign keys would refuse this anyway, but not in words anyone can act on. + count, err := p.cashAdvanceRepo.CountSettlements(ctx, id) + if err != nil { + return fmt.Errorf("failed to check cash advance settlements: %w", err) + } + if count > 0 { + return fmt.Errorf("cash advance cannot be deleted because %d purchase orders or expenses are charged to it", count) + } + + if err := p.cashAdvanceRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete cash advance: %w", err) + } + + return nil +} + +func (p *CashAdvanceProcessorImpl) GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) { + cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + response := mappers.CashAdvanceEntityToResponse(cashAdvance) + + // The detail view is where someone checks a cash advance off, so it carries the + // spending behind the settled figure. The list deliberately does not. + settlements, err := p.cashAdvanceRepo.ListSettlements(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to list cash advance settlements: %w", err) + } + response.Settlements = mappers.CashAdvanceSettlementEntitiesToModels(settlements) + + return response, nil +} + +func (p *CashAdvanceProcessorImpl) ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) { + offset := (page - 1) * limit + cashAdvances, total, err := p.cashAdvanceRepo.List(ctx, organizationID, filters, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list cash advances: %w", err) + } + + responses := mappers.CashAdvanceEntitiesToResponses(cashAdvances) + totalPages := int((total + int64(limit) - 1) / int64(limit)) + + return responses, totalPages, nil +} + +func (p *CashAdvanceProcessorImpl) UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) { + cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + if !constants.IsValidCashAdvanceStatus(status) { + return nil, fmt.Errorf("status must be one of: %s", strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")) + } + + if err := p.guardStatusChange(ctx, cashAdvance, status); err != nil { + return nil, err + } + + cashAdvance.Status = status + if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil { + return nil, fmt.Errorf("failed to update cash advance status: %w", err) + } + + updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) + if err != nil { + return nil, fmt.Errorf("failed to get updated cash advance: %w", err) + } + + return mappers.CashAdvanceEntityToResponse(updated), nil +} + +func (p *CashAdvanceProcessorImpl) ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { + return listTeams(ctx, p.categoryRepo, organizationID, outletID) +} + +// guardStatusChange refuses to withdraw an advance that spending already points at. +// Rejecting or cancelling it would leave those purchases claiming to have been paid +// out of cash the books say never went out. +func (p *CashAdvanceProcessorImpl) guardStatusChange(ctx context.Context, cashAdvance *entities.CashAdvance, status string) error { + if status != constants.CashAdvanceStatusRejected && status != constants.CashAdvanceStatusCancelled { + return nil + } + + count, err := p.cashAdvanceRepo.CountSettlements(ctx, cashAdvance.ID) + if err != nil { + return fmt.Errorf("failed to check cash advance settlements: %w", err) + } + if count > 0 { + return fmt.Errorf("cash advance cannot be %s because %d purchase orders or expenses are charged to it", status, count) + } + + return nil +} + +// resolveCashAdvanceTeam is resolveTeamSelection with the one rule an advance adds: +// the cash is handed to a team, so there is no such thing as one without a team. +func (p *CashAdvanceProcessorImpl) resolveCashAdvanceTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (string, *uuid.UUID, error) { + resolvedScope, resolvedCategoryID, err := resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID) + if err != nil { + return "", nil, err + } + if resolvedScope == nil { + return "", nil, fmt.Errorf("team_scope is required") + } + + return *resolvedScope, resolvedCategoryID, nil +} + +// resolveSpendingCashAdvance checks that a purchase order or expense may be charged +// to the advance it names: same organization and outlet, and the money actually +// approved to leave the drawer. Draft or cancelled advances cannot be spent against. +func resolveSpendingCashAdvance(ctx context.Context, cashAdvanceRepo CashAdvanceRepository, cashAdvanceID, organizationID uuid.UUID, outletID *uuid.UUID) (*entities.CashAdvance, error) { + cashAdvance, err := cashAdvanceRepo.GetByIDAndOrganizationID(ctx, cashAdvanceID, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + if cashAdvance.Status != constants.CashAdvanceStatusApproved { + return nil, fmt.Errorf("cash advance %s is %s, only an approved cash advance can be spent against", cashAdvance.CodeNumber, cashAdvance.Status) + } + + if outletID != nil && *outletID != uuid.Nil && cashAdvance.OutletID != *outletID { + return nil, fmt.Errorf("cash advance %s belongs to a different outlet", cashAdvance.CodeNumber) + } + + return cashAdvance, nil +} diff --git a/internal/processor/cash_advance_repository.go b/internal/processor/cash_advance_repository.go new file mode 100644 index 0000000..961bf58 --- /dev/null +++ b/internal/processor/cash_advance_repository.go @@ -0,0 +1,20 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "context" + + "github.com/google/uuid" +) + +type CashAdvanceRepository interface { + Create(ctx context.Context, cashAdvance *entities.CashAdvance) error + GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error) + GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error) + GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error) + Update(ctx context.Context, cashAdvance *entities.CashAdvance) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error) + ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error) + CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error) +} diff --git a/internal/processor/category_processor.go b/internal/processor/category_processor.go index 73be6d5..26eba2c 100644 --- a/internal/processor/category_processor.go +++ b/internal/processor/category_processor.go @@ -24,6 +24,7 @@ type CategoryRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error) GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error) + ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) Update(ctx context.Context, category *entities.Category) error Delete(ctx context.Context, id uuid.UUID) error diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 5419b1c..7a69991 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "strings" "time" "apskel-pos-be/internal/constants" @@ -25,12 +26,14 @@ type ExpenseProcessor interface { type ExpenseProcessorImpl struct { expenseRepo ExpenseRepository purchaseCategoryRepo PurchaseCategoryRepository + cashAdvanceRepo CashAdvanceRepository } -func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl { +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository, cashAdvanceRepo CashAdvanceRepository) *ExpenseProcessorImpl { return &ExpenseProcessorImpl{ expenseRepo: expenseRepo, purchaseCategoryRepo: purchaseCategoryRepo, + cashAdvanceRepo: cashAdvanceRepo, } } @@ -50,6 +53,11 @@ 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) @@ -84,6 +92,7 @@ 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) @@ -149,6 +158,14 @@ 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 { @@ -334,6 +351,25 @@ 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 { diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index ba0fb3e..b22eb25 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -99,10 +99,40 @@ 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)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -133,7 +163,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -160,7 +190,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -189,7 +219,7 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial), &expenseCashAdvanceRepositoryStub{}) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -266,7 +296,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, } - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ OrganizationID: uuid.New(), diff --git a/internal/processor/ingredient_processor.go b/internal/processor/ingredient_processor.go index 6a3ff3d..f804422 100644 --- a/internal/processor/ingredient_processor.go +++ b/internal/processor/ingredient_processor.go @@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe } func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil { - return nil, err + // The unit is optional, so it is only validated when one is supplied. + if req.UnitID != nil { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil { + return nil, err + } } ingredient := &entities.Ingredient{ @@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid. return nil, err } - if req.UnitID != existing.UnitID { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { + if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil { return nil, err } } diff --git a/internal/processor/ingredient_unit_converter_processor.go b/internal/processor/ingredient_unit_converter_processor.go index 9998fe7..dd91983 100644 --- a/internal/processor/ingredient_unit_converter_processor.go +++ b/internal/processor/ingredient_unit_converter_processor.go @@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get ingredient: %w", err) } - // Get the base unit details - baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get base unit: %w", err) + response := &models.IngredientUnitsResponse{ + IngredientID: ingredientID, + IngredientName: ingredient.Name, } - // Start with the base unit - units := []*models.UnitResponse{ - mappers.MapUnitEntityToResponse(baseUnit), + units := make([]*models.UnitResponse, 0) + unitMap := make(map[uuid.UUID]bool) + + // An ingredient does not necessarily have a unit assigned yet. When it has + // none there is no base unit to start from, so the only units on offer are + // the ones its converters mention. + if ingredient.UnitID != nil { + baseUnit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get base unit: %w", err) + } + + units = append(units, mappers.MapUnitEntityToResponse(baseUnit)) + unitMap[baseUnit.ID] = true + response.BaseUnitID = &baseUnit.ID + response.BaseUnitName = baseUnit.Name } // Get all converters for this ingredient @@ -283,10 +295,6 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get converters: %w", err) } - // Add unique units from converters - unitMap := make(map[uuid.UUID]bool) - unitMap[baseUnit.ID] = true - for _, converter := range converters { if converter.IsActive { // Add FromUnit if not already added @@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex } } - response := &models.IngredientUnitsResponse{ - IngredientID: ingredientID, - IngredientName: ingredient.Name, - BaseUnitID: baseUnit.ID, - BaseUnitName: baseUnit.Name, - Units: units, - } + response.Units = units return response, nil } diff --git a/internal/processor/order_ingredient_transaction_processor.go b/internal/processor/order_ingredient_transaction_processor.go index ba84237..0729b5f 100644 --- a/internal/processor/order_ingredient_transaction_processor.go +++ b/internal/processor/order_ingredient_transaction_processor.go @@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c // Get unit name unitName := "unit" // default - if ingredient.UnitID != uuid.Nil { - unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) + if ingredient.UnitID != nil { + unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) if err == nil { unitName = unit.Name } diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 6065005..3e14e23 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -149,6 +150,33 @@ func NewOrderProcessorImpl( } } +// resolveLineQuantity reconciles the requested quantity and weight against how the +// product is sold, returning the values to store on the order line. +// +// For weight-based products a line is exactly one weighing, so quantity is pinned to 1 +// and the weighed amount is what gets priced. This mirrors +// chk_order_items_weight_single_line in the database. +func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) { + if product.SellBy == constants.SellByWeight { + if weight == nil { + return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name) + } + rounded := entities.RoundWeight(*weight) + if rounded <= 0 { + return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name) + } + return 1, &rounded, nil + } + + if weight != nil { + return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name) + } + if quantity < 1 { + return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name) + } + return quantity, nil, nil +} + func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) { orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID) if err != nil { @@ -194,25 +222,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create } } - itemTotalPrice := float64(itemReq.Quantity) * unitPrice - itemTotalCost := float64(itemReq.Quantity) * unitCost - - subtotal += itemTotalPrice - totalCost += itemTotalCost + quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight) + if err != nil { + return nil, err + } orderItem := &entities.OrderItem{ ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, - Quantity: itemReq.Quantity, + Quantity: quantity, + Weight: weight, + UnitID: product.UnitID, UnitPrice: unitPrice, - TotalPrice: itemTotalPrice, UnitCost: unitCost, - TotalCost: itemTotalCost, Modifiers: entities.Modifiers(itemReq.Modifiers), Notes: itemReq.Notes, Metadata: entities.Metadata(itemReq.Metadata), Status: entities.OrderItemStatusPending, } + orderItem.CalculateTotalPrice() + orderItem.CalculateTotalCost() + + subtotal += orderItem.TotalPrice + totalCost += orderItem.TotalCost orderItems = append(orderItems, orderItem) } @@ -327,26 +359,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, } } - itemTotalPrice := float64(itemReq.Quantity) * unitPrice - itemTotalCost := float64(itemReq.Quantity) * unitCost - - newSubtotal += itemTotalPrice - newTotalCost += itemTotalCost + quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight) + if err != nil { + return nil, err + } orderItem := &entities.OrderItem{ OrderID: orderID, ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, - Quantity: itemReq.Quantity, + Quantity: quantity, + Weight: weight, + UnitID: product.UnitID, UnitPrice: unitPrice, - TotalPrice: itemTotalPrice, UnitCost: unitCost, - TotalCost: itemTotalCost, Modifiers: entities.Modifiers(itemReq.Modifiers), Notes: itemReq.Notes, Metadata: entities.Metadata(itemReq.Metadata), Status: entities.OrderItemStatusPending, } + orderItem.CalculateTotalPrice() + orderItem.CalculateTotalCost() + + newSubtotal += orderItem.TotalPrice + newTotalCost += orderItem.TotalCost addedOrderItems = append(addedOrderItems, orderItem) } @@ -602,8 +638,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID) } - voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice - voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost + billable := orderItem.BillableQuantityFor(itemVoid.Quantity) + voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice) + voidedCost := entities.RoundMoney(billable * orderItem.UnitCost) totalVoidedAmount += voidedAmount totalVoidedCost += voidedCost @@ -720,7 +757,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req return fmt.Errorf("refund quantity cannot exceed original quantity") } - refundAmount := float64(refundQuantity) * orderItem.UnitPrice + refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice) if itemRefund.RefundAmount != nil { refundAmount = *itemRefund.RefundAmount } @@ -1016,7 +1053,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item * PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, - TotalCost: float64(item.Quantity) * item.UnitCost, + TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost), ReferenceType: func() *entities.InventoryMovementReferenceType { t := entities.InventoryMovementReferenceTypePayment return &t @@ -1314,7 +1351,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde PreviousQuantity: float64(previousQuantity), NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, - TotalCost: float64(item.Quantity) * item.UnitCost, + TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost), ReferenceType: func() *entities.InventoryMovementReferenceType { t := entities.InventoryMovementReferenceTypePayment return &t @@ -1380,7 +1417,7 @@ type ingredientRecipeItem struct { // prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) { - totalIngredientQuantity := recipe.Quantity * float64(item.Quantity) + totalIngredientQuantity := recipe.Quantity * item.BillableQuantity() currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) if err != nil { @@ -1478,7 +1515,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie PreviousQuantity: float64(previousQuantity), NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, - TotalCost: float64(refundedQuantity) * item.UnitCost, + TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost), ReferenceType: func() *entities.InventoryMovementReferenceType { t := entities.InventoryMovementReferenceTypeRefund return &t @@ -1544,7 +1581,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con // prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) { // Calculate total ingredient quantity needed based on order item quantity - totalIngredientQuantity := recipe.Quantity * float64(item.Quantity) + totalIngredientQuantity := recipe.Quantity * item.BillableQuantity() // Get current ingredient details currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) diff --git a/internal/processor/order_weight_test.go b/internal/processor/order_weight_test.go new file mode 100644 index 0000000..1558707 --- /dev/null +++ b/internal/processor/order_weight_test.go @@ -0,0 +1,106 @@ +package processor + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +func fPtr(v float64) *float64 { return &v } + +func TestResolveLineQuantity(t *testing.T) { + weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight} + unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit} + + tests := []struct { + name string + product *entities.Product + quantity int + weight *float64 + wantQuantity int + wantWeight *float64 + wantErr bool + }{ + { + name: "weighed line is pinned to quantity 1", + // Mirrors chk_order_items_weight_single_line: even if a client sends 3, + // one weighing is one line. + product: weightProduct, quantity: 3, weight: fPtr(4.2), + wantQuantity: 1, wantWeight: fPtr(4.2), + }, + { + name: "weight is rounded to stored precision", + product: weightProduct, quantity: 1, weight: fPtr(5.6004), + wantQuantity: 1, wantWeight: fPtr(5.6), + }, + { + name: "weight product without a weight is rejected", + product: weightProduct, quantity: 1, weight: nil, + wantErr: true, + }, + { + name: "weight that rounds away to zero is rejected", + product: weightProduct, quantity: 1, weight: fPtr(0.0004), + wantErr: true, + }, + { + name: "unit line keeps its quantity and carries no weight", + product: unitProduct, quantity: 3, weight: nil, + wantQuantity: 3, wantWeight: nil, + }, + { + name: "weight on a unit product is rejected", + // Otherwise the line would silently be priced by weight. + product: unitProduct, quantity: 1, weight: fPtr(4.2), + wantErr: true, + }, + { + name: "non-positive quantity on a unit product is rejected", + product: unitProduct, quantity: 0, weight: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotQty != tt.wantQuantity { + t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity) + } + switch { + case tt.wantWeight == nil && gotWeight != nil: + t.Errorf("weight = %v, want nil", *gotWeight) + case tt.wantWeight != nil && gotWeight == nil: + t.Errorf("weight = nil, want %v", *tt.wantWeight) + case tt.wantWeight != nil && *gotWeight != *tt.wantWeight: + t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight) + } + }) + } +} + +// A product created before this feature has sell_by defaulted to 'unit' by the +// migration, but a zero-valued struct must behave the same way. +func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) { + legacy := &entities.Product{Name: "Legacy"} + + quantity, weight, err := resolveLineQuantity(legacy, 2, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if quantity != 2 || weight != nil { + t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight) + } +} diff --git a/internal/processor/product_processor.go b/internal/processor/product_processor.go index f778833..e08ea7c 100644 --- a/internal/processor/product_processor.go +++ b/internal/processor/product_processor.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" "apskel-pos-be/internal/mappers" @@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID, mappers.UpdateProductEntityFromRequest(existingProduct, req) + // Checked after the merge, not on the request: switching a product to sell_by + // "weight" is valid when it already carries a unit, and clearing the unit is + // invalid when it is already sold by weight. Only the merged product shows either. + if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil { + return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name) + } + if err := p.productRepo.Update(ctx, existingProduct); err != nil { return nil, fmt.Errorf("failed to update product: %w", err) } diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index fe8de6e..2b8d662 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -19,15 +19,21 @@ type PurchaseOrderProcessor interface { GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error) GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error) UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error) + ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) } type PurchaseOrderProcessorImpl struct { - purchaseOrderRepo PurchaseOrderRepository - vendorRepo VendorRepository - ingredientRepo IngredientRepository - purchaseCategoryRepo PurchaseCategoryRepository - unitRepo UnitRepository - fileRepo FileRepository + purchaseOrderRepo PurchaseOrderRepository + vendorRepo VendorRepository + ingredientRepo IngredientRepository + purchaseCategoryRepo PurchaseCategoryRepository + categoryRepo CategoryRepository + cashAdvanceRepo CashAdvanceRepository + unitRepo UnitRepository + fileRepo FileRepository + // Kept wired but currently unused: purchase orders are a record of spending + // only, so nothing here moves stock or converts units. These stay so that + // tying purchases back to inventory is a change in one place. inventoryMovementService InventoryMovementService unitConverterRepo IngredientUnitConverterRepository } @@ -37,6 +43,8 @@ func NewPurchaseOrderProcessorImpl( vendorRepo VendorRepository, ingredientRepo IngredientRepository, purchaseCategoryRepo PurchaseCategoryRepository, + categoryRepo CategoryRepository, + cashAdvanceRepo CashAdvanceRepository, unitRepo UnitRepository, fileRepo FileRepository, inventoryMovementService InventoryMovementService, @@ -47,6 +55,8 @@ func NewPurchaseOrderProcessorImpl( vendorRepo: vendorRepo, ingredientRepo: ingredientRepo, purchaseCategoryRepo: purchaseCategoryRepo, + categoryRepo: categoryRepo, + cashAdvanceRepo: cashAdvanceRepo, unitRepo: unitRepo, fileRepo: fileRepo, inventoryMovementService: inventoryMovementService, @@ -63,6 +73,16 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or } } + teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, outletID, req.TeamScope, req.TeamCategoryID) + if err != nil { + 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 { @@ -124,6 +144,9 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or Status: "draft", // Default status Message: req.Message, TotalAmount: totalAmount, + TeamScope: teamScope, + TeamCategoryID: teamCategoryID, + CashAdvanceID: req.CashAdvanceID, } if req.Status != nil { @@ -221,6 +244,36 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id poEntity.Message = req.Message } + // An omitted team_scope leaves the team as it is; an empty one clears it. + if req.TeamScope != nil { + teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, poEntity.OutletID, req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + poEntity.TeamScope = teamScope + poEntity.TeamCategoryID = teamCategoryID + } + + // 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 @@ -415,71 +468,11 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte fmt.Println("status:", po.Status) - // Check if status is changing to "received" and current status is not "received" - if status == "received" && po.Status != "received" { - // Get purchase order with items for inventory update - poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get purchase order with items: %w", err) - } - - // Update inventory for each item - for _, item := range poWithItems.Items { - if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { - continue - } - - if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { - return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) - } - - // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) - } - - // Convert quantity to ingredient's base unit if needed - quantityToAdd := *item.Quantity - if *item.UnitID != ingredient.UnitID { - // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) - if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) - } - quantityToAdd = convertedQuantity - } - - // Calculate unit cost in ingredient's base unit - unitCost := 0.0 - if quantityToAdd > 0 { - unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd - } - - // Create inventory movement for ingredient purchase - reason := fmt.Sprintf("Purchase order %s received", po.PONumber) - referenceType := entities.InventoryMovementReferenceTypePurchaseOrder - referenceID := &id - - err = p.inventoryMovementService.CreateIngredientMovement( - ctx, - *item.IngredientID, - organizationID, - outletID, - userID, - entities.InventoryMovementTypePurchase, - quantityToAdd, - unitCost, - reason, - &referenceType, - referenceID, - &item.ID, - ) - if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) - } - } - } + // A purchase order is a record of spending only. Receiving one does not move + // ingredient stock, does not recalculate ingredient cost, and never converts + // units: the quantity and unit on an item are kept exactly as the user + // entered them. Raw material items are therefore treated the same way expense + // items already were, and the ingredient on an item is just a reference. // Update the purchase order status statusOutletID := po.OutletID @@ -501,6 +494,51 @@ 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. +func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { + return listTeams(ctx, p.categoryRepo, organizationID, outletID) +} + +// resolvePurchaseTeam turns a requested team into the scope/category pair stored on +// the purchase order. A nil or empty scope leaves the purchase without a team, which +// is deliberately different from Pusat. +func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) { + return resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID) +} + +// applyCashAdvance checks a purchase may be charged to the cash advance it names, and returns +// the team it should carry. A purchase paid out of a team's cash belongs to that +// team, so an unassigned purchase inherits it and an assigned one has to agree. +func (p *PurchaseOrderProcessorImpl) applyCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID, cashAdvanceID *uuid.UUID, teamScope *string, teamCategoryID *uuid.UUID) (*string, *uuid.UUID, error) { + if cashAdvanceID == nil { + return teamScope, teamCategoryID, nil + } + + cashAdvance, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, *cashAdvanceID, organizationID, outletID) + if err != nil { + return nil, nil, err + } + + if teamScope == nil { + scope := cashAdvance.TeamScope + return &scope, cashAdvance.TeamCategoryID, nil + } + + if *teamScope != cashAdvance.TeamScope || !sameUUID(teamCategoryID, cashAdvance.TeamCategoryID) { + return nil, nil, fmt.Errorf("purchase order team must match the team cash advance %s was issued to", cashAdvance.CodeNumber) + } + + return teamScope, teamCategoryID, nil +} + +func sameUUID(a, b *uuid.UUID) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { diff --git a/internal/processor/split_bill_processor.go b/internal/processor/split_bill_processor.go index 5559125..d527a89 100644 --- a/internal/processor/split_bill_processor.go +++ b/internal/processor/split_bill_processor.go @@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa for _, item := range req.Items { orderItem := validation.OrderItems[item.OrderItemID] - itemAmount := float64(item.Quantity) * orderItem.UnitPrice + itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice) itemTaxAmount := itemAmount * validation.Outlet.TaxRate totalItemAmount := itemAmount + itemTaxAmount @@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error { for _, item := range req.Items { orderItem := validation.OrderItems[item.OrderItemID] - itemAmount := float64(item.Quantity) * orderItem.UnitPrice + itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice) itemTaxAmount := itemAmount * validation.Outlet.TaxRate totalItemAmount := itemAmount + itemTaxAmount @@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde quantityInfo[item.OrderItemID.String()] = map[string]interface{}{ "quantity": item.Quantity, "unit_price": orderItem.UnitPrice, - "total_amount": float64(item.Quantity) * orderItem.UnitPrice, + "total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice), } } order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo diff --git a/internal/processor/team.go b/internal/processor/team.go new file mode 100644 index 0000000..a38a533 --- /dev/null +++ b/internal/processor/team.go @@ -0,0 +1,89 @@ +package processor + +import ( + "context" + "fmt" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +// Teams are the parent product categories, plus Pusat for spending that belongs to +// no single team. Both purchase orders and cash advances are charged to one, so the rules +// for picking and storing a team live here rather than in either processor. + +// listTeams returns the teams money can be charged to: the parent categories of the +// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended +// here rather than read from the database. +func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { + categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID) + if err != nil { + return nil, fmt.Errorf("failed to list parent categories: %w", err) + } + + teams := make([]models.PurchaseTeam, 0, len(categories)+1) + for _, category := range categories { + categoryID := category.ID + teams = append(teams, models.PurchaseTeam{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: category.Name, + }) + } + + teams = append(teams, models.PurchaseTeam{ + Scope: constants.PurchaseTeamScopeCentral, + Name: constants.PurchaseTeamCentralName, + }) + + return &models.ListPurchaseTeamsResponse{Teams: teams}, nil +} + +// resolveTeamSelection turns a requested team into the scope/category pair that gets +// stored, mirroring the database check constraint. A nil or empty scope means no +// team, which is deliberately different from Pusat — callers that require a team +// reject that case before getting here. Which outlet's Pusat it is comes from the +// record's own outlet, so 'central' needs nothing stored beyond the scope itself. +func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) { + if scope == nil { + return nil, nil, nil + } + + switch strings.TrimSpace(*scope) { + case "": + return nil, nil, nil + + case constants.PurchaseTeamScopeCentral: + resolved := constants.PurchaseTeamScopeCentral + return &resolved, nil, nil + + case constants.PurchaseTeamScopeCategory: + if categoryID == nil { + return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category") + } + + category, err := categoryRepo.GetByID(ctx, *categoryID) + if err != nil { + return nil, nil, fmt.Errorf("team category not found: %w", err) + } + if category.OrganizationID != organizationID { + return nil, nil, fmt.Errorf("team category does not belong to this organization") + } + if category.ParentID != nil { + return nil, nil, fmt.Errorf("team must be a parent category") + } + // Categories without an outlet are shared, so only an outlet-specific + // category has to match the outlet the record is booked against. + if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID { + return nil, nil, fmt.Errorf("team category belongs to a different outlet") + } + + resolved := constants.PurchaseTeamScopeCategory + return &resolved, &category.ID, nil + } + + return nil, nil, fmt.Errorf("team_scope must be one of: category, central") +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index a1d6a86..1696973 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "github.com/google/uuid" @@ -15,7 +16,7 @@ import ( type AnalyticsRepository interface { GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error) - GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) + GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) @@ -28,6 +29,20 @@ type AnalyticsRepository interface { GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error) } +// A weight-based line is a single weighing: order_items.quantity is pinned to 1 and +// the weighed amount lives in order_items.weight, while unit_price and unit_cost are +// per unit of weight. Anything that multiplies or divides by a per-unit rate must use +// the weighed amount, not the row count, or a 4.2 ons fish is costed as a single ons. +// These mirror entities.OrderItem.BillableQuantity for aggregate SQL. +const ( + // billableQty is the multiplier for a per-unit price or cost, before refunds. + billableQty = `COALESCE(oi.weight, oi.quantity)` + + // billableQtyNet is billableQty after refunds. A weighed line is always refunded + // whole, so with quantity pinned to 1 the factor is the full weight or zero. + billableQtyNet = `COALESCE(oi.weight, 1) * (oi.quantity - COALESCE(oi.refund_quantity, 0))` +) + type AnalyticsRepositoryImpl struct { db *gorm.DB } @@ -159,7 +174,7 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz return results, err } -func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var outletName *string if outletID != nil { @@ -179,10 +194,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } - return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy) + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, team, outletName, dateFrom, dateTo, groupBy) } -func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var summary entities.PurchasingSummary summaryQuery := r.db.WithContext(ctx). Table("purchase_orders po"). @@ -210,6 +225,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) + summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team) if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err @@ -253,6 +269,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group(dateFormat). Order(dateFormat) dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) + dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team) if err := dataQuery.Scan(&data).Error; err != nil { return nil, err @@ -283,6 +300,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("i.id, i.name"). Order("total_cost DESC") ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) + ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team) if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { return nil, err @@ -310,20 +328,105 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("v.id, COALESCE(v.name, 'No Vendor')"). Order("total_cost DESC") vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) + vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team) if err := vendorQuery.Scan(&vendorData).Error; err != nil { return nil, err } + teamData, err := r.getPurchaseOrderTeamBreakdown(ctx, organizationID, outletID, team, dateFrom, dateTo, summary.TotalPurchases) + if err != nil { + return nil, err + } + summary.TotalTeams = int64(len(teamData)) + return &entities.PurchasingAnalytics{ OutletName: outletName, Summary: summary, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } +// getPurchaseOrderTeamBreakdown splits the purchases over the teams they were +// charged to. Purchases with no team are kept as their own row rather than +// dropped, so the rows still add up to the summary total. +func (r *AnalyticsRepositoryImpl) getPurchaseOrderTeamBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, totalPurchases float64) ([]entities.PurchasingTeamData, error) { + var rows []struct { + Scope *string + CategoryID *uuid.UUID + CategoryName *string + TotalPurchases float64 + RawMaterialPurchases float64 + ExpensePurchases float64 + PurchaseOrderCount int64 + Quantity float64 + } + + query := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + po.team_scope as scope, + po.team_category_id as category_id, + c.name as category_name, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, + COUNT(DISTINCT po.id) as purchase_order_count, + COALESCE(SUM(poi.quantity), 0) as quantity + `). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("LEFT JOIN categories c ON po.team_category_id = c.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("po.team_scope, po.team_category_id, c.name"). + Order("total_purchases DESC") + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + query = r.applyPurchaseOrderTeamFilter(query, team) + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + teamData := make([]entities.PurchasingTeamData, len(rows)) + for i, row := range rows { + entry := entities.PurchasingTeamData{ + CategoryID: row.CategoryID, + TotalPurchases: row.TotalPurchases, + RawMaterialPurchases: row.RawMaterialPurchases, + ExpensePurchases: row.ExpensePurchases, + PurchaseOrderCount: row.PurchaseOrderCount, + Quantity: row.Quantity, + } + + switch { + case row.Scope == nil: + entry.Scope = constants.PurchaseTeamNone + entry.Name = constants.PurchaseTeamNoneName + case *row.Scope == constants.PurchaseTeamScopeCentral: + entry.Scope = constants.PurchaseTeamScopeCentral + entry.Name = constants.PurchaseTeamCentralName + default: + entry.Scope = *row.Scope + if row.CategoryName != nil { + entry.Name = *row.CategoryName + } + } + + if totalPurchases != 0 { + entry.Percentage = row.TotalPurchases / totalPurchases * 100 + } + + teamData[i] = entry + } + + return teamData, nil +} + func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB { if outletID == nil { return query @@ -331,6 +434,28 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm return query.Where("po.outlet_id = ?", *outletID) } +// applyPurchaseOrderTeamFilter narrows a purchase order query to the team the +// report asked for. A nil filter, or a category team without a category, leaves +// the query spanning every team. +func (r *AnalyticsRepositoryImpl) applyPurchaseOrderTeamFilter(query *gorm.DB, team *entities.PurchaseTeamFilter) *gorm.DB { + if team == nil { + return query + } + + switch team.Scope { + case constants.PurchaseTeamNone: + return query.Where("po.team_scope IS NULL") + case constants.PurchaseTeamScopeCentral: + return query.Where("po.team_scope = ?", constants.PurchaseTeamScopeCentral) + case constants.PurchaseTeamScopeCategory: + if team.CategoryID != nil { + return query.Where("po.team_scope = ? AND po.team_category_id = ?", constants.PurchaseTeamScopeCategory, *team.CategoryID) + } + } + + return query +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics @@ -350,9 +475,10 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ c.name as category_name, c.order as category_order, COALESCE(SUM(oi.quantity), 0) as quantity_sold, + COALESCE(SUM(oi.weight), 0) as weight_sold, COALESCE(SUM(oi.total_price), 0) as revenue, CASE - WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity) + WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `) ELSE 0 END as average_price, COUNT(DISTINCT oi.order_id) as order_count, @@ -367,14 +493,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id WHERE pr.product_id = p.id - ), p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as standard_hpp_total, + ), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total, CASE - WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity) + WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `) ELSE 0 END as fifo_hpp_per_unit, COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -425,9 +551,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -478,9 +604,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -595,9 +721,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Scan(summary).Error if err != nil { @@ -616,9 +742,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Group("c.id, c.name, c.order"). Order("c.order ASC, c.name ASC"). @@ -648,18 +774,18 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue, COALESCE( SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) - / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0), + / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0), 0) as average_price, COUNT(DISTINCT oi.order_id) as order_count, COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit, - COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total, + COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as standard_hpp_total, COALESCE( SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) - / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0), + / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0), 0) as fifo_hpp_per_unit, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as moving_average_hpp_total `). Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit"). Order("revenue DESC"). @@ -1056,9 +1182,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C } type purchasingTotals struct { - Total float64 - RawMaterial float64 - Expense float64 + Total float64 + RawMaterial float64 + Expense float64 } func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) { diff --git a/internal/repository/cash_advance_repository.go b/internal/repository/cash_advance_repository.go new file mode 100644 index 0000000..738955c --- /dev/null +++ b/internal/repository/cash_advance_repository.go @@ -0,0 +1,219 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// cashAdvanceSettledAmountExpr sums the spending charged to an advance straight from +// the purchase orders and expenses that point at it. Keeping it as an expression +// rather than a column means an advance can never disagree with the purchases behind it, +// whichever screen edited them. Cancelled spending never accounted for anything. +const cashAdvanceSettledAmountExpr = `( + COALESCE((SELECT SUM(po.total_amount) FROM purchase_orders po + WHERE po.cash_advance_id = cash_advances.id AND po.status <> 'cancelled'), 0) + + COALESCE((SELECT SUM(e.total) FROM expenses e + WHERE e.cash_advance_id = cash_advances.id AND e.status <> 'cancel'), 0) +)` + +// Money is stored to two decimals, so half a cent is the smallest gap that means +// anything. The filters use it for the same reason the mapper does. +const cashAdvanceAmountEpsilon = 0.005 + +type CashAdvanceRepositoryImpl struct { + db *gorm.DB +} + +func NewCashAdvanceRepositoryImpl(db *gorm.DB) *CashAdvanceRepositoryImpl { + return &CashAdvanceRepositoryImpl{db: db} +} + +func (r *CashAdvanceRepositoryImpl) Create(ctx context.Context, cashAdvance *entities.CashAdvance) error { + return r.db.WithContext(ctx).Create(cashAdvance).Error +} + +func (r *CashAdvanceRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error) { + var cashAdvance entities.CashAdvance + err := r.db.WithContext(ctx). + Model(&entities.CashAdvance{}). + Select("cash_advances.*, "+cashAdvanceSettledAmountExpr+" AS settled_amount"). + Preload("Outlet"). + Preload("TeamCategory"). + Where("cash_advances.id = ?", id). + First(&cashAdvance).Error + if err != nil { + return nil, err + } + return &cashAdvance, nil +} + +func (r *CashAdvanceRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error) { + var cashAdvance entities.CashAdvance + err := r.db.WithContext(ctx). + Model(&entities.CashAdvance{}). + Select("cash_advances.*, "+cashAdvanceSettledAmountExpr+" AS settled_amount"). + Preload("Outlet"). + Preload("TeamCategory"). + Where("cash_advances.id = ? AND cash_advances.organization_id = ?", id, organizationID). + First(&cashAdvance).Error + if err != nil { + return nil, err + } + return &cashAdvance, nil +} + +func (r *CashAdvanceRepositoryImpl) GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error) { + var cashAdvance entities.CashAdvance + err := r.db.WithContext(ctx). + Where("code_number = ? AND organization_id = ?", codeNumber, organizationID). + First(&cashAdvance).Error + if err != nil { + return nil, err + } + return &cashAdvance, nil +} + +func (r *CashAdvanceRepositoryImpl) Update(ctx context.Context, cashAdvance *entities.CashAdvance) error { + // Omit associations so a preloaded TeamCategory or Outlet is not written back + // over the row it came from. + return r.db.WithContext(ctx).Omit(clause.Associations).Save(cashAdvance).Error +} + +func (r *CashAdvanceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.CashAdvance{}, "id = ?", id).Error +} + +func (r *CashAdvanceRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error) { + var cashAdvances []*entities.CashAdvance + var total int64 + + // Count on its own query: the select list carries a correlated subquery, which + // GORM would otherwise drag into the COUNT. + countQuery := applyCashAdvanceFilters(r.db.WithContext(ctx).Model(&entities.CashAdvance{}).Where("cash_advances.organization_id = ?", organizationID), filters) + if err := countQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + query := applyCashAdvanceFilters(r.db.WithContext(ctx).Model(&entities.CashAdvance{}).Where("cash_advances.organization_id = ?", organizationID), filters) + err := query. + Select("cash_advances.*, " + cashAdvanceSettledAmountExpr + " AS settled_amount"). + Preload("Outlet"). + Preload("TeamCategory"). + Order("cash_advances.issued_date DESC, cash_advances.created_at DESC"). + Limit(limit). + Offset(offset). + Find(&cashAdvances).Error + + return cashAdvances, total, err +} + +func applyCashAdvanceFilters(query *gorm.DB, filters map[string]interface{}) *gorm.DB { + for key, value := range filters { + switch key { + case "search": + if search, ok := value.(string); ok && search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(cash_advances.code_number) LIKE ? OR LOWER(cash_advances.description) LIKE ?", pattern, pattern) + } + case "status": + if status, ok := value.(string); ok && status != "" { + query = query.Where("cash_advances.status = ?", status) + } + case "outlet_id": + if outletID, ok := value.(uuid.UUID); ok { + query = query.Where("cash_advances.outlet_id = ?", outletID) + } + case "team_scope": + if teamScope, ok := value.(string); ok && teamScope != "" { + query = query.Where("cash_advances.team_scope = ?", teamScope) + } + case "team_category_id": + if teamCategoryID, ok := value.(uuid.UUID); ok { + query = query.Where("cash_advances.team_category_id = ?", teamCategoryID) + } + case "settlement_status": + query = applyCashAdvanceSettlementFilter(query, value) + case "start_date": + if startDate, ok := value.(time.Time); ok { + query = query.Where("cash_advances.issued_date >= ?", startDate) + } + case "end_date": + if endDate, ok := value.(time.Time); ok { + query = query.Where("cash_advances.issued_date <= ?", endDate) + } + } + } + + return query +} + +// applyCashAdvanceSettlementFilter reproduces in SQL what the mapper computes in Go: +// how much of the advance has been accounted for, by spending plus cash returned. +func applyCashAdvanceSettlementFilter(query *gorm.DB, value interface{}) *gorm.DB { + status, ok := value.(string) + if !ok || status == "" { + return query + } + + accounted := cashAdvanceSettledAmountExpr + " + cash_advances.returned_amount" + + switch status { + case constants.CashAdvanceSettlementOpen: + return query.Where(accounted+" <= ?", cashAdvanceAmountEpsilon) + case constants.CashAdvanceSettlementPartial: + return query. + Where(accounted+" > ?", cashAdvanceAmountEpsilon). + Where("cash_advances.amount - ("+accounted+") > ?", cashAdvanceAmountEpsilon) + case constants.CashAdvanceSettlementSettled: + return query. + Where(accounted+" > ?", cashAdvanceAmountEpsilon). + Where("cash_advances.amount - ("+accounted+") <= ?", cashAdvanceAmountEpsilon) + } + + return query +} + +// ListSettlements returns the spending charged to an advance, newest first. Purchase +// orders and expenses are two tables recording the same thing here, so they are +// read as one list. +func (r *CashAdvanceRepositoryImpl) ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error) { + query := fmt.Sprintf(` + SELECT '%s' AS type, po.id AS id, po.po_number AS number, + po.transaction_date AS date, po.total_amount AS amount, po.status AS status + FROM purchase_orders po + WHERE po.cash_advance_id = ? + UNION ALL + SELECT '%s' AS type, e.id AS id, e.code_number AS number, + e.transaction_date AS date, e.total AS amount, e.status AS status + FROM expenses e + WHERE e.cash_advance_id = ? + ORDER BY date DESC`, + constants.CashAdvanceSettlementTypePurchaseOrder, + constants.CashAdvanceSettlementTypeExpense, + ) + + var settlements []*entities.CashAdvanceSettlement + err := r.db.WithContext(ctx).Raw(query, cashAdvanceID, cashAdvanceID).Scan(&settlements).Error + return settlements, err +} + +// CountSettlements is what stops an advance being deleted once spending has been +// charged to it; the foreign keys would refuse anyway, but not with a readable error. +func (r *CashAdvanceRepositoryImpl) CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Raw(` + SELECT (SELECT COUNT(*) FROM purchase_orders WHERE cash_advance_id = ?) + + (SELECT COUNT(*) FROM expenses WHERE cash_advance_id = ?)`, + cashAdvanceID, cashAdvanceID).Scan(&count).Error + return count, err +} diff --git a/internal/repository/category_repository.go b/internal/repository/category_repository.go index ee4af10..1f007bf 100644 --- a/internal/repository/category_repository.go +++ b/internal/repository/category_repository.go @@ -48,6 +48,26 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza return categories, err } +// ListParentCategories returns the top-level categories of an organization. These are +// the buckets the parent category reports roll up to via COALESCE(parent_id, id), so +// the list is deliberately every top-level category, not only those with children — +// otherwise a team could show up in a report but not be selectable on a purchase. +// Categories with no outlet of their own are shared, so they are always included. +func (r *CategoryRepositoryImpl) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) { + var categories []*entities.Category + + query := r.db.WithContext(ctx). + Where("organization_id = ?", organizationID). + Where("parent_id IS NULL") + + if outletID != nil { + query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID) + } + + err := query.Order("\"order\" ASC, name ASC").Find(&categories).Error + return categories, err +} + func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) { var categories []*entities.Category err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error diff --git a/internal/repository/order_item_repository.go b/internal/repository/order_item_repository.go index d983b6b..6f31acc 100644 --- a/internal/repository/order_item_repository.go +++ b/internal/repository/order_item_repository.go @@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid err := r.db.WithContext(ctx). Preload("Product"). Preload("ProductVariant"). + Preload("Unit"). Where("order_id = ?", orderID). Find(&orderItems).Error return orderItems, err @@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI return err } - if voidQuantity >= orderItem.Quantity { - voidedAmount := float64(voidQuantity) * orderItem.UnitPrice + // A weighed line is one weighing, so it is voided whole — never split into a + // remainder row. chk_order_items_weight_single_line pins its quantity to 1, which + // already sends it down this branch; the explicit check keeps that guarantee + // visible and independent of the comparison below. + if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity { + voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice) updates := map[string]interface{}{ "refund_quantity": voidQuantity, @@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI Updates(updates).Error } + // Only unit-priced lines reach here: weighed lines were voided whole above. voidedOrderItem := entities.OrderItem{ OrderID: orderItem.OrderID, ProductID: orderItem.ProductID, ProductVariantID: orderItem.ProductVariantID, Quantity: voidQuantity, + UnitID: orderItem.UnitID, UnitPrice: orderItem.UnitPrice, - TotalPrice: float64(voidQuantity) * orderItem.UnitPrice, + TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice), UnitCost: orderItem.UnitCost, - TotalCost: float64(voidQuantity) * orderItem.UnitCost, - RefundAmount: float64(voidQuantity) * orderItem.UnitPrice, + TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost), + RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice), RefundQuantity: voidQuantity, IsPartiallyRefunded: false, IsFullyRefunded: true, @@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI } remainingQuantity := orderItem.Quantity - voidQuantity - remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice - remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost + remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice) + remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost) updates := map[string]interface{}{ "quantity": remainingQuantity, diff --git a/internal/repository/order_repository.go b/internal/repository/order_repository.go index 9d13a48..341ce14 100644 --- a/internal/repository/order_repository.go +++ b/internal/repository/order_repository.go @@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID Preload("OrderItems.Product.Category"). Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). + Preload("OrderItems.Unit"). Preload("Payments"). Preload("Payments.PaymentMethod"). Preload("Payments.PaymentOrderItems"). @@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter Preload("OrderItems.Product.Category"). Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). + Preload("OrderItems.Unit"). Preload("Payments"). Preload("Payments.PaymentMethod"). Preload("Payments.PaymentOrderItems"). @@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str Preload("OrderItems.Product.Category"). Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). + Preload("OrderItems.Unit"). Preload("Payments"). Preload("Payments.PaymentMethod"). Preload("Payments.PaymentOrderItems"). diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index d65cd6b..d88694f 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -10,6 +10,7 @@ import ( "apskel-pos-be/internal/entities" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type PurchaseOrderRepositoryImpl struct { @@ -30,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) var po entities.PurchaseOrder err := r.db.WithContext(ctx). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -45,6 +47,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte var po entities.PurchaseOrder err := r.db.WithContext(ctx). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -58,7 +61,10 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte } func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error { - return r.db.WithContext(ctx).Save(po).Error + // Omit associations so preloaded relations are not upserted back. Items and + // attachments are rewritten explicitly by the processor, and without this a + // preloaded TeamCategory would be written over the category row itself. + return r.db.WithContext(ctx).Omit(clause.Associations).Save(po).Error } func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { @@ -87,6 +93,18 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u if vendorID, ok := value.(uuid.UUID); ok { query = query.Where("vendor_id = ?", vendorID) } + case "team_scope": + if teamScope, ok := value.(string); ok && teamScope != "" { + query = query.Where("team_scope = ?", teamScope) + } + case "team_category_id": + if teamCategoryID, ok := value.(uuid.UUID); ok { + query = query.Where("team_category_id = ?", teamCategoryID) + } + case "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -106,6 +124,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u err := query. Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -137,6 +156,18 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID if vendorID, ok := value.(uuid.UUID); ok { query = query.Where("vendor_id = ?", vendorID) } + case "team_scope": + if teamScope, ok := value.(string); ok && teamScope != "" { + query = query.Where("team_scope = ?", teamScope) + } + case "team_category_id": + if teamCategoryID, ok := value.(uuid.UUID); ok { + query = query.Where("team_category_id = ?", teamCategoryID) + } + case "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -170,6 +201,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat err := r.db.WithContext(ctx). Where("organization_id = ? AND status = ?", organizationID, status). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -182,6 +214,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati err := r.db.WithContext(ctx). Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). diff --git a/internal/router/router.go b/internal/router/router.go index 26c1dae..6da7d3f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -53,12 +53,13 @@ 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, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -103,6 +104,7 @@ 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, } } @@ -388,6 +390,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders) purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus) purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders) + purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams) purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder) purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder) purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus) @@ -477,6 +480,19 @@ 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()) { diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 889b766..3ddb1a3 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -238,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu } } + if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil { + return err + } + return nil } diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index e300d77..c67a864 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -113,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { }, wantErr: "invalid group_by value: quarter", }, + { + name: "unknown team", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + Team: "marketing", + }, + wantErr: "team must be one of", + }, } for _, tt := range tests { diff --git a/internal/service/cash_advance_service.go b/internal/service/cash_advance_service.go new file mode 100644 index 0000000..4782649 --- /dev/null +++ b/internal/service/cash_advance_service.go @@ -0,0 +1,180 @@ +package service + +import ( + "context" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" + + "github.com/google/uuid" +) + +type CashAdvanceService interface { + CreateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateCashAdvanceRequest) *contract.Response + UpdateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateCashAdvanceRequest) *contract.Response + DeleteCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + GetCashAdvanceByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + ListCashAdvances(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListCashAdvancesRequest) *contract.Response + UpdateCashAdvanceStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response + ListCashAdvanceTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response +} + +type CashAdvanceServiceImpl struct { + cashAdvanceProcessor processor.CashAdvanceProcessor +} + +func NewCashAdvanceService(cashAdvanceProcessor processor.CashAdvanceProcessor) *CashAdvanceServiceImpl { + return &CashAdvanceServiceImpl{cashAdvanceProcessor: cashAdvanceProcessor} +} + +func (s *CashAdvanceServiceImpl) CreateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateCashAdvanceRequest) *contract.Response { + modelReq, err := transformer.CreateCashAdvanceRequestToModel(req) + if err != nil { + errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.CashAdvanceServiceEntity, "Invalid date format. Use YYYY-MM-DD format") + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + cashAdvance, err := s.cashAdvanceProcessor.CreateCashAdvance(ctx, apctx.OrganizationID, outletFromContext(apctx), modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) UpdateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateCashAdvanceRequest) *contract.Response { + modelReq, err := transformer.UpdateCashAdvanceRequestToModel(req) + if err != nil { + errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.CashAdvanceServiceEntity, "Invalid date format. Use YYYY-MM-DD format") + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + cashAdvance, err := s.cashAdvanceProcessor.UpdateCashAdvance(ctx, id, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) DeleteCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + if err := s.cashAdvanceProcessor.DeleteCashAdvance(ctx, id, apctx.OrganizationID); err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Cash advance deleted successfully", + }) +} + +func (s *CashAdvanceServiceImpl) GetCashAdvanceByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + cashAdvance, err := s.cashAdvanceProcessor.GetCashAdvanceByID(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) ListCashAdvances(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListCashAdvancesRequest) *contract.Response { + modelReq := transformer.ListCashAdvancesRequestToModel(req) + + filters := make(map[string]interface{}) + if modelReq.Search != "" { + filters["search"] = modelReq.Search + } + if modelReq.Status != "" { + filters["status"] = modelReq.Status + } + if modelReq.SettlementStatus != "" { + filters["settlement_status"] = modelReq.SettlementStatus + } + if modelReq.TeamScope != "" { + filters["team_scope"] = modelReq.TeamScope + } + if modelReq.TeamCategoryID != nil { + filters["team_category_id"] = *modelReq.TeamCategoryID + } + // team spells out the same two filters in one value; the validator has already + // ruled out sending it together with them. + switch modelReq.Team { + case "": + case constants.PurchaseTeamScopeCentral: + filters["team_scope"] = constants.PurchaseTeamScopeCentral + default: + if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil { + filters["team_scope"] = constants.PurchaseTeamScopeCategory + filters["team_category_id"] = teamCategoryID + } + } + if modelReq.StartDate != nil { + filters["start_date"] = *modelReq.StartDate + } + if modelReq.EndDate != nil { + filters["end_date"] = *modelReq.EndDate + } + // Cash belongs to the drawer it came out of, so a user signed in to one outlet + // only sees that outlet's cash advances. + if outletID := outletFromContext(apctx); outletID != nil { + filters["outlet_id"] = *outletID + } + + cashAdvances, totalPages, err := s.cashAdvanceProcessor.ListCashAdvances(ctx, apctx.OrganizationID, filters, modelReq.Page, modelReq.Limit) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + responses := make([]contract.CashAdvanceResponse, len(cashAdvances)) + for i, cashAdvance := range cashAdvances { + if response := transformer.CashAdvanceModelResponseToResponse(cashAdvance); response != nil { + responses[i] = *response + } + } + + return contract.BuildSuccessResponse(contract.ListCashAdvancesResponse{ + CashAdvances: responses, + TotalCount: len(responses), + Page: modelReq.Page, + Limit: modelReq.Limit, + TotalPages: totalPages, + }) +} + +func (s *CashAdvanceServiceImpl) UpdateCashAdvanceStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response { + cashAdvance, err := s.cashAdvanceProcessor.UpdateCashAdvanceStatus(ctx, id, apctx.OrganizationID, status) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) ListCashAdvanceTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response { + teams, err := s.cashAdvanceProcessor.ListCashAdvanceTeams(ctx, apctx.OrganizationID, outletFromContext(apctx)) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams)) +} + +// outletFromContext reads the caller's outlet as an optional value: an organization +// level user has none, and uuid.Nil is how that arrives on the context. +func outletFromContext(apctx *appcontext.ContextInfo) *uuid.UUID { + if apctx.OutletID == uuid.Nil { + return nil + } + + outletID := apctx.OutletID + return &outletID +} diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index 27fbcb7..b10c8d8 100644 --- a/internal/service/purchase_order_service.go +++ b/internal/service/purchase_order_service.go @@ -21,6 +21,7 @@ type PurchaseOrderService interface { GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response + ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response } type PurchaseOrderServiceImpl struct { @@ -113,6 +114,26 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx if modelReq.VendorID != nil { filters["vendor_id"] = *modelReq.VendorID } + if modelReq.TeamScope != "" { + filters["team_scope"] = modelReq.TeamScope + } + if modelReq.TeamCategoryID != nil { + filters["team_category_id"] = *modelReq.TeamCategoryID + } + // team spells out the same two filters in one value; the validator has already + // ruled out sending it together with them. + switch modelReq.Team { + case "": + case constants.PurchaseTeamNone: + filters["team_unassigned"] = true + case constants.PurchaseTeamScopeCentral: + filters["team_scope"] = constants.PurchaseTeamScopeCentral + default: + if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil { + filters["team_scope"] = constants.PurchaseTeamScopeCategory + filters["team_category_id"] = teamCategoryID + } + } if modelReq.StartDate != nil { filters["start_date"] = *modelReq.StartDate } @@ -145,6 +166,21 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx return contract.BuildSuccessResponse(response) } +func (s *PurchaseOrderServiceImpl) ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response { + var outletID *uuid.UUID + if apctx.OutletID != uuid.Nil { + outletID = &apctx.OutletID + } + + teams, err := s.purchaseOrderProcessor.ListPurchaseTeams(ctx, apctx.OrganizationID, outletID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams)) +} + func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response { poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status) if err != nil { diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 7c113ff..8ba124f 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -64,6 +64,7 @@ type reportSummary struct { type reportItem struct { Name string Quantity int64 + Weight string GrossSales string Discount string NetSales string @@ -161,6 +162,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org items = append(items, reportItem{ Name: p.ProductName, Quantity: p.QuantitySold, + Weight: formatWeight(p.WeightSold), GrossSales: formatCurrency(p.Revenue), Discount: formatCurrency(0), NetSales: formatCurrency(p.Revenue), @@ -489,6 +491,16 @@ func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLoss return result } +// formatWeight renders the weight sold for a product, or a dash when the product is +// not sold by weight. Trailing zeros are trimmed so 4.200 prints as "4,2". +func formatWeight(weight float64) string { + if weight <= 0 { + return "-" + } + s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", weight), "0"), ".") + return strings.Replace(s, ".", ",", 1) +} + func formatCurrencyOrDash(amount float64) string { if amount == 0 { return "-" diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 14d008f..8590c18 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -156,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest return &models.PurchasingAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), + Team: req.Team, DateFrom: dateFrom, DateTo: dateTo, GroupBy: req.GroupBy, @@ -208,10 +209,26 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse } } + teamData := make([]contract.PurchasingTeamData, len(resp.TeamData)) + for i, item := range resp.TeamData { + teamData[i] = contract.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &contract.PurchasingAnalyticsResponse{ OrganizationID: resp.OrganizationID, OutletID: resp.OutletID, OutletName: resp.OutletName, + Team: resp.Team, DateFrom: resp.DateFrom, DateTo: resp.DateTo, GroupBy: resp.GroupBy, @@ -226,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, TotalVendors: resp.Summary.TotalVendors, + TotalTeams: resp.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, } } @@ -272,6 +291,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con CategoryName: item.CategoryName, CategoryOrder: item.CategoryOrder, QuantitySold: item.QuantitySold, + WeightSold: item.WeightSold, Revenue: item.Revenue, AveragePrice: item.AveragePrice, OrderCount: item.OrderCount, @@ -487,6 +507,7 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna ProductSku: product.ProductSku, ProductPrice: product.ProductPrice, QuantitySold: product.QuantitySold, + WeightSold: product.WeightSold, Revenue: product.Revenue, AveragePrice: product.AveragePrice, OrderCount: product.OrderCount, @@ -573,6 +594,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) CategoryID: item.CategoryID, CategoryName: item.CategoryName, QuantitySold: item.QuantitySold, + WeightSold: item.WeightSold, Revenue: item.Revenue, AveragePrice: item.AveragePrice, OrderCount: item.OrderCount, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 4d1327e..300decc 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" @@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) { + categoryID := uuid.New() + + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + Team: categoryID.String(), + Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []models.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }) + + require.NotNil(t, result) + require.Equal(t, categoryID.String(), result.Team) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ OrganizationID: uuid.New(), diff --git a/internal/transformer/cash_advance_transformer.go b/internal/transformer/cash_advance_transformer.go new file mode 100644 index 0000000..34d7002 --- /dev/null +++ b/internal/transformer/cash_advance_transformer.go @@ -0,0 +1,126 @@ +package transformer + +import ( + "time" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" +) + +func CreateCashAdvanceRequestToModel(req *contract.CreateCashAdvanceRequest) (*models.CreateCashAdvanceRequest, error) { + issuedDate, err := time.Parse("2006-01-02", req.IssuedDate) + if err != nil { + return nil, err + } + + var dueDate *time.Time + if req.DueDate != nil && *req.DueDate != "" { + parsed, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return nil, err + } + dueDate = &parsed + } + + return &models.CreateCashAdvanceRequest{ + OutletID: req.OutletID, + CodeNumber: req.CodeNumber, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + Amount: req.Amount, + IssuedDate: issuedDate, + DueDate: dueDate, + Status: req.Status, + Description: req.Description, + }, nil +} + +func UpdateCashAdvanceRequestToModel(req *contract.UpdateCashAdvanceRequest) (*models.UpdateCashAdvanceRequest, error) { + var issuedDate *time.Time + if req.IssuedDate != nil && *req.IssuedDate != "" { + parsed, err := time.Parse("2006-01-02", *req.IssuedDate) + if err != nil { + return nil, err + } + issuedDate = &parsed + } + + var dueDate *time.Time + if req.DueDate != nil && *req.DueDate != "" { + parsed, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return nil, err + } + dueDate = &parsed + } + + return &models.UpdateCashAdvanceRequest{ + CodeNumber: req.CodeNumber, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + Amount: req.Amount, + ReturnedAmount: req.ReturnedAmount, + IssuedDate: issuedDate, + DueDate: dueDate, + Status: req.Status, + Description: req.Description, + }, nil +} + +func ListCashAdvancesRequestToModel(req *contract.ListCashAdvancesRequest) *models.ListCashAdvancesRequest { + return &models.ListCashAdvancesRequest{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + Status: req.Status, + SettlementStatus: req.SettlementStatus, + Team: req.Team, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + StartDate: req.StartDate, + EndDate: req.EndDate, + } +} + +func CashAdvanceModelResponseToResponse(cashAdvance *models.CashAdvanceResponse) *contract.CashAdvanceResponse { + if cashAdvance == nil { + return nil + } + + response := &contract.CashAdvanceResponse{ + ID: cashAdvance.ID, + OrganizationID: cashAdvance.OrganizationID, + OutletID: cashAdvance.OutletID, + CodeNumber: cashAdvance.CodeNumber, + TeamScope: cashAdvance.TeamScope, + TeamCategoryID: cashAdvance.TeamCategoryID, + Amount: cashAdvance.Amount, + SettledAmount: cashAdvance.SettledAmount, + ReturnedAmount: cashAdvance.ReturnedAmount, + RemainingAmount: cashAdvance.RemainingAmount, + SettlementStatus: cashAdvance.SettlementStatus, + IssuedDate: cashAdvance.IssuedDate, + DueDate: cashAdvance.DueDate, + Status: cashAdvance.Status, + Description: cashAdvance.Description, + CreatedAt: cashAdvance.CreatedAt, + UpdatedAt: cashAdvance.UpdatedAt, + Team: PurchaseTeamModelToResponse(cashAdvance.Team), + } + + if cashAdvance.Settlements != nil { + response.Settlements = make([]contract.CashAdvanceSettlementResponse, len(cashAdvance.Settlements)) + for i, settlement := range cashAdvance.Settlements { + response.Settlements[i] = contract.CashAdvanceSettlementResponse{ + Type: settlement.Type, + ID: settlement.ID, + Number: settlement.Number, + Date: settlement.Date, + Amount: settlement.Amount, + Status: settlement.Status, + } + } + } + + return response +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index bc8606c..6b6acb5 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -21,6 +21,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre Description: req.Description, Tax: req.Tax, Total: req.Total, + CashAdvanceID: req.CashAdvanceID, Items: items, } } @@ -46,6 +47,7 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd Tax: req.Tax, Total: req.Total, Reserved1: req.Reserved1, + CashAdvanceID: req.CashAdvanceID, } if req.Items != nil { @@ -103,6 +105,7 @@ 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, diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 88f88d6..b297cef 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -17,6 +17,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, UnitPrice: item.UnitPrice, // Now optional Modifiers: item.Modifiers, Notes: item.Notes, @@ -58,6 +59,7 @@ func AddToOrderContractToModel(req *contract.AddToOrderRequest) *models.AddToOrd ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, UnitPrice: item.UnitPrice, // Now optional Modifiers: item.Modifiers, Notes: item.Notes, @@ -103,6 +105,10 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { CategoryID: item.CategoryID, CategoryName: item.CategoryName, Quantity: item.Quantity, + Weight: item.Weight, + UnitID: item.UnitID, + UnitName: item.UnitName, + UnitAbbreviation: item.UnitAbbreviation, UnitPrice: item.UnitPrice, TotalPrice: item.TotalPrice, Modifiers: item.Modifiers, @@ -174,6 +180,10 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO CategoryID: item.CategoryID, CategoryName: item.CategoryName, Quantity: item.Quantity, + Weight: item.Weight, + UnitID: item.UnitID, + UnitName: item.UnitName, + UnitAbbreviation: item.UnitAbbreviation, UnitPrice: item.UnitPrice, TotalPrice: item.TotalPrice, Modifiers: item.Modifiers, diff --git a/internal/transformer/order_transformer_test.go b/internal/transformer/order_transformer_test.go new file mode 100644 index 0000000..c3067a0 --- /dev/null +++ b/internal/transformer/order_transformer_test.go @@ -0,0 +1,48 @@ +package transformer + +import ( + "testing" + + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCreateOrderContractToModelCarriesWeight(t *testing.T) { + weight := 1.25 + + result := CreateOrderContractToModel(&contract.CreateOrderRequest{ + OutletID: uuid.New(), + UserID: uuid.New(), + OrderItems: []contract.CreateOrderItemRequest{ + { + ProductID: uuid.New(), + Quantity: 1, + Weight: &weight, + }, + }, + }) + + require.Len(t, result.OrderItems, 1) + require.NotNil(t, result.OrderItems[0].Weight) + require.Equal(t, weight, *result.OrderItems[0].Weight) +} + +func TestAddToOrderContractToModelCarriesWeight(t *testing.T) { + weight := 0.8 + + result := AddToOrderContractToModel(&contract.AddToOrderRequest{ + OrderItems: []contract.CreateOrderItemRequest{ + { + ProductID: uuid.New(), + Quantity: 1, + Weight: &weight, + }, + }, + }) + + require.Len(t, result.OrderItems, 1) + require.NotNil(t, result.OrderItems[0].Weight) + require.Equal(t, weight, *result.OrderItems[0].Weight) +} diff --git a/internal/transformer/product_transformer.go b/internal/transformer/product_transformer.go index e6f0321..0a628f6 100644 --- a/internal/transformer/product_transformer.go +++ b/internal/transformer/product_transformer.go @@ -20,6 +20,11 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr businessType = constants.BusinessType(*req.BusinessType) } + sellBy := constants.SellByUnit + if req.SellBy != nil { + sellBy = *req.SellBy + } + var variants []models.CreateProductVariantRequest if req.Variants != nil { variants = make([]models.CreateProductVariantRequest, len(req.Variants)) @@ -58,6 +63,8 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr ImageURL: req.ImageURL, PrinterType: req.PrinterType, PrintToChecker: req.PrintToChecker, + UnitID: req.UnitID, + SellBy: sellBy, Metadata: metadata, Variants: variants, } @@ -86,6 +93,8 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up ImageURL: req.ImageURL, PrinterType: req.PrinterType, PrintToChecker: req.PrintToChecker, + UnitID: req.UnitID, + SellBy: req.SellBy, Metadata: metadata, IsActive: req.IsActive, } @@ -145,6 +154,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod ImageURL: prod.ImageURL, PrinterType: prod.PrinterType, PrintToChecker: prod.PrintToChecker, + UnitID: prod.UnitID, + SellBy: prod.SellBy, Metadata: prod.Metadata, IsActive: prod.IsActive, CreatedAt: prod.CreatedAt, diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 814b8c4..518e94a 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -44,6 +44,9 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest) Reference: req.Reference, Status: req.Status, Message: req.Message, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + CashAdvanceID: req.CashAdvanceID, Items: items, AttachmentFileIDs: req.AttachmentFileIDs, }, nil @@ -94,6 +97,9 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest) Reference: req.Reference, Status: req.Status, Message: req.Message, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + CashAdvanceID: req.CashAdvanceID, Items: items, AttachmentFileIDs: req.AttachmentFileIDs, }, nil @@ -101,16 +107,40 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest) func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest { return &models.ListPurchaseOrdersRequest{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - Status: req.Status, - VendorID: req.VendorID, - StartDate: req.StartDate, - EndDate: req.EndDate, + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + Status: req.Status, + VendorID: req.VendorID, + Team: req.Team, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + StartDate: req.StartDate, + EndDate: req.EndDate, } } +func PurchaseTeamModelToResponse(team *models.PurchaseTeam) *contract.PurchaseTeamResponse { + if team == nil { + return nil + } + + return &contract.PurchaseTeamResponse{ + Scope: team.Scope, + CategoryID: team.CategoryID, + Name: team.Name, + } +} + +func ListPurchaseTeamsModelResponseToResponse(resp *models.ListPurchaseTeamsResponse) *contract.ListPurchaseTeamsResponse { + teams := make([]contract.PurchaseTeamResponse, len(resp.Teams)) + for i, team := range resp.Teams { + teams[i] = *PurchaseTeamModelToResponse(&team) + } + + return &contract.ListPurchaseTeamsResponse{Teams: teams} +} + // Model to Contract conversions func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse { if po == nil { @@ -129,8 +159,12 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con Status: po.Status, Message: po.Message, TotalAmount: po.TotalAmount, + TeamScope: po.TeamScope, + TeamCategoryID: po.TeamCategoryID, + CashAdvanceID: po.CashAdvanceID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, + Team: PurchaseTeamModelToResponse(po.Team), } // Map vendor if present diff --git a/internal/validator/cash_advance_validator.go b/internal/validator/cash_advance_validator.go new file mode 100644 index 0000000..b1e404b --- /dev/null +++ b/internal/validator/cash_advance_validator.go @@ -0,0 +1,192 @@ +package validator + +import ( + "errors" + "strings" + "time" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" +) + +type CashAdvanceValidator interface { + ValidateCreateCashAdvanceRequest(req *contract.CreateCashAdvanceRequest) (error, string) + ValidateUpdateCashAdvanceRequest(req *contract.UpdateCashAdvanceRequest) (error, string) + ValidateListCashAdvancesRequest(req *contract.ListCashAdvancesRequest) (error, string) +} + +type CashAdvanceValidatorImpl struct{} + +func NewCashAdvanceValidator() *CashAdvanceValidatorImpl { + return &CashAdvanceValidatorImpl{} +} + +func (v *CashAdvanceValidatorImpl) ValidateCreateCashAdvanceRequest(req *contract.CreateCashAdvanceRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.CodeNumber) == "" { + return errors.New("code_number is required"), constants.MissingFieldErrorCode + } + if len(req.CodeNumber) > 50 { + return errors.New("code_number must be at most 50 characters"), constants.MalformedFieldErrorCode + } + + if req.OutletID != nil && *req.OutletID == uuid.Nil { + return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode + } + + // A cash advance is cash handed to a team, so the team is not optional here the way + // it is on a purchase order: allowClear stays false and an empty scope is rejected. + if err, code := validatePurchaseTeamSelection(&req.TeamScope, req.TeamCategoryID, false); err != nil { + return err, code + } + + if req.Amount <= 0 { + return errors.New("amount must be greater than 0"), constants.MalformedFieldErrorCode + } + + issuedDate, err := time.Parse("2006-01-02", strings.TrimSpace(req.IssuedDate)) + if err != nil { + return errors.New("issued_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } + + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + if dueDate.Before(issuedDate) { + return errors.New("due_date must be after issued_date"), constants.MalformedFieldErrorCode + } + } + + if req.Status != nil && !constants.IsValidCashAdvanceStatus(*req.Status) { + return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *CashAdvanceValidatorImpl) ValidateUpdateCashAdvanceRequest(req *contract.UpdateCashAdvanceRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.CodeNumber != nil { + if strings.TrimSpace(*req.CodeNumber) == "" { + return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode + } + if len(*req.CodeNumber) > 50 { + return errors.New("code_number must be at most 50 characters"), constants.MalformedFieldErrorCode + } + } + + // The team can be moved but never dropped, so clearing is not allowed here either. + if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil { + return err, code + } + + if req.Amount != nil && *req.Amount <= 0 { + return errors.New("amount must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.ReturnedAmount != nil && *req.ReturnedAmount < 0 { + return errors.New("returned_amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode + } + + var issuedDate *time.Time + if req.IssuedDate != nil { + if strings.TrimSpace(*req.IssuedDate) == "" { + return errors.New("issued_date cannot be empty"), constants.MalformedFieldErrorCode + } + parsed, err := time.Parse("2006-01-02", *req.IssuedDate) + if err != nil { + return errors.New("issued_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + issuedDate = &parsed + } + + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + if issuedDate != nil && dueDate.Before(*issuedDate) { + return errors.New("due_date must be after issued_date"), constants.MalformedFieldErrorCode + } + } + + if req.Status != nil && !constants.IsValidCashAdvanceStatus(*req.Status) { + return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *CashAdvanceValidatorImpl) ValidateListCashAdvancesRequest(req *contract.ListCashAdvancesRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Page < 1 { + return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode + } + + if req.Limit < 1 || req.Limit > 100 { + return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode + } + + if req.Status != "" && !constants.IsValidCashAdvanceStatus(req.Status) { + return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + if req.SettlementStatus != "" && !constants.IsValidCashAdvanceSettlementStatus(req.SettlementStatus) { + return errors.New("settlement_status must be one of: " + strings.Join(constants.GetAllCashAdvanceSettlementStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + if req.Team != "" { + if req.TeamScope != "" || req.TeamCategoryID != nil { + return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode + } + + // Every cash advance has a team, so unlike purchases there is nothing to filter + // for "no team yet": only Pusat or a category id make sense here. + if req.Team != constants.PurchaseTeamScopeCentral { + if categoryID, err := uuid.Parse(req.Team); err != nil || categoryID == uuid.Nil { + return errors.New("team must be either central or a category id"), constants.MalformedFieldErrorCode + } + } + } + + if req.TeamScope != "" { + validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} + if !contains(validScopes, req.TeamScope) { + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + + if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil { + return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode + } + } + + if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil { + return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode + } + + if req.StartDate != nil && req.EndDate != nil && req.EndDate.Before(*req.StartDate) { + return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode + } + + return nil, "" +} diff --git a/internal/validator/product_validator.go b/internal/validator/product_validator.go index 82d4e22..a5943bb 100644 --- a/internal/validator/product_validator.go +++ b/internal/validator/product_validator.go @@ -63,6 +63,32 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode } + if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil { + return err, code + } + + return nil, "" +} + +// validateSellBy checks how a product is sold and that it carries what that choice +// needs. A weight-based product without a unit would produce order lines with no unit +// to print, so the receipt could show "4,2" with no idea of what. +// +// unitID is the unit the request would leave on the product: for an update that does +// not touch unit_id, pass the product's current one. +func validateSellBy(sellBy *string, unitID *uuid.UUID) (error, string) { + if sellBy == nil { + return nil, "" + } + + if !constants.IsValidSellBy(*sellBy) { + return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode + } + + if *sellBy == constants.SellByWeight && unitID == nil { + return errors.New("unit_id is required when sell_by is 'weight'"), constants.MissingFieldErrorCode + } + return nil, "" } @@ -74,7 +100,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update // At least one field should be provided for update if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil && req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil && - req.PrinterType == nil && req.Metadata == nil && req.IsActive == nil { + req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil && + req.SellBy == nil && req.Metadata == nil && req.IsActive == nil { return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode } @@ -111,6 +138,13 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode } + // Only the value is checked here. Whether the product ends up with a unit depends on + // what it already has, which this request cannot see — the processor checks that + // against the stored product. + if req.SellBy != nil && !constants.IsValidSellBy(*req.SellBy) { + return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode + } + return nil, "" } diff --git a/internal/validator/product_validator_sell_by_test.go b/internal/validator/product_validator_sell_by_test.go new file mode 100644 index 0000000..6a6dd56 --- /dev/null +++ b/internal/validator/product_validator_sell_by_test.go @@ -0,0 +1,130 @@ +package validator + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" +) + +func strPtr(s string) *string { return &s } + +func baseCreateRequest() *contract.CreateProductRequest { + return &contract.CreateProductRequest{ + CategoryID: uuid.New(), + Name: "Ikan Tude", + Price: 4500, + } +} + +func TestValidateCreateProductRequestSellBy(t *testing.T) { + unitID := uuid.New() + + tests := []struct { + name string + sellBy *string + unitID *uuid.UUID + wantErr bool + wantMsg string + }{ + { + name: "omitted sell_by is allowed and defaults to unit", + sellBy: nil, unitID: nil, + }, + { + name: "unit product needs no unit_id", + sellBy: strPtr(constants.SellByUnit), unitID: nil, + }, + { + name: "weight product with a unit is accepted", + sellBy: strPtr(constants.SellByWeight), unitID: &unitID, + }, + { + name: "weight product without a unit is rejected", + // Otherwise its order lines would have no unit to print on the receipt. + sellBy: strPtr(constants.SellByWeight), unitID: nil, + wantErr: true, + wantMsg: "unit_id is required when sell_by is 'weight'", + }, + { + name: "unknown sell_by is rejected rather than silently corrected", + // The struct tags on this contract are not enforced — this validator is + // hand-written — so the check has to be explicit. + sellBy: strPtr("pisang"), unitID: &unitID, + wantErr: true, + wantMsg: "sell_by must be either 'unit' or 'weight'", + }, + } + + v := NewProductValidator() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := baseCreateRequest() + req.SellBy = tt.sellBy + req.UnitID = tt.unitID + + err, code := v.ValidateCreateProductRequest(req) + + if !tt.wantErr { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected an error, got none") + } + if err.Error() != tt.wantMsg { + t.Errorf("message = %q, want %q", err.Error(), tt.wantMsg) + } + if code == "" { + t.Error("expected an error code") + } + }) + } +} + +// An update carrying only sell_by must not be turned away as an empty update. +func TestValidateUpdateProductRequestAcceptsSellByAlone(t *testing.T) { + v := NewProductValidator() + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{ + SellBy: strPtr(constants.SellByWeight), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{}) + if err == nil { + t.Error("an update with no fields at all should be rejected") + } +} + +func TestValidateUpdateProductRequestRejectsUnknownSellBy(t *testing.T) { + v := NewProductValidator() + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{ + SellBy: strPtr("timbangan"), + }) + if err == nil { + t.Fatal("expected an error for an unknown sell_by") + } +} + +// The update path deliberately does NOT require unit_id on the request: a product that +// already has a unit can be switched to sell_by "weight" without resending it. That +// pairing is checked by the processor against the stored product. +func TestValidateUpdateProductRequestDefersUnitCheck(t *testing.T) { + v := NewProductValidator() + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{ + SellBy: strPtr(constants.SellByWeight), + }) + if err != nil { + t.Fatalf("update should not require unit_id on the request, got: %v", err) + } +} diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index b578a94..1de3855 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -76,6 +76,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con } } + if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil { + return err, code + } + if len(req.Items) == 0 { return errors.New("at least one item is required"), constants.MissingFieldErrorCode } @@ -139,6 +143,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con } } + if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil { + return err, code + } + // Validate items if provided if req.Items != nil { for i, item := range req.Items { @@ -151,6 +159,55 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con return nil, "" } +// validatePurchaseTeamSelection keeps team_scope and team_category_id in step with +// the database check constraint: a category team needs a category, Pusat must not +// carry one. allowClear lets an update send an empty scope to drop the team. +func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) { + if scope == nil { + if categoryID != nil { + return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode + } + return nil, "" + } + + switch strings.TrimSpace(*scope) { + case "": + if !allowClear { + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + if categoryID != nil { + return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode + } + case constants.PurchaseTeamScopeCategory: + if categoryID == nil || *categoryID == uuid.Nil { + return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode + } + case constants.PurchaseTeamScopeCentral: + if categoryID != nil { + return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode + } + default: + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat, +// no team at all, or the id of the parent category a purchase is charged to. +func validatePurchaseTeamFilter(team string) (error, string) { + switch team { + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return nil, "" + } + + if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil { + return errors.New("team must be one of: central, none, or a category id"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) { if req == nil { return errors.New("request body is required"), constants.MissingFieldErrorCode @@ -171,6 +228,31 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } } + if req.Team != "" { + if req.TeamScope != "" || req.TeamCategoryID != nil { + return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode + } + + if err, code := validatePurchaseTeamFilter(req.Team); err != nil { + return err, code + } + } + + if req.TeamScope != "" { + validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} + if !contains(validScopes, req.TeamScope) { + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + + if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil { + return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode + } + } + + if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil { + return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode + } + if req.StartDate != nil && req.EndDate != nil { if req.EndDate.Before(*req.StartDate) { return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index d7e146d..f7a7914 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -90,3 +90,164 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test require.Equal(t, constants.MalformedFieldErrorCode, code) require.Contains(t, err.Error(), "due_date must be after transaction_date") } + +func TestPurchaseOrderValidatorCreateAllowsCentralTeam(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := constants.PurchaseTeamScopeCentral + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestPurchaseOrderValidatorCreateRejectsCentralTeamWithCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := constants.PurchaseTeamScopeCentral + categoryID := uuid.New() + req.TeamScope = &scope + req.TeamCategoryID = &categoryID + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "team_category_id must be empty") +} + +func TestPurchaseOrderValidatorCreateRejectsCategoryTeamWithoutCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := constants.PurchaseTeamScopeCategory + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "team_category_id is required") +} + +func TestPurchaseOrderValidatorCreateRejectsCategoryWithoutScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + categoryID := uuid.New() + req.TeamCategoryID = &categoryID + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "team_scope is required") +} + +func TestPurchaseOrderValidatorCreateRejectsUnknownTeamScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := "outlet" + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "team_scope must be one of") +} + +// An update may clear the team with an empty scope; a create may not, because +// leaving the field out already means "no team". +func TestPurchaseOrderValidatorUpdateAllowsClearingTeam(t *testing.T) { + validator := NewPurchaseOrderValidator() + scope := "" + + err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{TeamScope: &scope}) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestPurchaseOrderValidatorCreateRejectsEmptyTeamScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := "" + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + scope := "" + categoryID := uuid.New() + + err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{ + TeamScope: &scope, + TeamCategoryID: &categoryID, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} { + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: team, + }) + + require.NoError(t, err, team) + require.Empty(t, code, team) + } +} + +func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: "marketing", + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: constants.PurchaseTeamNone, + TeamScope: constants.PurchaseTeamScopeCentral, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + categoryID := uuid.New() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + TeamScope: constants.PurchaseTeamScopeCentral, + TeamCategoryID: &categoryID, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} diff --git a/migrations/000085_add_team_to_purchase_orders.down.sql b/migrations/000085_add_team_to_purchase_orders.down.sql new file mode 100644 index 0000000..0632ce0 --- /dev/null +++ b/migrations/000085_add_team_to_purchase_orders.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_purchase_orders_team_scope; +DROP INDEX IF EXISTS idx_purchase_orders_team_category_id; + +ALTER TABLE purchase_orders + DROP CONSTRAINT IF EXISTS chk_purchase_orders_team; + +ALTER TABLE purchase_orders + DROP CONSTRAINT IF EXISTS fk_purchase_orders_team_category; + +ALTER TABLE purchase_orders + DROP COLUMN IF EXISTS team_category_id, + DROP COLUMN IF EXISTS team_scope; diff --git a/migrations/000085_add_team_to_purchase_orders.up.sql b/migrations/000085_add_team_to_purchase_orders.up.sql new file mode 100644 index 0000000..86f9f7f --- /dev/null +++ b/migrations/000085_add_team_to_purchase_orders.up.sql @@ -0,0 +1,33 @@ +-- A purchase is charged either to a team (a parent product category) or to Pusat. +-- Pusat has no category of its own, so it is stored as a scope rather than a row; +-- which outlet's Pusat it is comes from purchase_orders.outlet_id. +-- team_scope IS NULL means the team was never chosen, which is deliberately +-- distinct from a purchase that belongs to Pusat. +ALTER TABLE purchase_orders + ADD COLUMN IF NOT EXISTS team_scope VARCHAR(20), + ADD COLUMN IF NOT EXISTS team_category_id UUID; + +ALTER TABLE purchase_orders + ADD CONSTRAINT fk_purchase_orders_team_category + FOREIGN KEY (team_category_id) REFERENCES categories(id) ON DELETE RESTRICT; + +-- Deleting a category that is still charged on a purchase order must fail rather +-- than silently drop the attribution, hence RESTRICT above and this pairing check. +-- Written as a CASE because an OR chain would evaluate to NULL when team_scope is +-- NULL, and a CHECK only rejects FALSE — a stray team_category_id would slip past. +ALTER TABLE purchase_orders + ADD CONSTRAINT chk_purchase_orders_team + CHECK ( + CASE + WHEN team_scope IS NULL THEN team_category_id IS NULL + WHEN team_scope = 'category' THEN team_category_id IS NOT NULL + WHEN team_scope = 'central' THEN team_category_id IS NULL + ELSE false + END + ); + +CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_category_id + ON purchase_orders(team_category_id); + +CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_scope + ON purchase_orders(team_scope); diff --git a/migrations/000086_make_ingredients_unit_id_nullable.down.sql b/migrations/000086_make_ingredients_unit_id_nullable.down.sql new file mode 100644 index 0000000..a0de2d3 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.down.sql @@ -0,0 +1,6 @@ +-- Restoring NOT NULL fails if any ingredient still has a NULL unit_id. Assign a +-- unit to those rows first: +-- SELECT id, name FROM ingredients WHERE unit_id IS NULL; +COMMENT ON COLUMN ingredients.unit_id IS NULL; + +ALTER TABLE ingredients ALTER COLUMN unit_id SET NOT NULL; diff --git a/migrations/000086_make_ingredients_unit_id_nullable.up.sql b/migrations/000086_make_ingredients_unit_id_nullable.up.sql new file mode 100644 index 0000000..ba15a45 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.up.sql @@ -0,0 +1,5 @@ +-- An ingredient can be registered before its unit has been decided, so unit_id +-- is optional. Existing rows are untouched: they already have a unit. +ALTER TABLE ingredients ALTER COLUMN unit_id DROP NOT NULL; + +COMMENT ON COLUMN ingredients.unit_id IS 'Base unit of the ingredient. NULL means no unit has been assigned yet.'; diff --git a/migrations/000087_create_cash_advances_table.down.sql b/migrations/000087_create_cash_advances_table.down.sql new file mode 100644 index 0000000..3d6c961 --- /dev/null +++ b/migrations/000087_create_cash_advances_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cash_advances; diff --git a/migrations/000087_create_cash_advances_table.up.sql b/migrations/000087_create_cash_advances_table.up.sql new file mode 100644 index 0000000..462aa4c --- /dev/null +++ b/migrations/000087_create_cash_advances_table.up.sql @@ -0,0 +1,45 @@ +-- 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); diff --git a/migrations/000088_add_cash_advance_to_spending.down.sql b/migrations/000088_add_cash_advance_to_spending.down.sql new file mode 100644 index 0000000..68c239e --- /dev/null +++ b/migrations/000088_add_cash_advance_to_spending.down.sql @@ -0,0 +1,15 @@ +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; diff --git a/migrations/000088_add_cash_advance_to_spending.up.sql b/migrations/000088_add_cash_advance_to_spending.up.sql new file mode 100644 index 0000000..8d2a14c --- /dev/null +++ b/migrations/000088_add_cash_advance_to_spending.up.sql @@ -0,0 +1,24 @@ +-- 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); diff --git a/migrations/000089_add_weight_to_order_items.down.sql b/migrations/000089_add_weight_to_order_items.down.sql new file mode 100644 index 0000000..50803d6 --- /dev/null +++ b/migrations/000089_add_weight_to_order_items.down.sql @@ -0,0 +1,15 @@ +DROP INDEX IF EXISTS idx_order_items_unit_id; + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS chk_order_items_weight_single_line, + DROP CONSTRAINT IF EXISTS chk_order_items_weight_positive; + +ALTER TABLE order_items + DROP COLUMN IF EXISTS unit_id, + DROP COLUMN IF EXISTS weight; + +ALTER TABLE products + DROP CONSTRAINT IF EXISTS chk_products_sell_by; + +ALTER TABLE products + DROP COLUMN IF EXISTS sell_by; diff --git a/migrations/000089_add_weight_to_order_items.up.sql b/migrations/000089_add_weight_to_order_items.up.sql new file mode 100644 index 0000000..00157a2 --- /dev/null +++ b/migrations/000089_add_weight_to_order_items.up.sql @@ -0,0 +1,30 @@ +-- Weight-based products (e.g. fish sold per ons/kg). +-- One weighing = one order_items row: quantity stays 1, the weight goes in `weight`. + +ALTER TABLE products + ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'; + +ALTER TABLE products + ADD CONSTRAINT chk_products_sell_by + CHECK (sell_by IN ('unit', 'weight')); + +ALTER TABLE order_items + ADD COLUMN weight DECIMAL(12,3), + ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT; + +-- A weighed line always carries a positive weight... +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_weight_positive + CHECK (weight IS NULL OR weight > 0); + +-- ...and always represents exactly one weighing, so its quantity is pinned to 1. +-- This is what makes billable quantity unambiguous and keeps void all-or-nothing. +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_weight_single_line + CHECK (weight IS NULL OR quantity = 1); + +CREATE INDEX idx_order_items_unit_id ON order_items(unit_id); + +COMMENT ON COLUMN products.sell_by IS 'How the product is sold: unit (discrete count) or weight (weighed per transaction)'; +COMMENT ON COLUMN order_items.weight IS 'Weighed amount in unit_id units; NULL for unit-priced products. Price is weight * unit_price.'; +COMMENT ON COLUMN order_items.unit_id IS 'Snapshot of the product unit at sale time, so historical lines keep their meaning'; diff --git a/templates/daily_transaction.html b/templates/daily_transaction.html index b9e788e..ed43d76 100644 --- a/templates/daily_transaction.html +++ b/templates/daily_transaction.html @@ -524,6 +524,7 @@ No. Nama Item Qty + Berat Penjualan Kotor Diskon Penjualan Bersih @@ -537,6 +538,7 @@ {{add $i 1}} {{$item.Name}} {{$item.Quantity}} + {{$item.Weight}} {{$item.GrossSales}} {{$item.Discount}} {{$item.NetSales}}