# OPIS-86 — Analisa Performance Approval Memo & Approval Payment (Lambat untuk akun Direktur)

## 1. Gejala

- Login sebagai akun **Direktur** (final approver), buka menu **Approval > Approval Memo** atau **Approval > Approval Payment**, tab **Approved** → halaman lemot (network tab menunjukkan request `memo?...&tab=approve` & `payment?...&tab=approve` butuh **8–13 detik**).
- Terjadi di **production** dan **uat**.
- Dari screenshot Network tab:
  - `Approval Payment` tab Approved → **1.880** data, request `payment` 6.51s, request `payment?...` lain 11.83s.
  - `Approval Memo` tab Approved → **12.292** data, request `memo` 8.45–13.50s.
- Akun non-direktur (approver level cabang) relatif tidak mengeluh selambat ini — karena dataset yang mereka lihat jauh lebih kecil (hanya memo/payment yang pernah mampir ke level approval mereka di cabang masing‑masing).

## 2. Root Cause

### 2.1 Query listing approval tidak dibatasi rentang waktu ataupun arsip

File: [ApprovalController.php](app/Http/Controllers/User/ApprovalController.php)

```php
// index() — line 37
$memo = Memo::getMemoWithLastApprover(auth()->user()->id_employee, $tab, $request->input('search'), $request->input('checkedBranch'))->paginate(10);

// counttab — line 55-60, dieksekusi ULANG 4x, tiap kali build query dari nol
'counttab' => [
    'submit'  => Memo::getMemoWithLastApprover(...,'submit')->count(),
    'approve' => Memo::getMemoWithLastApprover(...,'approve')->count(),
    'reject'  => Memo::getMemoWithLastApprover(...,'reject')->count(),
    'revisi'  => Memo::getMemoWithLastApprover(...,'revisi')->count(),
],
```

Pola yang sama persis ada di `indexApprovalPayment()` (baris 82, 98‑101).

Artinya **setiap kali halaman dibuka**, server menjalankan **5 query berat** (1 untuk `paginate(10)` + 4 untuk `counttab`) — tidak ada filter tanggal, tidak ada `is_archived`, tidak ada cache. Untuk Direktur yang merupakan approver level terakhir, tab **Approved** ini berisi **seluruh memo/payment yang sudah final approve di seluruh cabang sejak awal aplikasi berjalan** (12.292 & 1.880 baris dan terus bertambah), bukan cuma milik satu cabang. Ini kenapa masalah paling terasa di akun Direktur — dataset yang di-scan jauh lebih besar dibanding approver cabang.

### 2.2 Query "approve" pakai join non-equi (date-range) yang tidak bisa memanfaatkan index

File: [Memo.php:882-917](app/Models/Memo.php#L882-L917) (mirip untuk payment di [Memo.php:1095-1130](app/Models/Memo.php#L1095-L1130))

```php
$proposeEmployee = DB::table(DB::raw('m_employees a'))
    ->select(...)
    ->join(DB::raw('emp_history as b'), 'a.id', '=', 'b.id_employee')
    ->join(DB::raw('m_branches as c'), 'c.id', '=', 'b.id_branch');

$memo = DB::table(DB::raw('m_memos a'))
    ->selectRaw('a.*, c.id id_approver, ...')
    ->joinSub($proposeEmployee, 'd', function ($join) {
        $join->on('a.id_employee', '=', 'd.id')
            ->whereRaw('d.year_started < a.propose_at')          // <-- non-equi join
            ->where(function ($sub) {
                $sub->whereRaw('d.year_finished > a.propose_at')
                    ->orWhere('d.year_finished', null);
            });
    })
    ->join(DB::raw('d_memo_approvers as c'), 'a.id', '=', 'c.id_memo')
    ->where('c.status', '=', $status)
    ->where('c.id_employee', $id_employee)
    ->orderBy('a.id', 'desc');
```

Tujuannya adalah mencari posisi/cabang karyawan **pada saat memo di-propose** (`year_started < propose_at < year_finished`). Karena kondisi join berupa **range comparison**, bukan equality, MySQL tidak bisa pakai index secara efisien di sisi `emp_history` — hasilnya derived table `d` sering dieksekusi sebagai nested loop terhadap semua baris `emp_history` untuk tiap baris `m_memos`. Ditambah `joinSub` (derived table) memaksa MySQL membentuk temporary table dulu sebelum `ORDER BY ... LIMIT 10` bisa dieksekusi, sehingga **LIMIT 10 tidak membantu mempercepat** — MySQL tetap harus memproses hampir seluruh dataset gabungan sebelum memotong 10 baris teratas.

### 2.3 Tidak ada index komposit yang mendukung filter approval

File: [2021_10_01_084308_create_d_memo_approvers.php](database/migrations/2021_10_01_084308_create_d_memo_approvers.php)

```php
$table->unsignedBigInteger('id_memo');
$table->unsignedBigInteger('id_employee');
$table->integer('idx');
$table->enum('status', [...]);
// hanya foreign key id_memo & id_employee — tidak ada index (id_employee, status) atau (status, id_memo, idx)
```

Query utama selalu filter `WHERE c.status = ? AND c.id_employee = ?` — tanpa composite index, MySQL harus scan berdasarkan salah satu kolom (index FK tunggal) lalu filter sisanya di memory. Sama juga di `d_payment_approver`. Tabel `emp_history` juga tidak punya index komposit `(id_employee, year_started, year_finished)` yang dibutuhkan oleh join range di atas.

### 2.4 `SELECT a.*` menarik kolom besar walau hanya 10 baris yang tampil

`selectRaw('a.*, ...')` menarik seluruh kolom `m_memos`, termasuk kolom teks besar (`background`, `information`, `conclusion`, `cost` JSON) — padahal listing hanya butuh beberapa kolom (title, doc_no, branch, from, status). Karena masalah 2.2 (materialize dulu baru limit), kolom besar ini ikut ditarik untuk (hampir) seluruh dataset, bukan hanya 10 baris hasil akhir.

### 2.5 Fitur archive sudah ada, tapi belum dipakai di halaman Approval

Kolom `is_archived` sudah ada di tabel `m_memos` sejak migration [2023_07_13_031004_add_field_is_archived_in_m_memos.php](database/migrations/2023_07_13_031004_add_field_is_archived_in_m_memos.php), dan sudah dipakai di modul **Memo** (submission list milik proposer sendiri) via `actionArchiveMemo()` di [MemoController.php:1658-1711](app/Http/Controllers/User/MemoController.php#L1658-L1711).

Tapi query approval (`ApprovalController::index`, `indexApprovalPayment`, `Memo::getMemoWithLastApprover`, `Memo::getMemoPaymentWithLastApprover`) **tidak pernah memfilter `is_archived`**, dan `actionArchiveMemo` juga cuma bisa dipakai proposer untuk memo miliknya sendiri (`where('id_employee', auth()->user()->id_employee)`), bukan untuk approver seperti Direktur yang melihat data lintas cabang. Inilah gap yang jadi scope tiket **OPIS-86**: tab Approved di Approval Memo/Approval Payment perlu tampil default **Current − 2 bulan**, sisanya "diarsipkan" dari tampilan utama.

## 3. Solusi

### 3.1 Fix langsung (sesuai scope OPIS-86) — filter default 2 bulan terakhir

Di `Memo::getMemoWithLastApprover()` dan `Memo::getMemoPaymentWithLastApprover()`, untuk `status == 'approve'`, tambahkan filter tanggal default:

```php
if ($status == 'approve') {
    $memo->where('a.propose_at', '>=', now()->subMonths(2)->startOfDay());
    // atau kolom tanggal approve terakhir jika ada, agar konsisten dgn arti "Approved 2 bulan terakhir"
}
```

- Tambahkan parameter `$showArchived = false` pada kedua fungsi, dan toggle "Show Archive" di UI (link menu **Archive** yang sudah ada di sidebar) untuk melihat data > 2 bulan **tanpa** default filter (tetap paginated, jangan `->get()` semua).
- Ini otomatis memangkas dataset dari ribuan/puluhan-ribu baris menjadi puluhan/ratusan baris per query → paginate & count jadi jauh lebih cepat walau index belum diperbaiki.

### 3.2 Tambah index database (migration baru)

```php
Schema::table('d_memo_approvers', function (Blueprint $table) {
    $table->index(['id_employee', 'status'], 'idx_memo_approver_employee_status');
    $table->index(['id_memo', 'idx'], 'idx_memo_approver_memo_idx');
});
Schema::table('d_payment_approver', function (Blueprint $table) {
    $table->index(['id_employee', 'status'], 'idx_payment_approver_employee_status');
    $table->index(['id_memo', 'idx'], 'idx_payment_approver_memo_idx');
});
Schema::table('m_memos', function (Blueprint $table) {
    $table->index(['status', 'propose_at'], 'idx_memos_status_propose_at');
    $table->index(['status_payment', 'propose_payment_at'], 'idx_memos_status_payment_propose_at');
    $table->index('is_archived');
});
Schema::table('emp_history', function (Blueprint $table) {
    $table->index(['id_employee', 'year_started', 'year_finished'], 'idx_emp_history_employee_range');
});
```

### 3.3 Kurangi jumlah query per page-load

- `counttab`: hitung dari query yang **sudah** difilter 2 bulan (jadi jauh lebih murah), atau cache per user+tab beberapa menit dengan `Cache::remember("counttab:memo:{$employeeId}", now()->addMinutes(5), fn () => [...])`, invalidate saat ada aksi approve/reject/revisi.
- Pertimbangkan load count tab secara lazy (AJAX terpisah / hanya tab aktif) agar initial page load tidak menunggu 5 query sekaligus.

### 3.4 Select kolom yang dibutuhkan saja untuk listing

Ganti `selectRaw('a.*, ...')` menjadi daftar kolom eksplisit yang dipakai di tabel listing (title, doc_no, branch_name, firstname, lastname, status, created_at, dsb). Kolom besar (background/information/conclusion/cost) cukup diambil di endpoint detail (`detail()`/`detailPayment()`), yang memang sudah query per-id.

### 3.5 (Perbaikan menengah, opsional di luar scope OPIS-86) Ganti join range dengan equi-join

Tambahkan kolom `is_current` (boolean) pada `emp_history`, di-maintain lewat model event / job saat baris histori baru dibuat (set baris lama `is_current = false`). Untuk kasus "posisi historis pada tanggal X" tetap pakai range join, tapi untuk kasus umum (lihat posisi terkini) bisa pakai equi-join `d.id_employee = a.id_employee AND d.is_current = 1` yang jauh lebih cepat dan index-friendly.

## 4. Rencana implementasi untuk OPIS-86

1. Branch `OPIS-86` dari `uat` (sudah dibahas).
2. Migration: tambah index di 3.2 (aman, non-destructive, bisa jalan di prod tanpa downtime besar karena hanya `ADD INDEX`).
3. Update `Memo::getMemoWithLastApprover` & `Memo::getMemoPaymentWithLastApprover`:
   - Tambah parameter default filter tanggal (Current − 2 bulan) khusus untuk tab `approve`.
   - Sediakan mode "archive" (tanpa filter tanggal, dengan pagination) untuk halaman **Archive > Approval Memo/Payment** (menu Archive sudah ada di sidebar).
4. Update `ApprovalController::index` & `indexApprovalPayment` untuk pass flag archive dari route/query string, dan sesuaikan `counttab` supaya konsisten (hitung dari dataset yang sama yang ditampilkan).
5. Update FE (Vue/Inertia `User/Approval` & `User/Approval_Payment`) menambahkan tombol/link "Lihat Arsip (> 2 bulan)".
6. Uji dengan `EXPLAIN` di uat sebelum & sesudah index ditambahkan, bandingkan waktu response di Network tab (target < 1s untuk index & count).
7. Deploy migration index dulu ke production di luar jam sibuk, verifikasi tidak locking lama (index MyISAM/InnoDB `ADD INDEX` di MySQL 5.7+/8 umumnya online untuk `ADD INDEX` biasa — cek versi MySQL prod dulu).

## 5. Best Practice ke Depan

- **Selalu batasi default rentang data** untuk listing pada tabel yang tumbuh terus-menerus (append-only): jangan pernah biarkan tab default menampilkan "semua data sejak awal berdiri" tanpa filter tanggal/pagination-friendly boundary.
- **Hindari join dengan kondisi range (`<`, `>`) di kolom yang sering di-query** kalau bisa diganti equi-join + kolom "current flag" yang di-maintain aplikasi.
- **Tambahkan composite index setiap kali menambahkan `WHERE`/`JOIN` kombinasi kolom baru** pada tabel besar — jadikan bagian dari code review checklist.
- **Jangan `SELECT *` pada listing**; ambil kolom secukupnya, kolom besar/JSON hanya di halaman detail.
- **Hindari menghitung ulang query yang sama berkali-kali** (mis. 4x `counttab`) — cache atau hitung dari hasil query utama yang sudah difilter.
- **Uji performa dengan volume data mendekati production** (12k+ baris) sebelum fitur listing baru di-deploy, bukan hanya dengan data dummy sedikit di lokal.
- **Aktifkan slow query log** (MySQL `long_query_time`) di staging/uat untuk menangkap regresi performa sedini mungkin, dan pertimbangkan Laravel Telescope di non-production untuk melihat query per-request.
- **Pisahkan data "aktif" vs "arsip"** secara eksplisit (flag `is_archived` atau tabel arsip terpisah) untuk modul apa pun yang datanya terus bertambah tanpa batas (memo, payment, PO, dsb), bukan cuma untuk approval.
