Просмотр исходного кода

fix(report): 老师周报按自然日与按日学情口径

- 本/上周期按 app.timezone 自然日;--days 控制近 N 个自然日
- 逐日学情:当日有分析记录的卷去重,避免 MIN 归因导致误 0
- 横轴日/日无月、压缩字号;Artisan 与 PDF 脚本说明同步

Made-with: Cursor
yemeishu 1 месяц назад
Родитель
Сommit
a6351e9719

+ 5 - 1
app/Console/Commands/ReportTeacherWeeklyPdfCommand.php

@@ -6,7 +6,8 @@ use Illuminate\Console\Command;
 
 class ReportTeacherWeeklyPdfCommand extends Command
 {
-    protected $signature = 'report:teacher-weekly-pdf';
+    protected $signature = 'report:teacher-weekly-pdf
+                            {--days=7 : 近几个自然日(本周期 = 含今日 0 点至今;上周期 = 紧邻的前若干个完整自然日;时区见 app.timezone)}';
 
     protected $description = '生成老师组卷与学情分析周报 PDF(文件名带日期与时间戳 teacher-weekly-stats-Y-m-d_His.pdf)';
 
@@ -19,6 +20,9 @@ class ReportTeacherWeeklyPdfCommand extends Command
             return self::FAILURE;
         }
 
+        $days = max(1, min(366, (int) $this->option('days')));
+        putenv('TEACHER_WEEKLY_REPORT_DAYS='.(string) $days);
+
         passthru(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script), $code);
 
         return $code === 0 ? self::SUCCESS : self::FAILURE;

+ 85 - 67
scripts/report_teacher_weekly_stats.php

@@ -1,12 +1,14 @@
 <?php
 
 /**
- * 近 7 天老师组卷 + 学情分析套数(exam_analysis_results 按 paper_id 去重,一套卷计 1)
- * 按老师:学案/分析/学生数为「本 / 上」并列(仅本侧数字在本大于上时绿色);保留学案·环比、学情·环比。
+ * 按「自然日」统计:近 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
- *   (shell 里 %H%M%S 会展开为当前时分秒;PDF 见 scripts/report_teacher_weekly_stats_pdf.php)
+ *
+ * 环境变量 / artisan:TEACHER_WEEKLY_REPORT_DAYS(默认 7)= 本/上周期各包含几个自然日。时区:config app.timezone。
  */
 
 require __DIR__ . '/../vendor/autoload.php';
@@ -14,10 +16,20 @@ 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;
+$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);
 
 $db = \Illuminate\Support\Facades\DB::class;
 
@@ -138,16 +150,18 @@ $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();
 
-// 近 7 天产生学情分析的试卷套数:按 ear.paper_id 去重后归到 papers.teacher_id
+// 本时间窗内学情:按 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();
@@ -254,76 +268,77 @@ $windowPrev = sprintf(
 );
 
 $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 套,七段之和 = 窗口内卷去重套数(与总量表一致)。
+ * 按自然日切段(app.timezone):每天一格;本周期末段为「今日 0 点—当前时刻」。
+ * 学案:当日创建的组卷数,各日之和 = 窗口总量。
+ * 学情:该自然日内有 analysis 记录的卷去重(同日多条只计 1);跨日同一卷可在多日重复出现,故各日之和可不等于上方「窗口内卷仅计一次」的总量。
  *
  * @return array{labels: list<string>, papers: list<int>, analysis: list<int>}
  */
-$dailySlices = static function (\Carbon\Carbon $from, \Carbon\Carbon $toExclusive) use ($db): array {
-    $totalSec = max(1, $toExclusive->getTimestamp() - $from->getTimestamp());
-    $sliceSec = $totalSec / 7;
+$dailyNaturalSlices = static function (string $which) use ($db, $reportPeriodDays, $todayStart, $endCurrent): array {
+    $n = $reportPeriodDays;
     $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();
-    }
+    $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');
+    };
 
-    $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 ($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);
         }
-        if ($idx > 6) {
-            $idx = 6;
+    } 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);
         }
-        $analysis[$idx]++;
     }
 
     return ['labels' => $labels, 'papers' => $papers, 'analysis' => $analysis];
 };
 
-$curDaily = $dailySlices($startCurrent, $endCurrent);
-$prevDaily = $dailySlices($startPrev, $endPrev);
+$curDaily = $dailyNaturalSlices('current');
+$prevDaily = $dailyNaturalSlices('prev');
 
-/** 左侧:学案(组卷)套数;右侧:学情分析套数(卷去重)。实线本周期,虚线上周期。 */
+/** 左侧:学案(组卷)套数;右侧:学情分析套数(卷去重)。横轴:本周期日 / 上周期同序号日。 */
 $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): string {
-    $labels = $curDaily['labels'];
+    $axisCur = $curDaily['labels'];
+    $axisPrev = $prevDaily['labels'];
     $pc = $curDaily['papers'];
     $ac = $curDaily['analysis'];
     $pp = $prevDaily['papers'];
     $ap = $prevDaily['analysis'];
 
     $halfSvg = static function (
-        array $labels,
+        array $labelsCur,
+        array $labelsPrev,
         array $cur,
         array $prev,
         string $strokeCur,
@@ -333,14 +348,14 @@ $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): stri
     ): string {
         $maxY = max(1, ...$cur, ...$prev);
         $W = 248;
-        $H = 200;
+        $H = 206;
         $padL = 38;
         $padR = 8;
         $padT = 14;
-        $padB = 34;
+        $padB = 38;
         $gw = $W - $padL - $padR;
         $gh = $H - $padT - $padB;
-        $n = 7;
+        $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));
         };
@@ -371,12 +386,14 @@ $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): stri
         $tickTxt = '';
         for ($i = 0; $i < $n; $i++) {
             $x = $xAt($i);
-            $lab = htmlspecialchars((string) ($labels[$i] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+            $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.5" fill="#374151" font-family="sun-exta,sans-serif">%s</text>',
+                '<text x="%.1f" y="%d" text-anchor="middle" font-size="7" fill="#374151" font-family="sun-exta,sans-serif">%s/%s</text>',
                 $x,
-                $H - 8,
-                $lab
+                $H - 9,
+                $lc,
+                $lp
             );
         }
 
@@ -443,8 +460,8 @@ $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): stri
         return $svg;
     };
 
-    $left = $halfSvg($labels, $pc, $pp, '#2563eb', '#93c5fd', '学案·本周期', '学案·上周期');
-    $right = $halfSvg($labels, $ac, $ap, '#ea580c', '#fdba74', '学情·本周期', '学情·上周期');
+    $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>'
@@ -454,8 +471,9 @@ $buildDualChartsHtml = static function (array $curDaily, array $prevDaily): stri
 
 $chartSvg = $buildDualChartsHtml($curDaily, $prevDaily);
 
-echo "## 老师组卷与学情分析(近7天)\n\n";
-echo "> 生成 {$generatedAt} · {$tz} · 本 {$windowCur} · 上 {$windowPrev}\n\n";
+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";
@@ -464,7 +482,7 @@ echo sprintf("| 组卷总套数 | %d | %d | %s |\n", $totalPapersCur, $totalPape
 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 "### 逐日对比(左:学案 · 右:学情)\n\n";
 echo '<div class="weekly-chart">';
 echo $chartSvg;
 echo "</div>\n\n";

+ 2 - 0
scripts/report_teacher_weekly_stats_pdf.php

@@ -6,6 +6,8 @@
  * 用法:
  *   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)