2026-08-22 20:34:05 +08:00
|
|
|
|
using YD_Event.Application.Scoring.Dtos;
|
|
|
|
|
|
|
|
|
|
|
|
namespace YD_Event.Application.Scoring;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 成绩聚合与排名计算服务。
|
|
|
|
|
|
/// 规定赛:T1=3 名创意编排裁判创意分的平均值,T2=3 名完成质量裁判完成分的平均值,
|
|
|
|
|
|
/// T3=主裁违例扣分(每项扣 5 分)合计,总分=T1+T2-T3,均四舍五入保留两位小数。
|
2026-08-23 16:21:49 +08:00
|
|
|
|
/// 花样赛:各难度裁判类型先聚合再按难度除数合并为 D,表现裁判平均分乘系数得 P(个人/车轮 0.05、交互绳 0.033),
|
|
|
|
|
|
/// 规定元素缺失数换算为 Q,主裁扣分换算为 M = max(0, 1 - 扣分),总分 = D × (1 + P) × Q × M(交互绳不计 Q)。
|
|
|
|
|
|
/// 排名:总分高者在前,总分相同按规则配置的比较字段依次比较,全部相同则并列,其后名次按 N+M 规则顺延。
|
2026-08-22 20:34:05 +08:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static class EntryResultCalculationService
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 把某个出场的各裁判评分单聚合成一条成绩。judgeNames 用于在计算明细里带上裁判姓名,可为空。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static EntryAggregateResult Aggregate(ScoreRuleConfig config, List<EventJudgeScoreSheet> sheets,
|
|
|
|
|
|
IReadOnlyDictionary<long, string>? judgeNames = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
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,
|
2026-08-23 16:21:49 +08:00
|
|
|
|
Aggregate = role.Aggregate, Sign = role.Sign, Factor = role.Factor
|
2026-08-22 20:34:05 +08:00
|
|
|
|
};
|
|
|
|
|
|
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 分计入,成绩是否可用由调用方按提交进度判断。
|
|
|
|
|
|
symbol.Value = config.Round(Combine(symbol.Judges.Select(x => x.Value).ToList(), role.Aggregate));
|
|
|
|
|
|
result.Symbols.Add(symbol);
|
|
|
|
|
|
ApplyTargetField(result, role.TargetField, symbol.Value);
|
|
|
|
|
|
total += role.Sign * symbol.Value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 16:21:49 +08:00
|
|
|
|
result.TotalScore = config.IsFactorTotal ? ApplyFactorTotal(config, result) : config.Round(total);
|
2026-08-22 20:34:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 任一裁判判定取消资格即取消该出场成绩,取消资格后不计总分、不参与排名。
|
|
|
|
|
|
var disqualified = sheets.FirstOrDefault(x => x.IsDisqualified);
|
|
|
|
|
|
if (disqualified != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
result.IsDisqualified = true;
|
|
|
|
|
|
result.DisqualifyReasonCode = disqualified.DisqualifyReasonCode;
|
|
|
|
|
|
result.TotalScore = 0m;
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 16:21:49 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 花样赛按乘法公式算总分:
|
|
|
|
|
|
/// D 由各难度裁判类型的聚合值加权后除以难度除数(未配置除数时按难度裁判类型数量取平均);
|
|
|
|
|
|
/// P = 表现裁判聚合值 × 表现系数;Q = max(0, 1 - 系数 × 规定元素缺失数);M = max(0, 1 - 扣分合计);
|
|
|
|
|
|
/// 未配置某个因子(如交互绳不计 Q)时该因子按 1 处理,不影响相乘结果。
|
|
|
|
|
|
/// <para>
|
|
|
|
|
|
/// 算完后把 D、P、Q 与扣分合计回写到成绩的四个分值字段,覆盖按裁判类型逐个写入的中间值
|
|
|
|
|
|
/// (车轮跳的 DA、DB 都写难度分会互相覆盖),排名的并列比较字段据此取值。
|
|
|
|
|
|
/// </para>
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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 = config.Round(difficulty / divisor);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var performance = 0m;
|
|
|
|
|
|
foreach (var (symbol, weight) in FactorSymbols(config, result, ScoreFactorCode.Performance))
|
|
|
|
|
|
performance += symbol.Value * weight;
|
|
|
|
|
|
performance = config.Round(performance);
|
|
|
|
|
|
|
|
|
|
|
|
// 规定元素与扣分都是“扣减型”因子,聚合值越大因子越小,下限取 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.DifficultyFactor = difficulty;
|
|
|
|
|
|
result.PerformanceFactor = performance;
|
|
|
|
|
|
result.RequiredFactor = config.Round(required);
|
|
|
|
|
|
result.DeductionFactor = config.Round(deduction);
|
|
|
|
|
|
// 排名的并列比较取的是成绩表上的四个分值字段,这里统一回写为花样赛口径:
|
|
|
|
|
|
// 难度分=D,表现分=P,规定分=Q,扣分=主裁扣分合计(越小越靠前)。
|
|
|
|
|
|
result.DifficultyScore = difficulty;
|
|
|
|
|
|
result.PerformanceScore = performance;
|
|
|
|
|
|
result.RequiredScore = result.RequiredFactor;
|
|
|
|
|
|
result.DeductionScore = config.Round(penalty);
|
|
|
|
|
|
return config.Round(Math.Max(0m, difficulty * (1m + performance) * required * deduction));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 取承担某个因子的计分符号及其因子系数
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 20:34:05 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 按规则的并列比较顺序计算名次,名次并列时其后名次按 N+M 规则顺延(两个并列第一,下一名为第三名)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static void AssignRanks(List<RankCandidate> candidates, ScoreRuleConfig config)
|
|
|
|
|
|
{
|
|
|
|
|
|
var fields = ResolveTieBreakers(config);
|
|
|
|
|
|
var ranked = new List<RankCandidate>();
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-08-23 16:21:49 +08:00
|
|
|
|
/// 生成计分公式文本。规定赛为 T1 + T2 - T3,花样赛为 D × (1 + P) × Q × M(未配置的因子不出现在公式里)。
|
2026-08-22 20:34:05 +08:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static string BuildFormula(ScoreRuleConfig config)
|
|
|
|
|
|
{
|
2026-08-23 16:21:49 +08:00
|
|
|
|
if (config.IsFactorTotal) return BuildFactorFormula(config);
|
2026-08-22 20:34:05 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 16:21:49 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 生成花样赛的乘法公式文本,只列出规则里实际配置了的因子
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static string BuildFactorFormula(ScoreRuleConfig config)
|
|
|
|
|
|
{
|
|
|
|
|
|
var parts = new List<string>();
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 20:34:05 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 计算明细 JSON,落库到成绩的 CalculationJson,成绩复核与申诉时可完整还原计算过程
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static string BuildCalculationJson(EntryAggregateResult result, ScoreRuleConfig config)
|
|
|
|
|
|
{
|
|
|
|
|
|
return JsonConvert.SerializeObject(new
|
|
|
|
|
|
{
|
|
|
|
|
|
ruleType = config.RuleType,
|
2026-08-23 16:21:49 +08:00
|
|
|
|
totalMode = config.TotalMode,
|
2026-08-22 20:34:05 +08:00
|
|
|
|
formula = result.Formula,
|
|
|
|
|
|
scoreDecimals = config.ScoreDecimals,
|
|
|
|
|
|
totalScore = result.TotalScore,
|
|
|
|
|
|
isDisqualified = result.IsDisqualified,
|
|
|
|
|
|
disqualifyReasonCode = result.DisqualifyReasonCode,
|
2026-08-23 16:21:49 +08:00
|
|
|
|
factors = config.IsFactorTotal
|
|
|
|
|
|
? new
|
|
|
|
|
|
{
|
|
|
|
|
|
d = result.DifficultyFactor, p = result.PerformanceFactor,
|
|
|
|
|
|
q = result.RequiredFactor, m = result.DeductionFactor
|
|
|
|
|
|
}
|
|
|
|
|
|
: null,
|
2026-08-22 20:34:05 +08:00
|
|
|
|
symbols = result.Symbols.Select(x => new
|
|
|
|
|
|
{
|
2026-08-23 16:21:49 +08:00
|
|
|
|
x.Symbol, x.Label, x.RoleType, x.Aggregate, x.Sign, x.Factor, x.Value, x.JudgeCount,
|
2026-08-22 20:34:05 +08:00
|
|
|
|
judges = x.Judges.Select(j => new { j.ScoreSheetId, j.RefereeId, j.JudgeName, j.JudgeRole, j.Value })
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 解析并列比较字段,字段名前加“-”表示该项越小越靠前
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 按比较字段顺序比较两条成绩,返回负数表示 left 名次更靠前
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 多名裁判之间的聚合。人数不固定时用平均值,因此裁判人数变化不影响计分口径。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static decimal Combine(List<decimal> 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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 按配置的字段名取评分单上的分值
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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}无效")
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 把聚合结果写入成绩对应的分值字段
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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}无效");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|