You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

232 lines
8.6 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Support;
/**
* 赛道打分表:结构校验、分项打分与总分汇总。
*
* 约定:
* - 配置时 Σ max_score 必须 = 100
* - 提交时须给每一项打分0 ≤ 得分 ≤ 该项 max_score最多两位小数
* - 项目总分 = 各项得分之和(服务端计算,不采信客户端填写的 line_total
* - 0 ≤ 总分 ≤ 100最多两位小数
*/
class TrackScoringSheet
{
public const FULL_SCORE = 100.0;
public const SCORE_DECIMALS = 2;
public static function roundScore(float $n): float
{
return (float) number_format($n, self::SCORE_DECIMALS, '.', '');
}
public static function formatScore(mixed $n, string $empty = '—'): string
{
if ($n === null || $n === '') {
return $empty;
}
if (! is_numeric($n)) {
return (string) $n;
}
return number_format((float) $n, self::SCORE_DECIMALS, '.', '');
}
/**
* @param mixed $raw
* @return array{ok: true, sheet: array{version: int, title: string, full_score: float, items: list<array{key: string, sort: int, category: string, title: string, criteria: string, max_score: float}>}}|array{ok: false, message: string}
*/
public static function parseAndValidateConfig(mixed $raw): array
{
if ($raw === null) {
return ['ok' => false, 'message' => '打分表不能为空'];
}
if (is_string($raw)) {
$trimmed = trim($raw);
if ($trimmed === '') {
return ['ok' => false, 'message' => '打分表不能为空'];
}
try {
$raw = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return ['ok' => false, 'message' => '打分表 JSON 格式不正确'];
}
}
if (! is_array($raw)) {
return ['ok' => false, 'message' => '打分表须为 JSON 对象'];
}
$title = isset($raw['title']) && is_string($raw['title']) ? trim($raw['title']) : '';
$itemsRaw = $raw['items'] ?? null;
if (! is_array($itemsRaw) || count($itemsRaw) === 0) {
return ['ok' => false, 'message' => '打分表至少包含一项指标'];
}
$items = [];
$keys = [];
$sumMax = 0.0;
foreach ($itemsRaw as $idx => $row) {
if (! is_array($row)) {
return ['ok' => false, 'message' => '第 '.((int) $idx + 1).' 项指标格式无效'];
}
$key = isset($row['key']) && is_string($row['key']) ? trim($row['key']) : '';
if ($key === '' || ! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $key)) {
return ['ok' => false, 'message' => '第 '.((int) $idx + 1).' 项指标 key 无效(字母开头,字母数字下划线)'];
}
if (isset($keys[$key])) {
return ['ok' => false, 'message' => '指标 key 重复:'.$key];
}
$keys[$key] = true;
$category = isset($row['category']) && is_string($row['category']) ? trim($row['category']) : '';
$itemTitle = isset($row['title']) && is_string($row['title']) ? trim($row['title']) : '';
$criteria = isset($row['criteria']) && is_string($row['criteria']) ? trim($row['criteria']) : '';
if ($category === '' || $itemTitle === '') {
return ['ok' => false, 'message' => '第 '.((int) $idx + 1).' 项须填写指标分类与评审指标'];
}
if (! isset($row['max_score']) || ! is_numeric($row['max_score'])) {
return ['ok' => false, 'message' => '「'.$itemTitle.'」分值无效'];
}
$max = (float) $row['max_score'];
if ($max <= 0 || $max > self::FULL_SCORE) {
return ['ok' => false, 'message' => '「'.$itemTitle.'」分值须在 0100 之间且大于 0'];
}
$sort = isset($row['sort']) && is_numeric($row['sort']) ? (int) $row['sort'] : ((int) $idx + 1);
$items[] = [
'key' => $key,
'sort' => $sort,
'category' => $category,
'title' => $itemTitle,
'criteria' => $criteria,
'max_score' => $max,
];
$sumMax += $max;
}
usort($items, fn (array $a, array $b) => $a['sort'] <=> $b['sort'] ?: strcmp($a['key'], $b['key']));
if (abs($sumMax - self::FULL_SCORE) > 0.0001) {
return [
'ok' => false,
'message' => '各项分值之和须为 '.self::FULL_SCORE.' 分,当前为 '.rtrim(rtrim(number_format($sumMax, 4, '.', ''), '0'), '.'),
];
}
$version = isset($raw['version']) && is_numeric($raw['version']) ? (int) $raw['version'] : 1;
return [
'ok' => true,
'sheet' => [
'version' => max(1, $version),
'title' => $title,
'full_score' => self::FULL_SCORE,
'items' => $items,
],
];
}
/**
* @param array<string, mixed>|null $sheet
*/
public static function isConfigured(?array $sheet): bool
{
if ($sheet === null) {
return false;
}
$parsed = self::parseAndValidateConfig($sheet);
return $parsed['ok'] === true;
}
/**
* @param array{version: int, title: string, full_score: float, items: list<array{key: string, sort: int, category: string, title: string, criteria: string, max_score: float}>} $sheet
* @param array<string, mixed> $payload 期望 { scores: { [itemKey]: number } }
* @return array{ok: true, scores: array<string, float>, line_total: float, payload: array<string, mixed>}|array{ok: false, message: string}
*/
public static function normalizeSubmitPayload(array $sheet, array $payload): array
{
$rawScores = $payload['scores'] ?? null;
if (! is_array($rawScores)) {
return ['ok' => false, 'message' => '请为每一项填写有效得分'];
}
$scores = [];
$sum = 0.0;
foreach ($sheet['items'] as $item) {
$key = $item['key'];
$title = $item['title'];
$max = (float) $item['max_score'];
if (! array_key_exists($key, $rawScores) || $rawScores[$key] === null || $rawScores[$key] === '') {
return ['ok' => false, 'message' => '请为每一项填写有效得分'];
}
$raw = $rawScores[$key];
if (is_bool($raw) || is_array($raw) || ! is_numeric($raw)) {
return ['ok' => false, 'message' => '「'.$title.'」得分须为数字'];
}
if (preg_match('/\.\d{3,}/', (string) $raw) === 1) {
return ['ok' => false, 'message' => '「'.$title.'」得分最多保留两位小数'];
}
$n = self::roundScore((float) $raw);
if ($n < 0) {
return ['ok' => false, 'message' => '「'.$title.'」得分不能小于 0'];
}
if ($n - $max > 0.0000001) {
$maxLabel = rtrim(rtrim(number_format($max, self::SCORE_DECIMALS, '.', ''), '0'), '.');
return ['ok' => false, 'message' => '「'.$title.'」得分不能高于该项分值 '.$maxLabel];
}
$scores[$key] = $n;
$sum += $n;
}
$lineTotal = self::roundScore($sum);
if ($lineTotal < 0) {
return ['ok' => false, 'message' => '项目总分不能小于 0'];
}
if ($lineTotal > self::FULL_SCORE) {
return ['ok' => false, 'message' => '项目总分不能大于 100'];
}
$outPayload = [
'line_total' => $lineTotal,
'scores' => $scores,
'sheet_snapshot' => [
'version' => $sheet['version'],
'title' => $sheet['title'],
'full_score' => $sheet['full_score'],
'items' => array_map(static fn (array $it) => [
'key' => $it['key'],
'sort' => $it['sort'],
'category' => $it['category'],
'title' => $it['title'],
'max_score' => $it['max_score'],
], $sheet['items']),
],
];
if (isset($payload['comment']) && is_string($payload['comment'])) {
$outPayload['comment'] = trim($payload['comment']);
}
return [
'ok' => true,
'scores' => $scores,
'line_total' => $lineTotal,
'payload' => $outPayload,
];
}
}