report_teacher_weekly_stats.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <?php
  2. /**
  3. * 近 7 天老师组卷 + 学情分析套数(exam_analysis_results 按 paper_id 去重,一套卷计 1)
  4. * 用法:
  5. * php scripts/report_teacher_weekly_stats.php
  6. * php scripts/report_teacher_weekly_stats.php > storage/app/reports/teacher-weekly-stats-$(date +%Y-%m-%d)_$(date +%H%M%S).md
  7. * (shell 里 %H%M%S 会展开为当前时分秒;PDF 见 scripts/report_teacher_weekly_stats_pdf.php)
  8. */
  9. require __DIR__ . '/../vendor/autoload.php';
  10. $app = require_once __DIR__ . '/../bootstrap/app.php';
  11. $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
  12. $endCurrent = now();
  13. $startCurrent = now()->subDays(7);
  14. $startPrev = now()->subDays(14);
  15. $endPrev = $startCurrent;
  16. $db = \Illuminate\Support\Facades\DB::class;
  17. $sumPapers = static function ($from, $toExclusive) use ($db) {
  18. $q = $db::table('papers')
  19. ->whereNotNull('teacher_id')
  20. ->where('teacher_id', '!=', '')
  21. ->where('created_at', '>=', $from);
  22. if ($toExclusive !== null) {
  23. $q->where('created_at', '<', $toExclusive);
  24. }
  25. return (int) $q->count();
  26. };
  27. /** 学情分析:以卷子为单位,同一 paper_id 在区间内多条记录只计 1 */
  28. $countDistinctAnalysisPapers = static function ($from, $toExclusive) use ($db) {
  29. $q = $db::table('exam_analysis_results as ear')
  30. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  31. ->whereNotNull('p.teacher_id')
  32. ->where('p.teacher_id', '!=', '')
  33. ->where('ear.created_at', '>=', $from);
  34. if ($toExclusive !== null) {
  35. $q->where('ear.created_at', '<', $toExclusive);
  36. }
  37. return (int) $q->distinct()->count('ear.paper_id');
  38. };
  39. $countActiveTeachers = static function ($from, $toExclusive) use ($db) {
  40. $q = $db::table('papers')
  41. ->whereNotNull('teacher_id')
  42. ->where('teacher_id', '!=', '')
  43. ->where('created_at', '>=', $from);
  44. if ($toExclusive !== null) {
  45. $q->where('created_at', '<', $toExclusive);
  46. }
  47. return (int) $q->distinct()->count('teacher_id');
  48. };
  49. $totalPapersCur = $sumPapers($startCurrent, null);
  50. $totalPapersPrev = $sumPapers($startPrev, $endPrev);
  51. $totalAnalysisCur = $countDistinctAnalysisPapers($startCurrent, null);
  52. $totalAnalysisPrev = $countDistinctAnalysisPapers($startPrev, $endPrev);
  53. $teachersCur = $countActiveTeachers($startCurrent, null);
  54. $teachersPrev = $countActiveTeachers($startPrev, $endPrev);
  55. $wowLine = static function (int $cur, int $prev): string {
  56. $delta = $cur - $prev;
  57. if ($prev === 0) {
  58. if ($cur === 0) {
  59. return '0';
  60. }
  61. return sprintf('+%d(上周期0)', $delta);
  62. }
  63. $pct = round(($delta / $prev) * 100, 2);
  64. $sign = $delta >= 0 ? '+' : '';
  65. $dir = match (true) {
  66. $delta > 0 => '↑',
  67. $delta < 0 => '↓',
  68. default => '→',
  69. };
  70. return sprintf('%s%d(%s%.2f%%)%s', $sign, $delta, $sign, $pct, $dir);
  71. };
  72. /** 总量表环比列:仅增长标绿 */
  73. $wowLineHtml = static function (int $cur, int $prev) use ($wowLine): string {
  74. $plain = $wowLine($cur, $prev);
  75. if ($cur > $prev) {
  76. return '<span style="color:#16a34a;font-weight:600;">'.$plain.'</span>';
  77. }
  78. return $plain;
  79. };
  80. /** 明细环比列:仅增长标绿 */
  81. $compareCellHtml = static function (int $cur, int $prev): string {
  82. $d = $cur - $prev;
  83. if ($d === 0) {
  84. return '0';
  85. }
  86. if ($prev === 0) {
  87. if ($cur === 0) {
  88. return '0';
  89. }
  90. return '<span style="color:#16a34a;font-weight:600;">+'.$d.'(上0)</span>';
  91. }
  92. $pct = round(($d / $prev) * 100, 1);
  93. $sign = $d > 0 ? '+' : '';
  94. $text = sprintf('%s%d(%s%.1f%%)', $sign, $d, $sign, $pct);
  95. if ($d > 0) {
  96. return '<span style="color:#16a34a;font-weight:600;">'.$text.'</span>';
  97. }
  98. return $text;
  99. };
  100. $byTeacher = \Illuminate\Support\Facades\DB::table('papers')
  101. ->whereNotNull('teacher_id')
  102. ->where('teacher_id', '!=', '')
  103. ->where('created_at', '>=', $startCurrent)
  104. ->selectRaw('teacher_id, COUNT(*) as paper_count')
  105. ->groupBy('teacher_id')
  106. ->get();
  107. // 近 7 天产生学情分析的试卷套数:按 ear.paper_id 去重后归到 papers.teacher_id
  108. $analysisByTeacher = \Illuminate\Support\Facades\DB::table('exam_analysis_results as ear')
  109. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  110. ->whereNotNull('p.teacher_id')
  111. ->where('p.teacher_id', '!=', '')
  112. ->where('ear.created_at', '>=', $startCurrent)
  113. ->selectRaw('p.teacher_id, COUNT(DISTINCT ear.paper_id) AS paper_set_count')
  114. ->groupBy('p.teacher_id')
  115. ->get();
  116. $analysisMap = [];
  117. foreach ($analysisByTeacher as $r) {
  118. $analysisMap[(string) $r->teacher_id] = (int) $r->paper_set_count;
  119. }
  120. $paperMap = [];
  121. foreach ($byTeacher as $r) {
  122. $paperMap[(string) $r->teacher_id] = (int) $r->paper_count;
  123. }
  124. $byTeacherPrev = $db::table('papers')
  125. ->whereNotNull('teacher_id')
  126. ->where('teacher_id', '!=', '')
  127. ->where('created_at', '>=', $startPrev)
  128. ->where('created_at', '<', $endPrev)
  129. ->selectRaw('teacher_id, COUNT(*) as paper_count')
  130. ->groupBy('teacher_id')
  131. ->get();
  132. $analysisByTeacherPrev = $db::table('exam_analysis_results as ear')
  133. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  134. ->whereNotNull('p.teacher_id')
  135. ->where('p.teacher_id', '!=', '')
  136. ->where('ear.created_at', '>=', $startPrev)
  137. ->where('ear.created_at', '<', $endPrev)
  138. ->selectRaw('p.teacher_id, COUNT(DISTINCT ear.paper_id) AS paper_set_count')
  139. ->groupBy('p.teacher_id')
  140. ->get();
  141. $paperMapPrev = [];
  142. foreach ($byTeacherPrev as $r) {
  143. $paperMapPrev[(string) $r->teacher_id] = (int) $r->paper_count;
  144. }
  145. $analysisMapPrev = [];
  146. foreach ($analysisByTeacherPrev as $r) {
  147. $analysisMapPrev[(string) $r->teacher_id] = (int) $r->paper_set_count;
  148. }
  149. $names = \Illuminate\Support\Facades\DB::table('teachers')->pluck('name', 'teacher_id');
  150. $nameStrMap = [];
  151. foreach ($names as $tid => $nm) {
  152. $nameStrMap[(string) $tid] = $nm;
  153. }
  154. $rows = [];
  155. foreach ($paperMap as $tid => $paperCount) {
  156. $rows[] = [
  157. 'teacher_id' => $tid,
  158. 'name' => (string) ($nameStrMap[$tid] ?? $tid),
  159. 'papers' => $paperCount,
  160. 'analysis_sets' => (int) ($analysisMap[$tid] ?? 0),
  161. 'papers_prev' => (int) ($paperMapPrev[$tid] ?? 0),
  162. 'analysis_sets_prev' => (int) ($analysisMapPrev[$tid] ?? 0),
  163. ];
  164. }
  165. usort($rows, static fn ($a, $b) => $b['papers'] <=> $a['papers']);
  166. $windowCur = sprintf(
  167. '%s ~ %s',
  168. $startCurrent->format('Y-m-d H:i:s'),
  169. $endCurrent->format('Y-m-d H:i:s')
  170. );
  171. $windowPrev = sprintf(
  172. '%s ~ %s',
  173. $startPrev->format('Y-m-d H:i:s'),
  174. $endPrev->format('Y-m-d H:i:s')
  175. );
  176. $generatedAt = $endCurrent->format('Y-m-d H:i:s');
  177. $tz = (string) config('app.timezone', 'UTC');
  178. /**
  179. * 将 [from, to) 均分为 7 段,返回每段内组卷数、学情去重套数。
  180. *
  181. * @return array{labels: list<string>, papers: list<int>, analysis: list<int>}
  182. */
  183. $dailySlices = static function (\Carbon\Carbon $from, \Carbon\Carbon $toExclusive) use ($db): array {
  184. $totalSec = max(1, $toExclusive->getTimestamp() - $from->getTimestamp());
  185. $sliceSec = $totalSec / 7;
  186. $labels = [];
  187. $papers = [];
  188. $analysis = [];
  189. for ($i = 0; $i < 7; $i++) {
  190. $sliceFrom = $from->copy()->addSeconds((int) floor($sliceSec * $i));
  191. $sliceTo = $i < 6
  192. ? $from->copy()->addSeconds((int) floor($sliceSec * ($i + 1)))
  193. : $toExclusive;
  194. $labels[] = $sliceFrom->format('n/j');
  195. $papers[] = (int) $db::table('papers')
  196. ->whereNotNull('teacher_id')
  197. ->where('teacher_id', '!=', '')
  198. ->where('created_at', '>=', $sliceFrom)
  199. ->where('created_at', '<', $sliceTo)
  200. ->count();
  201. $analysis[] = (int) $db::table('exam_analysis_results as ear')
  202. ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
  203. ->whereNotNull('p.teacher_id')
  204. ->where('p.teacher_id', '!=', '')
  205. ->where('ear.created_at', '>=', $sliceFrom)
  206. ->where('ear.created_at', '<', $sliceTo)
  207. ->distinct()
  208. ->count('ear.paper_id');
  209. }
  210. return ['labels' => $labels, 'papers' => $papers, 'analysis' => $analysis];
  211. };
  212. $curDaily = $dailySlices($startCurrent, $endCurrent);
  213. $prevDaily = $dailySlices($startPrev, $endPrev);
  214. $buildChartSvg = static function (array $curDaily, array $prevDaily): string {
  215. $labels = $curDaily['labels'];
  216. $pc = $curDaily['papers'];
  217. $ac = $curDaily['analysis'];
  218. $pp = $prevDaily['papers'];
  219. $ap = $prevDaily['analysis'];
  220. $maxY = max(1, ...$pc, ...$ac, ...$pp, ...$ap);
  221. $W = 480;
  222. $H = 200;
  223. $padL = 44;
  224. $padR = 12;
  225. $padT = 16;
  226. $padB = 36;
  227. $gw = $W - $padL - $padR;
  228. $gh = $H - $padT - $padB;
  229. $n = 7;
  230. $xAt = static function (int $i) use ($padL, $gw, $n): float {
  231. return $padL + ($n <= 1 ? $gw / 2 : $gw * $i / ($n - 1));
  232. };
  233. $yAt = static function (int $v) use ($padT, $gh, $maxY): float {
  234. return $padT + $gh - ($v / $maxY) * $gh;
  235. };
  236. $lineWithDots = static function (array $vals, string $stroke, string $dash, float $sw = 1.6) use ($xAt, $yAt, $n): string {
  237. $pts = [];
  238. $circles = '';
  239. for ($i = 0; $i < $n; $i++) {
  240. $x = $xAt($i);
  241. $y = $yAt((int) $vals[$i]);
  242. $pts[] = round($x, 1).','.round($y, 1);
  243. $circles .= sprintf(
  244. '<circle cx="%.1f" cy="%.1f" r="3.2" fill="%s" stroke="#fff" stroke-width="1"/>',
  245. $x,
  246. $y,
  247. $stroke
  248. );
  249. }
  250. $poly = implode(' ', $pts);
  251. $dashAttr = $dash !== '' ? ' stroke-dasharray="'.$dash.'"' : '';
  252. return '<polyline fill="none" stroke="'.$stroke.'" stroke-width="'.$sw.'"'.$dashAttr.' points="'.$poly.'" />'.$circles;
  253. };
  254. $xAxisY = $padT + $gh;
  255. $tickTxt = '';
  256. for ($i = 0; $i < $n; $i++) {
  257. $x = $xAt($i);
  258. $lab = htmlspecialchars((string) ($labels[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  259. $tickTxt .= sprintf(
  260. '<text x="%.1f" y="%d" text-anchor="middle" font-size="9" fill="#374151" font-family="sun-exta,sans-serif">%s</text>',
  261. $x,
  262. $H - 10,
  263. $lab
  264. );
  265. }
  266. $yTick = '';
  267. for ($k = 0; $k <= 4; $k++) {
  268. $v = (int) round($maxY * $k / 4);
  269. $y = $yAt($v);
  270. $yTick .= sprintf(
  271. '<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>',
  272. $padL,
  273. $y,
  274. $padL + $gw,
  275. $y
  276. );
  277. $yTick .= sprintf(
  278. '<text x="%d" y="%.1f" font-size="8" fill="#6b7280" font-family="sun-exta,sans-serif">%d</text>',
  279. 4,
  280. $y + 3,
  281. $v
  282. );
  283. }
  284. $legend = '<g font-family="sun-exta,sans-serif" font-size="9">';
  285. $lx = $padL + $gw - 168;
  286. $ly = $padT + 4;
  287. $items = [
  288. ['#2563eb', '组卷·本周期', ''],
  289. ['#ea580c', '学情·本周期', ''],
  290. ['#93c5fd', '组卷·上周期', '6,4'],
  291. ['#fdba74', '学情·上周期', '6,4'],
  292. ];
  293. foreach ($items as $idx => $it) {
  294. $yy = $ly + $idx * 13;
  295. $legend .= sprintf(
  296. '<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2" %s/>',
  297. $lx,
  298. $yy,
  299. $lx + 18,
  300. $yy,
  301. $it[0],
  302. $it[2] !== '' ? 'stroke-dasharray="'.$it[2].'"' : ''
  303. );
  304. $legend .= sprintf(
  305. '<text x="%d" y="%d" fill="#111">%s</text>',
  306. $lx + 24,
  307. $yy + 4,
  308. htmlspecialchars($it[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
  309. );
  310. }
  311. $legend .= '</g>';
  312. $svg = sprintf(
  313. '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" height="auto" style="max-width:520px;">',
  314. $W,
  315. $H
  316. );
  317. $svg .= sprintf('<rect x="0" y="0" width="%d" height="%d" fill="#fafafa"/>', $W, $H);
  318. $svg .= $yTick;
  319. $svg .= sprintf('<line x1="%d" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#9ca3af" stroke-width="1"/>', $padL, $xAxisY, $padL + $gw, $xAxisY);
  320. $svg .= $lineWithDots($pp, '#93c5fd', '6,4', 1.4);
  321. $svg .= $lineWithDots($ap, '#fdba74', '6,4', 1.4);
  322. $svg .= $lineWithDots($pc, '#2563eb', '', 1.8);
  323. $svg .= $lineWithDots($ac, '#ea580c', '', 1.8);
  324. $svg .= $tickTxt;
  325. $svg .= $legend;
  326. $svg .= '</svg>';
  327. return $svg;
  328. };
  329. $chartSvg = $buildChartSvg($curDaily, $prevDaily);
  330. echo "## 老师组卷与学情分析(近7天)\n\n";
  331. echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n";
  332. echo "### 总量\n\n";
  333. echo "| 指标 | 本周期 | 上周期 | 环比 |\n";
  334. echo "| --- | ---: | ---: | --- |\n";
  335. echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPapersPrev, $wowLineHtml($totalPapersCur, $totalPapersPrev));
  336. echo sprintf("| 学情分析套数(卷去重) | %d | %d | %s |\n", $totalAnalysisCur, $totalAnalysisPrev, $wowLineHtml($totalAnalysisCur, $totalAnalysisPrev));
  337. echo sprintf("| 有组卷老师数 | %d | %d | %s |\n", $teachersCur, $teachersPrev, $wowLineHtml($teachersCur, $teachersPrev));
  338. echo "\n### 近7段每日对比(本周期与上周期时间轴对齐)\n\n";
  339. echo '<div class="weekly-chart">';
  340. echo '<p style="font-size:9pt;color:#4b5563;margin:0 0 6px 0;">横轴为统计窗口均分的 7 段,数字为每段「组卷套数 / 学情分析套数(卷去重)」;实线本周期,虚线上一同期。</p>';
  341. echo $chartSvg;
  342. echo "</div>\n\n";
  343. echo "### 按老师\n\n";
  344. echo '<table class="weekly-teacher-table">';
  345. echo '<colgroup>';
  346. echo '<col style="width:5%" /><col style="width:6%" /><col style="width:8%" />';
  347. echo '<col style="width:8%" /><col style="width:8%" /><col style="width:10%" />';
  348. echo '<col class="col-an" style="width:11%" /><col class="col-an" style="width:11%" /><col style="width:9%" />';
  349. echo '</colgroup>';
  350. echo '<thead><tr>';
  351. echo '<th>排名</th><th>老师</th><th>teacher_id</th><th>组卷·本</th><th>组卷·上</th><th>组卷·环比</th><th>学情·本</th><th>学情·上</th><th>学情·环比</th>';
  352. echo "</tr></thead>\n<tbody>\n";
  353. $i = 1;
  354. foreach ($rows as $r) {
  355. $nm = htmlspecialchars((string) $r['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  356. $pc = $r['papers'];
  357. $pp = $r['papers_prev'];
  358. $ac = $r['analysis_sets'];
  359. $ap = $r['analysis_sets_prev'];
  360. echo '<tr>';
  361. echo '<td style="text-align:right">'.((string) $i++).'</td>';
  362. echo '<td class="td-name">'.$nm.'</td>';
  363. echo '<td>'.htmlspecialchars((string) $r['teacher_id'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</td>';
  364. echo '<td style="text-align:right">'.((string) $pc).'</td>';
  365. echo '<td style="text-align:right">'.((string) $pp).'</td>';
  366. echo '<td>'.$compareCellHtml($pc, $pp).'</td>';
  367. echo '<td style="text-align:right" class="td-an">'.((string) $ac).'</td>';
  368. echo '<td style="text-align:right" class="td-an">'.((string) $ap).'</td>';
  369. echo '<td>'.$compareCellHtml($ac, $ap).'</td>';
  370. echo "</tr>\n";
  371. }
  372. echo "</tbody></table>\n";
  373. echo sprintf("\n本周期有组卷 **%d** 人。\n", count($rows));