# Hướng Dẫn Tích Hợp Loại Chương Trình Đào Tạo Mới (Course Program Integration Guide)

Tài liệu này đóng vai trò là **Playbook & Checklist** chuẩn hóa quy trình khi cần thêm một loại chương trình đào tạo mới (ví dụ: `Easy IELTS - ei`, `Easy Pass - ep`, `Speakwell - spw`, `Speakup`,...) vào hệ thống LMS và `IclScoreService`.

---

## 1. Tổng quan Kiến trúc

Khi một chương trình đào tạo mới được bổ sung, hệ thống cần được cấu hình qua 3 tầng:

```mermaid
flowchart TD
    CSV[File CSV từ R&D / Học liệu] --> Tool[scripts/generate_<program>_bank.php]
    Tool --> Helper[app/Helpers/Helper.php]
    Tool --> IclService[app/Services/IclScoreService.php]

    subgraph "app/Helpers/Helper.php"
        FB_Bank["getFeedbackBank<Name>()"]
        FB_Get["getFeedback<Name>($examKey, $skill, $score)"]
    end

    subgraph "app/Services/IclScoreService.php"
        Const["public const PROGRAM_<NAME>"]
        Resolve["resolveCourseProgramType() & checkIs<Name>Course()"]
        CEFR["getScoreMappingMatrix<Name>() & getConvertedOverallScore()"]
        Status["evaluateAllComponents() -> evaluateAllComponents<Name>()"]
        Feedbacks["getSkillFeedbacks() -> getSkillFeedbacks<Name>()"]
        ActivitiesMap["getCourseActivitiesMap() (midterm, final)"]
        ZeusSync["getStudentCourseProgressData() & shouldTriggerMetricsFor<Name>()"]
    end
```

---

## 2. Thông tin Đầu vào cần chuẩn bị trước

Trước khi bắt đầu code, cần thu thập đủ 4 thông tin sau:
1. **Mã định danh chương trình (Program Code)**: ví dụ `ep` (Easy Pass), `spw` (Speakwell), `su` (Speakup)...
2. **Root Category Moodle ID**: Category gốc của chương trình trên Moodle:
   - Môi trường Staging: `moodle_id = ?`
   - Môi trường Production: `moodle_id = ?`
3. **File CSV từ Học thuật (R&D)**: gồm 2 phần:
   - Phần I: Bảng quy đổi điểm overall thang 10 sang các mức CEFR.
   - Phần II: Ngân hàng lời nhận xét (Feedback Bank) theo từng bài thi, mức điểm và kỹ năng.
4. **Mức độ tích hợp Zeus**:
   - Chỉ tính điểm & nhận xét nội bộ LMS/ICL.
   - Hoặc đồng bộ cả chỉ số tiến trình (metrics bundle) sang Zeus.

---

## 3. Checklist từng bước thực hiện

### BƯỚC 1: Xây dựng Ngân hàng nhận xét trong `Helper.php`
*(Có thể dùng tool script để tự động hóa: xem mục 4)*

- [ ] **1.1. Tạo hàm `getFeedbackBank<Name>(): array`**
  - Mảng cấu trúc theo khóa: `$bank[examKey][skillKey] = [ [min, max, result, feedback], ... ]`
  - Trong đó `examKey = "{courseId}_{sectionId}_{activityId}"`.
  - Tên kỹ năng chuẩn hóa `snake_case` (ví dụ: `speaking`, `listening`, `reading`, `writing`, `vocabulary`, `grammar`, `reading_writing`, `your_score_explained`).

- [ ] **1.2. Tạo hàm tra cứu `getFeedback<Name>(string $examKey, string $skill, float $score): ?array`**
  - Chuẩn hóa tên `$skill` (loại bỏ ký tự đặc biệt, map alias `Reading & Writing` -> `reading_writing`).
  - Xử lý mốc điểm nửa mở:
    - Nếu CSV ghi `< 5`: điều kiện so sánh là `$score >= $min && $score < 5.0`.
    - Mốc tối đa `10`: so sánh `$score <= 10.0`.
    - Xử lý điểm `<= 0.0` trả về mức 0 của bài thi.

---

### BƯỚC 2: Định nghĩa & Nhận diện Chương trình trong `IclScoreService.php`

- [ ] **2.1. Thêm Hằng số & Static Cache**
  ```php
  public const PROGRAM_SPEAKWELL = 'spw';
  
  private static array $cacheSpeakwellCourse = []; // "{course_id}:{conn}" => bool
  ```

- [ ] **2.2. Thêm hàm kiểm tra Course Category: `checkIs<Name>Course`**
  ```php
  public function checkIsSpeakwellCourse($courseNode, string $tenantConnection): bool
  {
      if (!$courseNode || !$courseNode->parent_id) return false;
      $cacheKey = $courseNode->id . ':' . $tenantConnection;
      if (array_key_exists($cacheKey, self::$cacheSpeakwellCourse)) {
          return self::$cacheSpeakwellCourse[$cacheKey];
      }

      try {
          $category = ApiMoodle::on($tenantConnection)
              ->where('id', $courseNode->parent_id)
              ->where('moodle_type', 'category')
              ->first();

          while ($category && $category->parent_id) {
              $parentCat = ApiMoodle::on($tenantConnection)
                  ->where('id', $category->parent_id)
                  ->where('moodle_type', 'category')
                  ->first();
              if (!$parentCat) break;
              $category = $parentCat;
          }

          $env = env('ENVIROMENT');
          $targetMoodleId = ($env === 'production') ? 10 : 235; // Cấu hình ID prod & stg

          if ($category && (int) $category->moodle_id === $targetMoodleId) {
              self::$cacheSpeakwellCourse[$cacheKey] = true;
              return true;
          }
      } catch (Exception $e) {
          Log::error("Lỗi checkIsSpeakwellCourse: " . $e->getMessage());
      }

      self::$cacheSpeakwellCourse[$cacheKey] = false;
      return false;
  }
  ```

- [ ] **2.3. Rẽ nhánh trong `resolveCourseProgramType`**
  ```php
  if ($this->checkIsSpeakwellCourse($courseNode, $tenantConnection)) {
      return self::PROGRAM_SPEAKWELL;
  }
  ```

---

### BƯỚC 3: Quy đổi Điểm Overall sang CEFR

- [ ] **3.1. Thêm ma trận `getScoreMappingMatrix<Name>(): array`**
  - Chứa ánh xạ điểm ngưỡng (`8.5`, `7.0`, `5.0`, `1.0`, `0.0`) sang nhãn CEFR cho từng `examKey`.

- [ ] **3.2. Thêm hàm quy đổi `getConvertedOverallScore<Name>()`**
  ```php
  public function getConvertedOverallScoreSpeakwell($overallScore, $courseId = null, $sectionId = null, $activityId = null, $pointScale = 10)
  {
      if ($courseId === null || $sectionId === null || $activityId === null) return $overallScore;
      $key = "{$courseId}_{$sectionId}_{$activityId}";
      $mappings = $this->getScoreMappingMatrixSpeakwell();
      if (!isset($mappings[$key])) return null;

      $map = $mappings[$key];
      $score = (float) $overallScore;
      if ($score >= 8.5) return $map['8.5'] ?? null;
      if ($score >= 7.0) return $map['7.0'] ?? null;
      if ($score >= 5.0) return $map['5.0'] ?? null;
      if ($score >= 1.0) return $map['1.0'] ?? null;
      return $map['0.0'] ?? null;
  }
  ```

- [ ] **3.3. Rẽ nhánh trong `getConvertedOverallScore()`**
  ```php
  if ($programType === self::PROGRAM_SPEAKWELL) {
      return $this->getConvertedOverallScoreSpeakwell($overallScore, $courseId, $sectionId, $activityId, $pointScale);
  }
  ```

---

### BƯỚC 4: Đánh giá trạng thái Kỹ năng (Đạt / Cần chú ý)

- [ ] **4.1. Thêm hàm `evaluateAllComponents<Name>` và `getComponentPassStatus<Name>`**
  - Tra cứu kết quả từ `getFeedback<Name>($examKey, $skillKey, (float) $score)`.
  - Hỗ trợ đa ngôn ngữ (`$langId == 1` trả về tiếng Anh: `Meets requirements` / `Needs improvement`).

- [ ] **4.2. Rẽ nhánh trong `evaluateAllComponents()`**
  ```php
  if ($programType === self::PROGRAM_SPEAKWELL) {
      return $this->evaluateAllComponentsSpeakwell($skillsScores, $courseId, $sectionId, $activityId, $langId);
  }
  ```

---

### BƯỚC 5: Trích xuất Nhận xét Kỹ năng (Skill Feedbacks)

- [ ] **5.1. Thêm hàm `getSkillFeedbacks<Name>()`**
  - Ưu tiên nhận xét từ giáo viên trong `$skillsDetail`.
  - Nếu không có: gọi `getFeedback<Name>($examKey, $key, (float) $score)`.
  - Fallback nếu không cấu hình bài thi: gọi `getFeedback('vi', $score, $key, $contestTypeId)`.

- [ ] **5.2. Rẽ nhánh trong `getSkillFeedbacks()`**
  ```php
  if ($programType === self::PROGRAM_SPEAKWELL) {
      return $this->getSkillFeedbacksSpeakwell($skillsDetail, $scores, $quizId, $tenantConnection, $examKey);
  }
  ```

---

### BƯỚC 6: Cấu hình Khóa học & Tích hợp Zeus / Thông báo

- [ ] **6.1. Cấu hình bài thi vào `getCourseActivitiesMap()`**
  - Khai báo danh sách các Course DB ID cùng các bài thi (`midterm`, `final`, `mini_1`, `mini_2`...).
  ```php
  // Khóa Oxford Discover 1 (Course DB ID: 23807)
  23807 => [
      'midterm' => ['section_id' => 23950, 'activity_id' => 23955],
      'final'   => ['section_id' => 23951, 'activity_id' => 23961],
  ],
  ```

- [ ] **6.2. Email cảnh báo bài chưa hoàn tất (`ielts_exam_result_not_complete`)**
  - Tại dòng 2137 & 2457: thêm `$isSpeakwell = $this->checkIsSpeakwellCourse(...)` vào điều kiện gửi mail.

- [ ] **6.3. Cấu trúc Metrics tiến trình học tập (`getStudentCourseProgressData`)**
  - Nếu chưa có cấu trúc riêng cho Zeus: trả về mảng rỗng để không ảnh hưởng dữ liệu.
  ```php
  } elseif ($programType === self::PROGRAM_SPEAKWELL) {
      $metrics = array_merge($commonMetrics, []);
  }
  ```

- [ ] **6.4. Trigger gửi chỉ số sang Zeus (`shouldTriggerMetricsFor<Name>`)**
  - Kiểm tra điều kiện nộp bài thi nào thì kích hoạt đồng bộ metrics sang Zeus.
  ```php
  if ($programType === self::PROGRAM_SPEAKWELL) {
      return true; // Kích hoạt ngay
  }
  ```

- [ ] **6.5. Batch Scan & Đồng bộ hàng loạt (`getFinalTestEligibleStudents` & `processCompletedFinalTestStudents`)**
  - Phân nhánh rõ ràng theo `$programType`:
    - `PROGRAM_EASY_IELTS`: Quét học sinh hoàn thành bài `final`.
    - `PROGRAM_EASY_PASS`: Quét các bài trigger (`midterm`, `mock_3`, `final`) (tạm skip nếu trigger realtime đang tắt).
    - `PROGRAM_SPEAKWELL`: Quét các bài trigger (`midterm`, `final`) (tạm skip nếu trigger realtime đang tắt).
    - Bỏ qua (`continue`) nếu khóa học không thuộc chương trình nào (`!$programType`).

---

## 4. Tự động hóa bằng Generator Script

Để tránh phải viết tay hàng nghìn dòng array PHP từ CSV, sử dụng script mẫu tại `scripts/generate_speakwell_bank.php`:

```bash
# Cú pháp chạy:
php scripts/generate_<program>_bank.php --file="path/to/bank.csv" --apply
```

Script sẽ:
1. Đọc tự động phần I (Bảng quy đổi CEFR) và phần II (Feedback bank).
2. Tự động chuẩn hóa dải điểm, toán tử so sánh (`< 5`, `> 0`, `<= 10`).
3. Chuẩn hóa tên kỹ năng sang `snake_case`.
4. Xuất code sạch và hỗ trợ cờ `--apply` để tự động ghép vào [Helper.php](file:///var/www/html/lms_hocmai/app/Helpers/Helper.php) và [IclScoreService.php](file:///var/www/html/lms_hocmai/app/Services/IclScoreService.php).

---

## 5. Bảng tổng hợp đối chiếu giữa các chương trình

| Tính năng | Easy IELTS (`ei`) | Easy Pass (`ep`) | Speakwell (`spw`) |
| :--- | :--- | :--- | :--- |
| **Root Category Moodle ID** | Prod: `2`, Stg: `140` | Prod: `3`, Stg: `254` | Prod: `10`, Stg: `235` |
| **Thang điểm / Quy đổi** | IELTS Band 0 - 9.0 | CEFR Pre-A1 -> B1 | CEFR Pre-A1 -> B1 |
| **Kỹ năng đánh giá** | 4 kỹ năng chuẩn IELTS | 4 kỹ năng + Vocab (Vocab gộp vào Writing khi gửi ICL) | 8 kỹ năng (OD + Cambridge) |
| **Hàm tra cứu Feedback** | `getFeedback()` | `getFeedbackEasyPass()` | `getFeedbackSpeakwell()` |
| **Hàm ma trận CEFR** | `getScoreMappingMatrix()` | `getScoreMappingMatrixEasyPass()` | `getScoreMappingMatrixSpeakwell()` |
| **Trigger Metrics Zeus** | Khi hoàn thành Final test | Hoàn thành Midterm, Mock 3 hoặc Final | Mặc định `true` |
