dump_priority_issue_pdfs_local.php 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. <?php
  2. /**
  3. * 将审计 priority 明细里的题目分批导出为本地 PDF(题目质检模板),不上传 CDN。
  4. *
  5. * 用法:
  6. * php scripts/dump_priority_issue_pdfs_local.php [--per-pdf 150]
  7. * [--file storage/app/audit_placeholder/rendered_placeholder_audit_priority_issues_*.ndjson]
  8. * [--out-dir storage/app/audit_placeholder/local_priority_pdfs]
  9. * [--connection mysql] [--table questions]
  10. *
  11. * 不指定 --file 时,取 storage/app/audit_placeholder 下最新的 priority_issues ndjson。
  12. * 每份 PDF 内题量由 --per-pdf 控制(默认 150,可改 50/200 等)。
  13. */
  14. declare(strict_types=1);
  15. require __DIR__.'/../vendor/autoload.php';
  16. $app = require __DIR__.'/../bootstrap/app.php';
  17. $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
  18. $kernel->bootstrap();
  19. use App\Services\ExamPdfExportService;
  20. use App\Support\PaperNaming;
  21. use Illuminate\Support\Facades\DB;
  22. $options = getopt('', [
  23. 'per-pdf::',
  24. 'file::',
  25. 'out-dir::',
  26. 'connection::',
  27. 'table::',
  28. ]);
  29. $perPdf = isset($options['per-pdf']) ? max(1, (int) $options['per-pdf']) : 150;
  30. $connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
  31. $table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
  32. $defaultOut = dirname(__DIR__).'/storage/app/audit_placeholder/local_priority_pdfs';
  33. $outDir = isset($options['out-dir']) ? rtrim((string) $options['out-dir'], '/') : $defaultOut;
  34. if ($outDir[0] !== '/') {
  35. $outDir = dirname(__DIR__).'/'.$outDir;
  36. }
  37. $ndjsonPath = isset($options['file']) ? trim((string) $options['file']) : '';
  38. if ($ndjsonPath === '') {
  39. $auditDir = dirname(__DIR__).'/storage/app/audit_placeholder';
  40. $glob = glob($auditDir.'/rendered_placeholder_audit_priority_issues_*.ndjson') ?: [];
  41. if ($glob === []) {
  42. fwrite(STDERR, "No priority ndjson under {$auditDir}. Run audit script first.\n");
  43. exit(1);
  44. }
  45. usort($glob, static fn(string $a, string $b): int => strcmp($b, $a));
  46. $ndjsonPath = $glob[0];
  47. }
  48. if (! is_readable($ndjsonPath)) {
  49. fwrite(STDERR, "Cannot read: {$ndjsonPath}\n");
  50. exit(1);
  51. }
  52. $orderedUniqueIds = [];
  53. $seen = [];
  54. $fh = fopen($ndjsonPath, 'rb');
  55. if ($fh === false) {
  56. fwrite(STDERR, "Failed to open {$ndjsonPath}\n");
  57. exit(1);
  58. }
  59. while (($line = fgets($fh)) !== false) {
  60. $line = trim($line);
  61. if ($line === '') {
  62. continue;
  63. }
  64. $row = json_decode($line, true);
  65. if (! is_array($row) || ! isset($row['id'])) {
  66. continue;
  67. }
  68. $id = (int) $row['id'];
  69. if ($id <= 0 || isset($seen[$id])) {
  70. continue;
  71. }
  72. $seen[$id] = true;
  73. $orderedUniqueIds[] = $id;
  74. }
  75. fclose($fh);
  76. if ($orderedUniqueIds === []) {
  77. fwrite(STDERR, "No question ids in {$ndjsonPath}\n");
  78. exit(1);
  79. }
  80. $batches = array_chunk($orderedUniqueIds, $perPdf);
  81. $stamp = date('Ymd_His');
  82. $runDir = $outDir.'/'.$stamp;
  83. if (! @mkdir($runDir, 0775, true) && ! is_dir($runDir)) {
  84. fwrite(STDERR, "Cannot mkdir {$runDir}\n");
  85. exit(1);
  86. }
  87. /** @var ExamPdfExportService $pdfService */
  88. $pdfService = $app->make(ExamPdfExportService::class);
  89. $written = [];
  90. $batchIndex = 0;
  91. foreach ($batches as $chunk) {
  92. $batchIndex++;
  93. $questions = DB::connection($connection)
  94. ->table($table)
  95. ->whereIn('id', $chunk)
  96. ->get();
  97. $questionMap = [];
  98. foreach ($questions as $q) {
  99. $questionMap[(int) $q->id] = $q;
  100. }
  101. $groupedQuestions = groupQuestionsByType($questionMap, $chunk);
  102. $firstId = $chunk[0];
  103. $lastId = $chunk[count($chunk) - 1];
  104. $safeTitle = PaperNaming::toSafeFilename(
  105. 'priority_issues_batch_'.$batchIndex.'_'.count($chunk).'qs_'.$firstId.'-'.$lastId
  106. );
  107. $filename = $safeTitle.'.pdf';
  108. $localPath = $runDir.'/'.$filename;
  109. $paper = buildVirtualPaper(
  110. '重点排查 PDF 第 '.$batchIndex.'/'.count($batches).' 批('.count($chunk).' 题)',
  111. 'priority_local_'.$stamp.'_b'.$batchIndex,
  112. $groupedQuestions
  113. );
  114. $result = $pdfService->generateQuestionCheckPdf(
  115. $paper,
  116. $groupedQuestions,
  117. ['name' => '本地导出', 'grade' => '________'],
  118. ['name' => '________'],
  119. $localPath
  120. );
  121. if (($result['local_path'] ?? '') === '') {
  122. fwrite(STDERR, "FAILED batch {$batchIndex}: ".json_encode($result, JSON_UNESCAPED_UNICODE)."\n");
  123. exit(1);
  124. }
  125. $written[] = [
  126. 'batch' => $batchIndex,
  127. 'count' => count($chunk),
  128. 'ids_range' => [$firstId, $lastId],
  129. 'local_path' => $localPath,
  130. 'bytes' => @filesize($localPath) ?: 0,
  131. ];
  132. fwrite(STDERR, "wrote batch {$batchIndex}/".count($batches)." → {$filename}\n");
  133. }
  134. $manifestPath = $runDir.'/manifest.json';
  135. file_put_contents($manifestPath, json_encode([
  136. 'generated_at' => date('c'),
  137. 'source_ndjson' => realpath($ndjsonPath) ?: $ndjsonPath,
  138. 'connection' => $connection,
  139. 'table' => $table,
  140. 'per_pdf' => $perPdf,
  141. 'total_questions' => count($orderedUniqueIds),
  142. 'batch_count' => count($batches),
  143. 'output_directory' => $runDir,
  144. 'batches' => $written,
  145. ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
  146. echo json_encode([
  147. 'output_directory' => $runDir,
  148. 'manifest_path' => $manifestPath,
  149. 'batch_count' => count($written),
  150. 'total_questions' => count($orderedUniqueIds),
  151. 'batches' => $written,
  152. ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
  153. /**
  154. * @param array<int, object> $questionMap
  155. */
  156. function groupQuestionsByType(array $questionMap, array $originalOrder): array
  157. {
  158. $grouped = [
  159. 'choice' => [],
  160. 'fill' => [],
  161. 'answer' => [],
  162. ];
  163. $questionNumber = 1;
  164. foreach ($originalOrder as $id) {
  165. if (! isset($questionMap[$id])) {
  166. continue;
  167. }
  168. $q = $questionMap[$id];
  169. $type = normalizeQuestionType($q->question_type ?? null);
  170. $questionObj = (object) [
  171. 'id' => $q->id,
  172. 'question_number' => $questionNumber++,
  173. 'content' => $q->stem,
  174. 'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
  175. 'answer' => $q->answer,
  176. 'solution' => $q->solution,
  177. 'score' => getDefaultScore($type),
  178. 'difficulty' => $q->difficulty,
  179. 'kp_code' => $q->kp_code,
  180. ];
  181. $grouped[$type][] = $questionObj;
  182. }
  183. return $grouped;
  184. }
  185. function normalizeQuestionType(?string $type): string
  186. {
  187. if (! $type) {
  188. return 'answer';
  189. }
  190. $type = strtolower(trim($type));
  191. $typeMap = [
  192. 'choice' => 'choice',
  193. '选择题' => 'choice',
  194. 'single_choice' => 'choice',
  195. 'multiple_choice' => 'choice',
  196. 'fill' => 'fill',
  197. '填空题' => 'fill',
  198. 'blank' => 'fill',
  199. 'answer' => 'answer',
  200. '解答题' => 'answer',
  201. 'subjective' => 'answer',
  202. 'calculation' => 'answer',
  203. 'proof' => 'answer',
  204. ];
  205. return $typeMap[$type] ?? 'answer';
  206. }
  207. function getDefaultScore(string $type): int
  208. {
  209. return match ($type) {
  210. 'choice' => 5,
  211. 'fill' => 5,
  212. 'answer' => 10,
  213. default => 5,
  214. };
  215. }
  216. /**
  217. * @param array<string, array<int, object>> $groupedQuestions
  218. */
  219. function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
  220. {
  221. $totalScore = 0;
  222. $totalQuestions = 0;
  223. foreach ($groupedQuestions as $questions) {
  224. foreach ($questions as $q) {
  225. $totalScore += $q->score;
  226. $totalQuestions++;
  227. }
  228. }
  229. $paperId = $studentId.'_'.uniqid();
  230. return (object) [
  231. 'paper_id' => $paperId,
  232. 'paper_name' => $paperName,
  233. 'total_score' => $totalScore,
  234. 'total_questions' => $totalQuestions,
  235. 'created_at' => now()->toDateTimeString(),
  236. ];
  237. }