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