storage/app/reports/teacher-weekly-stats-$(date +%Y-%m-%d)_$(date +%H%M%S).md * (shell 里 %H%M%S 会展开为当前时分秒;PDF 见 scripts/report_teacher_weekly_stats_pdf.php) */ require __DIR__ . '/../vendor/autoload.php'; $app = require_once __DIR__ . '/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); $endCurrent = now(); $startCurrent = now()->subDays(7); $startPrev = now()->subDays(14); $endPrev = $startCurrent; $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) ->selectRaw('teacher_id, COUNT(*) as paper_count') ->groupBy('teacher_id') ->get(); // 近 7 天产生学情分析的试卷套数:按 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) ->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'); $tz = (string) config('app.timezone', 'UTC'); /** * 将 [from, to) 均分为 7 段,返回每段组卷数、学情套数(与上方「总量」同一窗口可对齐)。 * * 学案:每段 COUNT(papers),七段之和 = 窗口内组卷总数。 * 学情:每卷在窗口内取 MIN(ear.created_at) 所在段计 1 套,七段之和 = 窗口内卷去重套数(与总量表一致)。 * * @return array{labels: list, papers: list, analysis: list} */ $dailySlices = static function (\Carbon\Carbon $from, \Carbon\Carbon $toExclusive) use ($db): array { $totalSec = max(1, $toExclusive->getTimestamp() - $from->getTimestamp()); $sliceSec = $totalSec / 7; $labels = []; $papers = []; $analysis = array_fill(0, 7, 0); for ($i = 0; $i < 7; $i++) { $sliceFrom = $from->copy()->addSeconds((int) floor($sliceSec * $i)); $sliceTo = $i < 6 ? $from->copy()->addSeconds((int) floor($sliceSec * ($i + 1))) : $toExclusive; // 横轴日期:取每段结束瞬间的前一秒,避免「最后一段落在 4/19 却仍标成 4/18」(原先用段起点做标签) $labels[] = $sliceTo->copy()->subSecond()->format('n/j'); $papers[] = (int) $db::table('papers') ->whereNotNull('teacher_id') ->where('teacher_id', '!=', '') ->where('created_at', '>=', $sliceFrom) ->where('created_at', '<', $sliceTo) ->count(); } $firstAnalysisRows = $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) ->selectRaw('ear.paper_id, MIN(ear.created_at) AS first_at') ->groupBy('ear.paper_id') ->get(); $fromTs = $from->getTimestamp(); foreach ($firstAnalysisRows as $row) { $t = \Carbon\Carbon::parse($row->first_at)->getTimestamp(); $idx = (int) floor(($t - $fromTs) / $sliceSec); if ($idx < 0) { $idx = 0; } if ($idx > 6) { $idx = 6; } $analysis[$idx]++; } return ['labels' => $labels, 'papers' => $papers, 'analysis' => $analysis]; }; $curDaily = $dailySlices($startCurrent, $endCurrent); $prevDaily = $dailySlices($startPrev, $endPrev); /** 左侧:学案(组卷)套数;右侧:学情分析套数(卷去重)。实线本周期,虚线上周期。 */ $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): string { $labels = $curDaily['labels']; $pc = $curDaily['papers']; $ac = $curDaily['analysis']; $pp = $prevDaily['papers']; $ap = $prevDaily['analysis']; $halfSvg = static function ( array $labels, array $cur, array $prev, string $strokeCur, string $strokePrev, string $legCur, string $legPrev ): string { $maxY = max(1, ...$cur, ...$prev); $W = 248; $H = 200; $padL = 38; $padR = 8; $padT = 14; $padB = 34; $gw = $W - $padL - $padR; $gh = $H - $padT - $padB; $n = 7; $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; }; $xAxisY = $padT + $gh; $tickTxt = ''; for ($i = 0; $i < $n; $i++) { $x = $xAt($i); $lab = htmlspecialchars((string) ($labels[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $tickTxt .= sprintf( '%s', $x, $H - 8, $lab ); } $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 = max($padL + 4, $padL + $gw - 118); $ly = $padT + 2; $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 .= $tickTxt; $svg .= $legend; $svg .= ''; return $svg; }; $left = $halfSvg($labels, $pc, $pp, '#2563eb', '#93c5fd', '学案·本周期', '学案·上周期'); $right = $halfSvg($labels, $ac, $ap, '#ea580c', '#fdba74', '学情·本周期', '学情·上周期'); return '' .'' .'' .'
'.$left.''.$right.'
'; }; $chartSvg = $buildDualChartsHtml($curDaily, $prevDaily); echo "## 老师组卷与学情分析(近7天)\n\n"; echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n"; 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### 近7段每日对比(时间轴对齐;左:学案 · 右:学情;七段合计与本表总量一致)\n\n"; echo '
'; echo $chartSvg; echo "
\n\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";