YD_Event.Admin/YD_Event.Application/Scoring/ScoreSheetCalculationService.cs

511 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using YD_Event.Application.Scoring.Dtos;
namespace YD_Event.Application.Scoring;
/// <summary>
/// 评分单计算服务。裁判端提交的分值一律在服务端按计分规则重算,客户端传来的合计值不参与落库。
/// </summary>
public static class ScoreSheetCalculationService
{
/// <summary>
/// 按项目类型判断评分项适用范围
/// </summary>
public static string ResolveScope(ProjectTypeEnum projectType) =>
projectType is ProjectTypeEnum.TeamSpeed or ProjectTypeEnum.TeamFreestyle ? ScoreScope.Team : ScoreScope.Personal;
/// <summary>
/// 取某个裁判类型在本场次需要评的评分项
/// </summary>
public static List<ScoreItemDefinition> ItemsOf(ScoringContext context, string judgeRoleType) =>
ResolveItems(context.RuleItems, judgeRoleType, context.Scope);
/// <summary>
/// 取某个裁判类型在当前项目范围下需要评的评分项
/// </summary>
public static List<ScoreItemDefinition> ResolveItems(IEnumerable<ScoreRuleItem> ruleItems, string judgeRoleType, string scope)
{
return ruleItems
.Where(x => string.Equals(x.JudgeRoleType, judgeRoleType, StringComparison.OrdinalIgnoreCase))
.Where(x => MatchScope(x, scope))
.OrderBy(x => x.ItemType).ThenBy(x => x.Sort).ThenBy(x => x.Id)
.Select(x => CreateDefinition(x, scope))
.ToList();
}
/// <summary>
/// 解析评分项定义。scope 为 personal 或 team,用于取对应的分值上限。
/// </summary>
public static ScoreItemDefinition CreateDefinition(ScoreRuleItem item, string scope)
{
var definition = new ScoreItemDefinition
{
Item = item,
MinValue = item.MinValue ?? 0m,
MaxValue = item.MaxValue ?? 0m,
StepValue = item.StepValue is > 0m ? item.StepValue!.Value : 1m
};
JObject? extension = null;
if (!string.IsNullOrWhiteSpace(item.ExtensionJson))
{
// 扩展配置由人工维护,格式不合法时按无扩展配置处理,评分项本身仍然可用。
try { extension = JObject.Parse(item.ExtensionJson); } catch (JsonException) { extension = null; }
}
if (extension == null) return definition;
definition.InputMode = extension["inputMode"]?.ToString() ?? definition.InputMode;
definition.UnitLabel = extension["unitLabel"]?.ToString();
definition.Remark = extension["remark"]?.ToString();
// 打分项的微调步长(0.5)优先于规则明细上的步长。
var fineTuneStep = ReadDecimal(extension["fineTuneStep"]);
if (fineTuneStep is > 0m) definition.StepValue = fineTuneStep.Value;
// 个人项目与集体项目的分值上限不同,按当前项目范围取值。
var scopedMax = ReadDecimal(extension["maxByScope"]?[scope]);
if (scopedMax.HasValue) definition.MaxValue = scopedMax.Value;
// 花样赛扩展:评分对象(车轮跳的 A/B)、表现维度权重、失误递增扣分、规定元素折算、难度聚合方式。
definition.IsAuto = ReadBool(extension["isAuto"]) ?? false;
definition.SubjectCode = Normalize(extension["subjectCode"]);
definition.SubjectName = Normalize(extension["subjectName"]);
definition.DimensionCode = Normalize(extension["dimensionCode"]);
definition.DimensionWeight = ReadDecimal(extension["dimensionWeight"]);
definition.BaseValue = ReadDecimal(extension["baseValue"]);
definition.AdjustSign = ReadInt(extension["adjustSign"]) ?? 0;
definition.IsFailureCount = ReadBool(extension["isFailureCount"]) ?? false;
definition.RequiredTarget = ReadDecimal(extension["requiredTarget"]);
definition.RequiredAsCategory = ReadBool(extension["requiredAsCategory"]) ?? false;
definition.DifficultyAggregate = Normalize(extension["difficultyAggregate"])?.ToLowerInvariant();
definition.DifficultyTopCount = ReadInt(extension["difficultyTopCount"]);
if (extension["stepDeductions"] is JArray stepDeductions && stepDeductions.Count > 0)
{
definition.StepDeductions = stepDeductions
.Select(x => ReadDecimal(x)).Where(x => x.HasValue).Select(x => x!.Value).ToList();
}
return definition;
}
/// <summary>
/// 读取扩展配置里的文本,空白按未配置处理
/// </summary>
private static string? Normalize(JToken? node)
{
var text = node?.ToString()?.Trim();
return string.IsNullOrEmpty(text) ? null : text;
}
/// <summary>
/// 读取扩展配置里的整数,非法值按未配置处理
/// </summary>
private static int? ReadInt(JToken? node)
{
try { return node?.Value<int?>(); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; }
}
/// <summary>
/// 读取扩展配置里的布尔值,非法值按未配置处理
/// </summary>
private static bool? ReadBool(JToken? node)
{
try { return node?.Value<bool?>(); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; }
}
/// <summary>
/// 读取扩展配置里的数值,非数字等非法值按未配置处理
/// </summary>
private static decimal? ReadDecimal(JToken? node)
{
try { return node?.Value<decimal?>(); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; }
}
/// <summary>
/// 判断本项是否适用于当前项目范围。扩展配置里的 scopes 列出了适用范围,未配置表示都适用。
/// </summary>
public static bool MatchScope(ScoreRuleItem item, string scope)
{
if (string.IsNullOrWhiteSpace(item.ExtensionJson)) return true;
JObject extension;
try { extension = JObject.Parse(item.ExtensionJson); } catch (JsonException) { return true; }
if (extension["scopes"] is not JArray scopes || scopes.Count == 0) return true;
return scopes.Any(x => string.Equals(x.ToString(), scope, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 按计分规则计算一张评分单。
/// requireAll 为 true(正式提交)时校验必填项与取值范围;为 false(保存草稿)时允许留空,只算已录入的部分。
/// 花样赛的表现维度分、失误自动减分、规定元素缺失数一律由服务端联动生成,客户端传来的自动项不参与计算。
/// </summary>
public static ScoreSheetCalculationResult Calculate(List<ScoreItemDefinition> definitions,
List<ScoreItemInputValue> inputs, ScoreRuleConfig config, bool requireAll)
{
var inputMap = inputs
.Where(x => !string.IsNullOrWhiteSpace(x.ItemCode))
.GroupBy(x => x.ItemCode.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last(), StringComparer.OrdinalIgnoreCase);
var unknown = inputMap.Keys.FirstOrDefault(code => definitions.All(d => !string.Equals(d.ItemCode, code, StringComparison.OrdinalIgnoreCase)));
if (unknown != null) throw Oops.Bah($"评分项{unknown}不属于当前裁判的评分表");
var result = new ScoreSheetCalculationResult();
// 自动项由服务端重建,先按录入项算出各维度小计。
var manual = definitions.Where(x => !x.IsAuto).ToList();
foreach (var definition in manual)
{
inputMap.TryGetValue(definition.ItemCode, out var input);
var line = BuildLine(definition, input, requireAll);
if (line == null) continue;
result.Lines.Add(line);
if (definition.IsDisqualifyReason)
{
// 取消资格原因勾选任意一项即判定取消资格,多选时取排序最前的一项作为主原因。
if (line.CountValue is > 0m && !result.IsDisqualified)
{
result.IsDisqualified = true;
result.DisqualifyReasonCode = definition.ItemCode;
result.DisqualifyReasonName = definition.Item.ItemName;
}
continue;
}
// 表现维度的加减项、递增扣分项、规定元素计数项各有专门算法,不按小计直接累加。
if (definition.DimensionCode != null && definition.AdjustSign != 0) continue;
if (definition.StepDeductions is { Count: > 0 }) continue;
if (definition.Item.ScoreDimension == ScoreDimensionCode.Required
&& definition.Item.ItemType == ScoreItemTypeCode.RequiredElement) continue;
switch (definition.Item.ScoreDimension)
{
case ScoreDimensionCode.Difficulty: result.DifficultyScore += line.Subtotal; break;
case ScoreDimensionCode.Performance: result.PerformanceScore += line.Subtotal; break;
case ScoreDimensionCode.Required: result.RequiredScore += line.Subtotal; break;
case ScoreDimensionCode.Deduction: result.DeductionScore += line.Subtotal; break;
}
}
ApplyDifficultyAggregate(definitions, result, config);
ApplyPerformanceDimensions(definitions, result, config);
ApplyStepDeductions(definitions, result, config);
ApplyRequiredElements(definitions, result, config);
// 花样赛的 D/P/Q/M 需要继续参与跨裁判乘法,不能在单张评分单阶段提前按最终展示精度取整。
// 尤其失误第 2 档为 0.075,提前保留两位会改变 M。规定赛仍沿用原有两位小数口径。
if (!config.IsFactorTotal)
{
result.DifficultyScore = config.Round(result.DifficultyScore);
result.PerformanceScore = config.Round(result.PerformanceScore);
result.RequiredScore = config.Round(result.RequiredScore);
result.DeductionScore = config.Round(result.DeductionScore);
}
// 花样赛的规定元素完成量只用于跨裁判聚合时计算 Q,不能加进单张评分单的本裁判得分。
// 最终 R 仍由跨裁判聚合按 D×(1+P)×Q×M 计算;规定赛保留原有加总口径。
result.TotalScore = config.IsFactorTotal
? config.Round(result.DifficultyScore + result.PerformanceScore)
: config.Round(result.DifficultyScore + result.PerformanceScore + result.RequiredScore - result.DeductionScore);
return result;
}
/// <summary>
/// 难度分聚合。交互绳跳绳者按全部动作取平均、摇绳者取最高 20 个动作的平均,
/// 其余项目(个人花样、车轮跳花样)保持数量 × 等级分值累加。
/// </summary>
private static void ApplyDifficultyAggregate(List<ScoreItemDefinition> definitions,
ScoreSheetCalculationResult result, ScoreRuleConfig config)
{
var mode = definitions.Select(x => x.DifficultyAggregate).FirstOrDefault(x => !string.IsNullOrEmpty(x));
if (mode == null) return;
var difficultyLines = result.Lines
.Where(x => x.ScoreDimension == ScoreDimensionCode.Difficulty && x.CountValue is > 0m).ToList();
var actionCount = difficultyLines.Sum(x => x.CountValue!.Value);
if (actionCount <= 0m)
{
result.DifficultyScore = 0m;
return;
}
if (mode == DifficultyAggregateMode.Top)
{
// 取难度等级最高的 N 个动作:按单位分值降序逐级取用,分母固定为规则配置的动作数。
var topCount = definitions.Select(x => x.DifficultyTopCount).FirstOrDefault(x => x is > 0) ?? 20;
var remaining = (decimal)topCount;
var selected = 0m;
foreach (var line in difficultyLines.OrderByDescending(x => x.UnitValue ?? 0m))
{
if (remaining <= 0m) break;
var taken = Math.Min(remaining, line.CountValue!.Value);
selected += taken * (line.UnitValue ?? 0m);
remaining -= taken;
}
result.DifficultyScore = selected / topCount;
result.Steps.Add(new ScoreSheetStep
{
Code = "DIFFICULTY_TOP", Label = $"取最高{topCount}个动作的难度均值",
Formula = $"Σ(入选动作分值) ÷ {topCount}", Value = config.Round(result.DifficultyScore)
});
return;
}
result.DifficultyScore = result.DifficultyScore / actionCount;
result.Steps.Add(new ScoreSheetStep
{
Code = "DIFFICULTY_AVG", Label = "按动作总数取难度均值",
Formula = $"Σ(数量 × 等级分值) ÷ {Trim(actionCount)}", Value = config.Round(result.DifficultyScore)
});
}
/// <summary>
/// 表现维度计算。页面减数量 = 人工减 + 失误自动减;按规则公式还需单独减失误次数。
/// 每个维度 = 基准分 + 人工加 - (人工减 + 失误自动减) - 失误次数,限制在上下限内,
/// 再按各维度权重加权求和得到本裁判的表现分。失误自动减分作为自动项落明细,裁判不可直接修改。
/// </summary>
private static void ApplyPerformanceDimensions(List<ScoreItemDefinition> definitions,
ScoreSheetCalculationResult result, ScoreRuleConfig config)
{
var dimensions = definitions
.Where(x => x.DimensionCode != null && x.DimensionWeight is > 0m && x.IsAuto)
.ToList();
if (dimensions.Count == 0) return;
// 失误数联动五个维度各自动减 1 次。
var failure = definitions.FirstOrDefault(x => x.IsFailureCount);
var failureCount = failure == null
? 0m
: result.Lines.FirstOrDefault(x => string.Equals(x.ItemCode, failure.ItemCode, StringComparison.OrdinalIgnoreCase))?.CountValue ?? 0m;
var weighted = 0m;
foreach (var dimension in dimensions)
{
var adjustItems = definitions
.Where(x => !x.IsAuto && x.AdjustSign != 0
&& string.Equals(x.DimensionCode, dimension.DimensionCode, StringComparison.OrdinalIgnoreCase))
.ToList();
var raw = dimension.BaseValue ?? 0m;
foreach (var adjust in adjustItems)
{
var count = result.Lines
.FirstOrDefault(x => string.Equals(x.ItemCode, adjust.ItemCode, StringComparison.OrdinalIgnoreCase))?.CountValue ?? 0m;
raw += adjust.AdjustSign * count * (adjust.Item.UnitValue ?? 1m);
}
// 失误同时增加页面减数量,并作为公式中的独立失误项再扣一次。
raw -= failureCount * 2m * (dimension.Item.UnitValue ?? 1m);
// 维度分限制在规则给出的上下限内(规程为 0~24)并取整。
var value = Math.Clamp(Math.Round(raw, 0, MidpointRounding.AwayFromZero), dimension.MinValue, dimension.MaxValue);
weighted += value * dimension.DimensionWeight!.Value;
result.Lines.Add(new ScoreSheetLine
{
ScoreRuleItemId = dimension.Item.Id, ItemCode = dimension.ItemCode, ItemName = dimension.Item.ItemName,
ItemType = dimension.Item.ItemType, ScoreDimension = dimension.Item.ScoreDimension,
InputValue = value, UnitValue = dimension.Item.UnitValue, Subtotal = value,
Sort = dimension.Item.Sort, IsAutoGenerated = true,
SubjectCode = dimension.SubjectCode,
ParentItemCode = failure?.ItemCode
});
result.Steps.Add(new ScoreSheetStep
{
Code = $"DIM_{dimension.DimensionCode}", Label = dimension.Item.ItemName,
Formula = $"{Trim(dimension.BaseValue ?? 0m)} + 人工加 - (人工减 + 失误联动减{Trim(failureCount)}) - 失误{Trim(failureCount)}次",
Value = value
});
}
result.PerformanceScore = weighted;
result.Steps.Add(new ScoreSheetStep
{
Code = "PERFORMANCE_WEIGHTED", Label = "表现分(各维度加权求和)",
Formula = "Σ(维度分 × 权重)", Value = config.Round(weighted)
});
}
/// <summary>
/// 递增扣分。主裁失误第 1 次扣 0.05、第 2 次 0.075、第 3 次起每次 0.1,序列用尽后按最后一档重复。
/// </summary>
private static void ApplyStepDeductions(List<ScoreItemDefinition> definitions,
ScoreSheetCalculationResult result, ScoreRuleConfig config)
{
foreach (var definition in definitions.Where(x => x.StepDeductions is { Count: > 0 }))
{
var line = result.Lines
.FirstOrDefault(x => string.Equals(x.ItemCode, definition.ItemCode, StringComparison.OrdinalIgnoreCase));
var count = (int)(line?.CountValue ?? 0m);
if (count <= 0) continue;
var steps = definition.StepDeductions!;
var deduction = 0m;
for (var i = 0; i < count; i++) deduction += steps[Math.Min(i, steps.Count - 1)];
if (line != null) line.Subtotal = deduction;
result.DeductionScore += deduction;
result.Steps.Add(new ScoreSheetStep
{
Code = $"STEP_{definition.ItemCode}", Label = definition.Item.ItemName,
Formula = string.Join(" + ", Enumerable.Range(0, count).Select(i => Trim(steps[Math.Min(i, steps.Count - 1)]))),
Value = config.IsFactorTotal ? deduction : config.Round(deduction)
});
}
}
/// <summary>
/// 规定元素完成量与缺失量。
/// 按数量折算时(个人花样)3 级及以上按 1 计、2 级及以下按 0.5 计;
/// 按类别计数时(车轮跳花样)每个类别只要录入数量大于 0 就计一类。
/// 缺失量 = max(0, 目标数 - 完成量),作为自动项落明细,供跨裁判聚合按 Q = 1 - 系数 × 缺失量 计算。
/// </summary>
private static void ApplyRequiredElements(List<ScoreItemDefinition> definitions,
ScoreSheetCalculationResult result, ScoreRuleConfig config)
{
var elements = definitions
.Where(x => !x.IsAuto && x.Item.ItemType == ScoreItemTypeCode.RequiredElement).ToList();
if (elements.Count == 0) return;
var completed = 0m;
foreach (var element in elements)
{
var line = result.Lines
.FirstOrDefault(x => string.Equals(x.ItemCode, element.ItemCode, StringComparison.OrdinalIgnoreCase));
if (line == null) continue;
var count = line.CountValue ?? 0m;
// 折算系数写在评分项的单位分值上:个人花样 3 级及以上为 1、2 级及以下为 0.5;按类别计数时录入即算一类。
line.Subtotal = element.RequiredAsCategory
? (count > 0m ? 1m : 0m)
: count * (element.Item.UnitValue ?? 1m);
completed += line.Subtotal;
}
result.RequiredCompletedCount = completed;
result.RequiredScore = completed;
var missingItem = definitions.FirstOrDefault(x => x.IsAuto && x.RequiredTarget.HasValue);
if (missingItem == null) return;
var target = missingItem.RequiredTarget!.Value;
var missing = Math.Max(0m, target - completed);
result.RequiredMissingCount = missing;
result.Lines.Add(new ScoreSheetLine
{
ScoreRuleItemId = missingItem.Item.Id, ItemCode = missingItem.ItemCode, ItemName = missingItem.Item.ItemName,
ItemType = missingItem.Item.ItemType, ScoreDimension = missingItem.Item.ScoreDimension,
InputValue = missing, CountValue = missing, Subtotal = 0m,
Sort = missingItem.Item.Sort, IsAutoGenerated = true
});
result.Steps.Add(new ScoreSheetStep
{
Code = "REQUIRED_MISSING", Label = "规定元素缺失数",
Formula = $"max(0, {Trim(target)} - {Trim(completed)})", Value = config.Round(missing)
});
}
/// <summary>
/// 计算明细 JSON,落库到评分单的 CalculationJson 便于复核与追溯
/// </summary>
public static string BuildCalculationJson(ScoreSheetCalculationResult result, string judgeRole, string judgeRoleType, string scope)
{
return JsonConvert.SerializeObject(new
{
judgeRole,
judgeRoleType,
scope,
difficultyScore = result.DifficultyScore,
performanceScore = result.PerformanceScore,
requiredScore = result.RequiredScore,
deductionScore = result.DeductionScore,
totalScore = result.TotalScore,
isDisqualified = result.IsDisqualified,
disqualifyReasonCode = result.DisqualifyReasonCode,
requiredCompletedCount = result.RequiredCompletedCount,
requiredMissingCount = result.RequiredMissingCount,
lines = result.Lines.Select(x => new
{
x.ItemCode, x.ItemName, x.ItemType, x.ScoreDimension,
x.CountValue, x.InputValue, x.UnitValue, x.Subtotal,
x.SubjectCode, x.IsAutoGenerated, x.ParentItemCode
}),
steps = result.Steps.Select(x => new { x.Code, x.Label, x.Formula, x.Value })
});
}
/// <summary>
/// 计算单个评分项,未录入且非必填时返回 null(不落明细)
/// </summary>
private static ScoreSheetLine? BuildLine(ScoreItemDefinition definition, ScoreItemInputValue? input, bool requireAll)
{
var item = definition.Item;
var line = new ScoreSheetLine
{
ScoreRuleItemId = item.Id, ItemCode = item.ItemCode, ItemName = item.ItemName,
ItemType = item.ItemType, ScoreDimension = item.ScoreDimension,
UnitValue = item.UnitValue, Sort = item.Sort,
SubjectCode = definition.SubjectCode
};
switch (item.CalcMode)
{
case ScoreCalcMode.DirectValue:
{
var value = input?.InputValue;
if (!value.HasValue)
{
if (requireAll && item.IsRequired) throw Oops.Bah($"请先给“{item.ItemName}”打分");
if (!requireAll) return null;
value = item.DefaultValue ?? definition.MinValue;
}
EnsureRange(definition, value.Value, "分值");
EnsureStep(definition, value.Value);
line.InputValue = value.Value;
line.Subtotal = value.Value;
return line;
}
case ScoreCalcMode.CountTimesUnit:
{
var count = input?.CountValue ?? (requireAll ? item.DefaultValue ?? 0m : (decimal?)null);
if (!count.HasValue) return null;
if (count.Value < 0m) throw Oops.Bah($"“{item.ItemName}”的次数不能为负数");
if (count.Value != Math.Truncate(count.Value)) throw Oops.Bah($"“{item.ItemName}”的次数必须是整数");
EnsureRange(definition, count.Value, "次数");
line.CountValue = count.Value;
line.Subtotal = count.Value * (item.UnitValue ?? 0m);
return line;
}
default:
{
// 取消资格原因等仅记录项:未勾选时不落明细。
var selected = input?.CountValue ?? 0m;
if (selected <= 0m) return null;
line.CountValue = 1m;
line.Subtotal = 0m;
return line;
}
}
}
/// <summary>
/// 校验取值是否落在规则给出的上下限内。规则未配置有效上限(上限不大于下限)时只校验下限,
/// 避免因漏配 MaxValue 把所有正常分值都判成超出范围。
/// </summary>
private static void EnsureRange(ScoreItemDefinition definition, decimal value, string label)
{
if (definition.MaxValue <= definition.MinValue)
{
if (value < definition.MinValue)
throw Oops.Bah($"“{definition.Item.ItemName}”的{label}不能小于 {Trim(definition.MinValue)}");
return;
}
if (value < definition.MinValue || value > definition.MaxValue)
throw Oops.Bah($"“{definition.Item.ItemName}”的{label}应在 {Trim(definition.MinValue)} ~ {Trim(definition.MaxValue)} 之间");
}
/// <summary>
/// 校验打分是否符合录入步长(规定赛支持 0.5 微调)
/// </summary>
private static void EnsureStep(ScoreItemDefinition definition, decimal value)
{
if (definition.StepValue <= 0m) return;
var offset = value - definition.MinValue;
if (offset % definition.StepValue != 0m)
throw Oops.Bah($"“{definition.Item.ItemName}”的分值需按 {Trim(definition.StepValue)} 分为单位录入");
}
/// <summary>
/// 去掉小数末尾多余的 0,用于错误提示
/// </summary>
private static string Trim(decimal value) => value == Math.Truncate(value) ? ((long)value).ToString() : value.ToString("0.##");
}