feat(export): 多考试对比导出(横向交叉表)
新增 export_multi 接口与多考试选择弹窗: - 第一行显示考试名称,前两列固定为学号/姓名,默认按学号排序 - 每场考试成绩可选「最新成绩」或「最高成绩」(latest 跳过 temp_save 草稿,highest 取跨全部尝试最高分) - 学生取并集(任一考试有提交即纳入),未考的考试单元格留空 - 纯逻辑抽离到 lib/multi_export.php,新增 26 项回归测试(tests/test-multi-export.php)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* 多考试对比导出:成绩交叉矩阵的纯逻辑(不依赖卷王客户端,便于单测)
|
||||
*
|
||||
* 矩阵约定(满足需求):
|
||||
* - 第一行 = 表头,列 A=学号、列 B=姓名、列 C 起每个选中的考试占一列,列标题为考试名称
|
||||
* - 数据行:列 A=学号、列 B=姓名、列 C 起为该生在该场考试里的最新/最高成绩(未考则为空)
|
||||
* - 默认按学号升序(数值优先,否则自然序)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 取某个学生在某场考试里的"单元格成绩"。
|
||||
*
|
||||
* - highest:取真实最高分(跨全部尝试,含重考),即聚合行的 best_score
|
||||
* - latest :取 attempts_list 中「最近一次非暂存提交」的成绩;
|
||||
* attempts_list 全为暂存草稿时退回最后一份;列表缺失时退回代表行 score
|
||||
*
|
||||
* 与单考试"按最新成绩"导出口径完全一致(跳过 temp_save 草稿,避免导出的"最新成绩"是草稿分)。
|
||||
*
|
||||
* @return float|null 未提交 / 无成绩返回 null
|
||||
*/
|
||||
function ny_exam_cell_score(array $r, string $mode): ?float
|
||||
{
|
||||
if ($mode === 'highest') {
|
||||
$v = $r['best_score'] ?? null;
|
||||
return ($v === null || $v === '') ? null : (float)$v;
|
||||
}
|
||||
// 最近一次非暂存提交
|
||||
$list = array_values((array)($r['attempts_list'] ?? []));
|
||||
$last = null;
|
||||
for ($i = count($list) - 1; $i >= 0; $i--) {
|
||||
if (empty($list[$i]['temp_save'])) {
|
||||
$last = $list[$i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($last === null && $list !== []) {
|
||||
$last = $list[count($list) - 1]; // 全是暂存时退回最后一份
|
||||
}
|
||||
if ($last === null) {
|
||||
$v = $r['score'] ?? null; // 无明细:退回代表行
|
||||
return ($v === null || $v === '') ? null : (float)$v;
|
||||
}
|
||||
$v = $last['score'] ?? null;
|
||||
return ($v === null || $v === '') ? null : (float)$v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把多场考试已构建好的结果,组装成"成绩交叉对比"二维矩阵。
|
||||
*
|
||||
* @param array $exams 关联数组 pid => ['name'=>string, 'rows'=>array](保持插入顺序即考试展示顺序)
|
||||
* @param string $scoreMode 'latest' | 'highest'
|
||||
* @return array{head:array,rows:array,exam_order:array,exam_names:array}
|
||||
*/
|
||||
function ny_assemble_multi_exam_matrix(array $exams, string $scoreMode): array
|
||||
{
|
||||
$examOrder = [];
|
||||
$examNames = [];
|
||||
$scoreByExam = []; // pid => [sid => float|null]
|
||||
$nameBySid = []; // sid => 姓名(首个非空)
|
||||
|
||||
foreach ($exams as $pid => $ex) {
|
||||
$examOrder[] = $pid;
|
||||
$examNames[$pid] = trim((string)($ex['name'] ?? '')) !== '' ? (string)$ex['name'] : (string)$pid;
|
||||
$map = [];
|
||||
foreach ((array)($ex['rows'] ?? []) as $r) {
|
||||
$sid = trim((string)($r['sid'] ?? ''));
|
||||
if ($sid === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($nameBySid[$sid]) && trim((string)($r['name'] ?? '')) !== '') {
|
||||
$nameBySid[$sid] = (string)$r['name'];
|
||||
}
|
||||
$map[$sid] = ny_exam_cell_score($r, $scoreMode);
|
||||
}
|
||||
$scoreByExam[$pid] = $map;
|
||||
}
|
||||
|
||||
// 学生并集(在任一选中考试里有提交即纳入)
|
||||
$sidSet = [];
|
||||
foreach ($scoreByExam as $map) {
|
||||
foreach ($map as $sid => $_) {
|
||||
$sidSet[$sid] = true;
|
||||
}
|
||||
}
|
||||
$sids = array_keys($sidSet);
|
||||
// 默认按学号排序(数值优先,否则自然序)
|
||||
usort($sids, static function ($a, $b): int {
|
||||
$a = (string)$a;
|
||||
$b = (string)$b;
|
||||
if (is_numeric($a) && is_numeric($b)) {
|
||||
return $a <=> $b;
|
||||
}
|
||||
return strnatcmp($a, $b);
|
||||
});
|
||||
|
||||
$head = array_merge(['学号', '姓名'], array_map(static fn($pid) => $examNames[$pid], $examOrder));
|
||||
$rows = [];
|
||||
foreach ($sids as $sid) {
|
||||
$line = [$sid, $nameBySid[$sid] ?? ''];
|
||||
foreach ($examOrder as $pid) {
|
||||
$sc = $scoreByExam[$pid][$sid] ?? null;
|
||||
$line[] = ($sc === null) ? '' : $sc;
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
return ['head' => $head, 'rows' => $rows, 'exam_order' => $examOrder, 'exam_names' => $examNames];
|
||||
}
|
||||
Reference in New Issue
Block a user