# Tick Up Data API — Hướng dẫn cho coding agent

> Dán file này (và khoá API, qua biến môi trường — KHÔNG dán khoá vào chat) cho coding agent của bạn
> (Claude Code, Codex, ChatGPT…). Agent đọc xong là làm được báo cáo/dashboard từ dữ liệu tuyển dụng
> của công ty bạn. Bản mới nhất luôn ở `https://api.tickup.vn/api/v1/data/agent-guide`.

## 1. Xác thực và cách gọi

- **Base URL:** `https://api.tickup.vn/api/v1/data`
- **Header:** `Authorization: Bearer <khoá>` — khoá dạng `tk_live_…`, admin tạo trong
  Tick Up → Cài đặt → **API & dữ liệu**. Khoá **chỉ đọc**, phạm vi toàn công ty.
- Đọc khoá từ biến môi trường (ví dụ `TICKUP_API_KEY`). **Không bao giờ** ghi khoá vào file code,
  commit git, URL, hay code chạy trên trình duyệt (API không mở CORS cho `/data`).
- Khoá đặt trong URL (`?key=…`) bị từ chối với `400 api_key_in_url` — khi đó khoá đã nằm trong log,
  hãy thu hồi và tạo khoá mới.
- Mọi route đều là `GET`. Lỗi trả dạng `{"detail": {"code": "...", "message": "..."}}`:
  `401 invalid_api_key` (thiếu/sai/hết hạn/đã thu hồi), `422 invalid_range` (`from` > `to`),
  `429 rate_limited`.
- **Giới hạn:** 120 lần/phút và 5.000 lần/ngày mỗi khoá cho các bảng + `/catalog`;
  `/metrics` 20 lần/phút, 500 lần/ngày. Khi gặp `429`, **đợi đúng số giây trong header `Retry-After`**
  rồi thử lại. Hãy cache kết quả thay vì gọi lại liên tục.

```bash
curl -s -H "Authorization: Bearer $TICKUP_API_KEY" \
  "https://api.tickup.vn/api/v1/data/applications?page_size=100&include_demo=false"
```

```python
import os, time, requests

BASE = "https://api.tickup.vn/api/v1/data"
HEADERS = {"Authorization": f"Bearer {os.environ['TICKUP_API_KEY']}"}

def fetch_all(table, **params):
    """Lấy hết mọi trang của một bảng."""
    rows, page = [], 1
    while True:
        r = requests.get(f"{BASE}/{table}", headers=HEADERS,
                         params={**params, "page": page, "page_size": 100}, timeout=60)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        r.raise_for_status()
        body = r.json()
        rows += body["items"]
        if page >= body["total_pages"]:
            return rows
        page += 1
```

## 2. Quy ước dữ liệu (đọc kỹ trước khi tính số)

- **Thời gian:** mọi trường thời gian là UTC, ISO-8601. Khi gom theo ngày/tuần/tháng hãy đổi sang
  giờ Việt Nam (`Asia/Ho_Chi_Minh`, UTC+7) trước — nếu không, hồ sơ nộp lúc 6 giờ sáng sẽ rơi sang
  hôm trước.
- **Cửa sổ 18 tháng:** API chỉ trả 18 tháng gần nhất. `from`/`to` là ngày theo giờ Việt Nam (tính cả
  hai đầu), lọc theo **trục thời gian riêng của từng bảng** (ghi ở đầu mỗi bảng dưới đây). `from` cũ hơn
  giới hạn sẽ được dời lên và `meta.from_clamped = true`.
- **Phân trang:** `page` (từ 1), `page_size` (tối đa 100). Thứ tự cố định (trục thời gian, rồi `id`)
  nên lật trang không trùng không sót. Phong bì trả về:
  `{"items": [...], "total": N, "page": 1, "page_size": 50, "total_pages": K, "meta": {"from", "to", "from_clamped", "generated_at"}}`.
- **Đồng bộ tăng dần:** `updated_since=<ISO datetime>` chỉ trả dòng đã đổi từ thời điểm đó
  (bảng `stage-transitions` dùng `changed_at`).
- **Dữ liệu demo:** `is_demo = true` là dữ liệu mẫu lúc mới đăng ký. Mặc định API **trả cả demo** để
  khớp màn Báo cáo; khi làm báo cáo thật hãy gọi với `include_demo=false`.
- **Cùng một luật với màn Báo cáo và file Excel:** `/applications` (và các bảng gắn với hồ sơ ứng tuyển:
  `stage-transitions`, `interviews`, `evaluations`, `offers`) đã loại sẵn: hồ sơ bị gỡ khỏi job
  (`status = removed`), job đã xoá, ứng viên đã xoá, và ứng viên đang bị đánh dấu **trùng**
  (`duplicate_of_id` khác null — người đó đã được đếm ở hồ sơ gốc). Đếm trên `/applications` sẽ ra đúng
  số của màn Báo cáo.
- **`/candidates` thì khác:** trả cả hồ sơ bị đánh dấu trùng (xem `duplicate_of_id`) để bạn thấy
  chúng. Khi đếm **người**, bỏ các dòng có `duplicate_of_id`.
- **Ẩn danh (`is_anonymized = true`):** ứng viên đã yêu cầu xoá thông tin cá nhân. Dòng vẫn còn (vẫn
  được đếm) nhưng tên là `[Đã xóa] #xxxx`, email/SĐT/ngày sinh/kỹ năng… là null.
- **Tên bước khác nhau giữa các job:** mỗi job có quy trình riêng, "Phỏng vấn" của job A có thể tên
  khác job B. So sánh giữa các job hãy gom theo `stage_type` (danh sách cố định: `sourced`, `applied`,
  `screening`, `assessment`, `interview`, `hm_review`, `offer`, `hired`, `custom`), không theo tên.
- **Nguồn:** `source` là **loại** nguồn (`job_board`, `linkedin`, `facebook`, `other_social`, `email`,
  `referral`, `career_page`, `headhunt`, `hr`, `manual`, `other`, `old_database`, hoặc giá trị nguồn
  riêng của công ty — xem `/catalog`), `source_detail` là **tên cụ thể** (ví dụ `ITViec`). Nguồn thuộc
  về từng hồ sơ ứng tuyển; hồ sơ cũ chưa có nguồn riêng thì lấy nguồn của ứng viên.
- **Offer có nhiều phiên bản:** mỗi lần phát hành lại offer cho cùng một hồ sơ ứng tuyển là một dòng
  mới, `version` tăng dần theo `application_id` (mỗi phiên bản có `offer_number` riêng). Muốn offer hiện
  tại thì lấy dòng `max(version)` theo `application_id`.
- **Trạng thái hồ sơ ứng tuyển:** `active` (đang xử lý), `hired`, `rejected`. Đang ở bước nào xem
  `stage_name` / `stage_type`.

## 3. Từ điển dữ liệu

### candidates

`GET /candidates` — hồ sơ ứng viên. Trục thời gian: `created_at`. Không có tham số `job_id`.

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã định danh ứng viên |
| `candidate_code` | string | Mã hiển thị, ví dụ `CAN-2026-0012` |
| `full_name` | string | Họ tên (ẩn danh: `[Đã xóa] #xxxx`) |
| `email` | string? | Email (null nếu ẩn danh) |
| `phone` | string? | Số điện thoại (null nếu ẩn danh) |
| `gender` | string? | `male` / `female` / `other` |
| `date_of_birth` | string? | Ngày sinh dạng chuỗi (có thể chỉ có năm) |
| `location_display` | string? | Nơi ở, dạng hiển thị |
| `vn_province_code` | string? | Mã tỉnh/thành Việt Nam |
| `country_code` | string? | Mã quốc gia (ISO) |
| `current_job_title` | string? | Chức danh hiện tại |
| `current_company` | string? | Công ty hiện tại |
| `total_experience_years` | int? | Số năm kinh nghiệm |
| `skills` | json? | Kỹ năng (danh sách/đối tượng do AI trích từ CV) |
| `education` | json? | Học vấn |
| `current_salary` | int? | Lương hiện tại |
| `current_salary_currency` | string? | Tiền tệ lương hiện tại |
| `current_salary_type` | string? | `gross` / `net` |
| `current_salary_period` | string? | `monthly` / `yearly` / `hourly` |
| `expected_salary_min` | int? | Lương mong muốn — thấp |
| `expected_salary_max` | int? | Lương mong muốn — cao |
| `salary_currency` | string? | Tiền tệ lương mong muốn |
| `salary_type` | string? | `gross` / `net` (lương mong muốn) |
| `salary_period` | string? | Kỳ lương mong muốn |
| `source` | string | Loại nguồn của hồ sơ ứng viên (xem mục 2) |
| `source_detail` | string? | Tên nguồn cụ thể |
| `tags` | list? | Nhãn do HR gắn |
| `star_rating` | int? | Đánh giá sao của HR (1–5) |
| `is_blacklisted` | bool | Nằm trong danh sách đen |
| `is_demo` | bool | Dữ liệu mẫu |
| `is_anonymized` | bool | Đã ẩn danh theo yêu cầu (PII đã xoá) |
| `duplicate_of_id` | uuid? | Khác null = nghi trùng với hồ sơ này; bỏ khi đếm người |
| `needs_manual_review` | bool | CV cần HR xem lại thông tin |
| `created_at` | datetime | Lúc tạo hồ sơ |
| `updated_at` | datetime | Lúc sửa gần nhất |

### applications

`GET /applications?job_id=` — mỗi dòng = một ứng viên ứng tuyển một job. Trục thời gian: ngày vào quy
trình = `applied_date`, nếu trống thì `created_at` (ứng viên do HR tự tìm không có `applied_date`).

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã hồ sơ ứng tuyển |
| `candidate_id` | uuid | → `candidates.id` |
| `candidate_code` | string | Mã ứng viên |
| `job_id` | uuid | → `jobs.id` |
| `job_code` | string | Mã job, ví dụ `JOB-2026-0003` |
| `stage_id` | uuid? | Bước hiện tại → `jobs.pipeline_stages[].id` |
| `stage_name` | string? | Tên bước hiện tại (tên riêng của job) |
| `stage_type` | string? | Loại bước — dùng để so sánh giữa các job |
| `status` | string | `active` / `hired` / `rejected` |
| `applied_date` | datetime? | Ngày nộp (null với ứng viên HR tự tìm) |
| `hired_date` | datetime? | Ngày tuyển — trục của chỉ số "số người được tuyển" |
| `rejected_date` | datetime? | Ngày từ chối |
| `rejection_reason_code` | string? | Mã lý do từ chối → `/catalog.rejection_reasons` |
| `rejection_reason` | string? | Ghi chú lý do do HR nhập |
| `source` | string? | Loại nguồn của lần ứng tuyển này |
| `source_detail` | string? | Tên nguồn cụ thể |
| `assigned_recruiter_id` | uuid? | Recruiter phụ trách → `/catalog.members` |
| `assigned_recruiter_name` | string? | Tên recruiter phụ trách |
| `offer_accepted_at` | datetime? | Lúc ứng viên nhận offer |
| `is_demo` | bool | Dữ liệu mẫu |
| `created_at` | datetime | Lúc tạo |
| `updated_at` | datetime | Lúc sửa gần nhất |
| `stage_updated_at` | datetime? | Lần đổi bước gần nhất |

### stage-transitions

`GET /stage-transitions?job_id=` — lịch sử chuyển bước / đổi trạng thái của các hồ sơ trên. Trục thời
gian: `changed_at`.

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã sự kiện |
| `application_id` | uuid | → `applications.id` |
| `candidate_id` | uuid | → `candidates.id` |
| `job_id` | uuid | → `jobs.id` |
| `change_type` | string | `initial` (vào quy trình) / `stage_move` / `status_change` / `note` |
| `from_stage_id` | uuid? | Bước trước |
| `from_stage_name` | string? | Tên bước trước |
| `from_stage_type` | string? | Loại bước trước |
| `to_stage_id` | uuid? | Bước sau |
| `to_stage_name` | string? | Tên bước sau |
| `to_stage_type` | string? | Loại bước sau |
| `from_status` | string? | Trạng thái trước |
| `to_status` | string? | Trạng thái sau (`hired`, `rejected`…) |
| `changed_at` | datetime | Thời điểm đổi |
| `changed_by_user_id` | uuid? | Người thao tác (null khi không có người thao tác, ví dụ ứng viên tự nộp, hoặc người đó đã rời công ty) |
| `changed_by_name` | string? | Tên người thao tác |
| `notes` | string? | Ghi chú kèm theo |

### jobs

`GET /jobs?job_id=` — tin tuyển dụng. Trục thời gian: `created_at`.

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã job |
| `job_code` | string | Mã hiển thị |
| `title` | string | Chức danh tuyển |
| `slug` | string? | Đường dẫn trên trang tuyển dụng |
| `department` | string? | Phòng ban |
| `location_display` | string? | Địa điểm làm việc |
| `employment_type` | string | `full_time` / `part_time` / `contract` / `internship` |
| `work_setup` | string? | `onsite` / `hybrid` / `remote` / `flexible` |
| `hire_type` | string? | `new_hire` / `replacement` |
| `status` | string | `draft` / `open` / `on_hold` / `closed` |
| `published_date` | datetime? | Ngày mở tuyển |
| `closed_date` | datetime? | Ngày đóng |
| `on_hold_date` | datetime? | Ngày tạm dừng |
| `close_reason` | string? | Lý do đóng |
| `expected_hire_date` | date? | Hạn tuyển mong muốn |
| `headcount` | int | Số người cần tuyển |
| `salary_min` | int? | Lương tối thiểu đăng tuyển |
| `salary_max` | int? | Lương tối đa đăng tuyển |
| `salary_currency` | string? | Tiền tệ |
| `recruitment_cost_items` | list | Chi phí tuyển HR ghi tay: `[{"category", "amount", "date"?}]`; tổng chi phí = tổng `amount` |
| `cost_currency` | string? | Tiền tệ của chi phí |
| `pipeline_template_id` | uuid? | Quy trình mẫu job dùng → `/catalog.pipeline_templates` |
| `is_demo` | bool | Dữ liệu mẫu |
| `created_by` | uuid | Người tạo job |
| `created_at` | datetime | Lúc tạo |
| `updated_at` | datetime | Lúc sửa gần nhất |
| `pipeline_stages` | list | Các bước của job (bảng dưới) |

### jobs.pipeline_stages

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã bước (→ `applications.stage_id`) |
| `name` | string | Tên bước |
| `stage_type` | string | Loại bước |
| `sort_order` | int | Thứ tự trong quy trình |
| `is_terminal` | bool | Bước kết thúc (Hired) |
| `is_optional` | bool | Bước có thể bỏ qua |

### interviews

`GET /interviews?job_id=` — lịch phỏng vấn. Trục thời gian: `scheduled_at` (được hỏi cả ngày tương lai
bằng `to`). Không có nhận xét phỏng vấn và link họp.

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã buổi phỏng vấn |
| `application_id` | uuid | → `applications.id` |
| `candidate_id` | uuid | → `candidates.id` |
| `job_id` | uuid | → `jobs.id` |
| `interview_round` | string | Tên vòng |
| `round_number` | int | Số thứ tự vòng |
| `scheduled_at` | datetime | Giờ bắt đầu |
| `duration_minutes` | int | Thời lượng (phút) |
| `location` | string? | Địa điểm |
| `status` | string | `scheduled` / `completed` / `cancelled` / `no_show` / `rescheduled` |
| `result` | string | `pending` / `passed` / `failed` / `neutral` |
| `rating` | int? | Điểm tổng (nếu có) |
| `interviewer_ids` | list? | Người phỏng vấn → `/catalog.members` |
| `interviewer_names` | list? | Tên người phỏng vấn (cùng thứ tự) |
| `is_self_schedule` | bool | Ứng viên tự chọn giờ |
| `created_at` | datetime | Lúc tạo |
| `updated_at` | datetime | Lúc sửa gần nhất |

### evaluations

`GET /evaluations?job_id=` — phiếu đánh giá. Trục thời gian: `evaluated_at`, nếu trống thì
`created_at`. Không có phần nhận xét chữ.

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã phiếu |
| `application_id` | uuid | → `applications.id` |
| `candidate_id` | uuid | → `candidates.id` |
| `job_id` | uuid | → `jobs.id` |
| `interview_id` | uuid? | → `interviews.id` (nếu gắn với buổi phỏng vấn) |
| `stage_id` | uuid? | Bước được đánh giá |
| `evaluator_type` | string | `internal` (trong công ty) / `external` |
| `evaluator_id` | uuid? | Người chấm (nội bộ) → `/catalog.members` |
| `evaluator_name` | string | Tên người chấm |
| `overall_rating` | int? | Điểm tổng |
| `rating_scale` | int | Thang điểm (ví dụ 5) — chuẩn hoá `overall_rating / rating_scale` trước khi so |
| `criteria_scores` | list? | Điểm theo tiêu chí |
| `weighted_score` | float? | Điểm có trọng số |
| `result` | string? | `passed` / `failed` / `pending` |
| `evaluation_type` | string? | `interview_feedback` / `hm_review` |
| `evaluated_at` | datetime? | Lúc nộp phiếu (null = chưa chấm) |
| `created_at` | datetime | Lúc tạo phiếu |
| `updated_at` | datetime | Lúc sửa gần nhất |

### offers

`GET /offers?job_id=` — thư mời nhận việc, **mọi phiên bản**. Trục thời gian: `created_at`.

| Trường | Kiểu | Ý nghĩa / lưu ý |
|---|---|---|
| `id` | uuid | Mã phiên bản offer |
| `application_id` | uuid | → `applications.id` |
| `candidate_id` | uuid | → `candidates.id` |
| `job_id` | uuid | → `jobs.id` |
| `offer_number` | string | Mã offer (mỗi phiên bản một mã) |
| `version` | int | Phiên bản trong hồ sơ ứng tuyển — lấy lớn nhất theo `application_id` khi cần offer hiện tại |
| `status` | string | `pending` / `accepted` / `declined` / `cancelled` |
| `job_title` | string? | Chức danh trên offer |
| `department` | string? | Phòng ban trên offer |
| `start_date` | date? | Ngày bắt đầu làm |
| `gross_salary_amount` | float? | Lương gross |
| `gross_salary_currency` | string? | Tiền tệ |
| `gross_salary_unit` | string? | Đơn vị lương (theo tháng/năm…) |
| `probation_period` | string? | Thời gian thử việc |
| `probation_salary_amount` | float? | Lương thử việc |
| `expiry_date` | datetime? | Hạn trả lời |
| `sent_at` | datetime | Lúc gửi |
| `responded_at` | datetime? | Lúc ứng viên trả lời |
| `decline_reason_code` | string? | Vì sao ứng viên từ chối: `compensation`, `role_and_job`, `external_factors`, `no_response`, `other` |
| `cancel_reason_code` | string? | Vì sao công ty huỷ: `business_decision`, `candidate_no_longer_eligible`, `other` |
| `created_at` | datetime | Lúc tạo phiên bản |
| `updated_at` | datetime | Lúc sửa gần nhất |

### /catalog

`GET /catalog` — danh mục để gắn nhãn cho dữ liệu thô (không phân trang):
`pipeline_templates` (quy trình mẫu + các bước), `custom_sources` (nguồn riêng: `name`, `value`,
`parent_source`), `rejection_reasons` (`code`, `label`), `members` (thành viên: `id`, `full_name`,
`role`, `is_active` — không có email), `departments` (các phòng ban đang dùng).

## 4. Chỉ số đã kiểm — `GET /metrics`

`/metrics?from=&to=&job_id=&department=` trả **đúng bộ số của màn Báo cáo** (cùng một hàm tính). Dùng
nó khi cần con số "chuẩn" để đối chiếu; dùng bảng thô khi cần cắt theo cách màn hình chưa có.

- `period_start` / `period_end`: kỳ thực tế đã tính (sau giới hạn 18 tháng); `previous_period_*`: kỳ
  liền trước cùng độ dài để so sánh.
- Mỗi chỉ số chính có `current`, `previous`, `delta`, `delta_pct` (null khi kỳ trước = 0).
- `total_jobs`: số job (chưa xoá) **tạo** trong kỳ.
- `active_candidates`: số **ứng viên khác nhau** vào quy trình trong kỳ (theo ngày vào quy trình).
- `total_hired`: số người được tuyển có `hired_date` trong kỳ.
- `avg_time_to_hire_days`: trung bình số ngày từ lúc vào quy trình tới `hired_date`, trên các người
  được tuyển trong kỳ.
- `offer_acceptance_rate`: tỷ lệ nhận offer = nhận / (nhận + từ chối ở bước offer), tính trên offer.
- `cost_per_hire`: tổng chi phí của các job có người được tuyển trong kỳ / tổng số người được tuyển.
  Null khi không job nào ghi chi phí.
- `aging_candidates_count`: hồ sơ còn đang xử lý nhưng không đổi bước hơn 5 ngày.
- `source_effectiveness`, `top_performing_jobs`, `recruiter_performance`, `application_trend` (theo
  tuần): các bảng phân tích như trên màn hình.
- ⚠ `offer_decline_reasons` là **tên cũ, dễ hiểu nhầm**: thực chất là lý do **loại hồ sơ**
  (`rejection_reason_code` của các hồ sơ bị từ chối trong kỳ). Lý do **ứng viên từ chối offer** nằm ở
  `/offers` → `decline_reason_code`.
- `candidates_by_stage`, `conversion_rates`, `stage_durations`, `pipeline_snapshot`: phễu theo bước —
  **cẩn trọng** khi các job dùng quy trình khác nhau (xem `funnel_stage_meta`,
  `per_template_funnels`).

**Bẫy thường gặp khi tự tính:**

- **Đừng lấy "tuyển kỳ này / nộp kỳ này" làm tỷ lệ tuyển.** Người được tuyển tháng này thường nộp từ
  tháng trước. Muốn tỷ lệ tuyển thì theo **nhóm nộp cùng kỳ** (cohort): trong các hồ sơ vào quy trình
  tháng X, bao nhiêu hồ sơ đã `hired` — và nhớ kỳ gần đây luôn còn hồ sơ đang xử lý.
- Tỷ lệ phần trăm trên mẫu rất nhỏ (1/2 = 50%) dễ gây hiểu sai — luôn ghi kèm `n`.
- Thời gian tuyển nên xem cả **trung vị**, vì một ca 300 ngày kéo trung bình lệch hẳn.

## 5. Mười câu hỏi mẫu và cách làm

1. **Tuần rồi bao nhiêu ứng viên vào vòng phỏng vấn, theo phòng ban?** — `stage-transitions` với
   `from`/`to` là tuần đó, lọc `to_stage_type = "interview"`, nối `jobs` qua `job_id` để lấy
   `department`, đếm `application_id` khác nhau.
2. **Nguồn nào tỷ lệ tuyển cao nhất?** — `applications` (`include_demo=false`), nhóm theo
   `source`/`source_detail`: số hồ sơ, số `status = hired`, tỷ lệ; ghi kèm `n`. Hoặc đọc
   `/metrics.source_effectiveness`.
3. **Job nào mở hơn 45 ngày mà chưa có offer?** — `jobs` với `status = "open"` và `published_date` cũ
   hơn 45 ngày; loại các job có ít nhất một dòng trong `offers`.
4. **Tỷ lệ nhận offer theo khoảng lương?** — `offers`, lấy phiên bản lớn nhất mỗi `application_id`, chia
   nhóm theo `gross_salary_amount`, tỷ lệ `accepted / (accepted + declined)`.
5. **6 tháng qua ứng viên từ chối offer vì lý do gì?** — `offers` 6 tháng, `status = "declined"`, đếm
   theo `decline_reason_code` (KHÔNG dùng `/metrics.offer_decline_reasons` — xem mục 4).
6. **Người phỏng vấn nào đang tải nặng nhất?** — `interviews` tháng này (dùng `to` tới cuối tháng), trải
   `interviewer_ids` ra từng người, đếm; tên lấy từ `/catalog.members`.
7. **Ai chấm điểm khắt khe nhất?** — `evaluations` đã nộp (`evaluated_at` khác null), điểm chuẩn hoá
   `overall_rating / rating_scale`, trung bình theo `evaluator_id` (chỉ người có ≥ 5 phiếu).
8. **Bước nào ứng viên nằm lâu nhất?** — `stage-transitions` sắp theo `application_id`, `changed_at`;
   thời gian ở một bước = khoảng cách giữa lần vào bước đó và sự kiện kế tiếp; trung vị theo
   `stage_type`. Hoặc đọc `/metrics.stage_durations`.
9. **Chi phí tuyển mỗi người?** — tổng `amount` trong `jobs.recruitment_cost_items` của các job có
   người được tuyển trong kỳ, chia cho số `applications` có `hired_date` trong kỳ (lọc `applications`
   theo `hired_date`, không theo `from`/`to` của API — API lọc theo ngày vào quy trình). Đối chiếu
   `/metrics.cost_per_hire`.
10. **Kỹ năng nào phổ biến nhất trong kho ứng viên?** — `candidates` (`include_demo=false`, bỏ dòng có
    `duplicate_of_id`), trải `skills`, chuẩn hoá chữ hoa/thường, đếm.

## 6. Tự kiểm chéo

- Cùng `from`/`to`, số dòng `/applications` phải **bằng** số dòng sheet "Applications" trong file Excel
  của màn Báo cáo (Xuất Excel) và khớp các con số của `/metrics`.
- Nếu lệch: kiểm tra múi giờ khi gom ngày, `include_demo`, và việc có bỏ hồ sơ trùng khi đếm người.
