# Phân tích & Giải pháp: Lệch trạng thái Lịch thi (StudentExamSchedule) khi Học sinh nộp bài trễ và mở Lịch thi mới

## 1. Tổng quan kịch bản thực tế (Scenario Breakdown)

### 1.1. Diễn biến thời gian (Timeline)

```mermaid
sequenceDiagram
    autonumber
    participant HS as Học sinh
    participant LMS as Hệ thống LMS
    participant CRON as Cron Quá hạn (DeadlineService)
    participant OBS as StudentScoreObserver
    participant DB as Database (Schedules & Scores)

    Note over DB: Ngày 08 -> 15: Lịch 1 mở (status=1, exam_status=0, deadline=15)
    HS->>LMS: Ngày 14: Bắt đầu làm bài (chưa submit)
    LMS->>DB: Tạo student_score (exam_status=0, fe_submission_status!=2)

    Note over CRON: Ngày 15: Hết hạn Lịch 1
    CRON->>DB: Đóng Lịch 1: status=0, exam_status=2 (Không nộp), submission_status=1
    CRON->>DB: setStatusAsUnSubmitted: student_score.exam_status=2

    HS->>LMS: Ngày 19: Bấm nộp bài (submit)
    LMS->>DB: checkSubmissionDeadline: student_score.exam_status=1, submission_status=1
    OBS->>DB: syncScoreToSchedule chạy (tìm schedule status=1 -> Không có -> Lịch 1 giữ exam_status=2)

    Note over DB: Ngày 20: Vận hành mở Lịch 2 (20 -> 23, status=1, exam_status=0)
    
    Note over LMS: Hôm nay: student_score bị update (chấm điểm, re-sync, v.v.)
    OBS->>DB: syncScoreToSchedule chạy lại (tìm schedule status=1 -> Thấy Lịch 2!)
    OBS->>DB: Update Lịch 2: exam_status=1 (Đã nộp) bằng dữ liệu bài thi cũ!
```

---

### 1.2. Bảng biến thiên trạng thái chi tiết

| Mốc thời gian | Hành động | Trạng thái `student_score` | Trạng thái `student_exam_schedules` (Lịch 1: 08 -> 15) | Trạng thái `student_exam_schedules` (Lịch 2: 20 -> 23) |
| :--- | :--- | :--- | :--- | :--- |
| **08/xx** | Tạo lịch thi 1 | Chưa có | `status = 1`<br>`exam_status = 0` (Chờ thi)<br>`submission_status = 0` | Chưa tạo |
| **14/xx** | Học sinh bắt đầu làm bài (chưa submit) | `exam_status = 0`<br>`fe_submission_status = 0/1`<br>`created_at = 14/xx` | `status = 1`<br>`exam_status = 0` | Chưa tạo |
| **15/xx** | Cron quét quá hạn xử lý Lịch 1 | `exam_status = 2` (Không nộp)<br>`score_processing_status = 1` | `status = 0` (Đã đóng)<br>`exam_status = 2` (Không nộp)<br>`submission_status = 1` (Trễ hạn) | Chưa tạo |
| **19/xx** | Học sinh bấm submit bài thi | `exam_status = 1` (Đã nộp)<br>`submission_status = 1` (Trễ hạn)<br>`is_deadline_checked = 1` | `status = 0`<br>`exam_status = 2` (Không nộp) *(Do Observer chỉ tìm `status=1` nên bỏ qua Lịch 1)* | Chưa tạo |
| **20/xx** | Vận hành tạo / mở Lịch 2 | Không đổi | `status = 0`<br>`exam_status = 2` | `status = 1`<br>`exam_status = 0` (Chờ thi)<br>`submission_status = 0` |
| **Hôm nay** | `student_score` phát sinh update (tính điểm / update điểm số / observer trigger) | `exam_status = 1`<br>`submission_status = 1` | `status = 0`<br>`exam_status = 2` | **`status = 1`**<br>**`exam_status = 1` (Bị ghi nhận đã nộp nhầm)**<br>**`submission_status = 1`** |

---

## 2. Phân tích nguyên nhân gốc rễ (Root Causes)

### 2.1. Observer lọc thiếu điều kiện mốc thời gian làm bài
Trong [StudentScoreObserver.php](file:///var/www/html/lms_hocmai/app/Observers/StudentScoreObserver.php#L157-L180):
```php
// Query hiện tại:
$scheduleQuery = \App\Models\StudentExamSchedule::on($connection)
    ->where('student_id', $studentScore->student_id)
    ->where('section_id', $sectionId);

if ($statusFilter !== 'all' && $statusFilter !== null) {
    $scheduleQuery->whereIn('status', [1]); // Mặc định status = 1
}

$scheduleQuery->update($updateData);
```
- **Vấn đề**: Observer chỉ giả định là *mọi bài thi được cập nhật đều thuộc về lịch thi đang mở (`status = 1`)*.
- Khi lịch thi mới được mở cho học sinh (để thi lại hoặc thi bù), bất kỳ sự kiện cập nhật nào trên bài thi cũ (ví dụ: chạy job tính điểm tổng, AI chấm speaking/writing, giáo viên sửa điểm) đều sẽ kích hoạt observer đè trạng thái bài cũ vào lịch thi mới.

### 2.2. Lệch pha giữa `checkSubmissionDeadline` và `syncScoreToSchedule`
1. Trong [AbnormalityCheckService.php](file:///var/www/html/lms_hocmai/app/Services/AbnormalityCheckService.php#L901-L905):
   Khi học sinh nộp bài ngày 19:
   - `checkSubmissionDeadline` thực hiện:
     ```php
     $studentScore->update(['exam_status' => 1]);
     $studentScore->update(['submission_status' => $submissionStatus, 'is_deadline_checked' => 1]);
     ```
   - Cả hai lệnh `update()` này đều trigger `StudentScoreObserver::updated()`.
   - Lúc này Lịch 1 đã có `status = 0`. Observer lọc `where('status', 1)` nên **không tìm thấy lịch nào để update**.
   - Kết quả: `student_score` thì thành `exam_status = 1`, nhưng Lịch 1 vẫn kẹt ở `exam_status = 2`.
2. Khi Lịch 2 được tạo ngày 20 với `status = 1`, `syncScoreToSchedule` trong lần update tiếp theo ngay lập tức bắt trúng Lịch 2 và đồng bộ trạng thái `exam_status = 1` sang Lịch 2.

---

## 3. Giải pháp đề xuất (Proposed Solutions)

### Giải pháp: Ràng buộc theo khoảng thời gian `[start_date, deadline]` (Khuyến nghị)

Nguyên tắc: Mỗi bài thi (`StudentScore`) chỉ được phép đồng bộ trạng thái sang Lịch thi (`StudentExamSchedule`) nếu thời gian làm bài của nó nằm trong khung thời gian hiệu lực của lịch thi đó.

#### Cách thực hiện: Nâng cấp hàm `syncScoreToSchedule` trong Observer

Khi đồng bộ điểm sang lịch thi:
1. Lấy mốc thời gian làm bài: `created_at` (hoặc `StudentExamHistory` gần nhất) + 7 giờ (giờ VN).
2. Tìm lịch thi tương ứng có:
   - `student_id` và `section_id` khớp.
   - Thời gian làm bài nằm trong `[start_date, deadline]` (xem xét cả `zeus_start_date` / `zeus_deadline`).
   - Nếu không nằm trong khung nào, chỉ cập nhật vào lịch thi gần nhất trước thời điểm nộp (Lịch 1), **tuyệt đối không cập nhật vào lịch thi có `start_date` sau thời điểm làm bài**.

```php
/**
 * Đoạn code minh họa cách xử lý trong StudentScoreObserver::syncScoreToSchedule
 */
public function syncScoreToSchedule(StudentScore $studentScore, bool $isDelete = false, $scheduleStatus = null): void
{
    $connection = $studentScore->getConnectionName();
    if (!$connection || $connection === 'mysql') {
        return;
    }

    $cacheKey = "{$studentScore->quiz_id}:{$connection}";
    if (!array_key_exists($cacheKey, self::$quizParentCache)) {
        $quiz = \App\Models\ApiMoodle::on($connection)
            ->where('moodle_id', $studentScore->quiz_id)
            ->where('moodle_type', 'quiz')
            ->first();
        self::$quizParentCache[$cacheKey] = $quiz?->parent_id;
    }
    $sectionId = self::$quizParentCache[$cacheKey];

    if ($sectionId) {
        $scoreCreatedVn = \Carbon\Carbon::parse($studentScore->created_at)->addHours(7);

        // 1. Tìm schedule phù hợp nhất với thời gian làm bài
        // Ưu tiên schedule có start_date <= thời gian làm bài
        $targetSchedule = \App\Models\StudentExamSchedule::on($connection)
            ->where('student_id', $studentScore->student_id)
            ->where('section_id', $sectionId)
            ->where(function ($q) use ($scoreCreatedVn) {
                $q->where(function ($sub) use ($scoreCreatedVn) {
                    $sub->whereNotNull('zeus_start_date')
                        ->where('zeus_start_date', '<=', $scoreCreatedVn->toDateTimeString());
                })->orWhere(function ($sub) use ($scoreCreatedVn) {
                    $sub->whereNull('zeus_start_date')
                        ->whereNotNull('start_date')
                        ->where('start_date', '<=', $scoreCreatedVn->toDateTimeString());
                });
            })
            ->orderBy('id', 'desc')
            ->first();

        if (!$targetSchedule) {
            // Nếu không tìm thấy schedule nào có start_date <= thời gian làm bài,
            // không tự ý update vào schedule mới được tạo sau này!
            return;
        }

        // 2. Xác định trạng thái nộp bài
        $scheduleExamStatus = 0;
        $check = StudentScore::on($connection)
            ->where('exam_status', 1)
            ->where('student_id', $studentScore->student_id)
            ->where('quiz_id', $studentScore->quiz_id)
            ->first();
        if ($check) {
            $scheduleExamStatus = 1;
        }

        $updateData = [
            'exam_status'             => $scheduleExamStatus,
            'submission_status'       => $studentScore->submission_status,
            'score_processing_status' => $studentScore->score_processing_status,
        ];

        // 3. Chỉ cập nhật đúng bản ghi targetSchedule tìm được
        $targetSchedule->update($updateData);
    }
}
```

---

## 4. Kết luận & Khuyến nghị vận hành

1. **Về tính chính xác của mạch logic**:
   - Phân tích của bạn hoàn toàn chính xác về mặt hiện tượng và nguyên nhân dây chuyền giữa Observer và Schedule.
   - Lỗi này xuất phát từ việc `syncScoreToSchedule` mặc định lấy `status = 1` mà không kiểm tra xem bài làm của học sinh diễn ra vào đợt thi nào.
2. **Kế hoạch theo dõi**:
   - Tài liệu này được lưu trữ để tham khảo khi hệ thống cần chuẩn hóa việc quản lý nhiều đợt thi (re-open / reschedule) cho cùng một học sinh và cùng một bài thi.
