# Chứng chỉ

> Cấu hình mẫu chứng chỉ, cấp chứng chỉ tự động khi học viên hoàn thành khoá, quản lý chứng chỉ chờ render và cấp thủ công cho từng nhân viên.

**Quyền truy cập:** Cần quyền **Quản lý mẫu chứng chỉ** (`CERTIFICATE_TEMPLATE_CREATE`), **Cấp chứng chỉ** (`CERTIFICATE_ISSUE`), Admin **Điều hướng:**

* Mẫu chứng chỉ: `Đào tạo > Mẫu chứng chỉ` hoặc cấu hình trong chương trình
* Chứng chỉ chờ render: `/manage/cert-pending`
* Cấp chứng chỉ thủ công: `/manage/cert-issue`

***

## Tổng quan

Tính năng Chứng chỉ đào tạo cấp chứng chỉ tự động khi nhân viên hoàn thành khoá học hoặc chương trình đào tạo, và cho phép admin cấp thủ công cho cá nhân.

Từ phiên bản 8.10, hệ thống được tái cấu trúc toàn diện:

* **Schema canonical:** mỗi chứng chỉ là 1 bản ghi với `forUser.userId` + `forCourse.courseId`, có status (PENDING\_RENDER / RENDERING / ISSUED / RENDER\_FAILED / REVOKED / ARCHIVED)
* **Render client-side:** PNG được vẽ trên trình duyệt (admin hoặc học viên) bằng fabric.js, không cần worker server-side
* **Lưu private trên DigitalOcean Spaces:** ACL=private, mỗi lần xem cần presigned URL ngắn hạn
* **Auto-issue qua StakeholderEvent COMPLETE:** không phụ thuộc collection-hooks, an toàn xuyên app
* **Code generator atomic:** mã chứng chỉ duy nhất theo `codePrefix + incrementValue`, có 3 mode đếm

***

## Mẫu chứng chỉ (Certificate Template)

### Tạo mẫu

1. Vào `Đào tạo > Mẫu chứng chỉ`, nhấp **"Tạo mẫu"**.
2. Thiết kế nội dung trên canvas: kéo thả text/ảnh, đặt 4 placeholder text (`{{fullName}}`, `{{courseTitle}}`, `{{issuedDate}}`, `{{code}}`).
3. Cấu hình các trường bên phải canvas.
4. Nhấp **"Cập nhật"** -- xuất hiện hộp thoại xác nhận.

### Trường cấu hình mẫu

| Trường                                                 | Bắt buộc | Mô tả                                                                                                          |
| ------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------- |
| **Tên chứng chỉ**                                      | Có       | Tên hiển thị                                                                                                   |
| **Trạng thái**                                         | Có       | Nháp / Đã kích hoạt                                                                                            |
| **Khoá học**                                           | Có       | "Tất cả" (mẫu áp cho mọi khoá) hoặc chọn 1 khoá cụ thể                                                         |
| **Thời hạn hiệu lực (tháng)**                          | Không    | Số tháng chứng chỉ có hiệu lực; để trống = vĩnh viễn                                                           |
| **Tự động cấp chứng chỉ khi học viên hoàn thành khoá** | Không    | Toggle bật/tắt auto-issue (mặc định bật)                                                                       |
| **Quy tắc mã (codeRule)**                              | Có       | 4 cấu hình con: enablePrefix, codePrefix, enableIncrement, incrementType, enableIncrementStart, incrementStart |

### Quy tắc mã chứng chỉ (codeRule)

| Tuỳ chọn                          | Mô tả                                                                                                                       |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Bật tiền tố**                   | Có/không sử dụng `codePrefix` (ví dụ `HL-2024/`)                                                                            |
| **Tiền tố mã**                    | Văn bản hiển thị trước số (ví dụ `HL-ACADEMY-2024/`)                                                                        |
| **Bật số tăng dần**               | Có/không thêm số thứ tự                                                                                                     |
| **Quy luật tăng (incrementType)** | 3 chế độ: `ALL_TOTAL` (tổng tất cả chứng chỉ), `PREFIX_TOTAL` (tổng theo cùng tiền tố), `COURSE_TOTAL` (tổng theo khoá học) |
| **Bật điểm khởi đầu**             | Có/không đặt giá trị bắt đầu cho số tăng                                                                                    |
| **Điểm khởi đầu**                 | Số khởi đầu (ví dụ 100 → mã đầu tiên là `HL-2024/101`)                                                                      |

Hệ thống hiển thị preview định dạng: `Mã số chỉ sẽ có định dạng: HL-ACADEMY-2024/1` (giá trị placeholder ABCDEF nếu chưa bật prefix/increment).

> **Lock-after-issue (8.10):** Sau khi đã cấp ít nhất 1 chứng chỉ từ mẫu, không cho phép đổi `codePrefix` hay `incrementType` nữa — chỉ cho tăng `incrementStart`. Nguyên tắc 1 mã = 1 cert vĩnh viễn để đảm bảo tính minh bạch audit.

***

## Cấp chứng chỉ tự động

Khi nhân viên hoàn thành khoá học (`Enrollment.completedAt` được set), hệ thống tự động:

1. StakeholderEventDispatcher fire event `COMPLETE`
2. Listener `certificate-issue-listener` kiểm tra:
   * Khoá có mẫu chứng chỉ matching (`template.forCourse.id === courseId` hoặc `'all'`)
   * Mẫu có `status: 'active'` và `autoIssue: true`
   * Chưa có chứng chỉ tồn tại (`{templateId, forUser.userId, forCourse.courseId}` unique, trừ REVOKED)
3. Insert bản ghi CertificateList với status `PENDING_RENDER`:
   * Reserve mã chứng chỉ qua `CertCodeService` (atomic findOneAndUpdate counter)
   * Snapshot `forUser` (userId, employeeId, fullName, email) + `forCourse` (courseId, courseTitle)
   * Set `eweb`, `timezone`, `enrollmentId`, `triggerEventId`
4. Khi học viên mở `/learn/certificates` (app employee/classroom), client tự render PNG bằng fabric, upload lên DigitalOcean Spaces, mark `ISSUED`
5. Học viên nhận thông báo qua `insertClientNotification`

***

## Trang "Chứng chỉ chờ render" (admin)

**Điều hướng:** `/manage/cert-pending`

Trang admin để xem và render các chứng chỉ orphan (học viên không kích hoạt render qua việc mở app).

### Giao diện

| Cột        | Mô tả                                                           |
| ---------- | --------------------------------------------------------------- |
| STT        | Thứ tự                                                          |
| Mã         | Mã chứng chỉ đã reserve (ví dụ `HL-2024/000123`)                |
| Người nhận | `forUser.fullName`                                              |
| Khoá học   | `forCourse.courseTitle`                                         |
| Trạng thái | Badge PENDING\_RENDER / RENDERING / RENDER\_FAILED + số lần thử |
| Lỗi        | Truncated stack/message từ `lastRenderError` (nếu có)           |
| Hành động  | Nút **Render** / **Reset** (cho RENDER\_FAILED)                 |

### Hành động

| Nút                   | Mô tả                                                                           |
| --------------------- | ------------------------------------------------------------------------------- |
| **Render tất cả (N)** | Render hàng loạt tất cả PENDING\_RENDER, hiển thị progress "Đang render X/Y..." |
| **Render** (mỗi dòng) | Render 1 chứng chỉ ngay trên trình duyệt admin, upload Spaces, mark ISSUED      |
| **Reset**             | Reset trạng thái RENDER\_FAILED về PENDING\_RENDER để thử lại                   |

**Auto-release stale RENDERING:** Nếu một chứng chỉ kẹt trạng thái `RENDERING` quá 60 giây (do client crash), hệ thống tự release về PENDING\_RENDER. Sau ≥5 lần thử, tự chuyển sang RENDER\_FAILED.

**Mobile UA:** Trên trình duyệt mobile, banner hiển thị "Mở trên desktop để hoàn tất chứng chỉ" — vì fabric render trên mobile có thể OOM.

***

## Cấp chứng chỉ thủ công

**Điều hướng:** Trang Cấp chứng chỉ trong sidebar admin

1. Chọn mẫu chứng chỉ (`templateId`).
2. Chọn người nhận qua **NUserSelector / EmployeePicker** (không nhập raw ID).
3. Chọn khoá học liên quan (`courseId`).
4. (Tuỳ chọn) Đặt `expiresAt`, `issueReason: 'ADMIN_MANUAL'`.
5. Nhấp **"Cấp chứng chỉ"**.

Hệ thống render PNG ngay trên trình duyệt admin (synchronous trong dialog), upload Spaces, hiển thị preview liên kết ngay sau khi cấp.

> **Idempotency:** Nếu đã có chứng chỉ tồn tại cho cùng `(templateId, userId, courseId)` ở status khác REVOKED, hộp thoại sẽ cảnh báo và không tạo bản ghi mới.

***

## Cấu hình chứng chỉ trong Chương trình đào tạo

Sub-section **Chứng chỉ** trong tab Thông tin của chương trình:

| Trường               | Mô tả                                                      |
| -------------------- | ---------------------------------------------------------- |
| **Bật chứng chỉ**    | Cấp chứng chỉ khi hoàn thành chương trình (không chỉ khoá) |
| **Mẫu chứng chỉ**    | Chọn mẫu áp cho chương trình                               |
| **Tiền tố mã**       | Ghi đè codePrefix mặc định cho riêng chương trình này      |
| **Thời hạn (tháng)** | Số tháng chứng chỉ có hiệu lực                             |
| **Yêu cầu gia hạn**  | Có cần gia hạn sau khi hết hạn                             |

Khi học viên hoàn thành chương trình (`TrainingEnrollment.completedAt` được set), StakeholderEvent `TRAINING_PROGRAM_COMPLETE` fire, listener tạo CertificateList với `issueReason: 'TRAINING_PROGRAM'`.

***

## Trạng thái chứng chỉ

| Trạng thái          | Mô tả                                 |
| ------------------- | ------------------------------------- |
| **PENDING\_RENDER** | Đã reserve mã, chờ render PNG         |
| **RENDERING**       | Đang render (client đang xử lý)       |
| **ISSUED**          | Đã render xong, file đã upload Spaces |
| **RENDER\_FAILED**  | Render thất bại sau ≥5 lần thử        |
| **REVOKED**         | Đã thu hồi, không hợp lệ              |
| **ARCHIVED**        | Đã lưu trữ                            |

***

## Xem chứng chỉ

### Admin

Trong trang chi tiết chứng chỉ, click **"Xem chứng chỉ"** -- hệ thống gọi method `certificate.getViewUrl` để tạo presigned URL ngắn hạn (1 giờ).

### Học viên

Trang `/learn/certificates` trong app employee/classroom liệt kê tất cả chứng chỉ của học viên. Nhấp "Xem" -- presigned URL được tạo và mở trong tab mới.

> **Bảo mật:** Tất cả file chứng chỉ lưu private trên DigitalOcean Spaces (ACL=private). Mỗi lần xem cần gen URL mới. Liên kết không thể chia sẻ trực tiếp -- chỉ người có quyền xem mới gen được URL.

***

## Thu hồi chứng chỉ

### Các bước

1. Mở chi tiết chứng chỉ đã ISSUED.
2. Nhấp **"Thu hồi"**.
3. Nhập **Lý do thu hồi**.
4. Xác nhận thu hồi.

> Kết quả: Trạng thái chứng chỉ chuyển sang `REVOKED`. Hệ thống ghi nhận thời gian và lý do thu hồi. Mã chứng chỉ KHÔNG được tái sử dụng cho người khác (giữ gap, counter chỉ tăng).

***

## Lưu ý

* **Không backward-compat:** Schema cũ (single `id` field, lowercase status) đã được canonicalize. Pre-launch DB sẽ reset.
* **Bundle fabric \~250KB:** Chỉ load khi route /learn/certificates hoặc /manage/cert-pending mount (dynamic import).
* **Code uniqueness DB-level:** Unique partial index trên `{eweb, code}` (status != REVOKED) -- không thể trùng mã trong cùng tenant.
* **Idempotency:** Auto-issue dùng unique partial index `{eweb, templateId, userId, courseId}` (status != REVOKED) — không tạo bản ghi trùng.
* **CodeRule lock:** Sau khi đã cấp ≥1 cert, không cho đổi codePrefix/incrementType nữa.
* **Không backward-compat field `pdfUrl`:** trường `certificateUrl`/`pdfUrl` cũ đã bỏ -- dùng `certificate.getViewUrl` để gen presigned URL.

***

## Xem thêm

* [Chương trình đào tạo](/hoc-tap-va-dao-tao/05-dao-tao/chuong-trinh.md) -- Cấu hình chứng chỉ trong chương trình
* [Ghi danh](/hoc-tap-va-dao-tao/05-dao-tao/ghi-danh.md) -- Quản lý ghi danh và hoàn thành


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.noova.vn/hoc-tap-va-dao-tao/05-dao-tao/chung-chi.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
