|
|
|
|
|
<?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;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @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 期望 { 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 = (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 = round($sum, self::SCORE_DECIMALS);
|
|
|
|
|
|
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,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|