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.

187 lines
7.1 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 ≤ line_total ≤ 100,最多两位小数
*/
class TrackScoringSheet
{
public const FULL_SCORE = 100.0;
public const SCORE_DECIMALS = 2;
/**
* @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.'」分值须在 0~100 之间且大于 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 期望 { line_total: number }(兼容 total)
* @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
{
$raw = $payload['line_total'] ?? $payload['total'] ?? null;
if ($raw === null || $raw === '') {
return ['ok' => false, 'message' => '请填写项目总分'];
}
if (! is_numeric($raw)) {
return ['ok' => false, 'message' => '项目总分须为数字'];
}
if (preg_match('/\.\d{3,}/', (string) $raw) === 1) {
return ['ok' => false, 'message' => '项目总分最多保留两位小数'];
}
$lineTotal = (float) $raw;
// 不做四舍五入截断:超过两位小数已在上方拒绝;合法值按原数入库
if ($lineTotal < 0) {
return ['ok' => false, 'message' => '项目总分不能为负数'];
}
if ($lineTotal > self::FULL_SCORE) {
return ['ok' => false, 'message' => '项目总分不能大于 100'];
}
$outPayload = [
'line_total' => $lineTotal,
'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' => [],
'line_total' => $lineTotal,
'payload' => $outPayload,
];
}
}