feat: update profit sharing
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user