}}|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|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} $sheet * @param array $payload 期望 { scores: { [itemKey]: number } } * @return array{ok: true, scores: array, line_total: float, payload: array}|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, ]; } }