原生志愿、录取、计划余量、指标资格和通知查询:[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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user