511 lines
25 KiB
C#
511 lines
25 KiB
C#
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.##");
|
||
}
|