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 ''.$plain.''; } 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 '+'.$d.'(上0)'; } $pct = round(($d / $prev) * 100, 1); $sign = $d > 0 ? '+' : ''; $text = sprintf('%s%d(%s%.1f%%)', $sign, $d, $sign, $pct); if ($d > 0) { return ''.$text.''; } return $text; }; /** 「本 / 上」并列:本>上时本侧用强调色(与上方左/右图折线一致);「 / 」与上周期数字固定深灰黑 */ $slashPairAccentHtml = static function (int $cur, int $prev, string $accent): string { $rest = ' / '.$prev.''; if ($cur > $prev) { return ''.$cur.''.$rest; } return ''.$cur.''.$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, papers: list, analysis: list} */ $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( '', $x, $y, $stroke ); } $poly = implode(' ', $pts); $dashAttr = $dash !== '' ? ' stroke-dasharray="'.$dash.'"' : ''; return ''.$circles; }; /** 标签:每列只放一条「本/上」,y = 最高点y - 固定偏移(以 dominant-baseline=middle 作为中心y) */ $labelFillCur = $strokeCur === '#2563eb' ? '#1d4ed8' : '#c2410c'; $labelFillPrev = $strokePrev === '#93c5fd' ? '#475569' : '#78716c'; $valueLabels = ''; $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( '%d/%d', $xv, $baseY, $labelFillCur, $vC, $labelFillPrev, $vP ); } $valueLabels .= ''; $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( '%s/%s', $x, $axisRowY, $lc, $lp ); } $yTick = ''; for ($k = 0; $k <= 4; $k++) { $v = (int) round($maxY * $k / 4); $y = $yAt($v); $yTick .= sprintf( '', $padL, $y, $padL + $gw, $y ); $yTick .= sprintf( '%d', 2, $y + 3, $v ); } $legend = ''; $lx = $padL + 4; $ly = $H - 26; $items = [ [$strokeCur, $legCur, ''], [$strokePrev, $legPrev, '6,4'], ]; foreach ($items as $idx => $it) { $yy = $ly + $idx * 12; $legend .= sprintf( '', $lx, $yy, $lx + 16, $yy, $it[0], $it[2] !== '' ? 'stroke-dasharray="'.$it[2].'"' : '' ); $legend .= sprintf( '%s', $lx + 20, $yy + 4, htmlspecialchars($it[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ); } $legend .= ''; $svg = sprintf( '', $W, $H ); $svg .= sprintf('', $W, $H); $svg .= $yTick; $svg .= sprintf('', $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 .= ''; return $svg; }; $left = $halfSvg($axisCur, $axisPrev, $pc, $pp, '#2563eb', '#93c5fd', '学案·本周期', '学案·上周期'); $right = $halfSvg($axisCur, $axisPrev, $ac, $ap, '#ea580c', '#fdba74', '学情·本周期', '学情·上周期'); return '' .'' .'' .'
'.$left.''.$right.'
'; }; $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 '
'; echo $chartSvg; echo "
\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 ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo "\n\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.' ('.$tidEsc.')'; $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 ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo "\n"; } echo "
排名老师学案数量学案·环比分析数量学情·环比学生数
'.((string) $i++).''.$nameWithId.''.$slashPairAccentHtml($pc, $pp, $colorPapers).''.$compareCellHtml($pc, $pp, $colorPapers).''.$slashPairAccentHtml($ac, $ap, $colorAnalysis).''.$compareCellHtml($ac, $ap, $colorAnalysis).''.$slashPairAccentHtml($stuC, $stuP, $colorStudents).'
\n";