inferenceService ??= app(ImportInferenceService::class); } public ?int $importId = null; public ?int $selectedPaperId = null; public array $selectedIds = []; public string $search = ''; public string $groupBy = 'paper'; public bool $dense = false; public bool $filenameValid = true; public array $filenameParsed = []; public ?string $filenameWarning = null; public array $form = [ 'title' => null, 'edition' => null, 'grade' => null, 'term' => null, 'chapter' => null, 'source_type' => null, 'source_year' => null, 'textbook_id' => null, 'textbook_series' => null, 'textbook_series_id' => null, 'source_name' => null, 'source_page' => null, 'subject' => '数学', // 默认学科 'tags' => '', 'bundle_key' => null, 'expected_count' => null, 'catalog_node_ids' => [], ]; public array $batch = [ 'edition' => null, 'grade' => null, 'term' => null, 'chapter' => null, 'source_type' => null, 'source_year' => null, 'textbook_id' => null, 'textbook_series' => null, 'source_name' => null, 'source_page' => null, 'tags' => '', 'bundle_key' => null, 'expected_count' => null, 'catalog_node_ids' => [], ]; private array $catalogNodeCache = []; public function mount(): void { $this->importId = request()->integer('import_id'); $parsed = $this->parseImportFilename(); if (empty($parsed)) { $this->filenameValid = false; $this->filenameWarning = '文件名不符合规范:系列_年级_学期_学科_名称(例:北师大版_7_1_数学_...)'; } else { $this->filenameParsed = $parsed; $this->applyFilenameDefaults(); } $first = $this->papers()->first(); if ($first) { $this->selectPaper($first->id); } } public function importRecord(): ?MarkdownImport { return $this->importId ? MarkdownImport::query()->find($this->importId) : null; } public function papers() { if (!$this->importId) { return collect(); } $query = SourcePaper::query() ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId)) ->withCount(['candidates', 'parts']) ->orderBy('order'); if ($this->search !== '') { $query->where(function ($q) { $q->where('title', 'like', '%' . $this->search . '%') ->orWhere('full_title', 'like', '%' . $this->search . '%') ->orWhere('paper_code', 'like', '%' . $this->search . '%'); }); } return $query->get(); } public function groupedPapers(): array { $papers = $this->papers(); if ($this->groupBy === 'paper') { return $papers->groupBy(fn ($paper) => $paper->title ?: $paper->full_title ?: '未命名卷子')->toArray(); } if ($this->groupBy === 'grade') { return $papers->groupBy(fn ($paper) => $paper->grade ? $paper->grade . '年级' : '未标注年级')->toArray(); } return $papers->groupBy(fn ($paper) => Arr::get($paper->meta ?? [], 'bundle_key', '未归并套卷'))->toArray(); } public function selectedPaper(): ?SourcePaper { return $this->selectedPaperId ? SourcePaper::query()->withCount(['candidates', 'parts'])->find($this->selectedPaperId) : null; } public function selectPaper(int $paperId): void { $paper = SourcePaper::query()->find($paperId); if (!$paper) { return; } Log::info('MarkdownImportWorkbench select paper', [ 'import_id' => $this->importId, 'paper_id' => $paperId, 'paper_title' => $paper->title, 'selected_ids' => $this->selectedIds, ]); $parsed = $this->parseImportFilename(); $this->selectedPaperId = $paperId; $meta = $paper->meta ?? []; // 核心修正:直接基于物理 series_id 初始化,如果没有则尝试从文件名或标题解析 $finalSeriesId = $paper->series_id; $finalSeriesName = null; if (!$finalSeriesId) { // 兜底1:从文件名解析 if (!empty($parsed['series'])) { $formalSeries = $this->inferenceService()->resolveSeries($parsed['series']); $finalSeriesId = $formalSeries?->id; $finalSeriesName = $formalSeries ? $formalSeries->name : $parsed['series']; } // 兜底2:从标题模糊推断 (应对数据库数据未对齐的情况) if (!$finalSeriesId) { $title = $paper->full_title ?: $paper->title; $formalSeries = $this->inferenceService()->resolveSeries($title); $finalSeriesId = $formalSeries?->id; $finalSeriesName = $formalSeries?->name; } } if ($finalSeriesId && !$finalSeriesName) { $finalSeriesName = TextbookSeries::find($finalSeriesId)?->name; } $resolvedTextbook = null; if (!$paper->textbook_id && $finalSeriesId) { $resolvedTextbook = $this->inferenceService()->findBestTextbook([ 'series_id' => $finalSeriesId, 'grade' => $paper->grade ?: ($parsed['grade'] ?? null), 'term' => $paper->term ?: ($parsed['term'] ?? null), ]); } $initialCatalogIds = (array) (Arr::get($meta, 'catalog_node_ids') ?: (Arr::get($meta, 'catalog_node_id') ? [Arr::get($meta, 'catalog_node_id')] : [])); $filteredCatalogIds = $this->filterCatalogNodeIdsForTextbook($paper->textbook_id ?: ($resolvedTextbook?->id ?? null), $initialCatalogIds); if ($initialCatalogIds !== $filteredCatalogIds && !empty($initialCatalogIds)) { Log::warning('MarkdownImportWorkbench catalog nodes not in textbook', [ 'paper_id' => $paper->id, 'paper_title' => $paper->title, 'textbook_id' => $paper->textbook_id, 'original_catalog_node_ids' => $initialCatalogIds, 'filtered_catalog_node_ids' => $filteredCatalogIds, ]); } $this->form = [ 'title' => $paper->title, 'edition' => $paper->edition, 'grade' => $paper->grade ?: ($parsed['grade'] ?? null), 'term' => $paper->term ?: ($parsed['term'] ?? null), 'chapter' => $paper->chapter, 'source_type' => $paper->source_type, 'source_year' => $paper->source_year, 'textbook_id' => $paper->textbook_id ?: ($resolvedTextbook?->id ?? null), 'textbook_series' => $finalSeriesName, 'textbook_series_id' => $finalSeriesId, 'source_name' => Arr::get($meta, 'source_name') ?: ($parsed['name'] ?? null), 'source_page' => Arr::get($meta, 'source_page'), 'tags' => implode(',', Arr::get($meta, 'tags', [])), 'bundle_key' => Arr::get($meta, 'bundle_key'), 'expected_count' => Arr::get($meta, 'expected_count'), 'catalog_node_ids' => $filteredCatalogIds, ]; } public function updatedSelectedIds(): void { if (empty($this->selectedIds) || $this->selectedPaperId === null) { return; } if ($this->isBatchEmpty()) { $this->seedBatchFromCurrent(); } } public function updated($name, $value): void { if (!$this->selectedPaperId) { return; } } /** * 教材属性联动:当系列、年级、学期改变时,自动匹配最合适的教材 */ public function updatedFormGrade(): void { $this->reInferTextbook(); } public function updatedFormTerm(): void { $this->reInferTextbook(); } public function updatedFormTextbookSeriesId(): void { // 核心联动:系列 ID 变动,清空教材并重推 $this->form['textbook_id'] = null; $this->form['catalog_node_ids'] = []; // 同时同步一下显示名称 (虽然逻辑以 ID 为准,但保留名称用于前端显示或保存) $series = TextbookSeries::find($this->form['textbook_series_id']); $this->form['textbook_series'] = $series?->name; $this->reInferTextbook(); } protected function reInferTextbook(): void { if (!empty($this->form['textbook_id'])) { return; } $best = $this->inferenceService()->findBestTextbook($this->form); if ($best) { // 无论 ID 是否改变,都强制执行一次属性校准,确保“一环扣一环” $this->syncTextbookAttributes($best); } } protected function syncTextbookAttributes(Textbook $textbook): void { $this->form['textbook_id'] = $textbook->id; $this->form['grade'] = (string)$textbook->grade; $this->form['term'] = $this->semesterToTerm($textbook->semester); $this->form['textbook_series_id'] = $textbook->series_id; $series = $textbook->getRelation('series') ?: $textbook->series()->first(); $seriesName = $textbook->track ?: ($series?->name ?? null); if ($seriesName) { $this->form['textbook_series'] = $seriesName; } $this->savePaper(); } public function updatedFormTextbookId($value): void { if (!$this->selectedPaperId || !$value) { return; } // 权威源:从数据库获取该教材的所有官方属性 $textbook = Textbook::query()->with('series')->find($value); if ($textbook) { $this->syncTextbookAttributes($textbook); } } protected function semesterToTerm(?int $semester): ?string { return match ($semester) { 1 => '上册', 2 => '下册', default => null, }; } public function seedBatchFromCurrent(): void { $this->batch = [ 'edition' => $this->form['edition'] ?? null, 'grade' => $this->form['grade'] ?? null, 'term' => $this->form['term'] ?? null, 'chapter' => $this->form['chapter'] ?? null, 'source_type' => $this->form['source_type'] ?? null, 'source_year' => $this->form['source_year'] ?? null, 'textbook_id' => $this->form['textbook_id'] ?? null, 'textbook_series_id' => $this->form['textbook_series_id'] ?? null, 'textbook_series' => $this->form['textbook_series'] ?? null, 'source_name' => $this->form['source_name'] ?? null, 'source_page' => $this->form['source_page'] ?? null, 'tags' => $this->form['tags'] ?? '', 'bundle_key' => $this->form['bundle_key'] ?? null, 'expected_count' => $this->form['expected_count'] ?? null, 'catalog_node_ids' => $this->form['catalog_node_ids'] ?? [], ]; } public function savePaper(bool $silent = false): void { $paper = $this->selectedPaper(); if (!$paper) { Log::warning('MarkdownImportWorkbench save failed: no paper selected', [ 'import_id' => $this->importId, 'selected_paper_id' => $this->selectedPaperId, ]); return; } $selectedIds = array_values(array_filter(array_unique(array_map('intval', $this->selectedIds)))); $meta = $paper->meta ?? []; $meta['source_name'] = $this->form['source_name'] ?? null; $meta['source_page'] = $this->form['source_page'] ?? null; $meta['tags'] = $this->explodeTags($this->form['tags'] ?? ''); $meta['bundle_key'] = $this->form['bundle_key'] ?? null; $meta['expected_count'] = $this->form['expected_count'] ?? null; // 关键修正:确保目录 ID 是干净的整型数组,解决保存失效 $catalogNodeIds = $this->normalizeCatalogNodeIds($this->form['catalog_node_ids'] ?? []); $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? null, $catalogNodeIds); $meta['catalog_node_ids'] = $catalogNodeIds; // 同时保留旧字段供向后兼容 $meta['catalog_node_id'] = !empty($meta['catalog_node_ids']) ? $meta['catalog_node_ids'][0] : null; // 核心同步:保存时根据 series_id 确保名称同步 if (!empty($this->form['textbook_series_id'])) { $series = TextbookSeries::find($this->form['textbook_series_id']); if ($series) { $this->form['textbook_series'] = $series->name; } } $fields = [ 'title', 'edition', 'grade', 'term', 'chapter', 'source_type', 'source_year', 'textbook_id' ]; $updates = []; foreach ($fields as $field) { $value = $this->form[$field] ?? null; $updates[$field] = ($value === '' ? null : $value); } $updates['series_id'] = $this->form['textbook_series_id'] ?? null; $updates['meta'] = $meta; Log::info('MarkdownImportWorkbench saving paper', [ 'import_id' => $this->importId, 'paper_id' => $paper->id, 'paper_title' => $paper->title, 'catalog_node_ids' => $catalogNodeIds, 'textbook_id' => $updates['textbook_id'] ?? null, 'series_id' => $updates['series_id'] ?? null, ]); $paper->update($updates); Log::info('MarkdownImportWorkbench saved paper', [ 'import_id' => $this->importId, 'paper_id' => $paper->id, 'meta_catalog_node_ids' => $meta['catalog_node_ids'] ?? [], ]); if (count($selectedIds) > 1) { $otherIds = array_values(array_diff($selectedIds, [$paper->id])); if (!empty($otherIds)) { $this->seedBatchFromCurrent(); $this->applyBatchToIds($otherIds); Log::info('MarkdownImportWorkbench batch saved papers', [ 'import_id' => $this->importId, 'paper_ids' => $otherIds, 'catalog_node_ids' => $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids'] ?? []), ]); } } $this->selectedIds = []; if (!$silent) { Notification::make() ->title('保存成功') ->success() ->send(); } } public function applyBatch(): void { if (empty($this->selectedIds)) { return; } $this->applyBatchToIds($this->selectedIds); } public function autoInfer(): void { $paper = $this->selectedPaper(); if (!$paper) { return; } $parsed = $this->parseImportFilename(); if (!empty($parsed)) { // 关键:推断出的系列必须经过正式化 ID 锁定,否则无法触发联动 if (empty($this->form['textbook_series_id']) && !empty($parsed['series'])) { $formal = $this->inferenceService()->resolveSeries($parsed['series']); if ($formal) { $this->form['textbook_series_id'] = $formal->id; $this->form['textbook_series'] = $formal->name; } else { $this->form['textbook_series'] = $parsed['series']; } } $this->form['grade'] = $this->form['grade'] ?: $parsed['grade']; $this->form['term'] = $this->form['term'] ?: $parsed['term']; $this->form['source_name'] = $this->form['source_name'] ?: $parsed['name']; } $title = (string) ($paper->title ?? $paper->full_title ?? ''); $raw = (string) ($paper->raw_markdown ?? ''); $context = $title . ' ' . $raw; $this->form['term'] = $this->inferenceService()->inferTerm($context) ?? $this->form['term']; $this->form['grade'] = $this->inferenceService()->inferGrade($context) ?? $this->form['grade']; $this->form['chapter'] = $this->inferenceService()->inferChapter($context) ?? $this->form['chapter']; $this->form['source_type'] = $this->inferenceService()->inferSourceType($context) ?? $this->form['source_type']; if (empty($this->form['catalog_node_ids'])) { $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $this->form['textbook_id']); if ($matchedCatalog) { $this->form['catalog_node_ids'] = [$matchedCatalog]; } } // 执行推断后,立即触发教材重新关联和保存,确保“一环扣一环” $this->reInferTextbook(); $this->savePaper(); } public function autoBundleKey(): void { $paper = $this->selectedPaper(); if (!$paper) { return; } $this->form['bundle_key'] = $this->buildBundleKey($paper); } public function autoBundleKeySelected(): void { if (empty($this->selectedIds)) { return; } foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) { $meta = $paper->meta ?? []; $meta['bundle_key'] = $this->buildBundleKey($paper); $paper->update(['meta' => $meta]); } } public function autoInferSelected(): void { if (empty($this->selectedIds)) { return; } $parsed = $this->parseImportFilename(); foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) { $context = (string) ($paper->title ?? $paper->full_title ?? '') . ' ' . (string) ($paper->raw_markdown ?? ''); $updates = array_filter([ 'term' => $this->inferenceService()->inferTerm($context), 'grade' => $this->inferenceService()->inferGrade($context), 'chapter' => $this->inferenceService()->inferChapter($context), ], fn ($value) => $value !== null && $value !== ''); if (!empty($parsed)) { if (empty($paper->textbook_series) && !empty($parsed['series'])) { $updates['textbook_series'] = $parsed['series']; } if (empty($paper->grade) && !empty($parsed['grade'])) { $updates['grade'] = $parsed['grade']; } if (empty($paper->term) && !empty($parsed['term'])) { $updates['term'] = $parsed['term']; } } $meta = $paper->meta ?? []; if (!empty($parsed['name']) && empty($meta['source_name'])) { $meta['source_name'] = $parsed['name']; } if (empty($meta['catalog_node_ids'])) { $candidateTextbookId = $updates['textbook_id'] ?? $paper->textbook_id ?? null; // 如果没有教材 ID,实时推断一个 if (!$candidateTextbookId) { $best = $this->inferenceService()->findBestTextbook([ 'series_id' => $updates['textbook_series_id'] ?? null, 'grade' => $updates['grade'] ?? $paper->grade, 'term' => $updates['term'] ?? $paper->term, ]); if ($best) { $candidateTextbookId = $best->id; $updates['textbook_id'] = $best->id; } } $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $candidateTextbookId); if ($matchedCatalog) { $meta['catalog_node_ids'] = [$matchedCatalog]; $meta['catalog_node_id'] = $matchedCatalog; } } if (!empty($updates)) { $updates['meta'] = $meta; $paper->update($updates); } elseif (!empty($meta)) { $paper->update(['meta' => $meta]); } } } public function mergeSelectedPapers(): void { if (count($this->selectedIds) < 2) { $this->dispatch('notify', ['type' => 'warning', 'message' => '请至少选择两套卷子进行合并']); return; } $papers = SourcePaper::query() ->whereIn('id', $this->selectedIds) ->orderBy('order') ->get(); $target = $papers->shift(); // 第一套作为目标 DB::transaction(function () use ($target, $papers) { foreach ($papers as $source) { // 1. 移动所有候选题目 DB::table('pre_question_candidates') ->where('source_paper_id', $source->id) ->update(['source_paper_id' => $target->id]); // 2. 移动所有 PaperPart (如果需要) DB::table('paper_parts') ->where('source_paper_id', $source->id) ->update(['source_paper_id' => $target->id]); // 3. 追加 Markdown 内容 (可选,但有助于保持记录完整) $target->raw_markdown .= "\n\n" . $source->raw_markdown; // 4. 删除原卷子 $source->delete(); } $target->save(); }); $this->selectedIds = []; $this->selectPaper($target->id); Notification::make() ->title('卷子合并成功') ->success() ->send(); } public function selectAllVisible(): void { $this->selectedIds = $this->papers()->pluck('id')->toArray(); } public function clearSelection(): void { $this->selectedIds = []; } public function gradeOptions(): array { return collect(range(1, 12))->mapWithKeys(fn ($grade) => [$grade => $grade . '年级'])->toArray(); } public function termOptions(): array { return [ '上册' => '上册', '下册' => '下册', '上学期' => '上学期', '下学期' => '下学期', ]; } public function sourceTypeOptions(): array { return [ '期中' => '期中卷', '期末' => '期末卷', '单元卷' => '单元卷', '专项卷' => '专项卷', '教材' => '教材', '其他' => '其他', ]; } public function textbookOptions(): array { $query = Textbook::query(); // 核心联动:基于 series_id 进行物理过滤 $seriesId = $this->form['textbook_series_id'] ?? null; if ($seriesId) { $query->where('series_id', $seriesId); } return $query->orderBy('id') ->get(['id', 'official_title']) ->mapWithKeys(function ($textbook) { $title = $textbook->official_title ?: '未命名教材'; return [$textbook->id => $title]; }) ->toArray(); } public function seriesOptions(): array { return \App\Models\TextbookSeries::query() ->orderBy('sort_order') ->pluck('name', 'id') ->toArray(); } public function catalogOptions(?int $textbookId = null): array { $textbookId ??= $this->form['textbook_id'] ?? null; if (empty($textbookId)) { return []; } $nodes = TextbookCatalog::query() ->where('textbook_id', $textbookId) ->orderBy('sort_order') ->get(['id', 'title', 'parent_id']); $grouped = $nodes->groupBy('parent_id'); $walk = function ($parent, int $depth) use (&$walk, $grouped): array { $items = []; foreach ($grouped->get($parent, collect()) as $node) { $title = $node->title ?: '未命名目录'; $indent = str_repeat('—', $depth); $items[$node->id] = trim($indent . ' ' . $title); $items += $walk($node->id, $depth + 1); } return $items; }; return $walk(null, 0); } public function catalogCoverageSummary(): array { $textbookId = $this->selectedTextbookId(); if (!$textbookId) { return []; } $nodes = TextbookCatalog::query() ->where('textbook_id', $textbookId) ->whereDoesntHave('children') ->get(['id']); $coverage = []; SourcePaper::query() ->where('textbook_id', $textbookId) ->get(['meta']) ->each(function ($paper) use (&$coverage) { $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []); foreach ((array) $ids as $id) { if ($id) { $coverage[$id] = ($coverage[$id] ?? 0) + 1; } } }); $linked = count($coverage); $missing = max(0, $nodes->count() - count($coverage)); return [ 'total' => $nodes->count(), 'linked' => $linked, 'missing' => $missing, ]; } public function missingCatalogNodes(): array { $textbookId = $this->selectedTextbookId(); if (!$textbookId) { return []; } $nodes = TextbookCatalog::query() ->where('textbook_id', $textbookId) ->whereDoesntHave('children') ->orderBy('sort_order') ->get(['id', 'title']); $coverage = []; SourcePaper::query() ->where('textbook_id', $textbookId) ->get(['meta']) ->each(function ($paper) use (&$coverage) { $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []); foreach ((array) $ids as $id) { if ($id) { $coverage[$id] = ($coverage[$id] ?? 0) + 1; } } }); $missing = []; foreach ($nodes as $node) { if (!isset($coverage[$node->id])) { $missing[] = [ 'id' => $node->id, 'title' => $node->title, ]; } } return array_slice($missing, 0, 8); } public function catalogTitlesForPaper(SourcePaper|array $paper): array { $meta = $paper instanceof SourcePaper ? ($paper->meta ?? []) : ($paper['meta'] ?? []); $textbookId = $paper instanceof SourcePaper ? $paper->textbook_id : ($paper['textbook_id'] ?? null); if (empty($textbookId)) { return []; } $ids = $this->normalizeCatalogNodeIds($meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? [])); if (empty($ids)) { return []; } $missing = array_values(array_diff($ids, array_keys($this->catalogNodeCache))); if (!empty($missing)) { TextbookCatalog::query() ->whereIn('id', $missing) ->get(['id', 'title']) ->each(function ($node) { $this->catalogNodeCache[(int) $node->id] = $node->title ?: ('目录 #' . $node->id); }); } return array_values(array_filter(array_map(fn ($id) => $this->catalogNodeCache[$id] ?? null, $ids))); } public function textbookSuggestions(): array { $paper = $this->selectedPaper(); if (!$paper) { return []; } $title = (string) ($paper->title ?? $paper->full_title ?? ''); $context = mb_strtolower($title); $parsed = $this->parseImportFilename(); $grade = $paper->grade ? (int) $paper->grade : ($parsed['grade'] ?? null); $semester = $this->termToSemester($paper->term) ?? $this->termToSemester($parsed['term'] ?? null); $seriesHint = $paper->textbook_series ?: ($parsed['series'] ?? null); $subjectHint = $parsed['subject'] ?? null; return $this->inferenceService()->getTextbookSuggestions($paper, $parsed); } public function catalogSuggestions(): array { $paper = $this->selectedPaper(); $textbookId = $paper?->textbook_id ?? ($this->form['textbook_id'] ?? null); if (!$paper || !$textbookId) { return []; } $needle = trim((string) ($paper->chapter ?? $paper->title ?? '')); if ($needle === '') { return []; } $nodes = TextbookCatalog::query() ->where('textbook_id', $textbookId) ->orderBy('sort_order') ->get(['id', 'title']); $matches = []; foreach ($nodes as $node) { $title = (string) $node->title; if ($title !== '' && str_contains($needle, $title)) { $matches[] = ['id' => $node->id, 'title' => $title]; } } return array_slice($matches, 0, 5); } public function applyTextbookSuggestion(int $textbookId): void { $textbook = Textbook::query()->with('series')->find($textbookId); if (!$textbook) { return; } $this->form['textbook_id'] = $textbook->id; $this->form['textbook_series'] = $textbook->series?->name ?? $this->form['textbook_series']; } public function applyCatalogSuggestion(int $catalogId): void { $ids = $this->form['catalog_node_ids'] ?? []; if (!in_array($catalogId, $ids)) { $ids[] = $catalogId; $this->form['catalog_node_ids'] = $ids; $this->savePaper(); } } public function candidateCountFor(SourcePaper $paper): int { return (int) ($paper->candidates_count ?? 0); } public function checkCompleteness(): array { $paper = $this->selectedPaper(); if (!$paper) { return []; } $candidates = $paper->candidates() ->where('is_question_candidate', true) ->get(); $service = app(QuestionCandidateToQuestionService::class); $total = $candidates->count(); $invalid = 0; $issueStats = []; foreach ($candidates as $candidate) { $errors = $service->validateCandidate($candidate); if (!empty($errors)) { $invalid++; foreach ($errors as $error) { $issueStats[$error] = ($issueStats[$error] ?? 0) + 1; } } } return [ 'total' => $total, 'valid' => $total - $invalid, 'invalid' => $invalid, 'issues' => $issueStats, 'is_ready' => $total > 0 && $invalid === 0, ]; } private function explodeTags(string $tags): array { return array_values(array_filter(array_map('trim', explode(',', $tags)))); } private function termToSemester(?string $term): ?int { return $this->inferenceService()->termToSemester($term); } private function buildBundleKey(SourcePaper $paper): string { $import = $this->importRecord(); $base = $import?->file_name ? pathinfo($import->file_name, PATHINFO_FILENAME) : ($paper->title ?: $paper->full_title ?: '卷子'); $grade = $paper->grade ? $this->gradeToLabel((int) $paper->grade) : null; $term = $paper->term ? $paper->term : null; $sourceType = $paper->source_type ?: null; $parts = array_filter([$grade, $term, $sourceType, $base]); return implode('·', $parts); } private function gradeToLabel(int $grade): string { $map = [ 7 => '七年级', 8 => '八年级', 9 => '九年级', 10 => '高一', 11 => '高二', 12 => '高三', ]; return $map[$grade] ?? $grade . '年级'; } private function selectedTextbookId(): ?int { return $this->form['textbook_id'] ?: $this->batch['textbook_id']; } private function applyFilenameDefaults(): void { if (!$this->importId || empty($this->filenameParsed)) { return; } $parsed = $this->filenameParsed; $resolvedTextbook = $this->inferenceService()->resolveTextbookFromFilename($parsed); $papers = SourcePaper::query() ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId)) ->get(); foreach ($papers as $paper) { $meta = $paper->meta ?? []; $hasNumericGrade = is_numeric((string) $paper->grade); $hasAllDefaults = !empty($paper->textbook_id) && !empty($paper->textbook_series) && $hasNumericGrade && !empty($paper->term); if (!empty($meta['filename_defaults_applied']) && $hasAllDefaults) { continue; } $updates = []; if (empty($paper->textbook_series) && !empty($parsed['series'])) { $formal = $this->inferenceService()->resolveSeries($parsed['series']); $updates['textbook_series_id'] = $formal ? $formal->id : null; $updates['textbook_series'] = $formal ? $formal->name : $parsed['series']; } if (!empty($parsed['grade']) && empty($paper->grade)) { $updates['grade'] = $parsed['grade']; } if (!empty($parsed['term']) && empty($paper->term)) { $updates['term'] = $parsed['term']; } // 在应用文件名默认值时,也触发一次教材重定向 if (empty($paper->textbook_id)) { $best = $this->inferenceService()->findBestTextbook([ 'series_id' => $updates['textbook_series_id'] ?? null, 'textbook_series' => $updates['textbook_series'] ?? $paper->textbook_series, 'grade' => $updates['grade'] ?? $paper->grade, 'term' => $updates['term'] ?? $paper->term, ]); if ($best) { $updates['textbook_id'] = $best->id; } } if (empty($meta['source_name']) && !empty($parsed['name'])) { $meta['source_name'] = $parsed['name']; } if (!empty($updates)) { $meta['filename_defaults_applied'] = true; $updates['meta'] = $meta; $paper->update($updates); } } } private function parseImportFilename(): array { $import = $this->importRecord(); return $import?->parseFilename() ?? []; } private function normalizeGradeForForm($grade, array $parsed): ?int { if (is_numeric($grade)) { return (int) $grade; } $map = [ '七年级' => 7, '八年级' => 8, '九年级' => 9, '高一' => 10, '高二' => 11, '高三' => 12, ]; $grade = is_string($grade) ? trim($grade) : ''; if ($grade !== '' && isset($map[$grade])) { return $map[$grade]; } $parsedGrade = $parsed['grade'] ?? null; return is_numeric($parsedGrade) ? (int) $parsedGrade : null; } private function normalizeCatalogNodeIds($value): array { $ids = is_array($value) ? $value : [$value]; $ids = array_values(array_unique(array_map('intval', array_filter($ids)))); return $ids; } private function filterCatalogNodeIdsForTextbook(?int $textbookId, array $ids): array { $ids = $this->normalizeCatalogNodeIds($ids); if (empty($textbookId) || empty($ids)) { return $ids; } $validIds = TextbookCatalog::query() ->where('textbook_id', $textbookId) ->whereIn('id', $ids) ->pluck('id') ->map(fn ($id) => (int) $id) ->toArray(); return $validIds; } private function applyBatchToIds(array $ids): void { $targetIds = array_values(array_filter(array_unique(array_map('intval', $ids)))); if (empty($targetIds)) { return; } $updates = array_filter([ 'edition' => $this->batch['edition'] ?? null, 'grade' => $this->batch['grade'] ?? null, 'term' => $this->batch['term'] ?? null, 'chapter' => $this->batch['chapter'] ?? null, 'source_type' => $this->batch['source_type'] ?? null, 'source_year' => $this->batch['source_year'] ?? null, 'textbook_id' => $this->batch['textbook_id'] ?? null, 'series_id' => $this->batch['textbook_series_id'] ?? null, ], fn ($value) => $value !== null && $value !== ''); foreach (SourcePaper::query()->whereIn('id', $targetIds)->get() as $paper) { $meta = $paper->meta ?? []; if (!empty($this->batch['source_name'])) { $meta['source_name'] = $this->batch['source_name']; } if (!empty($this->batch['source_page'])) { $meta['source_page'] = $this->batch['source_page']; } if (!empty($this->batch['tags'])) { $meta['tags'] = $this->explodeTags($this->batch['tags']); } if (!empty($this->batch['bundle_key'])) { $meta['bundle_key'] = $this->batch['bundle_key']; } if (!empty($this->batch['expected_count'])) { $meta['expected_count'] = $this->batch['expected_count']; } if (!empty($this->batch['catalog_node_ids'])) { $catalogNodeIds = $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids']); $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? $paper->textbook_id, $catalogNodeIds); $meta['catalog_node_ids'] = $catalogNodeIds; $meta['catalog_node_id'] = $catalogNodeIds[0] ?? null; } $paper->update(array_merge($updates, ['meta' => $meta])); } } private function isBatchEmpty(): bool { $fields = [ 'edition', 'grade', 'term', 'chapter', 'source_type', 'source_year', 'textbook_id', 'textbook_series_id', 'source_name', 'source_page', 'tags', 'bundle_key', 'expected_count', ]; foreach ($fields as $field) { if (!empty($this->batch[$field])) { return false; } } return empty($this->batch['catalog_node_ids']); } }