Browse Source

feat(assemble): add types 11/12/13 with KP explain PDF, map to 1/2/3

Introduce AssembleType helper; job passes strategy type; store 11-13 on
paper; unified PDF includes explain only for 11-12-13; labels match 1/2/3.

Co-authored-by: Cursor <cursoragent@cursor.com>
yemeishu 5 ngày trước cách đây
mục cha
commit
7fea63df76

+ 1 - 1
app/Http/Controllers/Api/IntelligentExamController.php

@@ -92,7 +92,7 @@ class IntelligentExamController extends Controller
             'mistake_question_ids.*' => 'string',
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             // 新增:组卷类型
-            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15,16,22',
+            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,11,12,13,15,16,22',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             // 错题本类型专用参数
             'paper_ids' => 'nullable|array',

+ 19 - 11
app/Jobs/AssembleExamTaskJob.php

@@ -9,6 +9,7 @@ use App\Services\QuestionBankService;
 use App\Services\QuestionPayloadMapper;
 use App\Services\TaskManager;
 use App\Services\WrongQuestionPracticePlanService;
+use App\Support\AssembleType;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -54,7 +55,8 @@ class AssembleExamTaskJob implements ShouldQueue
         try {
             $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...');
 
-            $assembleType = (int) ($data['assemble_type'] ?? 4);
+            $requestedAssembleType = (int) ($data['assemble_type'] ?? 4);
+            $strategyAssembleType = AssembleType::toStrategyType($requestedAssembleType);
             $difficultyCategory = $data['difficulty_category'] ?? 1;
             $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
             $mistakeIds = $data['mistake_ids'] ?? [];
@@ -68,16 +70,16 @@ class AssembleExamTaskJob implements ShouldQueue
             $explanationKpCodes = null;
             $wrongQuestionPracticePlan = null;
 
-            if (in_array($assembleType, [15, 16], true)) {
+            if (in_array($requestedAssembleType, [15, 16], true)) {
                 // assemble_type=15(错题再练):paper_ids 为题库 question_id,直组原错题。
                 // assemble_type=16(错题追练):paper_ids 仍为题库 question_id,但只用来生成知识点组卷计划。
                 $questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
                 if ($questionIdList === []) {
-                    $taskManager->markTaskFailed($this->taskId, ($assembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
+                    $taskManager->markTaskFailed($this->taskId, ($requestedAssembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
                     return;
                 }
 
-                if ($assembleType === 16) {
+                if ($requestedAssembleType === 16) {
                     $wrongQuestionPracticePlan = $wrongQuestionPracticePlanService->build(
                         (string) $data['student_id'],
                         $questionIdList,
@@ -153,7 +155,7 @@ class AssembleExamTaskJob implements ShouldQueue
                 }
             } elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
                 // assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
-                if ($assembleType === 5) {
+                if ($requestedAssembleType === 5) {
                     $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
                         (string) $data['student_id'],
                         $mistakeIds,
@@ -187,17 +189,17 @@ class AssembleExamTaskJob implements ShouldQueue
                     'student_id' => $data['student_id'],
                     'grade' => $data['grade'] ?? null,
                     'total_questions' => $data['total_questions'],
-                    'kp_codes' => $assembleType === 3 ? null : ($data['kp_codes'] ?? null),
+                    'kp_codes' => $strategyAssembleType === 3 ? null : ($data['kp_codes'] ?? null),
                     'skills' => $data['skills'] ?? [],
                     'question_type_ratio' => $questionTypeRatio,
                     'difficulty_category' => $difficultyCategory,
-                    'assemble_type' => $assembleType,
+                    'assemble_type' => $strategyAssembleType,
                     'exam_type' => $data['exam_type'] ?? 'general',
                     'paper_ids' => $paperIds,
                     'textbook_id' => $data['textbook_id'] ?? null,
                     'end_catalog_id' => $data['end_catalog_id'] ?? null,
                     'chapter_id_list' => $data['chapter_id_list'] ?? null,
-                    'kp_code_list' => $assembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
+                    'kp_code_list' => $strategyAssembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
                     'practice_options' => $data['practice_options'] ?? null,
                     'mistake_options' => $data['mistake_options'] ?? null,
                 ];
@@ -219,7 +221,8 @@ class AssembleExamTaskJob implements ShouldQueue
                 'task_id' => $this->taskId,
                 'phase' => 'select_and_prepare_questions',
                 'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
-                'assemble_type' => $assembleType,
+                'assemble_type' => $requestedAssembleType,
+                'strategy_assemble_type' => $strategyAssembleType,
                 'question_count' => count($questions),
             ]);
 
@@ -235,7 +238,12 @@ class AssembleExamTaskJob implements ShouldQueue
             $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
             $totalScore = array_sum(array_column($questions, 'score'));
 
-            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
+            $finalAssembleType = ($result !== null && isset($result['assemble_type']))
+                ? (int) $result['assemble_type']
+                : $requestedAssembleType;
+            if (in_array($requestedAssembleType, [11, 12, 13], true)) {
+                $finalAssembleType = $requestedAssembleType;
+            }
             if ($finalAssembleType === 16) {
                 $difficultyCategory = $this->deriveDifficultyCategoryFromSelectedDistribution($questions);
             }
@@ -268,7 +276,7 @@ class AssembleExamTaskJob implements ShouldQueue
 
             $finalStats = $result['stats'] ?? [
                 'total_selected' => count($questions),
-                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($assembleType, [15, 16], true),
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($requestedAssembleType, [15, 16], true),
             ];
             if ($wrongQuestionPracticePlan !== null) {
                 $finalStats['wrong_question_practice_plan'] = $wrongQuestionPracticePlan;

+ 1 - 1
app/Services/ApiDocumentation.php

@@ -277,7 +277,7 @@ class ApiDocumentation
                             ['name' => 'grade', 'type' => 'string', 'required' => true, 'description' => '年级(7/8/9)'],
                             ['name' => 'knowledge_points', 'type' => 'array', 'required' => false, 'description' => '指定知识点列表'],
                             ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度偏好:easy/medium/hard/adaptive'],
-                            ['name' => 'assemble_type', 'type' => 'integer', 'required' => false, 'description' => '组卷类型;15=错题再练,16=错题追练,22=知识点讲解PDF'],
+                            ['name' => 'assemble_type', 'type' => 'integer', 'required' => false, 'description' => '组卷类型;11/12/13 分别等同 1/2/3 选题规则且 PDF 含知识点讲解;15=错题再练;16=错题追练;22=知识点讲解PDF'],
                             ['name' => 'paper_ids', 'type' => 'array', 'required' => false, 'description' => 'assemble_type=15/16 时临时承载题库题目 question_id 列表'],
                         ],
                     ],

+ 3 - 2
app/Services/ExamPdfExportService.php

@@ -7,6 +7,7 @@ use App\DTO\ReportPayloadDto;
 use App\Http\Controllers\ExamPdfController;
 use App\Models\KnowledgeExplanation;
 use App\Models\Paper;
+use App\Support\AssembleType;
 use App\Models\Question;
 use App\Models\Student;
 use App\Models\Teacher;
@@ -161,11 +162,11 @@ class ExamPdfExportService
             $lastMark = $now;
         };
 
-        // 与组卷规则保持一致:仅知识点组卷类型(paper_type=2)包含知识点讲解
+        // assemble_type 11/12/13:与同基准 1/2/3 组卷相同,但 PDF 叠加知识点讲解;1/2/3 本身不带讲解页
         $paperType = Paper::query()
             ->where('paper_id', $paperId)
             ->value('paper_type');
-        $shouldIncludeKpExplain = ((int) $paperType) === 2;
+        $shouldIncludeKpExplain = AssembleType::includesKnowledgeExplanationPdf((int) $paperType);
         $mark('load_paper_type_ms');
 
         Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [

+ 1 - 1
app/Services/ExamTypeStrategy.php

@@ -35,7 +35,7 @@ class ExamTypeStrategy
 
     /**
      * 根据组卷类型构建参数
-     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 15-错题再练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理), 16-错题追练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理)
+     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 11/12/13-含知识点讲解变体(入参先映射为 1/2/3 再走下列规则), 15-错题再练, 16-错题追练
      *
      * 映射规则(前端不改,后端动态处理):
      * - 0, 9(摸底)→ 章节摸底(新逻辑)

+ 32 - 0
app/Support/AssembleType.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Support;
+
+/**
+ * assemble_type 约定:
+ * - 1/2/3(及 8 等既有类型):组卷逻辑按原语义;统一 PDF 不带知识点讲解前置页。
+ * - 11/12/13:分别与 1/2/3 相同的选题与策略,但 paper_type 存 11/12/13,且 PDF 叠加知识点讲解。
+ */
+final class AssembleType
+{
+    /**
+     * 「含讲解」变体 → 策略层类型(传给 ExamTypeStrategy / generateIntelligentExam)。
+     */
+    public static function toStrategyType(int $assembleType): int
+    {
+        return match ($assembleType) {
+            11 => 1,
+            12 => 2,
+            13 => 3,
+            default => $assembleType,
+        };
+    }
+
+    /**
+     * 统一 PDF(generateUnifiedPdf)是否在试卷前插入知识点讲解 HTML。
+     */
+    public static function includesKnowledgeExplanationPdf(int $paperType): bool
+    {
+        return in_array($paperType, [11, 12, 13], true);
+    }
+}

+ 3 - 3
app/Support/PaperNaming.php

@@ -11,9 +11,9 @@ class PaperNaming
     {
         return match ($assembleType) {
             0, 9 => '智能摸底',
-            1, 4, 8 => '智能组题',
-            2 => '知识点组题',
-            3 => '教材组题',
+            1, 4, 8, 11 => '智能组题',
+            2, 12 => '知识点组题',
+            3, 13 => '教材组题',
             5 => '智能追练',
             15 => '错题再练',
             16 => '错题追练',