|
|
@@ -317,8 +317,9 @@ class DiagnosticChapterService
|
|
|
$targetChapterIds = is_array($targetChapterIds)
|
|
|
? array_values(array_unique(array_filter(array_map('intval', $targetChapterIds), fn ($id) => $id > 0)))
|
|
|
: [];
|
|
|
+ $payloadCache = [];
|
|
|
|
|
|
- // 指定章节摸底流程:先在传入列表找未摸底章节,若都摸底则扩展到同教材继续找。
|
|
|
+ // 指定章节摸底流程:以传入章节为锚点。
|
|
|
if (!empty($targetChapterIds)) {
|
|
|
// 兜底:允许传入 section/subsection,自动向上映射到 chapter
|
|
|
$targetChapterIds = $this->normalizeToChapterIds($targetChapterIds);
|
|
|
@@ -331,51 +332,23 @@ class DiagnosticChapterService
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- // 保持传入顺序遍历,不使用教材排序字段重排
|
|
|
+ // 保持传入顺序,首个有效章作为锚点
|
|
|
$chapterMap = TextbookCatalog::query()
|
|
|
->whereIn('id', $targetChapterIds)
|
|
|
->where('node_type', 'chapter')
|
|
|
->get(['id', 'textbook_id', 'title', 'parent_id', 'node_type', 'sort_order', 'display_no'])
|
|
|
->keyBy('id');
|
|
|
|
|
|
- $orderedChapters = [];
|
|
|
+ $anchorChapter = null;
|
|
|
foreach ($targetChapterIds as $chapterId) {
|
|
|
- $chapter = $chapterMap->get($chapterId);
|
|
|
- if (!$chapter) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- $orderedChapters[] = $chapter;
|
|
|
-
|
|
|
- // 指定列表优先找“未摸底”章节
|
|
|
- if ($this->hasChapterDiagnostic($studentId, (int) $chapter->id)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
|
|
|
- $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
|
|
|
-
|
|
|
- if (empty($kpCodesWithQuestions)) {
|
|
|
- continue;
|
|
|
+ $candidate = $chapterMap->get($chapterId);
|
|
|
+ if ($candidate) {
|
|
|
+ $anchorChapter = $candidate;
|
|
|
+ break;
|
|
|
}
|
|
|
-
|
|
|
- Log::info('DiagnosticChapterService: 指定章节摸底命中未摸底章节', [
|
|
|
- 'textbook_id' => $chapter->textbook_id,
|
|
|
- 'student_id' => $studentId,
|
|
|
- 'target_chapter_ids' => $targetChapterIds,
|
|
|
- 'chapter_id' => $chapter->id,
|
|
|
- 'chapter_name' => $chapter->name ?? $chapter->title ?? '',
|
|
|
- 'kp_count' => count($kpCodesWithQuestions),
|
|
|
- ]);
|
|
|
-
|
|
|
- return [
|
|
|
- 'chapter_id' => $chapter->id,
|
|
|
- 'chapter_name' => $chapter->name ?? $chapter->title ?? '',
|
|
|
- 'section_ids' => $chapterData['section_ids'],
|
|
|
- 'kp_codes' => $kpCodesWithQuestions,
|
|
|
- ];
|
|
|
}
|
|
|
|
|
|
- if (empty($orderedChapters)) {
|
|
|
+ if (!$anchorChapter) {
|
|
|
Log::warning('DiagnosticChapterService: 指定章节未找到有效chapter节点', [
|
|
|
'student_id' => $studentId,
|
|
|
'target_chapter_ids' => $targetChapterIds,
|
|
|
@@ -383,8 +356,7 @@ class DiagnosticChapterService
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- // 指定章节都已摸底:在同教材内继续按顺序找未摸底章节
|
|
|
- $anchorTextbookId = (int) ($orderedChapters[0]->textbook_id ?? $textbookId);
|
|
|
+ $anchorTextbookId = (int) ($anchorChapter->textbook_id ?? $textbookId);
|
|
|
$chaptersInTextbook = TextbookCatalog::query()
|
|
|
->where('textbook_id', $anchorTextbookId)
|
|
|
->where('node_type', 'chapter')
|
|
|
@@ -393,61 +365,127 @@ class DiagnosticChapterService
|
|
|
->orderBy('id')
|
|
|
->get();
|
|
|
|
|
|
- foreach ($chaptersInTextbook as $chapter) {
|
|
|
- if ($this->hasChapterDiagnostic($studentId, (int) $chapter->id)) {
|
|
|
- continue;
|
|
|
+ if ($chaptersInTextbook->isEmpty()) {
|
|
|
+ Log::warning('DiagnosticChapterService: 指定章节所属教材无章节', [
|
|
|
+ 'textbook_id' => $anchorTextbookId,
|
|
|
+ 'student_id' => $studentId,
|
|
|
+ 'target_chapter_ids' => $targetChapterIds,
|
|
|
+ ]);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $chaptersInTextbookIds = $chaptersInTextbook->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
+ $diagnosedSet = $this->getDiagnosedChapterIdSet($studentId, $chaptersInTextbookIds);
|
|
|
+
|
|
|
+ // 全教材都摸底:输入什么章就输出什么章(is_restart=true)
|
|
|
+ $allDiagnosed = true;
|
|
|
+ foreach ($chaptersInTextbookIds as $chapterId) {
|
|
|
+ if (!isset($diagnosedSet[$chapterId])) {
|
|
|
+ $allDiagnosed = false;
|
|
|
+ break;
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
|
|
|
- $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
|
|
|
- if (empty($kpCodesWithQuestions)) {
|
|
|
- continue;
|
|
|
+ $anchorPayload = $this->buildChapterPayload((int) $anchorChapter->id, $anchorChapter, $payloadCache);
|
|
|
+ if ($allDiagnosed) {
|
|
|
+ if ($anchorPayload !== null) {
|
|
|
+ Log::info('DiagnosticChapterService: 全教材已摸底,返回输入锚点章节并重启', [
|
|
|
+ 'textbook_id' => $anchorTextbookId,
|
|
|
+ 'student_id' => $studentId,
|
|
|
+ 'anchor_chapter_id' => $anchorChapter->id,
|
|
|
+ ]);
|
|
|
+ $anchorPayload['is_restart'] = true;
|
|
|
+ return $anchorPayload;
|
|
|
}
|
|
|
|
|
|
- Log::info('DiagnosticChapterService: 指定章节已摸底完,切换同教材后续未摸底章节', [
|
|
|
- 'anchor_textbook_id' => $anchorTextbookId,
|
|
|
- 'student_id' => $studentId,
|
|
|
- 'target_chapter_ids' => $targetChapterIds,
|
|
|
- 'chapter_id' => $chapter->id,
|
|
|
- 'chapter_name' => $chapter->name ?? $chapter->title ?? '',
|
|
|
- 'kp_count' => count($kpCodesWithQuestions),
|
|
|
- ]);
|
|
|
+ // 兜底返回教材第一章(is_restart=true)
|
|
|
+ $firstChapter = $chaptersInTextbook->first();
|
|
|
+ if ($firstChapter) {
|
|
|
+ $firstPayload = $this->buildChapterPayload((int) $firstChapter->id, $firstChapter, $payloadCache);
|
|
|
+ if ($firstPayload !== null) {
|
|
|
+ $firstPayload['is_restart'] = true;
|
|
|
+ Log::info('DiagnosticChapterService: 全教材已摸底且输入章无有效题,兜底第一章重启', [
|
|
|
+ 'textbook_id' => $anchorTextbookId,
|
|
|
+ 'student_id' => $studentId,
|
|
|
+ 'chapter_id' => $firstChapter->id,
|
|
|
+ ]);
|
|
|
+ return $firstPayload;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- return [
|
|
|
- 'chapter_id' => $chapter->id,
|
|
|
- 'chapter_name' => $chapter->name ?? $chapter->title ?? '',
|
|
|
- 'section_ids' => $chapterData['section_ids'],
|
|
|
- 'kp_codes' => $kpCodesWithQuestions,
|
|
|
- ];
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- // 与普通摸底逻辑保持一致:全教材都摸底后重启,返回第一章
|
|
|
- $firstChapter = $chaptersInTextbook->first();
|
|
|
- if ($firstChapter) {
|
|
|
- $chapterData = $this->getChapterKnowledgePointsSimple((int) $firstChapter->id);
|
|
|
- $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
|
|
|
+ // 未全教材摸底:优先检查锚点章,未达标就仍以该章摸底
|
|
|
+ if ($anchorPayload !== null) {
|
|
|
+ $allMastered = StudentKnowledgeMastery::allAtLeastSkipNoQuestions(
|
|
|
+ $studentId,
|
|
|
+ $anchorPayload['kp_codes'],
|
|
|
+ 0.9
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!$allMastered) {
|
|
|
+ Log::info('DiagnosticChapterService: 锚点章节未达标,继续以该章摸底', [
|
|
|
+ 'textbook_id' => $anchorTextbookId,
|
|
|
+ 'student_id' => $studentId,
|
|
|
+ 'anchor_chapter_id' => $anchorChapter->id,
|
|
|
+ 'kp_count' => count($anchorPayload['kp_codes']),
|
|
|
+ ]);
|
|
|
+ return $anchorPayload;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 锚点达标后,按教材顺序向后找第一个未摸底章节
|
|
|
+ $anchorIndex = $chaptersInTextbook->search(fn ($c) => (int) $c->id === (int) $anchorChapter->id);
|
|
|
+ $anchorIndex = $anchorIndex === false ? 0 : (int) $anchorIndex;
|
|
|
+
|
|
|
+ for ($i = $anchorIndex + 1; $i < $chaptersInTextbook->count(); $i++) {
|
|
|
+ $chapter = $chaptersInTextbook[$i];
|
|
|
+ if (isset($diagnosedSet[(int) $chapter->id])) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
|
|
|
+ if ($payload === null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
- Log::info('DiagnosticChapterService: 指定章节及同教材已摸底完成,重启到第一章', [
|
|
|
+ Log::info('DiagnosticChapterService: 锚点章节已达标,向后推进到未摸底章节', [
|
|
|
'textbook_id' => $anchorTextbookId,
|
|
|
'student_id' => $studentId,
|
|
|
- 'target_chapter_ids' => $targetChapterIds,
|
|
|
- 'chapter_id' => $firstChapter->id,
|
|
|
+ 'anchor_chapter_id' => $anchorChapter->id,
|
|
|
+ 'next_chapter_id' => $chapter->id,
|
|
|
]);
|
|
|
+ return $payload;
|
|
|
+ }
|
|
|
|
|
|
- return [
|
|
|
- 'chapter_id' => $firstChapter->id,
|
|
|
- 'chapter_name' => $firstChapter->name ?? $firstChapter->title ?? '',
|
|
|
- 'section_ids' => $chapterData['section_ids'],
|
|
|
- 'kp_codes' => $kpCodesWithQuestions,
|
|
|
- 'is_restart' => true,
|
|
|
- ];
|
|
|
+ // 若后续没有可用未摸底章,再从教材起点补找未摸底章
|
|
|
+ for ($i = 0; $i <= $anchorIndex; $i++) {
|
|
|
+ $chapter = $chaptersInTextbook[$i];
|
|
|
+ if (isset($diagnosedSet[(int) $chapter->id])) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
|
|
|
+ if ($payload !== null) {
|
|
|
+ Log::info('DiagnosticChapterService: 锚点后无未摸底章,回到教材前序未摸底章节', [
|
|
|
+ 'textbook_id' => $anchorTextbookId,
|
|
|
+ 'student_id' => $studentId,
|
|
|
+ 'anchor_chapter_id' => $anchorChapter->id,
|
|
|
+ 'fallback_chapter_id' => $chapter->id,
|
|
|
+ ]);
|
|
|
+ return $payload;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- Log::warning('DiagnosticChapterService: 指定章节所属教材无章节可用于重启', [
|
|
|
- 'textbook_id' => $anchorTextbookId,
|
|
|
- 'student_id' => $studentId,
|
|
|
- 'target_chapter_ids' => $targetChapterIds,
|
|
|
- ]);
|
|
|
+ // 理论兜底:返回输入章并重启
|
|
|
+ if ($anchorPayload !== null) {
|
|
|
+ $anchorPayload['is_restart'] = true;
|
|
|
+ Log::info('DiagnosticChapterService: 指定章节流程兜底返回输入章节重启', [
|
|
|
+ 'textbook_id' => $anchorTextbookId,
|
|
|
+ 'student_id' => $studentId,
|
|
|
+ 'anchor_chapter_id' => $anchorChapter->id,
|
|
|
+ ]);
|
|
|
+ return $anchorPayload;
|
|
|
+ }
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
@@ -464,15 +502,14 @@ class DiagnosticChapterService
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
+ $chapterIds = $chapters->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
|
+ $diagnosedSet = $this->getDiagnosedChapterIdSet($studentId, $chapterIds);
|
|
|
+
|
|
|
foreach ($chapters as $chapter) {
|
|
|
// 检查是否已摸底
|
|
|
- if (!$this->hasChapterDiagnostic($studentId, $chapter->id)) {
|
|
|
- $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
|
|
|
-
|
|
|
- // 过滤掉没有题目的知识点
|
|
|
- $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
|
|
|
-
|
|
|
- if (empty($kpCodesWithQuestions)) {
|
|
|
+ if (!isset($diagnosedSet[(int) $chapter->id])) {
|
|
|
+ $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
|
|
|
+ if ($payload === null) {
|
|
|
// 这个章节没有题目,跳过
|
|
|
continue;
|
|
|
}
|
|
|
@@ -481,23 +518,20 @@ class DiagnosticChapterService
|
|
|
'textbook_id' => $textbookId,
|
|
|
'student_id' => $studentId,
|
|
|
'chapter_id' => $chapter->id,
|
|
|
- 'chapter_name' => $chapter->name ?? '',
|
|
|
- 'kp_count' => count($kpCodesWithQuestions),
|
|
|
+ 'chapter_name' => $chapter->name ?? $chapter->title ?? '',
|
|
|
+ 'kp_count' => count($payload['kp_codes']),
|
|
|
]);
|
|
|
|
|
|
- return [
|
|
|
- 'chapter_id' => $chapter->id,
|
|
|
- 'chapter_name' => $chapter->name ?? '',
|
|
|
- 'section_ids' => $chapterData['section_ids'],
|
|
|
- 'kp_codes' => $kpCodesWithQuestions,
|
|
|
- ];
|
|
|
+ return $payload;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 所有章节都已摸底,返回第一章(重新开始)
|
|
|
$firstChapter = $chapters->first();
|
|
|
- $chapterData = $this->getChapterKnowledgePointsSimple($firstChapter->id);
|
|
|
- $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
|
|
|
+ $firstPayload = $firstChapter ? $this->buildChapterPayload((int) $firstChapter->id, $firstChapter, $payloadCache) : null;
|
|
|
+ if ($firstPayload === null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
Log::info('DiagnosticChapterService: 所有章节都已摸底,返回第一章', [
|
|
|
'textbook_id' => $textbookId,
|
|
|
@@ -505,13 +539,8 @@ class DiagnosticChapterService
|
|
|
'chapter_id' => $firstChapter->id,
|
|
|
]);
|
|
|
|
|
|
- return [
|
|
|
- 'chapter_id' => $firstChapter->id,
|
|
|
- 'chapter_name' => $firstChapter->name ?? '',
|
|
|
- 'section_ids' => $chapterData['section_ids'],
|
|
|
- 'kp_codes' => $kpCodesWithQuestions,
|
|
|
- 'is_restart' => true, // 标记是重新开始
|
|
|
- ];
|
|
|
+ $firstPayload['is_restart'] = true;
|
|
|
+ return $firstPayload; // 标记是重新开始
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -568,6 +597,82 @@ class DiagnosticChapterService
|
|
|
return $chapterIds;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 根据 chapter_id 生成摸底章节载荷,若章节无可用题目知识点则返回 null。
|
|
|
+ */
|
|
|
+ private function buildChapterPayload(int $chapterId, $chapter = null, ?array &$payloadCache = null): ?array
|
|
|
+ {
|
|
|
+ if (is_array($payloadCache) && array_key_exists($chapterId, $payloadCache)) {
|
|
|
+ return $payloadCache[$chapterId];
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($chapter === null) {
|
|
|
+ $chapter = TextbookCatalog::query()
|
|
|
+ ->where('id', $chapterId)
|
|
|
+ ->where('node_type', 'chapter')
|
|
|
+ ->first(['id', 'title']);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$chapter) {
|
|
|
+ if (is_array($payloadCache)) {
|
|
|
+ $payloadCache[$chapterId] = null;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $chapterData = $this->getChapterKnowledgePointsSimple($chapterId);
|
|
|
+ $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
|
|
|
+ if (empty($kpCodesWithQuestions)) {
|
|
|
+ if (is_array($payloadCache)) {
|
|
|
+ $payloadCache[$chapterId] = null;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $payload = [
|
|
|
+ 'chapter_id' => $chapterId,
|
|
|
+ 'chapter_name' => $chapter->name ?? $chapter->title ?? '',
|
|
|
+ 'section_ids' => $chapterData['section_ids'],
|
|
|
+ 'kp_codes' => $kpCodesWithQuestions,
|
|
|
+ ];
|
|
|
+ if (is_array($payloadCache)) {
|
|
|
+ $payloadCache[$chapterId] = $payload;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $payload;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量获取学生在指定章节中的已摸底集合,避免循环 exists N+1 查询。
|
|
|
+ *
|
|
|
+ * @param array<int> $chapterIds
|
|
|
+ * @return array<int, bool>
|
|
|
+ */
|
|
|
+ private function getDiagnosedChapterIdSet(int $studentId, array $chapterIds): array
|
|
|
+ {
|
|
|
+ $chapterIds = array_values(array_unique(array_filter(array_map('intval', $chapterIds), fn ($id) => $id > 0)));
|
|
|
+ if (empty($chapterIds)) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $ids = \App\Models\Paper::query()
|
|
|
+ ->where('student_id', $studentId)
|
|
|
+ ->where('paper_type', 0)
|
|
|
+ ->whereIn('diagnostic_chapter_id', $chapterIds)
|
|
|
+ ->pluck('diagnostic_chapter_id')
|
|
|
+ ->map(fn ($id) => (int) $id)
|
|
|
+ ->unique()
|
|
|
+ ->values()
|
|
|
+ ->all();
|
|
|
+
|
|
|
+ $set = [];
|
|
|
+ foreach ($ids as $id) {
|
|
|
+ $set[$id] = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $set;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 获取当前应该学习的章节(第一个有未达标知识点的章节)
|
|
|
* 用于智能组卷流程
|