using YD_Event.Application.Scoring.Dtos; namespace YD_Event.Application.Scoring; /// /// 成绩聚合与排名计算服务。 /// 规定赛:T1=3 名创意编排裁判创意分的平均值,T2=3 名完成质量裁判完成分的平均值, /// T3=主裁违例扣分(每项扣 5 分)合计,总分=T1+T2-T3,均四舍五入保留两位小数。 /// 花样赛:各难度裁判类型先聚合再按难度除数合并为 D,表现裁判平均分乘系数得 P(个人/车轮 0.05、交互绳 0.033), /// 规定元素缺失数换算为 Q;扣分中的失误按所有表现裁判与主裁的实际人数求平均,取整后换算,并加上主裁其他扣分, /// 得到 M = max(0, 1 - 扣分),总分 = D × (1 + P) × Q × M(交互绳不计 Q)。 /// 排名:总分高者在前,总分相同按规则配置的比较字段依次比较,全部相同则并列,其后名次按 N+M 规则顺延。 /// public static class EntryResultCalculationService { private const string RolePerformance = "P"; private const string RoleChief = "T"; private const string PerformanceFailureItemCode = "FAILURE_COUNT"; private const string ChiefFailureItemCode = "T_FAILURE"; /// /// 把某个出场的各裁判评分单聚合成一条成绩。judgeNames 用于在计算明细里带上裁判姓名,可为空。 /// public static EntryAggregateResult Aggregate(ScoreRuleConfig config, List sheets, IReadOnlyDictionary? judgeNames = null, IReadOnlyCollection? scoreDetails = null, IReadOnlyCollection? ruleItems = null, string scope = ScoreScope.Personal) { var result = new EntryAggregateResult { Formula = BuildFormula(config) }; var total = 0m; foreach (var role in config.RoleAggregations) { var symbol = new EntrySymbolScore { Symbol = role.Symbol, Label = role.Label, RoleType = role.RoleType, Aggregate = role.Aggregate, Sign = role.Sign, Factor = role.Factor }; var roleSheets = sheets .Where(x => string.Equals(JudgeRoleSchemaService.NormalizeRoleType(x.JudgeRoleType), role.RoleType, StringComparison.OrdinalIgnoreCase)) .OrderBy(x => x.JudgeRole).ThenBy(x => x.Id) .ToList(); foreach (var sheet in roleSheets) { symbol.Judges.Add(new EntryJudgeScore { ScoreSheetId = sheet.Id, RefereeId = sheet.RefereeId, JudgeName = judgeNames != null && judgeNames.TryGetValue(sheet.RefereeId, out var name) ? name : null, JudgeRole = sheet.JudgeRole, JudgeRoleType = sheet.JudgeRoleType, Value = SheetValue(sheet, role.SourceField), IsDisqualified = sheet.IsDisqualified }); } // 该类型暂无裁判提交时按 0 分计入,成绩是否可用由调用方按提交进度判断。 var combinedValue = Combine(symbol.Judges.Select(x => x.Value).ToList(), role.Aggregate); symbol.Value = role.AggregateDecimals.HasValue ? Math.Round(combinedValue, role.AggregateDecimals.Value, MidpointRounding.AwayFromZero) : (config.IsFactorTotal ? combinedValue : config.Round(combinedValue)); result.Symbols.Add(symbol); ApplyTargetField(result, role.TargetField, symbol.Value); total += role.Sign * symbol.Value; } // 所有表现裁判与主裁的失误按实际人数求平均后计算阶梯扣分;其他扣分保持原值。 ApplyCombinedFailureDeduction(config, result, sheets, scoreDetails, ruleItems, scope); result.TotalScore = config.IsFactorTotal ? ApplyFactorTotal(config, result) : config.Round(total); // 任一裁判判定取消资格即取消该出场成绩,取消资格后不计总分、不参与排名。 var disqualified = sheets.FirstOrDefault(x => x.IsDisqualified); if (disqualified != null) { result.IsDisqualified = true; result.DisqualifyReasonCode = disqualified.DisqualifyReasonCode; result.TotalScore = 0m; } return result; } /// /// 对所有表现裁判与主裁记录的失误次数求平均,并替换扣分因子中的失误部分。 /// 以评分项是否同时存在 P/FAILURE_COUNT 和 T/T_FAILURE 作为启用条件,规定赛等不含这些评分项的规则不受影响。 /// private static void ApplyCombinedFailureDeduction(ScoreRuleConfig config, EntryAggregateResult result, IReadOnlyCollection sheets, IReadOnlyCollection? scoreDetails, IReadOnlyCollection? ruleItems, string scope) { if (!config.IsFactorTotal || scoreDetails == null || ruleItems == null) return; var performanceFailureItem = ruleItems.FirstOrDefault(x => string.Equals(x.JudgeRoleType, RolePerformance, StringComparison.OrdinalIgnoreCase) && string.Equals(x.ItemCode, PerformanceFailureItemCode, StringComparison.OrdinalIgnoreCase)); var chiefFailureItem = ruleItems.FirstOrDefault(x => string.Equals(x.JudgeRoleType, RoleChief, StringComparison.OrdinalIgnoreCase) && string.Equals(x.ItemCode, ChiefFailureItemCode, StringComparison.OrdinalIgnoreCase)); if (performanceFailureItem == null || chiefFailureItem == null) return; var deductionRole = config.RoleAggregations.FirstOrDefault(x => string.Equals(x.Factor, ScoreFactorCode.Deduction, StringComparison.OrdinalIgnoreCase) && string.Equals(x.RoleType, RoleChief, StringComparison.OrdinalIgnoreCase)); if (deductionRole == null) return; var deductionSymbol = result.Symbols.FirstOrDefault(x => string.Equals(x.Symbol, deductionRole.Symbol, StringComparison.OrdinalIgnoreCase)); if (deductionSymbol == null) return; var performanceSheets = sheets .Where(x => string.Equals(JudgeRoleSchemaService.NormalizeRoleType(x.JudgeRoleType), RolePerformance, StringComparison.OrdinalIgnoreCase)) .OrderBy(x => x.JudgeRole).ThenBy(x => x.Id) .ToList(); var chiefSheets = sheets .Where(x => string.Equals(JudgeRoleSchemaService.NormalizeRoleType(x.JudgeRoleType), RoleChief, StringComparison.OrdinalIgnoreCase)) .OrderBy(x => x.JudgeRole).ThenBy(x => x.Id) .ToList(); // 规则要求至少一名表现裁判和一名主裁;数据不完整时不执行跨裁判失误合并。 if (performanceSheets.Count == 0 || chiefSheets.Count != 1) return; var detailsBySheet = scoreDetails.GroupBy(x => x.ScoreSheetId) .ToDictionary(x => x.Key, x => x.ToList()); decimal FailureCount(long sheetId, string itemCode) => detailsBySheet.TryGetValue(sheetId, out var details) ? details.Where(x => string.Equals(x.ItemCode, itemCode, StringComparison.OrdinalIgnoreCase)) .Sum(x => x.CountValue ?? x.InputValue ?? 0m) : 0m; var performanceCounts = performanceSheets .Select(x => FailureCount(x.Id, PerformanceFailureItemCode)) .ToList(); var chiefSheet = chiefSheets[0]; var chiefCount = FailureCount(chiefSheet.Id, ChiefFailureItemCode); var performanceJudgeCount = performanceCounts.Count; var performanceAverageCount = performanceCounts.Average(); var totalJudgeCount = performanceJudgeCount + 1; var combinedCount = performanceCounts.Sum() + chiefCount; var averageCount = combinedCount / totalJudgeCount; var roundedCount = (int)Math.Round(averageCount, 0, MidpointRounding.AwayFromZero); var chiefDefinition = ScoreSheetCalculationService.CreateDefinition(chiefFailureItem, scope); var steps = chiefDefinition.StepDeductions; if (steps == null || steps.Count == 0) return; var failureDeduction = 0m; for (var i = 0; i < roundedCount; i++) failureDeduction += steps[Math.Min(i, steps.Count - 1)]; var originalChiefFailureDeduction = detailsBySheet.TryGetValue(chiefSheet.Id, out var chiefDetails) ? chiefDetails .Where(x => string.Equals(x.ItemCode, ChiefFailureItemCode, StringComparison.OrdinalIgnoreCase)) .Sum(x => x.Subtotal) : 0m; var otherDeduction = Math.Max(0m, deductionSymbol.Value - originalChiefFailureDeduction); // 保留扣分过程的精度,避免阶梯扣分在因子计算前丢失小数。 var totalDeduction = otherDeduction + failureDeduction; deductionSymbol.Value = totalDeduction; result.DeductionScore = totalDeduction; result.FailureAggregation = new EntryFailureAggregation { PerformanceFailureCounts = performanceCounts, ChiefFailureCount = chiefCount, JudgeCount = performanceJudgeCount, TotalJudgeCount = totalJudgeCount, PerformanceAverageFailureCount = performanceAverageCount, CombinedFailureCount = combinedCount, AverageFailureCount = averageCount, FinalAverageFailureCount = averageCount, RoundedFailureCount = roundedCount, OriginalChiefFailureDeduction = originalChiefFailureDeduction, FailureDeduction = failureDeduction, OtherDeduction = otherDeduction, TotalDeduction = totalDeduction }; } /// /// 花样赛按乘法公式算总分: /// D 由各难度裁判类型的聚合值加权后除以难度除数(未配置除数时按难度裁判类型数量取平均); /// P = 表现裁判聚合值 × 表现系数;Q = max(0, 1 - 系数 × 规定元素缺失数);M = max(0, 1 - 扣分合计); /// 未配置某个因子(如交互绳不计 Q)时该因子按 1 处理,不影响相乘结果。 /// /// 算完后把 D、P、Q 与扣分合计回写到成绩的四个分值字段,覆盖按裁判类型逐个写入的中间值 /// (车轮跳的 DA、DB 都写难度分会互相覆盖),排名的并列比较字段据此取值。 /// /// private static decimal ApplyFactorTotal(ScoreRuleConfig config, EntryAggregateResult result) { var difficultySymbols = FactorSymbols(config, result, ScoreFactorCode.Difficulty); var difficulty = 0m; if (difficultySymbols.Count > 0) { foreach (var (symbol, weight) in difficultySymbols) difficulty += symbol.Value * weight; // 除数为 0 视为漏配,按裁判类型数量取平均,避免除零。 var divisor = config.DifficultyDivisor is > 0m ? config.DifficultyDivisor!.Value : difficultySymbols.Count; difficulty /= divisor; } var performance = 0m; foreach (var (symbol, weight) in FactorSymbols(config, result, ScoreFactorCode.Performance)) performance += symbol.Value * weight; // 规定元素与扣分都是“扣减型”因子,聚合值越大因子越小,下限取 0。 var required = 1m; var requiredSymbols = FactorSymbols(config, result, ScoreFactorCode.Required); if (requiredSymbols.Count > 0) { var missing = 0m; foreach (var (symbol, weight) in requiredSymbols) missing += symbol.Value * weight; required = Math.Max(0m, 1m - missing); } var deduction = 1m; var penalty = 0m; var deductionSymbols = FactorSymbols(config, result, ScoreFactorCode.Deduction); if (deductionSymbols.Count > 0) { foreach (var (symbol, weight) in deductionSymbols) penalty += symbol.Value * weight; deduction = Math.Max(0m, 1m - penalty); } // 总分计算前,D/P/Q 保留两位、M 保留三位;规定缺失量按角色聚合配置取整,失误次数在扣分流程中取整。 result.RawFactors = new EntryRawFactors(difficulty, performance, required, deduction); difficulty = Math.Round(difficulty, 2, MidpointRounding.AwayFromZero); performance = Math.Round(performance, 2, MidpointRounding.AwayFromZero); required = Math.Round(required, 2, MidpointRounding.AwayFromZero); deduction = Math.Round(deduction, 3, MidpointRounding.AwayFromZero); result.DifficultyFactor = difficulty; result.PerformanceFactor = performance; result.RequiredFactor = required; result.DeductionFactor = deduction; // 花样赛排名字段分别保存 D、P、Q 和扣分合计。 result.DifficultyScore = difficulty; result.PerformanceScore = performance; result.RequiredScore = result.RequiredFactor; result.DeductionScore = penalty; return config.Round(Math.Max(0m, difficulty * (1m + performance) * required * deduction)); } /// /// 取承担某个因子的计分符号及其因子系数 /// private static List<(EntrySymbolScore Symbol, decimal Weight)> FactorSymbols(ScoreRuleConfig config, EntryAggregateResult result, string factor) { var list = new List<(EntrySymbolScore, decimal)>(); foreach (var role in config.RoleAggregations) { if (!string.Equals(role.Factor, factor, StringComparison.OrdinalIgnoreCase)) continue; var symbol = result.Symbols.FirstOrDefault(x => string.Equals(x.Symbol, role.Symbol, StringComparison.OrdinalIgnoreCase)); if (symbol == null) continue; list.Add((symbol, role.FactorWeight)); } return list; } /// /// 按规则的并列比较顺序计算名次,名次并列时其后名次按 N+M 规则顺延(两个并列第一,下一名为第三名) /// public static void AssignRanks(List candidates, ScoreRuleConfig config) { var fields = ResolveTieBreakers(config); var ranked = new List(); foreach (var candidate in candidates) { candidate.Rank = 0; if (candidate.IsRanked && !candidate.IsDisqualified) ranked.Add(candidate); } ranked.Sort((left, right) => Compare(left, right, fields)); var rank = 1; var index = 0; while (index < ranked.Count) { // 所有比较字段都相等的视为并列。 var tied = 1; while (index + tied < ranked.Count && Compare(ranked[index], ranked[index + tied], fields) == 0) tied++; for (var i = 0; i < tied; i++) ranked[index + i].Rank = rank; index += tied; rank += tied; } } /// /// 生成计分公式文本。规定赛为 T1 + T2 - T3,花样赛为 D × (1 + P) × Q × M(未配置的因子不出现在公式里)。 /// public static string BuildFormula(ScoreRuleConfig config) { if (config.IsFactorTotal) return BuildFactorFormula(config); var text = new System.Text.StringBuilder(); foreach (var role in config.RoleAggregations) { if (text.Length == 0) text.Append(role.Sign < 0 ? "-" + role.Symbol : role.Symbol); else text.Append(role.Sign < 0 ? " - " : " + ").Append(role.Symbol); } return text.ToString(); } /// /// 生成花样赛的乘法公式文本,只列出规则里实际配置了的因子 /// private static string BuildFactorFormula(ScoreRuleConfig config) { var parts = new List(); if (config.RoleAggregations.Any(x => x.Factor == ScoreFactorCode.Difficulty)) parts.Add("D"); if (config.RoleAggregations.Any(x => x.Factor == ScoreFactorCode.Performance)) parts.Add("(1 + P)"); if (config.RoleAggregations.Any(x => x.Factor == ScoreFactorCode.Required)) parts.Add("Q"); if (config.RoleAggregations.Any(x => x.Factor == ScoreFactorCode.Deduction)) parts.Add("M"); return string.Join(" × ", parts); } /// /// 计算明细 JSON,落库到成绩的 CalculationJson,成绩复核与申诉时可完整还原计算过程 /// public static string BuildCalculationJson(EntryAggregateResult result, ScoreRuleConfig config) { return JsonConvert.SerializeObject(new { ruleType = config.RuleType, totalMode = config.TotalMode, formula = result.Formula, scoreDecimals = config.ScoreDecimals, calculationVersion = "54-69-70-71-79-v1", rawFactors = result.RawFactors, totalScore = result.TotalScore, isDisqualified = result.IsDisqualified, disqualifyReasonCode = result.DisqualifyReasonCode, factors = config.IsFactorTotal ? new { d = result.DifficultyFactor, p = result.PerformanceFactor, q = result.RequiredFactor, m = result.DeductionFactor } : null, failureAggregation = result.FailureAggregation == null ? null : new { performanceFailureCounts = result.FailureAggregation.PerformanceFailureCounts, chiefFailureCount = result.FailureAggregation.ChiefFailureCount, judgeCount = result.FailureAggregation.JudgeCount, totalJudgeCount = result.FailureAggregation.TotalJudgeCount, averageFailureCount = result.FailureAggregation.AverageFailureCount, performanceAverageFailureCount = result.FailureAggregation.PerformanceAverageFailureCount, combinedFailureCount = result.FailureAggregation.CombinedFailureCount, finalAverageFailureCount = result.FailureAggregation.FinalAverageFailureCount, roundedFailureCount = result.FailureAggregation.RoundedFailureCount, originalChiefFailureDeduction = result.FailureAggregation.OriginalChiefFailureDeduction, failureDeduction = result.FailureAggregation.FailureDeduction, otherDeduction = result.FailureAggregation.OtherDeduction, totalDeduction = result.FailureAggregation.TotalDeduction }, symbols = result.Symbols.Select(x => new { x.Symbol, x.Label, x.RoleType, x.Aggregate, x.Sign, x.Factor, x.Value, x.JudgeCount, judges = x.Judges.Select(j => new { j.ScoreSheetId, j.RefereeId, j.JudgeName, j.JudgeRole, j.Value }) }) }); } /// /// 解析并列比较字段,字段名前加“-”表示该项越小越靠前 /// private static List<(string Field, bool Ascending)> ResolveTieBreakers(ScoreRuleConfig config) { var fields = new List<(string, bool)>(); foreach (var raw in config.TieBreakers) { var text = (raw ?? string.Empty).Trim(); if (text.Length == 0) continue; var ascending = text.StartsWith('-'); fields.Add((ascending ? text[1..] : text, ascending)); } if (fields.Count == 0) fields.Add((nameof(RankCandidate.TotalScore), false)); return fields; } /// /// 按比较字段顺序比较两条成绩,返回负数表示 left 名次更靠前 /// private static int Compare(RankCandidate left, RankCandidate right, List<(string Field, bool Ascending)> fields) { foreach (var (field, ascending) in fields) { var compared = left.FieldValue(field).CompareTo(right.FieldValue(field)); if (compared == 0) continue; return ascending ? compared : -compared; } return 0; } /// /// 多名裁判之间的聚合。人数不固定时用平均值,因此裁判人数变化不影响计分口径。 /// private static decimal Combine(List values, string aggregate) { if (values.Count == 0) return 0m; return aggregate switch { ScoreRuleConfig.AggregateSum => values.Sum(), ScoreRuleConfig.AggregateMax => values.Max(), ScoreRuleConfig.AggregateMin => values.Min(), _ => values.Sum() / values.Count }; } /// /// 按配置的字段名取评分单上的分值 /// private static decimal SheetValue(EventJudgeScoreSheet sheet, string field) => field switch { nameof(EventJudgeScoreSheet.DifficultyScore) => sheet.DifficultyScore, nameof(EventJudgeScoreSheet.PerformanceScore) => sheet.PerformanceScore, nameof(EventJudgeScoreSheet.RequiredScore) => sheet.RequiredScore, nameof(EventJudgeScoreSheet.DeductionScore) => sheet.DeductionScore, nameof(EventJudgeScoreSheet.RequiredCompletedCount) => sheet.RequiredCompletedCount, nameof(EventJudgeScoreSheet.RequiredMissingCount) => sheet.RequiredMissingCount, nameof(EventJudgeScoreSheet.TotalScore) => sheet.TotalScore, // 宁可报错也不能按 0 分静默计入,避免规则配置写错却算出一份看似正常的成绩。 _ => throw Oops.Bah($"计分规则配置的取值字段{field}无效") }; /// /// 把聚合结果写入成绩对应的分值字段 /// private static void ApplyTargetField(EntryAggregateResult result, string field, decimal value) { switch (field) { case nameof(EventResult.DifficultyScore): result.DifficultyScore = value; break; case nameof(EventResult.PerformanceScore): result.PerformanceScore = value; break; case nameof(EventResult.RequiredScore): result.RequiredScore = value; break; case nameof(EventResult.DeductionScore): result.DeductionScore = value; break; default: throw Oops.Bah($"计分规则配置的成绩字段{field}无效"); } } }