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

250 lines
11 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;
return definition;
}
/// <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();
foreach (var definition in definitions)
{
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;
}
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;
}
}
result.DifficultyScore = config.Round(result.DifficultyScore);
result.PerformanceScore = config.Round(result.PerformanceScore);
result.RequiredScore = config.Round(result.RequiredScore);
result.DeductionScore = config.Round(result.DeductionScore);
result.TotalScore = config.Round(result.DifficultyScore + result.PerformanceScore + result.RequiredScore - result.DeductionScore);
return result;
}
/// <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,
lines = result.Lines.Select(x => new
{
x.ItemCode, x.ItemName, x.ItemType, x.ScoreDimension,
x.CountValue, x.InputValue, x.UnitValue, x.Subtotal
})
});
}
/// <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
};
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.##");
}