Jelajahi Sumber

fix(pdf): derive KP explain list from questions, sort by KP tree

Reorder explanation sections DFS by parent_kp_code; fallback chain unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
yemeishu 5 hari lalu
induk
melakukan
72cb39bd87
1 mengubah file dengan 191 tambahan dan 33 penghapusan
  1. 191 33
      app/Http/Controllers/ExamPdfController.php

+ 191 - 33
app/Http/Controllers/ExamPdfController.php

@@ -1081,6 +1081,180 @@ class ExamPdfController extends Controller
         ]);
     }
 
+    /**
+     * 按题号顺序从试卷题目反推知识点编码,去重(集合顺序用于同级兜底)。
+     * 展示顺序由 {@see sortKnowledgePointCodesByHierarchy} 按知识点树 DFS 决定。
+     *
+     * @return array<int,string>
+     */
+    private function extractKnowledgePointCodesFromPaperQuestions(string $paperId): array
+    {
+        $paperQuestions = \App\Models\PaperQuestion::query()
+            ->where('paper_id', $paperId)
+            ->orderBy('question_number')
+            ->get();
+
+        if ($paperQuestions->isEmpty()) {
+            return [];
+        }
+
+        $questionBankIds = $paperQuestions
+            ->pluck('question_bank_id')
+            ->filter()
+            ->unique()
+            ->values();
+
+        $questionKpMap = [];
+        if ($questionBankIds->isNotEmpty()) {
+            $questionKpMap = \App\Models\Question::query()
+                ->whereIn('id', $questionBankIds)
+                ->pluck('kp_code', 'id')
+                ->toArray();
+        }
+
+        $ordered = [];
+        $seen = [];
+
+        foreach ($paperQuestions as $pq) {
+            $kpCode = trim((string) ($pq->knowledge_point ?? ''));
+            if ($kpCode === '' && ! empty($pq->question_bank_id)) {
+                $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
+            }
+            if ($kpCode === '') {
+                continue;
+            }
+            if (isset($seen[$kpCode])) {
+                continue;
+            }
+            $seen[$kpCode] = true;
+            $ordered[] = $kpCode;
+        }
+
+        return $ordered;
+    }
+
+    /**
+     * papers.params 中的 kp_codes / kp_code_list(仅作无 paper_questions 时的兜底,例如未入库题目的讲解卷)。
+     *
+     * @param  array<string,mixed>|null  $params
+     * @return array<int,string>
+     */
+    private function kpCodesFromAssembleRequestPayload(?array $params): array
+    {
+        if ($params === null || $params === []) {
+            return [];
+        }
+
+        $seen = [];
+        foreach (['kp_codes', 'kp_code_list'] as $key) {
+            if (! isset($params[$key])) {
+                continue;
+            }
+            $raw = $params[$key];
+            if (is_string($raw)) {
+                $parts = array_filter(array_map('trim', explode(',', $raw)));
+                foreach ($parts as $c) {
+                    if ($c !== '') {
+                        $seen[$c] = true;
+                    }
+                }
+            } elseif (is_array($raw)) {
+                foreach ($raw as $c) {
+                    $c = trim((string) $c);
+                    if ($c !== '') {
+                        $seen[$c] = true;
+                    }
+                }
+            }
+        }
+
+        return array_keys($seen);
+    }
+
+    /**
+     * 按知识点树层级排序:根在前,同层兄弟按 kp_code 字典序,深度优先。
+     * 库中不存在的编码保留在末尾,顺序与原列表一致。
+     *
+     * @param  array<int,string>  $kpCodes
+     * @return array<int,string>
+     */
+    private function sortKnowledgePointCodesByHierarchy(array $kpCodes): array
+    {
+        $kpCodes = array_values(array_unique(array_filter(array_map(static fn ($c) => trim((string) $c), $kpCodes), static fn ($c) => $c !== '')));
+        if (count($kpCodes) <= 1) {
+            return $kpCodes;
+        }
+
+        $byCode = [];
+        $pending = $kpCodes;
+        $guard = 0;
+        while ($pending !== [] && $guard < 100) {
+            $guard++;
+            $rows = \App\Models\KnowledgePoint::query()
+                ->whereIn('kp_code', $pending)
+                ->get()
+                ->keyBy('kp_code');
+            $next = [];
+            foreach ($rows as $code => $row) {
+                $byCode[$code] = $row;
+                $p = trim((string) ($row->parent_kp_code ?? ''));
+                if ($p !== '' && ! isset($byCode[$p])) {
+                    $next[$p] = true;
+                }
+            }
+            $pending = array_keys($next);
+        }
+
+        if ($byCode === []) {
+            return $kpCodes;
+        }
+
+        $childrenByParent = [];
+        foreach ($byCode as $code => $row) {
+            $p = trim((string) ($row->parent_kp_code ?? ''));
+            if ($p !== '' && isset($byCode[$p])) {
+                $childrenByParent[$p][] = $code;
+            }
+        }
+        foreach ($childrenByParent as &$kids) {
+            sort($kids, SORT_STRING);
+        }
+        unset($kids);
+
+        $roots = [];
+        foreach ($byCode as $code => $row) {
+            $p = trim((string) ($row->parent_kp_code ?? ''));
+            if ($p === '' || ! isset($byCode[$p])) {
+                $roots[] = $code;
+            }
+        }
+        sort($roots, SORT_STRING);
+
+        $want = array_flip($kpCodes);
+        $out = [];
+
+        $visit = function (string $node) use (&$visit, &$out, &$want, &$childrenByParent): void {
+            if (isset($want[$node])) {
+                $out[] = $node;
+            }
+            foreach ($childrenByParent[$node] ?? [] as $child) {
+                $visit($child);
+            }
+        };
+
+        foreach ($roots as $r) {
+            $visit($r);
+        }
+
+        foreach ($kpCodes as $c) {
+            if (! isset($byCode[$c])) {
+                $out[] = $c;
+            }
+        }
+
+        return $out;
+    }
+
     /**
      * 知识点讲解视图
      */
@@ -1099,40 +1273,24 @@ class ExamPdfController extends Controller
         // 生成时间(格式:2026年01月30日 15:04:05)
         $generateDateTime = now()->format('Y年m月d日 H:i:s');
 
-        // 优先使用 paper 中保存的 explanation_kp_codes(组卷时指定的知识点,最多2个)
-        $kpCodes = $paper->explanation_kp_codes ?? [];
-
-        // 如果没有保存 explanation_kp_codes,回退到从题目中提取(兼容旧数据)
-        if (empty($kpCodes)) {
-            $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
-            $seen = [];
-
-            $questionBankIds = $paperQuestions
-                ->pluck('question_bank_id')
-                ->filter()
-                ->unique()
-                ->values();
-            $questionKpMap = [];
-            if ($questionBankIds->isNotEmpty()) {
-                $questionKpMap = \App\Models\Question::whereIn('id', $questionBankIds)
-                    ->pluck('kp_code', 'id')
-                    ->toArray();
-            }
+        // 1) 主路径:由题目反推知识点(去重后有几个展示几块)
+        $kpCodes = $this->extractKnowledgePointCodesFromPaperQuestions((string) $paper_id);
 
-            foreach ($paperQuestions as $pq) {
-                $kpCode = trim((string) ($pq->knowledge_point ?? ''));
-                if ($kpCode === '' && ! empty($pq->question_bank_id)) {
-                    $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
-                }
-                if ($kpCode === '') {
-                    continue;
-                }
-                if (isset($seen[$kpCode])) {
-                    continue;
-                }
-                $seen[$kpCode] = true;
-                $kpCodes[] = $kpCode;
-            }
+        // 2) 无题目行时:策略层 explanation_kp_codes
+        if ($kpCodes === []) {
+            $kpCodes = $paper->explanation_kp_codes ?? [];
+        }
+        if (! is_array($kpCodes)) {
+            $kpCodes = [];
+        }
+
+        // 3) 仍为空:params 快照(无 paper_questions 的边界场景)
+        if ($kpCodes === []) {
+            $kpCodes = $this->kpCodesFromAssembleRequestPayload(is_array($paper->params) ? $paper->params : null);
+        }
+
+        if (count($kpCodes) > 1) {
+            $kpCodes = $this->sortKnowledgePointCodesByHierarchy($kpCodes);
         }
 
         // 使用 ExamPdfExportService 构建知识点数据