Files
ny-exam-platform/lib/multi_export.php
T
nxxy335 0ba9c7c33c feat(export): 多考试对比导出(横向交叉表)
新增 export_multi 接口与多考试选择弹窗:
- 第一行显示考试名称,前两列固定为学号/姓名,默认按学号排序
- 每场考试成绩可选「最新成绩」或「最高成绩」(latest 跳过 temp_save 草稿,highest 取跨全部尝试最高分)
- 学生取并集(任一考试有提交即纳入),未考的考试单元格留空
- 纯逻辑抽离到 lib/multi_export.php,新增 26 项回归测试(tests/test-multi-export.php)
2026-09-25 08:32:24 +08:00

110 lines
4.1 KiB
PHP

<?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];
}