Main #41
@@ -0,0 +1,289 @@
|
|||||||
|
# Migrasi profit sharing
|
||||||
|
|
||||||
|
4 Oktober 2026
|
||||||
|
|
||||||
|
## Ringkasan
|
||||||
|
|
||||||
|
Ada tiga perubahan di backend:
|
||||||
|
|
||||||
|
1. **Parent category bisa ditandai bukan team.** Kategori punya field baru `is_team`. Parent category dengan `is_team: false` tidak bisa dipilih sebagai team di purchase order dan cash advance, dan tidak ikut laporan profit sharing.
|
||||||
|
2. **Endpoint laporan pindah path.** `/api/v1/analytics/parent-categories` menjadi `/api/v1/analytics/profit-sharing`.
|
||||||
|
3. **Pembagian revenue tinggal dua porsi.** Porsi purchase (60%) dihapus. Revenue sekarang dibagi ke owner (SDL) dan team, dengan porsi team = 100% − fee owner.
|
||||||
|
|
||||||
|
Nomor 2 dan 3 adalah breaking change. Setelah backend baru dirilis, client yang masih memanggil path lama mendapat 404, dan `limit_purchase` serta `percentages.purchase` tidak ada lagi di response. Karena itu client harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||||
|
|
||||||
|
Yang perlu bertindak, di dashboard maupun app mobile, mana pun yang punya layarnya:
|
||||||
|
|
||||||
|
- **Form kategori**: tambah toggle team untuk parent category.
|
||||||
|
- **Laporan profit sharing**: ganti path, hapus porsi purchase, tampilkan dua porsi.
|
||||||
|
- **Form purchase order dan cash advance**: picker team tidak perlu diubah, tapi form edit perlu menyesuaikan, lihat [Purchase order dan cash advance](#purchase-order-dan-cash-advance).
|
||||||
|
|
||||||
|
## Kategori: field `is_team`
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Semua response kategori membawa `is_team` (boolean, tidak pernah `null`): `POST /api/v1/categories`, `PUT /api/v1/categories/:id`, `GET /api/v1/categories`, dan `GET /api/v1/categories/:id`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<uuid>",
|
||||||
|
"name": "Merchandise",
|
||||||
|
"parent_id": null,
|
||||||
|
"owner_fee_percent": null,
|
||||||
|
"is_team": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
`POST /api/v1/categories` dan `PUT /api/v1/categories/:id` menerima `is_team`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Merchandise",
|
||||||
|
"is_team": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Request | `is_team` yang dikirim | Hasil |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Create | tidak dikirim | `true` |
|
||||||
|
| Create | `false` | `false` |
|
||||||
|
| Update | tidak dikirim atau `null` | tidak berubah |
|
||||||
|
| Update | `true` atau `false` | diganti |
|
||||||
|
|
||||||
|
Update yang hanya berisi `is_team` diterima.
|
||||||
|
|
||||||
|
Aturan nilainya:
|
||||||
|
|
||||||
|
- `is_team` hanya berpengaruh di parent category, yaitu kategori dengan `parent_id: null`. Di sub-category nilainya disimpan tapi tidak dipakai, jadi tampilkan toggle hanya untuk parent category.
|
||||||
|
- Semua kategori yang sudah ada sebelum rilis bernilai `true`. Tidak ada yang berubah sampai admin mematikannya.
|
||||||
|
- Flag dibaca saat request, bukan saat transaksi. Kalau parent category dimatikan, penjualannya di periode lampau juga hilang dari laporan profit sharing. Kalau dinyalakan lagi, semuanya muncul kembali.
|
||||||
|
|
||||||
|
### Efek `is_team: false`
|
||||||
|
|
||||||
|
| Endpoint | Efek |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/purchase-orders/teams`, `GET /api/v1/cash-advances/teams` | Kategori tidak muncul. Pusat tetap ada. |
|
||||||
|
| Create dan update purchase order dan cash advance | `team_scope: "category"` dengan `team_category_id` kategori ini ditolak. |
|
||||||
|
| `GET /api/v1/analytics/profit-sharing` | Kategori tidak muncul di `data`, dan revenue-nya tidak dihitung di `budget`. |
|
||||||
|
| `GET /api/v1/analytics/profit-sharing/:parent_category_id` | Ditolak. |
|
||||||
|
|
||||||
|
Data yang sudah ada tidak diubah. Purchase order dan cash advance yang sudah tercatat ke kategori itu tetap menyimpan team-nya. Laporan purchasing (`GET /api/v1/analytics/purchasing`) masih menampilkannya di `team_data`, dan filter `team=<category_id>` tetap bisa dipakai.
|
||||||
|
|
||||||
|
Laporan lain yang tidak menyaring `is_team`, misalnya `GET /api/v1/analytics/categories`, tetap menampilkan penjualan kategori itu.
|
||||||
|
|
||||||
|
## Purchase order dan cash advance
|
||||||
|
|
||||||
|
Picker team sudah mengambil dari `GET /api/v1/purchase-orders/teams` dan `GET /api/v1/cash-advances/teams`, jadi kategori non-team otomatis tidak muncul tanpa perubahan di client.
|
||||||
|
|
||||||
|
Yang perlu diubah ada di form edit. Purchase order atau cash advance lama bisa tercatat ke kategori yang sekarang sudah non-team. Kalau form edit mengirim ulang `team_scope` dan `team_category_id` yang sama, request ditolak, walaupun user tidak mengubah team-nya.
|
||||||
|
|
||||||
|
1. Kirim `team_scope` dan `team_category_id` hanya kalau user mengganti team. Kalau `team_scope` tidak dikirim, team yang tersimpan tidak berubah.
|
||||||
|
2. Kalau team yang tersimpan tidak ada di daftar `/teams`, tampilkan namanya dari field `team` di response apa adanya, dan jangan memilihkan team lain secara otomatis.
|
||||||
|
|
||||||
|
Contoh error saat team ditolak, dengan HTTP status 500:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "900",
|
||||||
|
"entity": "purchase_order_service",
|
||||||
|
"cause": "category Merchandise is not a team"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk cash advance, `entity` bernilai `cash_advance_service`. Cocokkan dengan teks `is not a team` di `cause` kalau perlu menampilkan pesan khusus.
|
||||||
|
|
||||||
|
## Laporan profit sharing
|
||||||
|
|
||||||
|
### Path baru
|
||||||
|
|
||||||
|
| Lama | Baru |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/analytics/parent-categories` | `GET /api/v1/analytics/profit-sharing` |
|
||||||
|
| `GET /api/v1/analytics/parent-categories/:parent_category_id` | `GET /api/v1/analytics/profit-sharing/:parent_category_id` |
|
||||||
|
|
||||||
|
Query parameter dan role tidak berubah:
|
||||||
|
|
||||||
|
- `date_from` dan `date_to` wajib, dengan format `DD-MM-YYYY`, misalnya `28-09-2026`.
|
||||||
|
- `outlet_id` opsional.
|
||||||
|
- Hanya bisa diakses superadmin, admin, manager, owner, dan purchasing.
|
||||||
|
|
||||||
|
Path lama sudah tidak ada dan mengembalikan 404.
|
||||||
|
|
||||||
|
### Pembagian revenue
|
||||||
|
|
||||||
|
Revenue tiap parent category yang team dibagi dua:
|
||||||
|
|
||||||
|
- **SDL (fee owner)** = revenue × `owner_fee_percent` / 100. Default-nya 20%, dan bisa diganti per parent category lewat `owner_fee_percent` di kategori.
|
||||||
|
- **Team** = revenue − SDL.
|
||||||
|
|
||||||
|
Contoh dengan tiga parent category dalam satu minggu:
|
||||||
|
|
||||||
|
| Parent category | `is_team` | Fee owner | Revenue | SDL | Team |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Food | `true` | 20% (default) | 1.000.000 | 200.000 | 800.000 |
|
||||||
|
| Drink | `true` | 35% | 2.000.000 | 700.000 | 1.300.000 |
|
||||||
|
| Merchandise | `false` | - | 500.000 | tidak dihitung | tidak dihitung |
|
||||||
|
| **Budget minggu ini** | | | **3.000.000** | **900.000** | **2.100.000** |
|
||||||
|
|
||||||
|
### Perubahan field
|
||||||
|
|
||||||
|
| Field | Sebelum | Sesudah |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data[]` | semua parent category | hanya parent category team |
|
||||||
|
| `budget.percentages.purchase` | `60` | dihapus |
|
||||||
|
| `budget.percentages.owner` | `20`, atau fee parent itu di endpoint detail | tidak berubah |
|
||||||
|
| `budget.percentages.team` | `20` | `100 − owner`: `80` di list, `100 − fee parent` di detail |
|
||||||
|
| `limit_purchase` di `budget.total`, `budget.weekly[]`, `budget.monthly[]` | 60% revenue | dihapus |
|
||||||
|
| `limit_team` di tempat yang sama | 20% revenue | `revenue − sdl` |
|
||||||
|
| `revenue` dan `sdl` di `budget` | semua parent category | hanya parent category team |
|
||||||
|
|
||||||
|
Di endpoint list, `budget.percentages` selalu berisi default `20` dan `80`, walaupun ada parent dengan fee berbeda. Angka `sdl` dan `limit_team` dihitung per parent dengan fee masing-masing, jadi `limit_team / revenue` bisa tidak persis 80%. Tampilkan angka rupiah dari response, jangan dihitung ulang dari persentase.
|
||||||
|
|
||||||
|
Baris di `data[]` membawa `sdl` tapi tidak membawa porsi team. Kalau porsi team per parent perlu ditampilkan, hitung dari `total_revenue − sdl`.
|
||||||
|
|
||||||
|
Contoh response list, dipotong:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"date_from": "2026-09-28T00:00:00+07:00",
|
||||||
|
"date_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"parent_category_id": "<uuid>",
|
||||||
|
"parent_category_name": "Drink",
|
||||||
|
"owner_fee_percent": 35,
|
||||||
|
"sdl": 700000,
|
||||||
|
"total_revenue": 2000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parent_category_id": "<uuid>",
|
||||||
|
"parent_category_name": "Food",
|
||||||
|
"owner_fee_percent": 20,
|
||||||
|
"sdl": 200000,
|
||||||
|
"total_revenue": 1000000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"budget": {
|
||||||
|
"percentages": { "owner": 20, "team": 80 },
|
||||||
|
"cut_off_from": "2026-09-28T00:00:00+07:00",
|
||||||
|
"cut_off_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"total": {
|
||||||
|
"period_start": "2026-09-28T00:00:00+07:00",
|
||||||
|
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"revenue": 3000000,
|
||||||
|
"order_count": 4,
|
||||||
|
"sdl": 900000,
|
||||||
|
"limit_team": 2100000
|
||||||
|
},
|
||||||
|
"weekly": [
|
||||||
|
{
|
||||||
|
"period_start": "2026-09-28T00:00:00+07:00",
|
||||||
|
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"revenue": 3000000,
|
||||||
|
"order_count": 4,
|
||||||
|
"sdl": 900000,
|
||||||
|
"limit_team": 2100000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"monthly": [
|
||||||
|
{
|
||||||
|
"month": "2026-09",
|
||||||
|
"week_count": 1,
|
||||||
|
"period_start": "2026-09-28T00:00:00+07:00",
|
||||||
|
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||||
|
"revenue": 3000000,
|
||||||
|
"order_count": 4,
|
||||||
|
"sdl": 900000,
|
||||||
|
"limit_team": 2100000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Di endpoint detail, `budget` bentuknya sama, tapi `percentages` memakai fee parent itu, misalnya `{ "owner": 35, "team": 65 }` untuk Drink.
|
||||||
|
|
||||||
|
### Detail kategori non-team
|
||||||
|
|
||||||
|
Detail untuk parent category non-team ditolak dengan HTTP status 500:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "internal_error",
|
||||||
|
"entity": "AnalyticsHandler::GetParentCategoryAnalyticsDetail",
|
||||||
|
"cause": "failed to get parent category analytics detail: failed to get parent category analytics detail: category Merchandise is not a team"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ini bisa terjadi kalau user membuka link lama atau bookmark ke parent yang baru dimatikan. Cocokkan dengan teks `is not a team` di `cause`, lalu arahkan user kembali ke list.
|
||||||
|
|
||||||
|
## Migrasi client
|
||||||
|
|
||||||
|
### Laporan profit sharing
|
||||||
|
|
||||||
|
1. Ganti path ke `/api/v1/analytics/profit-sharing`. Selama backend lama masih jalan, path baru mengembalikan 404. Kalau dapat 404, panggil path lama `/api/v1/analytics/parent-categories`, supaya client baru bisa dirilis sebelum backend.
|
||||||
|
2. Hapus tampilan limit purchase dan persentase purchase. Jangan menganggap `limit_purchase` atau `percentages.purchase` selalu ada.
|
||||||
|
3. Tampilkan dua porsi dengan label "SDL / Fee owner" dan "Team", ambil angkanya dari `sdl` dan `limit_team`.
|
||||||
|
4. Kalau detail ditolak dengan `is not a team`, arahkan kembali ke list.
|
||||||
|
|
||||||
|
Selama fallback ke backend lama, `limit_team` masih berisi 20% revenue. Angkanya baru jadi `revenue − sdl` setelah backend baru dirilis.
|
||||||
|
|
||||||
|
### Form kategori
|
||||||
|
|
||||||
|
1. Tambah toggle `is_team` untuk parent category, misalnya berlabel "Ikut profit sharing (team)". Untuk kategori baru, toggle menyala secara default.
|
||||||
|
2. Saat membuka form edit, isi toggle dari `is_team`. Selama backend lama masih jalan, field ini tidak ada di response, jadi anggap `true`.
|
||||||
|
3. Saat admin mematikan toggle, tampilkan konfirmasi bahwa kategori itu tidak akan muncul di pilihan team dan di laporan profit sharing, termasuk untuk periode lampau.
|
||||||
|
4. Di daftar kategori, beri penanda untuk parent category dengan `is_team: false`.
|
||||||
|
|
||||||
|
Backend lama mengabaikan `is_team` di request, jadi toggle bisa dirilis lebih dulu, tapi belum berpengaruh sampai backend baru jalan. Pengecualiannya update yang hanya berisi `is_team`: backend lama menolaknya dengan error code `303` dan pesan `at least one field must be provided for update`. Selama masa transisi, kirim `is_team` bersama field form lainnya.
|
||||||
|
|
||||||
|
## Database dan urutan rilis
|
||||||
|
|
||||||
|
Migration `000101_add_is_team_to_categories` menambah kolom `categories.is_team` (`BOOLEAN NOT NULL DEFAULT TRUE`). Semua kategori yang ada otomatis bernilai `true`. Migration ini hanya menambah kolom, jadi backend lama tetap jalan normal setelahnya.
|
||||||
|
|
||||||
|
Urutan rilis:
|
||||||
|
|
||||||
|
1. Rilis client baru: path profit sharing dengan fallback ke path lama, tanpa porsi purchase, dan dengan toggle `is_team`. Untuk app mobile, pastikan versi baru sudah dipakai sebagian besar user sebelum langkah 3, karena versi lama yang memanggil `/parent-categories` mendapat 404 setelah itu.
|
||||||
|
2. Jalankan migration `000101`.
|
||||||
|
3. Deploy backend baru.
|
||||||
|
4. Hapus fallback path lama di client.
|
||||||
|
5. Admin mematikan `is_team` di parent category yang bukan team.
|
||||||
|
|
||||||
|
Rollback: deploy backend lama, lalu jalankan down migration yang menghapus kolom `is_team`. Client dengan fallback tetap jalan di backend lama. Nilai `is_team` yang sudah diatur admin hilang saat kolom dihapus.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Client baru (fallback path, tanpa porsi purchase, toggle `is_team`) dirilis
|
||||||
|
- [ ] Migration `000101` dan backend baru dirilis di staging
|
||||||
|
- [ ] Uji: parent category yang dimatikan hilang dari `/purchase-orders/teams` dan `/cash-advances/teams`
|
||||||
|
- [ ] Uji: purchase order dan cash advance baru dengan kategori itu ditolak, dan edit purchase order lama tanpa mengganti team tetap berhasil
|
||||||
|
- [ ] Uji: kategori itu hilang dari `/analytics/profit-sharing`, dan detailnya ditolak
|
||||||
|
- [ ] Uji: `limit_team = revenue − sdl`, dan `sdl` mengikuti fee masing-masing parent
|
||||||
|
- [ ] Migration `000101` dan backend baru dirilis di production
|
||||||
|
- [ ] Fallback path lama di client dihapus
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Kenapa porsi team jadi 100% − fee owner, bukan tetap 20%?** Porsi purchase sudah tidak ada, jadi seluruh revenue dibagi dua. Owner mengambil fee-nya, dan sisanya untuk team. Kalau fee owner sebuah parent dinaikkan, porsi team parent itu turun sebesar yang sama.
|
||||||
|
|
||||||
|
**Apakah sub-category bisa dijadikan non-team sendiri?** Tidak. Team dan profit sharing dihitung per parent category, jadi semua sub-category ikut status parent-nya.
|
||||||
|
|
||||||
|
**Bagaimana dengan kategori top-level yang tidak punya sub-category?** Kategori itu tetap parent category, jadi `is_team` berlaku untuknya.
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
package constants
|
package constants
|
||||||
|
|
||||||
// Budget allocation of revenue used by the parent category cut-off report.
|
// Revenue split used by the profit sharing report: the owner takes its fee as SDL and
|
||||||
// The three shares are expected to add up to 100. BudgetLimitOwnerPercent is only the
|
// the team takes the rest. BudgetLimitOwnerPercent is only the default: a parent
|
||||||
// default: a parent category can override it with categories.owner_fee_percent.
|
// category can override it with categories.owner_fee_percent.
|
||||||
const (
|
const BudgetLimitOwnerPercent = 20.0
|
||||||
BudgetLimitPurchasePercent = 60.0
|
|
||||||
BudgetLimitOwnerPercent = 20.0
|
|
||||||
BudgetLimitTeamPercent = 20.0
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -372,19 +372,17 @@ type BudgetCutOff struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPercentages struct {
|
type BudgetPercentages struct {
|
||||||
Purchase float64 `json:"purchase"`
|
Owner float64 `json:"owner"`
|
||||||
Owner float64 `json:"owner"`
|
Team float64 `json:"team"`
|
||||||
Team float64 `json:"team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPeriod struct {
|
type BudgetPeriod struct {
|
||||||
PeriodStart time.Time `json:"period_start"`
|
PeriodStart time.Time `json:"period_start"`
|
||||||
PeriodEnd time.Time `json:"period_end"`
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
LimitPurchase float64 `json:"limit_purchase"`
|
SDL float64 `json:"sdl"`
|
||||||
SDL float64 `json:"sdl"`
|
LimitTeam float64 `json:"limit_team"`
|
||||||
LimitTeam float64 `json:"limit_team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetMonthPeriod struct {
|
type BudgetMonthPeriod struct {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CreateCategoryRequest struct {
|
|||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||||
|
IsTeam *bool `json:"is_team,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ type UpdateCategoryRequest struct {
|
|||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||||
|
IsTeam *bool `json:"is_team,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
|
||||||
// Set when the field is sent as null (or "" for parent_id), which asks for the
|
// Set when the field is sent as null (or "" for parent_id), which asks for the
|
||||||
@@ -99,6 +101,7 @@ type CategoryResponse struct {
|
|||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
OwnerFeePercent *float64 `json:"owner_fee_percent"`
|
OwnerFeePercent *float64 `json:"owner_fee_percent"`
|
||||||
|
IsTeam bool `json:"is_team"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ type Category struct {
|
|||||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||||
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
|
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
|
||||||
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
|
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
// IsTeam marks a parent category as a team in the revenue split. It has no gorm
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
// default on purpose: GORM would turn an explicit false into the default on insert,
|
||||||
|
// so callers creating a category set it themselves.
|
||||||
|
IsTeam bool `gorm:"not null" json:"is_team"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
|||||||
metadata["image_url"] = *req.ImageURL
|
metadata["image_url"] = *req.ImageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A category is a team unless the request says otherwise
|
||||||
|
isTeam := true
|
||||||
|
if req.IsTeam != nil {
|
||||||
|
isTeam = *req.IsTeam
|
||||||
|
}
|
||||||
|
|
||||||
return &entities.Category{
|
return &entities.Category{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
@@ -66,6 +72,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
OwnerFeePercent: req.OwnerFeePercent,
|
OwnerFeePercent: req.OwnerFeePercent,
|
||||||
|
IsTeam: isTeam,
|
||||||
BusinessType: "restaurant",
|
BusinessType: "restaurant",
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,7 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
|||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
Order: entity.Order,
|
Order: entity.Order,
|
||||||
OwnerFeePercent: entity.OwnerFeePercent,
|
OwnerFeePercent: entity.OwnerFeePercent,
|
||||||
|
IsTeam: entity.IsTeam,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
@@ -142,6 +150,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
|||||||
entity.OwnerFeePercent = req.OwnerFeePercent
|
entity.OwnerFeePercent = req.OwnerFeePercent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.IsTeam != nil {
|
||||||
|
entity.IsTeam = *req.IsTeam
|
||||||
|
}
|
||||||
|
|
||||||
if req.OutletID != nil {
|
if req.OutletID != nil {
|
||||||
entity.OutletID = req.OutletID
|
entity.OutletID = req.OutletID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mappers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateCategoryRequestToEntityDefaultsToTeam(t *testing.T) {
|
||||||
|
notTeam := false
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
isTeam *bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "omitted is a team", isTeam: nil, want: true},
|
||||||
|
{name: "false is not a team", isTeam: ¬Team, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
entity := CreateCategoryRequestToEntity(&models.CreateCategoryRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
Name: "Merchandise",
|
||||||
|
IsTeam: tt.isTeam,
|
||||||
|
})
|
||||||
|
require.Equal(t, tt.want, entity.IsTeam)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
||||||
|
|||||||
@@ -412,19 +412,17 @@ type BudgetCutOff struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPercentages struct {
|
type BudgetPercentages struct {
|
||||||
Purchase float64 `json:"purchase"`
|
Owner float64 `json:"owner"`
|
||||||
Owner float64 `json:"owner"`
|
Team float64 `json:"team"`
|
||||||
Team float64 `json:"team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetPeriod struct {
|
type BudgetPeriod struct {
|
||||||
PeriodStart time.Time `json:"period_start"`
|
PeriodStart time.Time `json:"period_start"`
|
||||||
PeriodEnd time.Time `json:"period_end"`
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
Revenue float64 `json:"revenue"`
|
Revenue float64 `json:"revenue"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
LimitPurchase float64 `json:"limit_purchase"`
|
SDL float64 `json:"sdl"`
|
||||||
SDL float64 `json:"sdl"`
|
LimitTeam float64 `json:"limit_team"`
|
||||||
LimitTeam float64 `json:"limit_team"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetMonthPeriod struct {
|
type BudgetMonthPeriod struct {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type CreateCategoryRequest struct {
|
|||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
Order int `validate:"min=0"`
|
Order int `validate:"min=0"`
|
||||||
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||||
|
// IsTeam defaults to true when left out
|
||||||
|
IsTeam *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCategoryRequest struct {
|
type UpdateCategoryRequest struct {
|
||||||
@@ -38,6 +40,7 @@ type UpdateCategoryRequest struct {
|
|||||||
ParentID *uuid.UUID
|
ParentID *uuid.UUID
|
||||||
Order *int `validate:"omitempty,min=0"`
|
Order *int `validate:"omitempty,min=0"`
|
||||||
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||||
|
IsTeam *bool
|
||||||
IsActive *bool
|
IsActive *bool
|
||||||
|
|
||||||
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
|
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
|
||||||
@@ -56,6 +59,7 @@ type CategoryResponse struct {
|
|||||||
ImageURL *string
|
ImageURL *string
|
||||||
Order int
|
Order int
|
||||||
OwnerFeePercent *float64
|
OwnerFeePercent *float64
|
||||||
|
IsTeam bool
|
||||||
IsActive bool
|
IsActive bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
|||||||
// The block covers a single parent category, so it reports that category's own fee
|
// The block covers a single parent category, so it reports that category's own fee
|
||||||
if detail.Summary != nil {
|
if detail.Summary != nil {
|
||||||
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
|
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
|
||||||
|
budget.Percentages.Team = 100 - detail.Summary.OwnerFeePercent
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.ParentCategoryAnalyticsDetailResponse{
|
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||||
@@ -597,17 +598,17 @@ func endOfWeek(t time.Time) time.Time {
|
|||||||
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newBudgetPeriod splits a period's revenue into the spending limits. The owner limit
|
// newBudgetPeriod splits a period's revenue between the owner and the team. The owner
|
||||||
// is passed in because a parent category can carry its own owner fee percent.
|
// share is passed in because a parent category can carry its own owner fee percent,
|
||||||
|
// and the team takes whatever the owner does not.
|
||||||
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
|
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
|
||||||
return models.BudgetPeriod{
|
return models.BudgetPeriod{
|
||||||
PeriodStart: start,
|
PeriodStart: start,
|
||||||
PeriodEnd: end,
|
PeriodEnd: end,
|
||||||
Revenue: revenue,
|
Revenue: revenue,
|
||||||
OrderCount: orderCount,
|
OrderCount: orderCount,
|
||||||
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
SDL: sdl,
|
||||||
SDL: sdl,
|
LimitTeam: revenue - sdl,
|
||||||
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,9 +622,8 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
|||||||
|
|
||||||
budget := models.BudgetCutOff{
|
budget := models.BudgetCutOff{
|
||||||
Percentages: models.BudgetPercentages{
|
Percentages: models.BudgetPercentages{
|
||||||
Purchase: constants.BudgetLimitPurchasePercent,
|
Owner: constants.BudgetLimitOwnerPercent,
|
||||||
Owner: constants.BudgetLimitOwnerPercent,
|
Team: 100 - constants.BudgetLimitOwnerPercent,
|
||||||
Team: constants.BudgetLimitTeamPercent,
|
|
||||||
},
|
},
|
||||||
CutOffFrom: cutOffFrom,
|
CutOffFrom: cutOffFrom,
|
||||||
CutOffTo: cutOffTo,
|
CutOffTo: cutOffTo,
|
||||||
|
|||||||
@@ -607,7 +607,7 @@ func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A parent category with its own owner fee percent moves the owner limit away from the
|
// A parent category with its own owner fee percent moves the owner limit away from the
|
||||||
// default share, while purchase and team stay on the default split of revenue.
|
// default share, and the team limit shrinks by the same amount.
|
||||||
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||||
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
|
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
|
||||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||||
@@ -636,8 +636,9 @@ func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, result.Budget.Weekly, 1)
|
require.Len(t, result.Budget.Weekly, 1)
|
||||||
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
|
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
|
||||||
require.Equal(t, float64(1800), result.Budget.Weekly[0].LimitPurchase)
|
require.Equal(t, float64(2100), result.Budget.Weekly[0].LimitTeam)
|
||||||
require.Equal(t, float64(600), result.Budget.Weekly[0].LimitTeam)
|
require.Equal(t, float64(20), result.Budget.Percentages.Owner)
|
||||||
|
require.Equal(t, float64(80), result.Budget.Percentages.Team)
|
||||||
require.Len(t, result.Budget.Monthly, 1)
|
require.Len(t, result.Budget.Monthly, 1)
|
||||||
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
|
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
|
||||||
require.Equal(t, float64(900), result.Budget.Total.SDL)
|
require.Equal(t, float64(900), result.Budget.Total.SDL)
|
||||||
|
|||||||
@@ -93,18 +93,17 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, budget.Weekly, 1)
|
require.Len(t, budget.Weekly, 1)
|
||||||
|
|
||||||
// 60 / 20 of the week's revenue; the owner limit is whatever the repository
|
// The owner limit is whatever the repository summed from each parent category's
|
||||||
// summed from each parent category's own fee percent
|
// own fee percent, and the team takes the rest of the week's revenue
|
||||||
week := budget.Weekly[0]
|
week := budget.Weekly[0]
|
||||||
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
|
||||||
require.Equal(t, float64(2_500_000), week.SDL)
|
require.Equal(t, float64(2_500_000), week.SDL)
|
||||||
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
require.Equal(t, float64(7_500_000), week.LimitTeam)
|
||||||
require.Equal(t, int64(120), week.OrderCount)
|
require.Equal(t, int64(120), week.OrderCount)
|
||||||
|
|
||||||
// Totals mirror the single week
|
// Totals mirror the single week
|
||||||
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||||
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
|
||||||
require.Equal(t, week.SDL, budget.Total.SDL)
|
require.Equal(t, week.SDL, budget.Total.SDL)
|
||||||
|
require.Equal(t, week.LimitTeam, budget.Total.LimitTeam)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||||
@@ -112,8 +111,8 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
|||||||
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
|
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100, SDL: 2_000_000},
|
||||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
|
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60, SDL: 1_000_000},
|
||||||
}}
|
}}
|
||||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||||
|
|
||||||
@@ -127,9 +126,10 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
|||||||
require.Equal(t, float64(15_000_000), month.Revenue)
|
require.Equal(t, float64(15_000_000), month.Revenue)
|
||||||
require.Equal(t, int64(160), month.OrderCount)
|
require.Equal(t, int64(160), month.OrderCount)
|
||||||
|
|
||||||
// The month limit is the accumulation of its weeks
|
// The month limits are the accumulation of its weeks
|
||||||
require.Equal(t, float64(9_000_000), month.LimitPurchase)
|
require.Equal(t, float64(3_000_000), month.SDL)
|
||||||
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
|
require.Equal(t, float64(12_000_000), month.LimitTeam)
|
||||||
|
require.Equal(t, budget.Weekly[0].LimitTeam+budget.Weekly[1].LimitTeam, month.LimitTeam)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||||
@@ -147,7 +147,7 @@ func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, budget.Weekly, 3)
|
require.Len(t, budget.Weekly, 3)
|
||||||
require.Zero(t, budget.Weekly[0].Revenue)
|
require.Zero(t, budget.Weekly[0].Revenue)
|
||||||
require.Zero(t, budget.Weekly[0].LimitPurchase)
|
require.Zero(t, budget.Weekly[0].LimitTeam)
|
||||||
require.Zero(t, budget.Weekly[1].Revenue)
|
require.Zero(t, budget.Weekly[1].Revenue)
|
||||||
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
||||||
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Teams are the parent product categories, plus Pusat for spending that belongs to
|
// Teams are the parent product categories flagged is_team, plus Pusat for spending
|
||||||
// no single team. Both purchase orders and cash advances are charged to one, so the rules
|
// that belongs to no single team. Both purchase orders and cash advances are charged to
|
||||||
// for picking and storing a team live here rather than in either processor.
|
// 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
|
// listTeams returns the teams money can be charged to: the team parent categories of
|
||||||
// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
|
// the outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
|
||||||
// here rather than read from the database.
|
// here rather than read from the database.
|
||||||
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||||
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
||||||
@@ -26,6 +27,9 @@ func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizatio
|
|||||||
|
|
||||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||||
for _, category := range categories {
|
for _, category := range categories {
|
||||||
|
if !category.IsTeam {
|
||||||
|
continue
|
||||||
|
}
|
||||||
categoryID := category.ID
|
categoryID := category.ID
|
||||||
teams = append(teams, models.PurchaseTeam{
|
teams = append(teams, models.PurchaseTeam{
|
||||||
Scope: constants.PurchaseTeamScopeCategory,
|
Scope: constants.PurchaseTeamScopeCategory,
|
||||||
@@ -75,6 +79,9 @@ func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository,
|
|||||||
if category.ParentID != nil {
|
if category.ParentID != nil {
|
||||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||||
}
|
}
|
||||||
|
if !category.IsTeam {
|
||||||
|
return nil, nil, fmt.Errorf("category %s is not a team", category.Name)
|
||||||
|
}
|
||||||
// Categories without an outlet are shared, so only an outlet-specific
|
// Categories without an outlet are shared, so only an outlet-specific
|
||||||
// category has to match the outlet the record is booked against.
|
// category has to match the outlet the record is booked against.
|
||||||
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamCategoryRepositoryStub serves the two lookups the team rules make. The embedded
|
||||||
|
// interface is left nil, so any other call panics and shows up in the test.
|
||||||
|
type teamCategoryRepositoryStub struct {
|
||||||
|
CategoryRepository
|
||||||
|
categories []*entities.Category
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *teamCategoryRepositoryStub) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
||||||
|
return s.categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *teamCategoryRepositoryStub) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||||
|
for _, category := range s.categories {
|
||||||
|
if category.ID == id {
|
||||||
|
return category, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("record not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTeamsSkipsParentCategoriesThatAreNotTeams(t *testing.T) {
|
||||||
|
organizationID := uuid.New()
|
||||||
|
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
|
||||||
|
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
|
||||||
|
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
|
||||||
|
|
||||||
|
result, err := listTeams(context.Background(), repo, organizationID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, result.Teams, 2)
|
||||||
|
require.Equal(t, "Food", result.Teams[0].Name)
|
||||||
|
require.Equal(t, &food.ID, result.Teams[0].CategoryID)
|
||||||
|
require.Equal(t, constants.PurchaseTeamCentralName, result.Teams[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTeamSelectionRejectsParentCategoryThatIsNotATeam(t *testing.T) {
|
||||||
|
organizationID := uuid.New()
|
||||||
|
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
|
||||||
|
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
|
||||||
|
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
|
||||||
|
scope := constants.PurchaseTeamScopeCategory
|
||||||
|
|
||||||
|
resolvedScope, resolvedID, err := resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &food.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, constants.PurchaseTeamScopeCategory, *resolvedScope)
|
||||||
|
require.Equal(t, food.ID, *resolvedID)
|
||||||
|
|
||||||
|
_, _, err = resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &merch.ID)
|
||||||
|
require.EqualError(t, err, "category Merchandise is not a team")
|
||||||
|
}
|
||||||
@@ -614,6 +614,8 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
|||||||
Joins("JOIN categories c ON p.category_id = c.id").
|
Joins("JOIN categories c ON p.category_id = c.id").
|
||||||
// Categories without a parent roll up to themselves, so top-level categories still appear
|
// Categories without a parent roll up to themselves, so top-level categories still appear
|
||||||
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||||
|
// Profit sharing only covers the parent categories that are teams
|
||||||
|
Where("pc.is_team = ?", true).
|
||||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||||
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
|
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
|
||||||
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
|
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
|
||||||
@@ -665,6 +667,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
|||||||
Joins("JOIN categories c ON p.category_id = c.id").
|
Joins("JOIN categories c ON p.category_id = c.id").
|
||||||
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||||
|
Where("pc.is_team = ?", true).
|
||||||
Where("o.organization_id = ?", organizationID).
|
Where("o.organization_id = ?", organizationID).
|
||||||
Where("o.is_void = ?", false).
|
Where("o.is_void = ?", false).
|
||||||
Where("o.is_refund = ?", false).
|
Where("o.is_refund = ?", false).
|
||||||
@@ -739,11 +742,12 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
var parent struct {
|
var parent struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
IsTeam bool
|
||||||
OwnerFeePercent float64
|
OwnerFeePercent float64
|
||||||
}
|
}
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Table("categories").
|
Table("categories").
|
||||||
Select("id, name, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent).
|
Select("id, name, is_team, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent).
|
||||||
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
|
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
|
||||||
Scan(&parent).Error; err != nil {
|
Scan(&parent).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -751,6 +755,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
if parent.ID == uuid.Nil {
|
if parent.ID == uuid.Nil {
|
||||||
return nil, fmt.Errorf("category not found")
|
return nil, fmt.Errorf("category not found")
|
||||||
}
|
}
|
||||||
|
if !parent.IsTeam {
|
||||||
|
return nil, fmt.Errorf("category %s is not a team", parent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
detail := &entities.ParentCategoryAnalyticsDetail{
|
detail := &entities.ParentCategoryAnalyticsDetail{
|
||||||
ParentCategoryID: parent.ID,
|
ParentCategoryID: parent.ID,
|
||||||
@@ -849,7 +856,8 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
|
|||||||
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
|
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
|
||||||
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
|
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
|
||||||
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
|
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
|
||||||
// A nil parentCategoryID covers every category in scope.
|
// A nil parentCategoryID covers every team in scope; parent categories that are not a
|
||||||
|
// team stay out of the split.
|
||||||
func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) {
|
||||||
var results []*entities.BudgetCutOffWeek
|
var results []*entities.BudgetCutOffWeek
|
||||||
|
|
||||||
@@ -868,6 +876,8 @@ func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, org
|
|||||||
// The owner fee lives on the parent category; a category without a parent is its own group
|
// The owner fee lives on the parent category; a category without a parent is its own group
|
||||||
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
|
||||||
Joins("JOIN orders o ON oi.order_id = o.id").
|
Joins("JOIN orders o ON oi.order_id = o.id").
|
||||||
|
// Only teams take part in the revenue split
|
||||||
|
Where("pc.is_team = ?", true).
|
||||||
Where("o.organization_id = ?", organizationID).
|
Where("o.organization_id = ?", organizationID).
|
||||||
Where("o.is_void = ?", false).
|
Where("o.is_void = ?", false).
|
||||||
Where("o.is_refund = ?", false).
|
Where("o.is_refund = ?", false).
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type Router struct {
|
|||||||
customerDeviceHandler *handler.CustomerDeviceHandler
|
customerDeviceHandler *handler.CustomerDeviceHandler
|
||||||
customerOutletHandler *handler.CustomerOutletHandler
|
customerOutletHandler *handler.CustomerOutletHandler
|
||||||
customerOrderHandler *handler.CustomerOrderHandler
|
customerOrderHandler *handler.CustomerOrderHandler
|
||||||
authMiddleware *middleware.AuthMiddleware
|
authMiddleware *middleware.AuthMiddleware
|
||||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||||
redisClient *redis.Client
|
redisClient *redis.Client
|
||||||
}
|
}
|
||||||
@@ -374,8 +374,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
||||||
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||||
analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
|
analytics.GET("/profit-sharing", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
|
||||||
analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
|
analytics.GET("/profit-sharing/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
|
||||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
|
||||||
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
|
||||||
|
|||||||
@@ -224,23 +224,23 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
|||||||
|
|
||||||
// profitLossReportData holds data for the profit/loss PDF template
|
// profitLossReportData holds data for the profit/loss PDF template
|
||||||
type profitLossReportData struct {
|
type profitLossReportData struct {
|
||||||
OrganizationName string
|
OrganizationName string
|
||||||
MonthName string
|
MonthName string
|
||||||
ReportDate string
|
ReportDate string
|
||||||
ReportDateUpper string
|
ReportDateUpper string
|
||||||
TotalPenjualan string
|
TotalPenjualan string
|
||||||
TotalBiaya string
|
TotalBiaya string
|
||||||
LabaRugi string
|
LabaRugi string
|
||||||
LabaRugiClass string
|
LabaRugiClass string
|
||||||
LabaRugiValueClass string
|
LabaRugiValueClass string
|
||||||
LabaRugiMtd string
|
LabaRugiMtd string
|
||||||
LabaRugiMtdClass string
|
LabaRugiMtdClass string
|
||||||
LabaRugiMtdValueClass string
|
LabaRugiMtdValueClass string
|
||||||
MainSummary []profitLossSummaryRowView
|
MainSummary []profitLossSummaryRowView
|
||||||
PurchasingItems []profitLossPurchasingItem
|
PurchasingItems []profitLossPurchasingItem
|
||||||
PurchasingTotal string
|
PurchasingTotal string
|
||||||
GeneratedBy string
|
GeneratedBy string
|
||||||
PrintTime string
|
PrintTime string
|
||||||
}
|
}
|
||||||
|
|
||||||
type profitLossSummaryRowView struct {
|
type profitLossSummaryRowView struct {
|
||||||
|
|||||||
@@ -446,13 +446,12 @@ func parentCategoryTopProductModelToContract(product *models.ParentCategoryTopPr
|
|||||||
// budgetPeriodModelToContract converts one budget period to contract
|
// budgetPeriodModelToContract converts one budget period to contract
|
||||||
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
|
||||||
return contract.BudgetPeriod{
|
return contract.BudgetPeriod{
|
||||||
PeriodStart: period.PeriodStart,
|
PeriodStart: period.PeriodStart,
|
||||||
PeriodEnd: period.PeriodEnd,
|
PeriodEnd: period.PeriodEnd,
|
||||||
Revenue: period.Revenue,
|
Revenue: period.Revenue,
|
||||||
OrderCount: period.OrderCount,
|
OrderCount: period.OrderCount,
|
||||||
LimitPurchase: period.LimitPurchase,
|
SDL: period.SDL,
|
||||||
SDL: period.SDL,
|
LimitTeam: period.LimitTeam,
|
||||||
LimitTeam: period.LimitTeam,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,9 +473,8 @@ func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutO
|
|||||||
|
|
||||||
return contract.BudgetCutOff{
|
return contract.BudgetCutOff{
|
||||||
Percentages: contract.BudgetPercentages{
|
Percentages: contract.BudgetPercentages{
|
||||||
Purchase: budget.Percentages.Purchase,
|
Owner: budget.Percentages.Owner,
|
||||||
Owner: budget.Percentages.Owner,
|
Team: budget.Percentages.Team,
|
||||||
Team: budget.Percentages.Team,
|
|
||||||
},
|
},
|
||||||
CutOffFrom: budget.CutOffFrom,
|
CutOffFrom: budget.CutOffFrom,
|
||||||
CutOffTo: budget.CutOffTo,
|
CutOffTo: budget.CutOffTo,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
|
|||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
Order: order,
|
Order: order,
|
||||||
OwnerFeePercent: req.OwnerFeePercent,
|
OwnerFeePercent: req.OwnerFeePercent,
|
||||||
|
IsTeam: req.IsTeam,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
|
|||||||
ParentID: req.ParentID,
|
ParentID: req.ParentID,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
OwnerFeePercent: req.OwnerFeePercent,
|
OwnerFeePercent: req.OwnerFeePercent,
|
||||||
|
IsTeam: req.IsTeam,
|
||||||
IsActive: nil,
|
IsActive: nil,
|
||||||
|
|
||||||
ClearParentID: req.ClearParentID,
|
ClearParentID: req.ClearParentID,
|
||||||
@@ -55,6 +57,7 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
|
|||||||
BusinessType: "restaurant",
|
BusinessType: "restaurant",
|
||||||
Order: cat.Order,
|
Order: cat.Order,
|
||||||
OwnerFeePercent: cat.OwnerFeePercent,
|
OwnerFeePercent: cat.OwnerFeePercent,
|
||||||
|
IsTeam: cat.IsTeam,
|
||||||
Metadata: map[string]interface{}{},
|
Metadata: map[string]interface{}{},
|
||||||
CreatedAt: cat.CreatedAt,
|
CreatedAt: cat.CreatedAt,
|
||||||
UpdatedAt: cat.UpdatedAt,
|
UpdatedAt: cat.UpdatedAt,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
|
|||||||
}
|
}
|
||||||
|
|
||||||
// At least one field should be provided for update
|
// At least one field should be provided for update
|
||||||
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent {
|
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.IsTeam == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent {
|
||||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE categories DROP COLUMN is_team;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Whether a parent category is a team: it can be charged with purchases and cash
|
||||||
|
-- advances, and its revenue takes part in the parent category budget split. Every
|
||||||
|
-- existing category stays a team, since that is how parent categories were treated.
|
||||||
|
ALTER TABLE categories ADD COLUMN is_team BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
Reference in New Issue
Block a user