原生志愿、录取、计划余量、指标资格和通知查询:[CandidateService.Admissions.cs (line 29)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs:29)

志愿提交、资格校验、补录限制、次数上限与自动锁定:[CandidateService.Admissions.cs (line 152)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs:152)
通用招生记录读取及事务写入:[CandidateAdmissionRepository.cs (line 25)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateAdmissionRepository.cs:25)
原生路由已接入:[NativeCandidateEndpoints.cs (line 70)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs:70)
迁移状态现在会把 Candidate 标记为原生:[MigrationFeatureCatalog.cs (line 12)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs:12)
This commit is contained in:
2026-07-22 20:22:46 +08:00 Unverified
parent 4417d6d674
commit 4b0dae6d71
13 changed files with 859 additions and 7 deletions
@@ -0,0 +1,132 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Candidate;
internal sealed record CandidateAdmissionRecord(
string Id,
string Kind,
string ExamId,
string? UserId,
string? SchoolId,
string Status,
JsonObject Payload,
string CreatedAt,
string UpdatedAt);
internal sealed record CandidateAdmissionSchool(string Id, string Code, string Name);
internal sealed record CandidateAdmissionSnapshot(
IReadOnlyList<CandidateAdmissionRecord> Records,
IReadOnlyDictionary<string, CandidateAdmissionSchool> Schools);
internal sealed class CandidateAdmissionRepository(IRelationalConnectionFactory connectionFactory)
{
public async Task<CandidateAdmissionSnapshot> LoadAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var records = new List<CandidateAdmissionRecord>();
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records ORDER BY created_at, id";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
records.Add(new CandidateAdmissionRecord(
ReadString(reader, "id"),
ReadString(reader, "kind"),
ReadString(reader, "exam_id"),
ReadOptionalString(reader, "user_id"),
ReadOptionalString(reader, "school_id"),
ReadString(reader, "status"),
JsonNode.Parse(ReadString(reader, "payload_json"))?.AsObject() ?? new JsonObject(),
ReadString(reader, "created_at"),
ReadString(reader, "updated_at")));
}
}
var schools = new List<CandidateAdmissionSchool>();
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT id, code, name FROM schools ORDER BY id";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
schools.Add(new CandidateAdmissionSchool(
ReadString(reader, "id"),
ReadString(reader, "code"),
ReadString(reader, "name")));
}
}
return new CandidateAdmissionSnapshot(
records,
schools.ToDictionary(item => item.Id, StringComparer.Ordinal));
}
public async Task SavePreferenceAsync(
CandidateAdmissionRecord record,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await using (var delete = connection.CreateCommand())
{
delete.Transaction = transaction;
delete.CommandText = "DELETE FROM admission_records WHERE id = @id";
AddParameter(delete, "@id", record.Id);
await delete.ExecuteNonQueryAsync(cancellationToken);
}
await using (var insert = connection.CreateCommand())
{
insert.Transaction = transaction;
insert.CommandText = """
INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (
@id, @kind, @examId, @userId, @schoolId, @status, @payload, @createdAt, @updatedAt
)
""";
AddParameter(insert, "@id", record.Id);
AddParameter(insert, "@kind", record.Kind);
AddParameter(insert, "@examId", record.ExamId);
AddParameter(insert, "@userId", record.UserId);
AddParameter(insert, "@schoolId", record.SchoolId);
AddParameter(insert, "@status", record.Status);
AddParameter(insert, "@payload", record.Payload.ToJsonString());
AddParameter(insert, "@createdAt", record.CreatedAt);
AddParameter(insert, "@updatedAt", record.UpdatedAt);
await insert.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static void AddParameter(DbCommand command, string name, object? value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
private static string ReadString(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
private static string? ReadOptionalString(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
}
@@ -22,7 +22,7 @@ public sealed record CandidateMigrationOptions(bool NativeEnabled)
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"考生域尚有接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话");
"应用仍有受保护接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话");
}
return new CandidateMigrationOptions(enabled);
@@ -0,0 +1,573 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Candidate;
namespace Eis.Infrastructure.Candidate;
internal sealed partial class CandidateService
{
private static readonly IReadOnlyDictionary<string, (string Category, string Type)> LegacySpecialties =
new Dictionary<string, (string Category, string Type)>(StringComparer.Ordinal)
{
["田径"] = ("sports", "track_field"),
["篮球"] = ("sports", "basketball"),
["足球"] = ("sports", "football"),
["排球"] = ("sports", "volleyball"),
["乒乓球"] = ("sports", "table_tennis"),
["羽毛球"] = ("sports", "badminton"),
["游泳"] = ("sports", "swimming"),
["武术"] = ("sports", "martial_arts"),
["健美操与啦啦操"] = ("sports", "aerobics_cheer"),
["声乐"] = ("arts", "vocal_music"),
["器乐"] = ("arts", "instrumental_music"),
["舞蹈"] = ("arts", "dance"),
["美术"] = ("arts", "fine_arts"),
["书法"] = ("arts", "calligraphy"),
["戏剧与播音"] = ("arts", "drama_broadcasting")
};
public async Task<CandidateEndpointResult> GetAdmissionsAsync(
string sessionToken,
string verificationBaseUrl,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken);
if (context.Error is not null)
{
return context.Error;
}
var user = context.User!;
var profile = context.Profile!;
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
var admission = await admissionRepository.LoadAsync(cancellationToken);
var settings = new List<JsonObject>();
foreach (var setting in Records(admission, "setting").Where(item => AdmissionBoolean(item.Payload["enabled"])))
{
var exam = snapshot.Exams.FirstOrDefault(item => item.Id == setting.ExamId);
if (exam is null)
{
continue;
}
var round = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1));
var preference = ActivePreference(admission, setting.ExamId, user.Id, round);
var qualification = Records(admission, "indicator_qualification", setting.ExamId)
.FirstOrDefault(item => item.UserId == user.Id);
var placement = Records(admission, "placement", setting.ExamId)
.FirstOrDefault(item => item.UserId == user.Id && item.Status != "withdrawn");
var blocking = setting.Status == "supplementary"
? Records(admission, "placement", setting.ExamId).FirstOrDefault(item =>
item.UserId == user.Id && BlockingPlacementStatuses.Contains(item.Status, StringComparer.Ordinal))
: null;
var supplementEligible = blocking is null;
var supplementarySchools = SupplementarySchoolIds(admission, setting);
var plans = supplementEligible
? BuildAvailablePlans(admission, profile, setting.ExamId, qualification, supplementarySchools)
: [];
var registration = snapshot.Registrations.FirstOrDefault(item => item.ExamId == setting.ExamId);
var submissionCount = AdmissionInteger(preference?.Payload["submissionCount"]);
var maxSubmissions = Math.Max(1, AdmissionInteger(setting.Payload["maxSubmissions"], 3));
var school = placement?.SchoolId is not null
? admission.Schools.GetValueOrDefault(placement.SchoolId)
: null;
var templateRecord = placement?.SchoolId is not null
? Records(admission, "notification").FirstOrDefault(item => item.SchoolId == placement.SchoolId && item.Status == "template")
: null;
var verificationCode = placement?.Status == "final"
? documentCodes.AdmissionNoticeCode(
placement.Id,
placement.UserId ?? string.Empty,
placement.SchoolId ?? string.Empty,
exam.Id,
AdmissionText(placement.Payload["categoryCode"]),
AdmissionText(placement.Payload["noticeNumber"]),
placement.UpdatedAt)
: string.Empty;
var item = AdmissionRecordJson(setting);
item["exam"] = PublicExamJson(exam);
item["preference"] = preference is null ? null : PreferenceView(admission, preference);
if (placement is not null)
{
item["placement"] = AdmissionRecordJson(placement);
}
item["placementSchool"] = school is null
? null
: new JsonObject { ["id"] = school.Id, ["name"] = school.Name, ["code"] = school.Code };
item["noticeTemplate"] = templateRecord?.Payload["template"]?.DeepClone();
item["noticeVerificationCode"] = verificationCode;
item["noticeVerificationQr"] = verificationCode.Length == 0
? string.Empty
: CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(verificationCode)}");
item["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]);
item["plans"] = new JsonArray(plans.Select(plan => (JsonNode)plan).ToArray());
item["supplementEligible"] = supplementEligible;
item["supplementIneligibilityReason"] = blocking?.Status switch
{
"forfeited" => "因本轮未按规定完成报到,不能再次参加补录。",
not null => "你已被录取,本轮补录无需且不能再次填报。",
_ => string.Empty
};
item["totalScore"] = JsonValue.Create(CandidateTotalScore(snapshot, setting.ExamId));
item["featureScore"] = registration?.FeatureScore ?? 0;
item["specialtyQualification"] = SpecialtyQualification(profile);
item["indicatorQualification"] = qualification is null ? null : AdmissionRecordJson(qualification);
item["submissionCount"] = submissionCount;
item["maxSubmissions"] = maxSubmissions;
item["remainingSubmissions"] = Math.Max(0, maxSubmissions - submissionCount);
item["preferenceLocked"] = submissionCount >= maxSubmissions;
settings.Add(item);
}
var notifications = Records(admission, "notification")
.Where(item => item.UserId == user.Id)
.OrderByDescending(item => ParseDate(item.CreatedAt))
.Select(item =>
{
var placement = Records(admission, "placement", item.ExamId)
.FirstOrDefault(entry => entry.Id == AdmissionText(item.Payload["placementId"]));
var schoolId = placement?.SchoolId ?? item.SchoolId;
var school = schoolId is not null ? admission.Schools.GetValueOrDefault(schoolId) : null;
var exam = snapshot.Exams.FirstOrDefault(entry => entry.Id == item.ExamId);
var output = AdmissionRecordJson(item);
output["examName"] = exam?.Name ?? string.Empty;
output["schoolName"] = school?.Name ?? string.Empty;
output["schoolCode"] = school?.Code ?? string.Empty;
output["categoryName"] = AdmissionText(placement?.Payload["categoryName"]);
output["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]);
output["placementStatus"] = placement?.Status ?? string.Empty;
return output;
})
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admissions"] = new JsonArray(settings.Select(item => (JsonNode)item).ToArray()),
["notifications"] = new JsonArray(notifications.Select(item => (JsonNode)item).ToArray())
});
}
public async Task<CandidateEndpointResult> UpdateAdmissionPreferencesAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken);
if (context.Error is not null)
{
return context.Error;
}
var user = context.User!;
var profile = context.Profile!;
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
var admission = await admissionRepository.LoadAsync(cancellationToken);
var setting = Records(admission, "setting", examId).FirstOrDefault();
if (setting is null || !AdmissionBoolean(setting.Payload["enabled"]))
{
return Error(404, "该考试未开放志愿填报");
}
if (setting.Status is not ("filling" or "supplementary"))
{
return Error(409, "当前不在志愿填报阶段");
}
if (setting.Status == "supplementary")
{
var blocking = Records(admission, "placement", setting.ExamId).FirstOrDefault(item =>
item.UserId == user.Id && BlockingPlacementStatuses.Contains(item.Status, StringComparer.Ordinal));
if (blocking?.Status == "forfeited")
{
return Error(403, "因未按规定完成报到,本轮不能再次参加补录");
}
if (blocking is not null)
{
return Error(403, "你已被录取,本轮补录不能再次填报");
}
}
var now = DateTimeOffset.UtcNow;
if (TryAdmissionDate(AdmissionText(setting.Payload["preferenceStart"]), out var start) && now < start)
{
return Error(409, "志愿填报尚未开始");
}
if (TryAdmissionDate(AdmissionText(setting.Payload["preferenceEnd"]), out var end) && now > end)
{
return Error(409, "志愿填报已经截止");
}
if (CandidateTotalScore(snapshot, setting.ExamId) is null)
{
return Error(403, "本场考试成绩全部发布后才能填报志愿");
}
var maxChoices = Math.Max(1, AdmissionInteger(setting.Payload["maxChoices"], 5));
var round = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1));
var current = ActivePreference(admission, setting.ExamId, user.Id, round);
var maxSubmissions = Math.Max(1, AdmissionInteger(setting.Payload["maxSubmissions"], 3));
var submissionCount = AdmissionInteger(current?.Payload["submissionCount"]);
if (submissionCount >= maxSubmissions)
{
return Error(409, $"志愿已达到 {maxSubmissions} 次提交上限,现已自动锁定");
}
var choices = body["choices"] is JsonArray requested
? requested.Take(maxChoices + 1).OfType<JsonObject>().Select(item => new AdmissionChoice(
Clean(item, "schoolId", 64),
Clean(item, "categoryCode", 40),
Text(item, "preferenceType") == "indicator" ? "indicator" : "general")).ToArray()
: [];
if (choices.Length == 0)
{
return Error(400, "请至少选择一个志愿");
}
var indicatorChoices = choices.Count(item => item.PreferenceType == "indicator");
var generalChoices = choices.Count(item => item.PreferenceType == "general");
if (indicatorChoices > 1 || generalChoices > maxChoices)
{
return Error(400, $"本轮最多填报 1 个指标分配志愿和 {maxChoices} 个普通志愿");
}
if (indicatorChoices > 0 && choices[0].PreferenceType != "indicator")
{
return Error(400, "指标分配志愿必须位于专用第一栏");
}
if (choices.Select(item => $"{item.PreferenceType}|{item.SchoolId}|{item.CategoryCode}")
.Distinct(StringComparer.Ordinal).Count() != choices.Length)
{
return Error(400, "同类志愿中同一学校和招生类别不能重复填报");
}
var supplementarySchools = SupplementarySchoolIds(admission, setting);
var plans = Records(admission, "plan", setting.ExamId)
.Where(item => item.Status == "approved" &&
(supplementarySchools is null || item.SchoolId is not null && supplementarySchools.Contains(item.SchoolId)))
.ToArray();
var qualification = Records(admission, "indicator_qualification", setting.ExamId)
.FirstOrDefault(item => item.UserId == user.Id);
if (choices.Any(choice => !ValidAdmissionChoice(admission, profile, plans, qualification, choice)))
{
return Error(400, "志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别");
}
var nowValue = NowIso();
var storedChoices = choices.Select(choice =>
{
var school = admission.Schools.GetValueOrDefault(choice.SchoolId);
var category = PlanCategories(plans.First(item => item.SchoolId == choice.SchoolId))
.First(item => AdmissionText(item["code"]) == choice.CategoryCode);
return new JsonObject
{
["schoolId"] = choice.SchoolId,
["categoryCode"] = choice.CategoryCode,
["preferenceType"] = choice.PreferenceType,
["schoolCode"] = school?.Code ?? string.Empty,
["schoolName"] = school?.Name ?? string.Empty,
["categoryName"] = AdmissionText(category["name"])
};
}).ToArray();
var preference = new CandidateAdmissionRecord(
current?.Id ?? Uid("preference"),
"preference",
setting.ExamId,
user.Id,
null,
"submitted",
new JsonObject
{
["round"] = round,
["choices"] = new JsonArray(storedChoices.Select(item => (JsonNode)item).ToArray()),
["submittedAt"] = nowValue,
["submissionCount"] = submissionCount + 1
},
current?.CreatedAt ?? nowValue,
nowValue);
await admissionRepository.SavePreferenceAsync(preference, cancellationToken);
var locked = submissionCount + 1 >= maxSubmissions;
return Success(new JsonObject
{
["ok"] = true,
["preference"] = AdmissionRecordJson(preference),
["remainingSubmissions"] = Math.Max(0, maxSubmissions - submissionCount - 1),
["locked"] = locked,
["message"] = locked ? "志愿已保存并达到提交上限,现已自动锁定" : "志愿已由本人保存"
});
}
private static readonly string[] BlockingPlacementStatuses =
["school_review", "admitted", "final", "withdrawal_pending", "forfeited"];
private static IEnumerable<CandidateAdmissionRecord> Records(
CandidateAdmissionSnapshot snapshot,
string kind,
string? examId = null) => snapshot.Records.Where(item =>
item.Kind == kind && (examId is null || item.ExamId == examId));
private static CandidateAdmissionRecord? ActivePreference(
CandidateAdmissionSnapshot snapshot,
string examId,
string userId,
int round) => Records(snapshot, "preference", examId).FirstOrDefault(item =>
item.UserId == userId && AdmissionInteger(item.Payload["round"], 1) == round);
private static JsonObject PreferenceView(
CandidateAdmissionSnapshot snapshot,
CandidateAdmissionRecord preference)
{
var output = AdmissionRecordJson(preference);
var payload = preference.Payload.DeepClone().AsObject();
var choices = preference.Payload["choices"]?.AsArray().OfType<JsonObject>().Select(choice =>
{
var item = choice.DeepClone().AsObject();
var schoolId = AdmissionText(choice["schoolId"]);
var categoryCode = AdmissionText(choice["categoryCode"]);
var school = snapshot.Schools.GetValueOrDefault(schoolId);
var categories = Records(snapshot, "plan", preference.ExamId)
.Where(plan => plan.SchoolId == schoolId)
.SelectMany(PlanCategories)
.ToArray();
var category = categories.FirstOrDefault(entry => AdmissionText(entry["code"]) == categoryCode)
?? (categoryCode == "general" ? categories.FirstOrDefault(entry =>
AdmissionText(entry["specialtyCategory"]).Length == 0 && AdmissionText(entry["specialtyType"]).Length == 0) : null)
?? (categoryCode is "sport" or "sports" ? categories.FirstOrDefault(entry => AdmissionText(entry["specialtyCategory"]) == "sports") : null)
?? (categoryCode is "art" or "arts" ? categories.FirstOrDefault(entry => AdmissionText(entry["specialtyCategory"]) == "arts") : null);
item["schoolCode"] = AdmissionText(choice["schoolCode"]).Length > 0 ? AdmissionText(choice["schoolCode"]) : school?.Code ?? string.Empty;
item["schoolName"] = AdmissionText(choice["schoolName"]).Length > 0 ? AdmissionText(choice["schoolName"]) : school?.Name ?? string.Empty;
item["categoryName"] = AdmissionText(choice["categoryName"]).Length > 0 ? AdmissionText(choice["categoryName"]) : AdmissionText(category?["name"]);
return item;
}).ToArray() ?? [];
payload["choices"] = new JsonArray(choices.Select(item => (JsonNode)item).ToArray());
output["payload"] = payload;
return output;
}
private static IReadOnlyList<JsonObject> BuildAvailablePlans(
CandidateAdmissionSnapshot snapshot,
JsonObject profile,
string examId,
CandidateAdmissionRecord? qualification,
IReadOnlySet<string>? supplementarySchools)
{
var output = new List<JsonObject>();
foreach (var plan in Records(snapshot, "plan", examId).Where(item => item.Status == "approved" &&
(supplementarySchools is null || item.SchoolId is not null && supplementarySchools.Contains(item.SchoolId))))
{
var school = plan.SchoolId is not null ? snapshot.Schools.GetValueOrDefault(plan.SchoolId) : null;
var placements = Records(snapshot, "placement", examId).Where(item =>
item.SchoolId == plan.SchoolId && item.Status is not ("withdrawn" or "forfeited")).ToArray();
var categories = new List<JsonObject>();
foreach (var source in PlanCategories(plan))
{
if (!CandidateEligibleForCategory(profile, source))
{
continue;
}
var category = source.DeepClone().AsObject();
var categoryCode = AdmissionText(source["code"]);
var quota = AdmissionNumber(source["quota"]);
var allocations = source["indicatorAllocations"]?.AsArray().OfType<JsonObject>().ToArray() ?? [];
var used = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode);
category["used"] = used;
category["remaining"] = Math.Max(0, quota - used);
var allocation = allocations.FirstOrDefault(item => AdmissionText(item["sourceSchoolId"]) == Text(profile, "schoolId"));
var indicatorUsed = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode &&
AdmissionText(item.Payload["quotaBucket"]) == $"indicator:{Text(profile, "schoolId")}");
var generalQuota = Math.Max(0, quota - allocations.Sum(item => AdmissionNumber(item["quota"])));
var generalUsed = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode &&
AdmissionText(item.Payload["quotaBucket"]) == "general");
var indicatorRemaining = Math.Max(0, AdmissionNumber(allocation?["quota"]) - indicatorUsed);
var generalRemaining = Math.Max(0, generalQuota - generalUsed);
var preferenceTypes = new List<JsonNode>();
if (generalRemaining > 0) preferenceTypes.Add(JsonValue.Create("general")!);
if (AdmissionBoolean(qualification?.Payload["eligible"]) && indicatorRemaining > 0) preferenceTypes.Add(JsonValue.Create("indicator")!);
category["generalRemaining"] = generalRemaining;
category["indicatorRemaining"] = indicatorRemaining;
category["preferenceTypes"] = new JsonArray(preferenceTypes.ToArray());
if (preferenceTypes.Count > 0)
{
categories.Add(category);
}
}
if (categories.Count > 0)
{
output.Add(new JsonObject
{
["id"] = plan.Id,
["schoolId"] = plan.SchoolId,
["schoolCode"] = school?.Code ?? string.Empty,
["schoolName"] = school?.Name ?? string.Empty,
["categories"] = new JsonArray(categories.Select(item => (JsonNode)item).ToArray())
});
}
}
return output;
}
private static bool ValidAdmissionChoice(
CandidateAdmissionSnapshot snapshot,
JsonObject profile,
IReadOnlyList<CandidateAdmissionRecord> plans,
CandidateAdmissionRecord? qualification,
AdmissionChoice choice)
{
var plan = plans.FirstOrDefault(item => item.SchoolId == choice.SchoolId);
var category = plan is null
? null
: PlanCategories(plan).FirstOrDefault(item => AdmissionText(item["code"]) == choice.CategoryCode);
if (plan is null || category is null || !CandidateEligibleForCategory(profile, category))
{
return false;
}
var placements = Records(snapshot, "placement", plan.ExamId).Where(item =>
item.SchoolId == plan.SchoolId && AdmissionText(item.Payload["categoryCode"]) == choice.CategoryCode &&
item.Status is not ("withdrawn" or "forfeited")).ToArray();
var allocations = category["indicatorAllocations"]?.AsArray().OfType<JsonObject>().ToArray() ?? [];
if (choice.PreferenceType == "indicator")
{
var schoolId = Text(profile, "schoolId");
var allocation = allocations.FirstOrDefault(item => AdmissionText(item["sourceSchoolId"]) == schoolId);
var used = placements.Count(item => AdmissionText(item.Payload["quotaBucket"]) == $"indicator:{schoolId}");
return AdmissionBoolean(qualification?.Payload["eligible"]) && AdmissionNumber(allocation?["quota"]) > used;
}
var quota = AdmissionNumber(category["quota"]) - allocations.Sum(item => AdmissionNumber(item["quota"]));
return quota > placements.Count(item => AdmissionText(item.Payload["quotaBucket"]) == "general");
}
private static IReadOnlySet<string>? SupplementarySchoolIds(
CandidateAdmissionSnapshot snapshot,
CandidateAdmissionRecord setting)
{
if (setting.Status != "supplementary")
{
return null;
}
var sourceRound = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1) - 1);
var ids = Records(snapshot, "notification", setting.ExamId)
.Where(item => item.UserId is null && item.Status == "approved" &&
AdmissionText(item.Payload["type"]) == "admission_reporting" &&
AdmissionInteger(item.Payload["round"], 1) == sourceRound &&
AdmissionText(item.Payload["supplementDecision"]) == "supplement" && item.SchoolId is not null)
.Select(item => item.SchoolId!)
.ToHashSet(StringComparer.Ordinal);
return ids.Count == 0 ? null : ids;
}
private static double? CandidateTotalScore(CandidateReadSnapshot snapshot, string examId)
{
var registration = snapshot.Registrations.FirstOrDefault(item => item.ExamId == examId && item.Status == "approved");
if (registration is null || registration.SubjectIds.Count == 0)
{
return null;
}
var results = snapshot.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray();
if (registration.SubjectIds.Any(id => results.All(result => result.SubjectId != id)))
{
return null;
}
return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero);
}
private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category)
{
var requiredCategory = AdmissionText(category["specialtyCategory"]);
var requiredType = AdmissionText(category["specialtyType"]);
if (requiredCategory.Length == 0 && LegacySpecialties.TryGetValue(requiredType, out var legacy))
{
(requiredCategory, requiredType) = legacy;
}
if (requiredCategory.Length == 0)
{
return true;
}
var qualification = ResolveSpecialty(profile);
return qualification.Category == requiredCategory &&
(requiredType.Length == 0 || qualification.Type == requiredType);
}
private static JsonObject SpecialtyQualification(JsonObject profile)
{
var specialty = ResolveSpecialty(profile);
return new JsonObject { ["category"] = specialty.Category, ["type"] = specialty.Type };
}
private static (string Category, string Type) ResolveSpecialty(JsonObject profile)
{
var category = Text(profile, "specialtyCategory");
var type = Text(profile, "specialtyType");
if (ValidSpecialty(category, type) && category.Length > 0)
{
return (category, type);
}
foreach (var value in profile["specialtyTypes"]?.AsArray() ?? [])
{
if (LegacySpecialties.TryGetValue(value?.ToString() ?? string.Empty, out var legacy))
{
return legacy;
}
}
return (string.Empty, string.Empty);
}
private static JsonObject PublicExamJson(CandidateExam exam)
{
var output = ExamJson(exam);
output["totalScore"] = exam.Subjects.Sum(item => item.FullScore);
output["registrationState"] = exam.ArchivedAt is not null ? "archived" : RegistrationState(exam);
return output;
}
private static JsonObject AdmissionRecordJson(CandidateAdmissionRecord item) => new()
{
["id"] = item.Id,
["kind"] = item.Kind,
["examId"] = item.ExamId,
["userId"] = JsonValue.Create(item.UserId),
["schoolId"] = JsonValue.Create(item.SchoolId),
["status"] = item.Status,
["payload"] = item.Payload.DeepClone(),
["createdAt"] = item.CreatedAt,
["updatedAt"] = item.UpdatedAt
};
private static IReadOnlyList<JsonObject> PlanCategories(CandidateAdmissionRecord plan) =>
plan.Payload["categories"]?.AsArray().OfType<JsonObject>().ToArray() ?? [];
private static string AdmissionText(JsonNode? value) => value?.ToString() ?? string.Empty;
private static double AdmissionNumber(JsonNode? value, double fallback = 0) =>
double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
private static int AdmissionInteger(JsonNode? value, int fallback = 0) =>
double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? (int)Math.Truncate(parsed)
: fallback;
private static bool AdmissionBoolean(JsonNode? value) =>
bool.TryParse(value?.ToString(), out var parsed) && parsed;
private static bool TryAdmissionDate(string value, out DateTimeOffset parsed) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out parsed);
private sealed record AdmissionChoice(string SchoolId, string CategoryCode, string PreferenceType);
}
@@ -14,7 +14,8 @@ internal sealed partial class CandidateService(
IPublicQueryService publicQueries,
CandidateWriteRepository writeRepository,
RegionCatalog regionCatalog,
DocumentVerificationCodeService documentCodes) : ICandidateService
DocumentVerificationCodeService documentCodes,
CandidateAdmissionRepository admissionRepository) : ICandidateService
{
public async Task<CandidateEndpointResult> GetDashboardAsync(
string sessionToken,
@@ -46,6 +46,7 @@ public static class DependencyInjection
services.AddSingleton<RegionCatalog>();
services.AddScoped<CandidateReadSnapshotLoader>();
services.AddScoped<CandidateWriteRepository>();
services.AddScoped<CandidateAdmissionRepository>();
services.AddScoped<ICandidateService, CandidateService>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
@@ -5,11 +5,11 @@ namespace Eis.Infrastructure.Migration;
public static class MigrationFeatureCatalog
{
public static IReadOnlyList<MigrationFeature> Current(bool authenticationNative) =>
public static IReadOnlyList<MigrationFeature> Current(bool authenticationNative, bool candidateNative) =>
[
new(FeatureArea.Public, true, "/api/public"),
new(FeatureArea.Authentication, authenticationNative, "/api/auth"),
new(FeatureArea.Candidate, false, "/api/candidate"),
new(FeatureArea.Candidate, candidateNative, "/api/candidate"),
new(FeatureArea.Administration, false, "/api/admin"),
new(FeatureArea.Admission, false, "/api/admission"),
new(FeatureArea.Documents, false, "/api"),