Files
apskel-pos-backend/docs/migrasi-profit-sharing.md
T
2026-10-04 21:54:13 +07:00

290 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.