Jelajahi Sumber

feat(report): add teacher weekly stats scripts

Extract report_teacher_weekly_stats.php and
report_teacher_weekly_stats_pdf.php from ye/report-teacher-weekly-stats
branch. These scripts generate weekly teacher activity reports covering
paper assembly counts, exam analysis counts, active teacher counts with
period-over-period comparison.

Usage: php scripts/report_teacher_weekly_stats.php
  php scripts/report_teacher_weekly_stats_pdf.php
  TEACHER_WEEKLY_REPORT_DAYS=14 php scripts/report_teacher_weekly_stats.php
Co-authored-by: Cursor <cursoragent@cursor.com>
yemeishu 4 hari lalu
induk
melakukan
720817b7dd

+ 693 - 0
scripts/report_teacher_weekly_stats.php

@@ -0,0 +1,693 @@
+<?php
+
+/**
+ * 按「自然日」统计:近 N 个自然日(今日为 0 点—生成时刻)vs 紧邻的前 N 个完整自然日;exam_analysis_results 按 paper_id 去重计套。
+ * 按老师:学案/分析/学生数为「本 / 上」并列;保留学案·环比、学情·环比。
+ * 用法:
+ *   php scripts/report_teacher_weekly_stats.php
+ *   TEACHER_WEEKLY_REPORT_DAYS=14 php scripts/report_teacher_weekly_stats.php
+ *   php scripts/report_teacher_weekly_stats.php > storage/app/reports/teacher-weekly-stats-$(date +%Y-%m-%d)_$(date +%H%M%S).md
+ *
+ * 环境变量 / artisan:TEACHER_WEEKLY_REPORT_DAYS(默认 7)= 本/上周期各包含几个自然日。时区:config app.timezone。
+ */
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$app = require_once __DIR__ . '/../bootstrap/app.php';
+$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+
+$tz = (string) config('app.timezone', 'UTC');
+
+$reportPeriodDays = 7;
+$envDays = getenv('TEACHER_WEEKLY_REPORT_DAYS');
+if ($envDays !== false && $envDays !== '') {
+    $reportPeriodDays = max(1, min(366, (int) $envDays));
+}
+
+/** 本周期:自「今日 0 点」往回数第 N 天 0 点起,至当前时刻。上周期:紧邻的前 N 个完整自然日。 */
+$endCurrent = now()->timezone($tz);
+$todayStart = $endCurrent->copy()->startOfDay();
+$startCurrent = $todayStart->copy()->subDays($reportPeriodDays - 1);
+$startPrev = $todayStart->copy()->subDays(2 * $reportPeriodDays - 1);
+$endPrev = $todayStart->copy()->subDays($reportPeriodDays - 1);
+
+$db = \Illuminate\Support\Facades\DB::class;
+
+$sumPapers = static function ($from, $toExclusive) use ($db) {
+    $q = $db::table('papers')
+        ->whereNotNull('teacher_id')
+        ->where('teacher_id', '!=', '')
+        ->where('created_at', '>=', $from);
+    if ($toExclusive !== null) {
+        $q->where('created_at', '<', $toExclusive);
+    }
+
+    return (int) $q->count();
+};
+
+/** 学情分析:以卷子为单位,同一 paper_id 在区间内多条记录只计 1 */
+$countDistinctAnalysisPapers = static function ($from, $toExclusive) use ($db) {
+    $q = $db::table('exam_analysis_results as ear')
+        ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
+        ->whereNotNull('p.teacher_id')
+        ->where('p.teacher_id', '!=', '')
+        ->where('ear.created_at', '>=', $from);
+    if ($toExclusive !== null) {
+        $q->where('ear.created_at', '<', $toExclusive);
+    }
+
+    return (int) $q->distinct()->count('ear.paper_id');
+};
+
+$countActiveTeachers = static function ($from, $toExclusive) use ($db) {
+    $q = $db::table('papers')
+        ->whereNotNull('teacher_id')
+        ->where('teacher_id', '!=', '')
+        ->where('created_at', '>=', $from);
+    if ($toExclusive !== null) {
+        $q->where('created_at', '<', $toExclusive);
+    }
+
+    return (int) $q->distinct()->count('teacher_id');
+};
+
+$totalPapersCur = $sumPapers($startCurrent, $endCurrent);
+$totalPapersPrev = $sumPapers($startPrev, $endPrev);
+$totalAnalysisCur = $countDistinctAnalysisPapers($startCurrent, $endCurrent);
+$totalAnalysisPrev = $countDistinctAnalysisPapers($startPrev, $endPrev);
+$teachersCur = $countActiveTeachers($startCurrent, $endCurrent);
+$teachersPrev = $countActiveTeachers($startPrev, $endPrev);
+
+$wowLine = static function (int $cur, int $prev): string {
+    $delta = $cur - $prev;
+    if ($prev === 0) {
+        if ($cur === 0) {
+            return '0';
+        }
+
+        return sprintf('+%d(上周期0)', $delta);
+    }
+    $pct = round(($delta / $prev) * 100, 2);
+    $sign = $delta >= 0 ? '+' : '';
+    $dir = match (true) {
+        $delta > 0 => '↑',
+        $delta < 0 => '↓',
+        default => '→',
+    };
+
+    return sprintf('%s%d(%s%.2f%%)%s', $sign, $delta, $sign, $pct, $dir);
+};
+
+/** 总量表环比列:正增长着色(学案蓝 / 学情橙 / 其余绿) */
+$wowLineHtml = static function (int $cur, int $prev, string $posColor = '#16a34a') use ($wowLine): string {
+    $plain = $wowLine($cur, $prev);
+    if ($cur > $prev) {
+        return '<span style="color:'.$posColor.';font-weight:600;">'.$plain.'</span>';
+    }
+
+    return $plain;
+};
+
+/** 环比列:正增长用 $posColor(学案蓝 / 学情橙 / 默认绿) */
+$compareCellHtml = static function (int $cur, int $prev, string $posColor = '#16a34a'): string {
+    $d = $cur - $prev;
+    if ($d === 0) {
+        return '0';
+    }
+    if ($prev === 0) {
+        if ($cur === 0) {
+            return '0';
+        }
+
+        return '<span style="color:'.$posColor.';font-weight:600;">+'.$d.'(上0)</span>';
+    }
+    $pct = round(($d / $prev) * 100, 1);
+    $sign = $d > 0 ? '+' : '';
+    $text = sprintf('%s%d(%s%.1f%%)', $sign, $d, $sign, $pct);
+    if ($d > 0) {
+        return '<span style="color:'.$posColor.';font-weight:600;">'.$text.'</span>';
+    }
+
+    return $text;
+};
+
+/** 「本 / 上」并列:本>上时本侧用强调色(与上方左/右图折线一致);「 / 」与上周期数字固定深灰黑 */
+$slashPairAccentHtml = static function (int $cur, int $prev, string $accent): string {
+    $rest = '<span style="color:#111827;font-weight:normal;"> / '.$prev.'</span>';
+    if ($cur > $prev) {
+        return '<span style="color:'.$accent.';font-weight:700;">'.$cur.'</span>'.$rest;
+    }
+
+    return '<span style="color:#111827;font-weight:normal;">'.$cur.'</span>'.$rest;
+};
+
+/** 与每日对比图一致:学案蓝、学情橙、学生绿 */
+$colorPapers = '#2563eb';
+$colorAnalysis = '#ea580c';
+$colorStudents = '#16a34a';
+
+$byTeacher = \Illuminate\Support\Facades\DB::table('papers')
+    ->whereNotNull('teacher_id')
+    ->where('teacher_id', '!=', '')
+    ->where('created_at', '>=', $startCurrent)
+    ->where('created_at', '<', $endCurrent)
+    ->selectRaw('teacher_id, COUNT(*) as paper_count')
+    ->groupBy('teacher_id')
+    ->get();
+
+// 本时间窗内学情:按 ear.paper_id 去重后归到 papers.teacher_id
+$analysisByTeacher = \Illuminate\Support\Facades\DB::table('exam_analysis_results as ear')
+    ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
+    ->whereNotNull('p.teacher_id')
+    ->where('p.teacher_id', '!=', '')
+    ->where('ear.created_at', '>=', $startCurrent)
+    ->where('ear.created_at', '<', $endCurrent)
+    ->selectRaw('p.teacher_id, COUNT(DISTINCT ear.paper_id) AS paper_set_count')
+    ->groupBy('p.teacher_id')
+    ->get();
+
+$analysisMap = [];
+foreach ($analysisByTeacher as $r) {
+    $analysisMap[(string) $r->teacher_id] = (int) $r->paper_set_count;
+}
+
+$paperMap = [];
+foreach ($byTeacher as $r) {
+    $paperMap[(string) $r->teacher_id] = (int) $r->paper_count;
+}
+
+$byTeacherPrev = $db::table('papers')
+    ->whereNotNull('teacher_id')
+    ->where('teacher_id', '!=', '')
+    ->where('created_at', '>=', $startPrev)
+    ->where('created_at', '<', $endPrev)
+    ->selectRaw('teacher_id, COUNT(*) as paper_count')
+    ->groupBy('teacher_id')
+    ->get();
+
+$analysisByTeacherPrev = $db::table('exam_analysis_results as ear')
+    ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
+    ->whereNotNull('p.teacher_id')
+    ->where('p.teacher_id', '!=', '')
+    ->where('ear.created_at', '>=', $startPrev)
+    ->where('ear.created_at', '<', $endPrev)
+    ->selectRaw('p.teacher_id, COUNT(DISTINCT ear.paper_id) AS paper_set_count')
+    ->groupBy('p.teacher_id')
+    ->get();
+
+$paperMapPrev = [];
+foreach ($byTeacherPrev as $r) {
+    $paperMapPrev[(string) $r->teacher_id] = (int) $r->paper_count;
+}
+
+$analysisMapPrev = [];
+foreach ($analysisByTeacherPrev as $r) {
+    $analysisMapPrev[(string) $r->teacher_id] = (int) $r->paper_set_count;
+}
+
+/** 学生数:组卷 ∪ 学情,student_id 合并去重(按老师、时间窗) */
+$studentUnionSql = <<<'SQL'
+SELECT u.teacher_id, COUNT(DISTINCT u.student_id) AS c
+FROM (
+    SELECT teacher_id, student_id FROM papers
+    WHERE teacher_id IS NOT NULL AND teacher_id != ''
+    AND student_id IS NOT NULL AND student_id != ''
+    AND created_at >= ? AND created_at < ?
+    UNION
+    SELECT p.teacher_id, ear.student_id
+    FROM exam_analysis_results ear
+    INNER JOIN papers p ON p.paper_id = ear.paper_id
+    WHERE p.teacher_id IS NOT NULL AND p.teacher_id != ''
+    AND ear.student_id IS NOT NULL AND ear.student_id != ''
+    AND ear.created_at >= ? AND ear.created_at < ?
+) u
+GROUP BY u.teacher_id
+SQL;
+
+$studentUnionCurRows = $db::select($studentUnionSql, [$startCurrent, $endCurrent, $startCurrent, $endCurrent]);
+$studentUnionPrevRows = $db::select($studentUnionSql, [$startPrev, $endPrev, $startPrev, $endPrev]);
+
+$studentUnionCurMap = [];
+foreach ($studentUnionCurRows as $row) {
+    $studentUnionCurMap[(string) $row->teacher_id] = (int) $row->c;
+}
+$studentUnionPrevMap = [];
+foreach ($studentUnionPrevRows as $row) {
+    $studentUnionPrevMap[(string) $row->teacher_id] = (int) $row->c;
+}
+
+$names = \Illuminate\Support\Facades\DB::table('teachers')->pluck('name', 'teacher_id');
+$nameStrMap = [];
+foreach ($names as $tid => $nm) {
+    $nameStrMap[(string) $tid] = $nm;
+}
+
+$rows = [];
+foreach ($paperMap as $tid => $paperCount) {
+    $rows[] = [
+        'teacher_id' => $tid,
+        'name' => (string) ($nameStrMap[$tid] ?? $tid),
+        'papers' => $paperCount,
+        'analysis_sets' => (int) ($analysisMap[$tid] ?? 0),
+        'papers_prev' => (int) ($paperMapPrev[$tid] ?? 0),
+        'analysis_sets_prev' => (int) ($analysisMapPrev[$tid] ?? 0),
+    ];
+}
+
+usort($rows, static fn ($a, $b) => $b['papers'] <=> $a['papers']);
+
+$windowCur = sprintf(
+    '%s ~ %s',
+    $startCurrent->format('Y-m-d H:i:s'),
+    $endCurrent->format('Y-m-d H:i:s')
+);
+$windowPrev = sprintf(
+    '%s ~ %s',
+    $startPrev->format('Y-m-d H:i:s'),
+    $endPrev->format('Y-m-d H:i:s')
+);
+
+$generatedAt = $endCurrent->format('Y-m-d H:i:s');
+
+/**
+ * 按自然日切段(app.timezone):每天一格;本周期末段为「今日 0 点—当前时刻」。
+ * 学案:当日创建的组卷数,各日之和 = 窗口总量。
+ * 学情:该自然日内有 analysis 记录的卷去重(同日多条只计 1);跨日同一卷可在多日重复出现,故各日之和可不等于上方「窗口内卷仅计一次」的总量。
+ *
+ * @return array{labels: list<string>, papers: list<int>, analysis: list<int>}
+ */
+$dailyNaturalSlices = static function (string $which) use ($db, $reportPeriodDays, $todayStart, $endCurrent): array {
+    $n = $reportPeriodDays;
+    $labels = [];
+    $papers = [];
+    $analysis = [];
+
+    $countAnalysisSetsInRange = static function ($from, $toExclusive) use ($db): int {
+        return (int) $db::table('exam_analysis_results as ear')
+            ->join('papers as p', 'p.paper_id', '=', 'ear.paper_id')
+            ->whereNotNull('p.teacher_id')
+            ->where('p.teacher_id', '!=', '')
+            ->where('ear.created_at', '>=', $from)
+            ->where('ear.created_at', '<', $toExclusive)
+            ->distinct()
+            ->count('ear.paper_id');
+    };
+
+    if ($which === 'current') {
+        for ($i = 0; $i < $n; $i++) {
+            $dayStart = $todayStart->copy()->subDays($n - 1 - $i);
+            $dayEnd = ($i < $n - 1) ? $dayStart->copy()->addDay() : $endCurrent;
+            $labels[] = $dayStart->format('j');
+            $papers[] = (int) $db::table('papers')
+                ->whereNotNull('teacher_id')
+                ->where('teacher_id', '!=', '')
+                ->where('created_at', '>=', $dayStart)
+                ->where('created_at', '<', $dayEnd)
+                ->count();
+            $analysis[] = $countAnalysisSetsInRange($dayStart, $dayEnd);
+        }
+    } else {
+        for ($i = 0; $i < $n; $i++) {
+            $dayStart = $todayStart->copy()->subDays(2 * $n - 1 - $i);
+            $dayEnd = $dayStart->copy()->addDay();
+            $labels[] = $dayStart->format('j');
+            $papers[] = (int) $db::table('papers')
+                ->whereNotNull('teacher_id')
+                ->where('teacher_id', '!=', '')
+                ->where('created_at', '>=', $dayStart)
+                ->where('created_at', '<', $dayEnd)
+                ->count();
+            $analysis[] = $countAnalysisSetsInRange($dayStart, $dayEnd);
+        }
+    }
+
+    return ['labels' => $labels, 'papers' => $papers, 'analysis' => $analysis];
+};
+
+$curDaily = $dailyNaturalSlices('current');
+$prevDaily = $dailyNaturalSlices('prev');
+
+/** 左侧:学案(组卷)套数;右侧:学情分析套数(卷去重)。横轴:本周期日 / 上周期同序号日。 */
+$buildDualChartsHtml = static function (array $curDaily, array $prevDaily): string {
+    $axisCur = $curDaily['labels'];
+    $axisPrev = $prevDaily['labels'];
+    $pc = $curDaily['papers'];
+    $ac = $curDaily['analysis'];
+    $pp = $prevDaily['papers'];
+    $ap = $prevDaily['analysis'];
+
+    $halfSvg = static function (
+        array $labelsCur,
+        array $labelsPrev,
+        array $cur,
+        array $prev,
+        string $strokeCur,
+        string $strokePrev,
+        string $legCur,
+        string $legPrev
+    ): string {
+        $maxY = max(1, ...$cur, ...$prev);
+        $W = 296;
+        $H = 256;
+        $padL = 42;
+        /** 右侧、顶部留白:数值标签 text-anchor=middle 时不依赖末列特殊锚点 */
+        $padR = 62;
+        $padT = 72;
+        $padB = 56;
+        $gw = $W - $padL - $padR;
+        $gh = $H - $padT - $padB;
+        $n = max(1, count($labelsCur));
+        $xAt = static function (int $i) use ($padL, $gw, $n): float {
+            return $padL + ($n <= 1 ? $gw / 2 : $gw * $i / ($n - 1));
+        };
+        $yAt = static function (int $v) use ($padT, $gh, $maxY): float {
+            return $padT + $gh - ($v / $maxY) * $gh;
+        };
+        $lineWithDots = static function (array $vals, string $stroke, string $dash, float $sw = 1.6) use ($xAt, $yAt, $n): string {
+            $pts = [];
+            $circles = '';
+            for ($i = 0; $i < $n; $i++) {
+                $x = $xAt($i);
+                $y = $yAt((int) $vals[$i]);
+                $pts[] = round($x, 1).','.round($y, 1);
+                $circles .= sprintf(
+                    '<circle cx="%.1f" cy="%.1f" r="3.2" fill="%s" stroke="#fff" stroke-width="1"/>',
+                    $x,
+                    $y,
+                    $stroke
+                );
+            }
+            $poly = implode(' ', $pts);
+            $dashAttr = $dash !== '' ? ' stroke-dasharray="'.$dash.'"' : '';
+
+            return '<polyline fill="none" stroke="'.$stroke.'" stroke-width="'.$sw.'"'.$dashAttr.' points="'.$poly.'" />'.$circles;
+        };
+
+        /** 标签:每列只放一条「本/上」,y = 最高点y - 固定偏移(以 dominant-baseline=middle 作为中心y) */
+        $labelFillCur = $strokeCur === '#2563eb' ? '#1d4ed8' : '#c2410c';
+        $labelFillPrev = $strokePrev === '#93c5fd' ? '#475569' : '#78716c';
+        $valueLabels = '<g font-family="sun-exta,sans-serif">';
+        $labelOffset = 22; // 标签中心到点的固定上移像素
+        for ($vi = 0; $vi < $n; $vi++) {
+            $xv = $xAt($vi);
+            $yCv = $yAt((int) $cur[$vi]);
+            $yPv = $yAt((int) $prev[$vi]);
+            $vC = (int) $cur[$vi];
+            $vP = (int) $prev[$vi];
+            $yUpper = min($yCv, $yPv);
+            // 允许跑到绘图区上方的留白里;只在极端情况下防止贴到 SVG 顶边
+            $baseY = max(14, $yUpper - $labelOffset);
+            $valueLabels .= sprintf(
+                '<text x="%.1f" y="%.1f" text-anchor="middle" dominant-baseline="middle" font-size="9" font-family="sun-exta,sans-serif"><tspan fill="%s">%d</tspan><tspan fill="#94a3b8">/</tspan><tspan fill="%s">%d</tspan></text>',
+                $xv,
+                $baseY,
+                $labelFillCur,
+                $vC,
+                $labelFillPrev,
+                $vP
+            );
+        }
+        $valueLabels .= '</g>';
+
+        $xAxisY = $padT + $gh;
+        $tickTxt = '';
+        $axisRowY = $H - 48;
+        for ($i = 0; $i < $n; $i++) {
+            $x = $xAt($i);
+            $lc = htmlspecialchars((string) ($labelsCur[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+            $lp = htmlspecialchars((string) ($labelsPrev[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+            $tickTxt .= sprintf(
+                '<text x="%.1f" y="%d" text-anchor="middle" font-size="8" font-family="sun-exta,sans-serif"><tspan fill="#111827">%s</tspan><tspan fill="#94a3b8">/</tspan><tspan fill="#475569">%s</tspan></text>',
+                $x,
+                $axisRowY,
+                $lc,
+                $lp
+            );
+        }
+
+        $yTick = '';
+        for ($k = 0; $k <= 4; $k++) {
+            $v = (int) round($maxY * $k / 4);
+            $y = $yAt($v);
+            $yTick .= sprintf(
+                '<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>',
+                $padL,
+                $y,
+                $padL + $gw,
+                $y
+            );
+            $yTick .= sprintf(
+                '<text x="%d" y="%.1f" font-size="8" fill="#6b7280" font-family="sun-exta,sans-serif">%d</text>',
+                2,
+                $y + 3,
+                $v
+            );
+        }
+
+        $legend = '<g font-family="sun-exta,sans-serif" font-size="9">';
+        $lx = $padL + 4;
+        $ly = $H - 26;
+        $items = [
+            [$strokeCur, $legCur, ''],
+            [$strokePrev, $legPrev, '6,4'],
+        ];
+        foreach ($items as $idx => $it) {
+            $yy = $ly + $idx * 12;
+            $legend .= sprintf(
+                '<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2" %s/>',
+                $lx,
+                $yy,
+                $lx + 16,
+                $yy,
+                $it[0],
+                $it[2] !== '' ? 'stroke-dasharray="'.$it[2].'"' : ''
+            );
+            $legend .= sprintf(
+                '<text x="%d" y="%d" fill="#111">%s</text>',
+                $lx + 20,
+                $yy + 4,
+                htmlspecialchars($it[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+            );
+        }
+        $legend .= '</g>';
+
+        $svg = sprintf(
+            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" height="auto" style="max-width:100%%;">',
+            $W,
+            $H
+        );
+        $svg .= sprintf('<rect x="0" y="0" width="%d" height="%d" fill="#fafafa"/>', $W, $H);
+        $svg .= $yTick;
+        $svg .= sprintf('<line x1="%d" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#9ca3af" stroke-width="1"/>', $padL, $xAxisY, $padL + $gw, $xAxisY);
+        $svg .= $lineWithDots($prev, $strokePrev, '6,4', 1.4);
+        $svg .= $lineWithDots($cur, $strokeCur, '', 1.8);
+        $svg .= $valueLabels;
+        $svg .= $tickTxt;
+        $svg .= $legend;
+        $svg .= '</svg>';
+
+        return $svg;
+    };
+
+    $left = $halfSvg($axisCur, $axisPrev, $pc, $pp, '#2563eb', '#93c5fd', '学案·本周期', '学案·上周期');
+    $right = $halfSvg($axisCur, $axisPrev, $ac, $ap, '#ea580c', '#fdba74', '学情·本周期', '学情·上周期');
+
+    return '<table class="weekly-chart-pair" style="width:100%;border-collapse:collapse;margin:0;"><tr>'
+        .'<td style="width:50%;vertical-align:top;padding:2px 5px 2px 0;">'.$left.'</td>'
+        .'<td style="width:50%;vertical-align:top;padding:2px 0 2px 5px;">'.$right.'</td>'
+        .'</tr></table>';
+};
+
+$chartSvg = $buildDualChartsHtml($curDaily, $prevDaily);
+
+echo sprintf("## 老师组卷与学情分析(近%d个自然日)\n\n", $reportPeriodDays);
+echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n";
+echo sprintf("> 口径:自然日边界(config timezone);今日段为当日 0:00—生成时刻;上周期为紧邻的前 **%d** 个完整自然日。\n\n", $reportPeriodDays);
+
+echo "### 总量\n\n";
+echo "| 指标 | 本周期 | 上周期 | 环比 |\n";
+echo "| :---: | :---: | :---: | :---: |\n";
+echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPapersPrev, $wowLineHtml($totalPapersCur, $totalPapersPrev, $colorPapers));
+echo sprintf("| 学情分析套数(卷去重) | %d | %d | %s |\n", $totalAnalysisCur, $totalAnalysisPrev, $wowLineHtml($totalAnalysisCur, $totalAnalysisPrev, $colorAnalysis));
+echo sprintf("| 有组卷老师数 | %d | %d | %s |\n", $teachersCur, $teachersPrev, $wowLineHtml($teachersCur, $teachersPrev, $colorStudents));
+
+echo "### 逐日对比(左:学案 · 右:学情)\n\n";
+echo '<div class="weekly-chart">';
+echo $chartSvg;
+echo "</div>\n\n";
+
+// 老师列表汇总:先给出增/降总体倾向,再给出异常清单
+$calcPct = static function (int $delta, int $prev): ?float {
+    if ($prev <= 0) {
+        return null;
+    }
+    return round(($delta / $prev) * 100, 1);
+};
+
+$fmtDeltaWithPct = static function (int $delta, int $prev): string {
+    $sign = $delta > 0 ? '+' : '';
+    $pct = $prev > 0 ? sprintf('(%s%.1f%%)', $sign, ($delta / $prev) * 100) : '(上0)';
+    return $sign.$delta.$pct;
+};
+
+$paperUp = 0; $paperDown = 0; $paperFlat = 0;
+$analysisUp = 0; $analysisDown = 0; $analysisFlat = 0;
+$studentUp = 0; $studentDown = 0; $studentFlat = 0;
+$sumPaperDelta = 0; $sumAnalysisDelta = 0; $sumStudentDelta = 0;
+$anomalies = [];
+
+foreach ($rows as $r) {
+    $tidKey = (string) $r['teacher_id'];
+    $name = (string) $r['name'];
+    $papersCur = (int) $r['papers'];
+    $papersPrev = (int) $r['papers_prev'];
+    $analysisCur = (int) $r['analysis_sets'];
+    $analysisPrev = (int) $r['analysis_sets_prev'];
+    $studentCur = (int) ($studentUnionCurMap[$tidKey] ?? 0);
+    $studentPrev = (int) ($studentUnionPrevMap[$tidKey] ?? 0);
+
+    $paperDelta = $papersCur - $papersPrev;
+    $analysisDelta = $analysisCur - $analysisPrev;
+    $studentDelta = $studentCur - $studentPrev;
+    $sumPaperDelta += $paperDelta;
+    $sumAnalysisDelta += $analysisDelta;
+    $sumStudentDelta += $studentDelta;
+
+    if ($paperDelta > 0) { $paperUp++; } elseif ($paperDelta < 0) { $paperDown++; } else { $paperFlat++; }
+    if ($analysisDelta > 0) { $analysisUp++; } elseif ($analysisDelta < 0) { $analysisDown++; } else { $analysisFlat++; }
+    if ($studentDelta > 0) { $studentUp++; } elseif ($studentDelta < 0) { $studentDown++; } else { $studentFlat++; }
+
+    $paperPct = $calcPct($paperDelta, $papersPrev);
+    $analysisPct = $calcPct($analysisDelta, $analysisPrev);
+    $studentPct = $calcPct($studentDelta, $studentPrev);
+
+    // 异常口径(用于快速巡检):
+    // 1) 学案/学情明显下滑;2) 学案有量但学情为 0;3) 学情明显高于学案;
+    // 4) 学案与学情方向背离;5) 覆盖学生数异常波动。
+    if (
+        ($papersPrev >= 20 && ($paperDelta <= -20 || ($paperPct !== null && $paperPct <= -50.0))) ||
+        ($analysisPrev >= 15 && ($analysisDelta <= -15 || ($analysisPct !== null && $analysisPct <= -50.0))) ||
+        ($papersCur >= 10 && $analysisCur === 0) ||
+        ($analysisCur >= 10 && $analysisCur > $papersCur + 5) ||
+        (($paperDelta > 0 && $analysisDelta < 0 && abs($analysisDelta) >= 5) ||
+         ($paperDelta < 0 && $analysisDelta > 0 && abs($paperDelta) >= 5)) ||
+        (abs($studentDelta) >= 10 || ($studentPct !== null && abs($studentPct) >= 100.0 && $studentPrev >= 5))
+    ) {
+        $severity = abs($paperDelta) + abs($analysisDelta) + abs($studentDelta);
+        if ($papersCur >= 10 && $analysisCur === 0) { $severity += 20; }
+        if ($analysisCur >= 10 && $analysisCur > $papersCur + 5) { $severity += 12; }
+
+        $reason = [];
+        if ($papersPrev >= 20 && ($paperDelta <= -20 || ($paperPct !== null && $paperPct <= -50.0))) { $reason[] = '学案下滑'; }
+        if ($analysisPrev >= 15 && ($analysisDelta <= -15 || ($analysisPct !== null && $analysisPct <= -50.0))) { $reason[] = '学情下滑'; }
+        if ($papersCur >= 10 && $analysisCur === 0) { $reason[] = '学案有量但学情为0'; }
+        if ($analysisCur >= 10 && $analysisCur > $papersCur + 5) { $reason[] = '学情高于学案'; }
+        if (($paperDelta > 0 && $analysisDelta < 0) || ($paperDelta < 0 && $analysisDelta > 0)) { $reason[] = '学案学情背离'; }
+        if (abs($studentDelta) >= 10 || ($studentPct !== null && abs($studentPct) >= 100.0 && $studentPrev >= 5)) { $reason[] = '学生覆盖波动'; }
+
+        $anomalies[] = [
+            'severity' => $severity,
+            'teacher_id' => $tidKey,
+            'name' => $name,
+            'papers_cur' => $papersCur,
+            'papers_prev' => $papersPrev,
+            'analysis_cur' => $analysisCur,
+            'analysis_prev' => $analysisPrev,
+            'student_cur' => $studentCur,
+            'student_prev' => $studentPrev,
+            'paper_delta_text' => $fmtDeltaWithPct($paperDelta, $papersPrev),
+            'analysis_delta_text' => $fmtDeltaWithPct($analysisDelta, $analysisPrev),
+            'student_delta_text' => $fmtDeltaWithPct($studentDelta, $studentPrev),
+            'reason' => implode(' / ', array_values(array_unique($reason))),
+        ];
+    }
+}
+
+usort($anomalies, static fn ($a, $b) => $b['severity'] <=> $a['severity']);
+
+$paperTrend = $paperUp > $paperDown ? '学案整体偏增长' : ($paperUp < $paperDown ? '学案整体偏下降' : '学案整体增降持平');
+$analysisTrend = $analysisUp > $analysisDown ? '学情整体偏增长' : ($analysisUp < $analysisDown ? '学情整体偏下降' : '学情整体增降持平');
+$studentTrend = $studentUp > $studentDown ? '学生覆盖整体偏增长' : ($studentUp < $studentDown ? '学生覆盖整体偏下降' : '学生覆盖整体增降持平');
+
+echo "### 老师列表汇总(增降对比)\n\n";
+echo "| 维度 | 增长老师数 | 下降老师数 | 持平老师数 | 判断 |\n";
+echo "| :--- | ---: | ---: | ---: | :--- |\n";
+echo sprintf("| 学案(组卷) | %d | %d | %d | %s |\n", $paperUp, $paperDown, $paperFlat, $paperTrend);
+echo sprintf("| 学情(分析) | %d | %d | %d | %s |\n", $analysisUp, $analysisDown, $analysisFlat, $analysisTrend);
+echo sprintf("| 学生覆盖(去重) | %d | %d | %d | %s |\n\n", $studentUp, $studentDown, $studentFlat, $studentTrend);
+
+echo "### 异常数据(学案本/上超过100,文字清单)\n\n";
+$highVolumeAnomalies = array_values(array_filter(
+    $anomalies,
+    static fn (array $a): bool => ((int) ($a['papers_cur'] ?? 0) > 100) || ((int) ($a['papers_prev'] ?? 0) > 100)
+));
+if ($highVolumeAnomalies === []) {
+    echo "本周期未命中“学案本/上超过100”的异常老师。\n\n";
+} else {
+    $display = array_slice($highVolumeAnomalies, 0, 30);
+    $idx = 1;
+    foreach ($display as $a) {
+        $nameEsc = htmlspecialchars((string) $a['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        $tidEsc = htmlspecialchars((string) $a['teacher_id'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        $reasonEsc = htmlspecialchars((string) $a['reason'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        $line = sprintf(
+            '%d) %s(%s):学案 %d/%d(%s);学情 %d/%d(%s);学生 %d/%d(%s);异常:%s',
+            $idx++,
+            $nameEsc,
+            $tidEsc,
+            (int) $a['papers_cur'],
+            (int) $a['papers_prev'],
+            (string) $a['paper_delta_text'],
+            (int) $a['analysis_cur'],
+            (int) $a['analysis_prev'],
+            (string) $a['analysis_delta_text'],
+            (int) $a['student_cur'],
+            (int) $a['student_prev'],
+            (string) $a['student_delta_text'],
+            $reasonEsc
+        );
+        echo '- '.$line."\n";
+    }
+    if (count($highVolumeAnomalies) > 30) {
+        echo sprintf("\n> 仅展示前 30 条,共 %d 条。\n", count($highVolumeAnomalies));
+    }
+    echo "\n";
+}
+
+echo sprintf("### 按老师 本周期有组卷 **%d** 人。\n\n", count($rows));
+
+echo '<table class="weekly-teacher-table">';
+echo '<colgroup>';
+echo '<col style="width:3%" /><col class="col-name" style="width:18mm;max-width:18mm;" />';
+echo '<col class="col-slash" style="width:11%" /><col style="width:14%" />';
+echo '<col class="col-slash" style="width:11%" /><col style="width:14%" />';
+echo '<col class="col-slash" style="width:11%" />';
+echo '</colgroup>';
+echo '<thead><tr>';
+echo '<th>排名</th><th>老师</th><th>学案数量</th><th>学案·环比</th><th>分析数量</th><th>学情·环比</th><th>学生数</th>';
+echo "</tr></thead>\n<tbody>\n";
+
+$i = 1;
+foreach ($rows as $r) {
+    $tidKey = (string) $r['teacher_id'];
+    $nmRaw = (string) $r['name'];
+    $nmEsc = htmlspecialchars($nmRaw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+    $tidEsc = htmlspecialchars($tidKey, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+    $nameWithId = $nmEsc.' <span class="teacher-id">('.$tidEsc.')</span>';
+    $pc = $r['papers'];
+    $pp = $r['papers_prev'];
+    $ac = $r['analysis_sets'];
+    $ap = $r['analysis_sets_prev'];
+    $stuC = (int) ($studentUnionCurMap[$tidKey] ?? 0);
+    $stuP = (int) ($studentUnionPrevMap[$tidKey] ?? 0);
+    echo '<tr>';
+    echo '<td>'.((string) $i++).'</td>';
+    echo '<td class="td-name">'.$nameWithId.'</td>';
+    echo '<td class="td-slash td-slash-papers" title="本周期 / 上周期:组卷套数">'.$slashPairAccentHtml($pc, $pp, $colorPapers).'</td>';
+    echo '<td class="td-wow-papers">'.$compareCellHtml($pc, $pp, $colorPapers).'</td>';
+    echo '<td class="td-slash td-slash-analysis" title="本周期 / 上周期:学情分析套数(卷去重)">'.$slashPairAccentHtml($ac, $ap, $colorAnalysis).'</td>';
+    echo '<td class="td-wow-analysis">'.$compareCellHtml($ac, $ap, $colorAnalysis).'</td>';
+    echo '<td class="td-slash td-slash-stu td-stu" title="本周期 / 上周期:组卷∪学情学生合并去重">'.$slashPairAccentHtml($stuC, $stuP, $colorStudents).'</td>';
+    echo "</tr>\n";
+}
+echo "</tbody></table>\n";

+ 107 - 0
scripts/report_teacher_weekly_stats_pdf.php

@@ -0,0 +1,107 @@
+<?php
+
+/**
+ * 生成「老师组卷 + 学情分析」周报 PDF(与 report_teacher_weekly_stats.php 同源数据)
+ *
+ * 用法:
+ *   php scripts/report_teacher_weekly_stats_pdf.php
+ *   php scripts/report_teacher_weekly_stats_pdf.php /path/to/out.pdf
+ *   php artisan report:teacher-weekly-pdf [--days=7]
+ *   或:TEACHER_WEEKLY_REPORT_DAYS=14 php scripts/...
+ *
+ * 输出:
+ *   - teacher-weekly-stats-{Y-m-d}_{His}.pdf(His = 24 小时制六位数字时分秒,如 143052)
+ *   - 不配 latest 文件名;后台「打开 PDF」会自动选目录内最新的一份带时间戳 PDF。
+ */
+
+use League\CommonMark\Environment\Environment;
+use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
+use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
+use League\CommonMark\MarkdownConverter;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+ob_start();
+require __DIR__ . '/report_teacher_weekly_stats.php';
+$markdown = ob_get_clean();
+
+$env = new Environment([
+    'html_input' => 'allow',
+    'allow_unsafe_links' => false,
+]);
+$env->addExtension(new CommonMarkCoreExtension());
+$env->addExtension(new GithubFlavoredMarkdownExtension());
+$converter = new MarkdownConverter($env);
+$bodyHtml = $converter->convert($markdown)->getContent();
+
+$css = <<<'CSS'
+<style>
+/* mPDF 内置 Sun-ExtA(sun-exta),覆盖中文、日文、韩文;勿用 DejaVu,否则会显示为方块 */
+body { font-family: 'sun-exta', sans-serif; font-size: 10pt; color: #111; }
+h2 { font-size: 14pt; margin: 0 0 8px 0; }
+h3 { font-size: 11pt; margin: 16px 0 8px 0; }
+blockquote { margin: 0 0 10px 0; padding: 6px 10px; background: #f9fafb; border-left: 3px solid #d1d5db; font-size: 9pt; color: #4b5563; }
+table { border-collapse: collapse; width: 100%; margin: 8px 0 12px 0; }
+th, td { border: 1px solid #d1d5db; padding: 4px 6px; text-align: center; vertical-align: middle; }
+th { background: #f3f4f6; font-weight: 600; }
+.weekly-chart { margin: 8px 0 14px 0; page-break-inside: avoid; }
+.weekly-chart p { margin: 0 0 6px 0; }
+/* 每日对比:左学案、右学情,各占 50%,独立纵轴刻度 */
+.weekly-chart-pair { width: 100%; border-collapse: collapse; margin: 0; page-break-inside: avoid; }
+.weekly-chart-pair td,
+.weekly-chart-pair th { border: none !important; padding: 2px 4px !important; vertical-align: top !important; text-align: center !important; background: transparent !important; }
+.weekly-teacher-table { table-layout: fixed; width: 100%; font-size: 9pt; }
+.weekly-teacher-table th,
+.weekly-teacher-table td { text-align: center; vertical-align: middle; }
+.weekly-teacher-table col.col-name { width: 18mm !important; max-width: 18mm !important; }
+.weekly-teacher-table col.col-slash { width: 11%; }
+.weekly-teacher-table th:nth-child(2),
+.weekly-teacher-table td.td-name {
+  width: 18mm !important;
+  max-width: 18mm !important;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  padding: 4px 3px;
+  box-sizing: border-box;
+  color: #111827;
+  font-size: 9pt;
+}
+.weekly-teacher-table .td-slash {
+  font-variant-numeric: tabular-nums;
+  font-size: 9pt;
+  color: #111827;
+}
+.weekly-teacher-table .td-stu { font-size: 8.5pt; }
+.weekly-teacher-table .td-name .teacher-id { color: #6b7280; font-size: 9pt; font-weight: normal; }
+</style>
+CSS;
+
+$html = '<!DOCTYPE html><html><head><meta charset="UTF-8">'.$css.'</head><body>'.$bodyHtml.'</body></html>';
+
+$defaultOut = storage_path('app/reports/teacher-weekly-stats-'.date('Y-m-d_His').'.pdf');
+$outPath = $argv[1] ?? $defaultOut;
+
+$dir = dirname($outPath);
+if (! is_dir($dir)) {
+    mkdir($dir, 0755, true);
+}
+
+$mpdf = new \Mpdf\Mpdf([
+    'mode' => 'utf-8',
+    'format' => 'A4',
+    'margin_top' => 14,
+    'margin_bottom' => 14,
+    'margin_left' => 12,
+    'margin_right' => 12,
+    'default_font' => 'sun-exta',
+    'autoScriptToLang' => true,
+    'autoLangToFont' => true,
+]);
+$mpdf->WriteHTML($html);
+$mpdf->Output($outPath, \Mpdf\Output\Destination::FILE);
+
+$mdPath = preg_replace('/\.pdf$/i', '.md', $outPath);
+file_put_contents($mdPath, $markdown);
+
+fwrite(STDERR, "PDF: {$outPath}\nMD:  {$mdPath}\n");