| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693 |
- <?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";
|