本轮迁移已完成,生产运行链路现已切换为纯 ASP.NET Core 10。

主要完成:
补齐招生学校计划、投档审核、报到、扫码、补录等原生接口。
新增 SQLite/MySQL 空库初始化及默认审批流、号码规则。
删除 Node 兼容代理,未知 API 直接返回原生 404。
Docker、Compose、Gitea CI 全部切换到 Eis.Web.dll。
CKEditor 已固化到 Web 发布资源,不再依赖 node_modules。
新增纯 .NET 冒烟脚本:[smoke-dotnet-native.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/smoke-dotnet-native.ps1:1)。
迁移状态已更新:[MIGRATION.md (line 14)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:14)。
容器入口见 [Dockerfile (line 31)](C:/Users/BI/Documents/EIS-dotnet/Dockerfile:31)。
This commit is contained in:
biss committed 2026-07-23 15:14:42 +08:00
1 parent fea91a19af
commit e3c04dad8a
36 files changed
+1449 -491

No files matched your search

@@ -18,12 +18,22 @@ public interface IAdminAdmissionService
Task<AdminEndpointResult> ReviewReportingAsync(string sessionToken, string recordId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewWithdrawalAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolNoticeTemplateAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolContextAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolPlansAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolPlanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolNoticeTemplateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolReportingAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdmissionSchoolReportingAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> ImportAdmissionSchoolReportingAsync(string sessionToken, string examId, byte[] content, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingDraftAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> PreviewAdmissionSchoolReportingScanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingScanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SubmitAdmissionSchoolReportingAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingDecisionAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolPlacementsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdmissionSchoolPlacementsAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementsAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdminLedgerAsync(
string sessionToken,
string kind,
@@ -0,0 +1,501 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Administration;
internal sealed partial class AdminAdmissionService
{
private static readonly Regex AdmissionNoticePattern =
new(@"AN-[A-F0-9]{24}", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
public async Task<AdminEndpointResult> GetAdmissionSchoolContextAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var plans = Records(data, "plan")
.Where(item => item.SchoolId == school.Id && item.Status == "approved")
.Select(item =>
{
var output = RecordJson(item);
output["examName"] = Text(data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["name"]);
output["progress"] = PlanProgress(data, item);
return (JsonNode)output;
}).ToArray();
var home = await publicQueries.GetHomeAsync(cancellationToken);
var notifications = (home["notices"] as JsonArray ?? []).OfType<JsonObject>()
.Where(item => Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id)
.Take(6)
.Select(item =>
{
var output = item.DeepClone().AsObject();
if (Text(output["noticeId"]).Length > 0) output["id"] = output["noticeId"]!.DeepClone();
return (JsonNode)output;
}).ToArray();
var exams = data.Operational.Exams.Where(item =>
item.Data["archivedAt"] is null &&
Records(data, "setting", item.Id).Any(setting => Boolean(setting.Payload["enabled"])))
.Select(item => (JsonNode)PublicExam(item)).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["notifications"] = new JsonArray(notifications),
["exams"] = new JsonArray(exams)
});
}
public async Task<AdminEndpointResult> GetAdmissionSchoolPlansAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var plans = Records(data, "plan").Where(item => item.SchoolId == school.Id).Select(item =>
{
var output = RecordJson(item);
output["remainingCategories"] = RemainingPlanQuota(data, item);
output["progress"] = PlanProgress(data, item);
return (JsonNode)output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["exams"] = new JsonArray(data.Operational.Exams.Where(item => item.Data["archivedAt"] is null)
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
["sourceSchools"] = new JsonArray(data.Directory.Schools
.Where(item => item.Active && item.IsSourceSchool)
.Select(item => (JsonNode)SchoolJson(item)).ToArray())
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolPlanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var examId = Clean(Text(body["examId"]), 64);
var exam = data.Operational.Exams.FirstOrDefault(item =>
item.Id == examId && item.Data["archivedAt"] is null);
if (exam is null) return Error(404, "考试不存在或已经归档");
var categories = NormalizeCategories(body["categories"]);
if (categories.Count == 0) return Error(400, "请至少填写一个有效招生类别和计划人数");
if (categories.Select(item => Text(item["code"])).Distinct(StringComparer.Ordinal).Count() != categories.Count)
return Error(400, "招生类别代码不能重复");
if (categories.Any(item => !ValidSpecialty(Text(item["specialtyCategory"]), Text(item["specialtyType"]))))
return Error(400, "特长生招生类别的大类与小类不对应");
foreach (var category in categories)
{
var allocations = (category["indicatorAllocations"] as JsonArray ?? []).OfType<JsonObject>().ToArray();
if (allocations.Select(item => Text(item["sourceSchoolId"])).Distinct(StringComparer.Ordinal).Count() != allocations.Length)
return Error(400, "同一招生类别不能重复分配同一生源校指标");
if (allocations.Sum(item => Number(item["quota"])) > Number(category["quota"]))
return Error(400, "指标分配合计不能超过该类别计划人数");
if (allocations.Any(item => !data.Directory.Schools.Any(schoolItem =>
schoolItem.Id == Text(item["sourceSchoolId"]) && schoolItem.Active && schoolItem.IsSourceSchool)))
return Error(400, "指标分配中包含无效的生源学校");
}
var existing = Records(data, "plan", exam.Id).FirstOrDefault(item => item.SchoolId == school.Id);
if (existing?.Status == "approved") return Error(409, "已审核通过的招生计划只能由超级管理员调整");
var now = NowIso();
var plan = new CandidateAdmissionRecord(
existing?.Id ?? Uid("plan"),
"plan",
exam.Id,
existing?.UserId ?? user.Id,
school.Id,
"pending",
new JsonObject
{
["categories"] = new JsonArray(categories.Select(item => (JsonNode)item).ToArray()),
["note"] = Clean(Text(body["note"]), 500),
["submittedBy"] = user.DisplayName,
["reviewNote"] = ""
},
existing?.CreatedAt ?? now,
now);
await repository.SaveRecordsAsync(
[plan],
Audit(user, "提交招生计划", $"{school.Name} · {Text(exam.Data["name"])}"),
cancellationToken);
return new AdminEndpointResult(existing is null ? 201 : 200,
new JsonObject { ["ok"] = true, ["plan"] = RecordJson(plan) });
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingDraftAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(sessionToken, Text(body["examId"]), cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var available = ReportingRows(resolved.Data!, resolved.Plan!, resolved.Record!)
.Select(item => Text(item["placementId"])).ToHashSet(StringComparer.Ordinal);
var now = NowIso();
var updates = (body["rows"] as JsonArray ?? []).OfType<JsonObject>().Select(item => new JsonObject
{
["placementId"] = Clean(Text(item["placementId"]), 64),
["status"] = Clean(Text(item["status"]), 30),
["note"] = Clean(Text(item["note"]), 300),
["updatedAt"] = now,
["source"] = "manual"
}).ToArray();
if (updates.Length == 0 || updates.Any(item =>
!available.Contains(Text(item["placementId"])) ||
Text(item["status"]) is not ("pending" or "reported" or "not_reported")))
return Error(400, "报到暂存数据无效");
var merged = ReportingRowMap(resolved.Record!);
foreach (var update in updates) merged[Text(update["placementId"])] = update;
var updated = UpdateReportingRecord(
resolved.Record!, "draft", merged.Values, resolved.User!.DisplayName, now,
("savedAt", JsonValue.Create(now)), ("savedBy", JsonValue.Create(resolved.User.DisplayName)));
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, "暂存考生报到状态", $"{resolved.School!.Name} · {updates.Length} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> PreviewAdmissionSchoolReportingScanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var target = await ResolveScanTargetAsync(sessionToken, Text(body["code"]), cancellationToken);
if (target.Error is not null) return target.Error;
if (Text(body["examId"]) is { Length: > 0 } examId && Clean(examId, 64) != target.Placement!.ExamId)
return Error(400, "二维码不属于当前考试报到批次");
var row = ReportingRows(target.Data!, target.Plan!, target.Record!)
.First(item => Text(item["placementId"]) == target.Placement!.Id);
return Success(new JsonObject
{
["ok"] = true,
["code"] = target.Code,
["examId"] = target.Placement!.ExamId,
["row"] = row
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingScanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var target = await ResolveScanTargetAsync(sessionToken, Text(body["code"]), cancellationToken);
if (target.Error is not null) return target.Error;
if (Text(body["examId"]) is { Length: > 0 } examId && Clean(examId, 64) != target.Placement!.ExamId)
return Error(400, "二维码不属于当前考试报到批次");
var status = Clean(Text(body["status"]), 30);
if (status is not ("reported" or "not_reported" or "pending"))
return Error(400, "请选择有效的报到确认状态");
var now = NowIso();
var fallback = status switch
{
"reported" => "扫描录取通知书二维码确认报到",
"not_reported" => "扫描录取通知书二维码确认未报到",
_ => "扫描录取通知书二维码后暂待确认"
};
var merged = ReportingRowMap(target.Record!);
merged[target.Placement!.Id] = new JsonObject
{
["placementId"] = target.Placement.Id,
["status"] = status,
["note"] = Clean(Text(body["note"]), 300) is { Length: > 0 } note ? note : fallback,
["updatedAt"] = now,
["source"] = "qr_scan"
};
var updated = UpdateReportingRecord(
target.Record!, "draft", merged.Values, target.User!.DisplayName, now,
("savedAt", JsonValue.Create(now)), ("savedBy", JsonValue.Create(target.User.DisplayName)));
await repository.SaveRecordsAsync(
[updated],
Audit(target.User, "扫码确认并暂存考生报到",
$"{target.School!.Name} · {(Text(target.Placement.Payload["noticeNumber"]) is { Length: > 0 } number ? number : target.Placement.Id)} · {ReportingCode(status)}"),
cancellationToken);
var data = ReplaceRecord(target.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["row"] = ReportingRows(data, target.Plan!, updated)
.First(item => Text(item["placementId"]) == target.Placement.Id),
["batch"] = ReportingBatch(data, target.Plan!, updated)
});
}
public async Task<AdminEndpointResult> SubmitAdmissionSchoolReportingAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(sessionToken, Text(body["examId"]), cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var rows = ReportingRows(resolved.Data!, resolved.Plan!, resolved.Record!);
var pending = rows.Count(item => Text(item["status"]) == "pending");
if (pending > 0) return Error(409, $"仍有 {pending} 名考生待确认,请全部标记后提交");
var now = NowIso();
var payload = resolved.Record!.Payload.DeepClone().AsObject();
payload["submittedAt"] = now;
payload["submittedBy"] = resolved.User!.DisplayName;
var updated = resolved.Record with { Status = "submitted", UpdatedAt = now, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, "提交考生报到情况", $"{resolved.School!.Name} · {rows.Length} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingDecisionAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(
sessionToken, Text(body["examId"]), cancellationToken, requiredStatus: "submitted");
if (resolved.Error is not null) return resolved.Error;
var progress = PlanProgress(resolved.Data!, resolved.Plan!);
var gap = Integer(progress["reportingGap"]);
var supplement = ExactlyTrue(body["supplement"]) && gap > 0;
var note = Clean(Text(body["decisionNote"]), 500);
if (supplement && note.Length < 4)
return Error(400, "申请补录时请填写至少 4 个字的补录说明");
var now = NowIso();
var payload = resolved.Record!.Payload.DeepClone().AsObject();
payload["supplementDecision"] = supplement ? "supplement" : "no_supplement";
payload["decisionNote"] = note.Length > 0 ? note :
gap > 0 ? "经学校研究决定,本轮不进行补录。" : "本校招生计划已完成。";
payload["decisionSubmittedAt"] = now;
payload["decisionSubmittedBy"] = resolved.User!.DisplayName;
payload["statistics"] = progress.DeepClone();
var updated = resolved.Record with { Status = "pending_approval", UpdatedAt = now, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, supplement ? "提交补录申请" : "提交不补录决定",
$"{resolved.School!.Name} · 缺额 {gap} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementsAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var ids = (body["ids"] as JsonArray ?? []).Select(Text).Select(item => Clean(item, 64))
.Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
var decision = Clean(Text(body["decision"]), 30);
var note = Clean(Text(body["note"]), 500);
if (ids.Length == 0) return Error(400, "请至少选择一名待审核考生");
if (decision is not ("accept" or "withdraw")) return Error(400, "请选择接收或申请退档");
if (decision == "withdraw" && note.Length < 8)
return Error(400, "批量申请退档必须填写至少 8 个字的特殊理由");
var placements = Records(data, "placement").Where(item =>
ids.Contains(item.Id, StringComparer.Ordinal) && item.SchoolId == school.Id && item.Status == "school_review").ToArray();
if (placements.Length != ids.Length)
return Error(409, "所选记录中包含已处理或不属于本校的投档记录,请刷新后重试");
var now = NowIso();
var updated = placements.Select(item => ReviewedPlacement(item, decision, note, now)).ToArray();
await repository.SaveRecordsAsync(
updated,
Audit(user, decision == "accept" ? "批量接收投档考生" : "批量申请退档",
$"{school.Name} · {updated.Length} 人"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["count"] = updated.Length, ["decision"] = decision });
}
public async Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementAsync(
string sessionToken,
string placementId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var placement = Records(data, "placement").FirstOrDefault(item =>
item.Id == placementId && item.SchoolId == school.Id && item.Status == "school_review");
if (placement is null) return Error(404, "待审核投档记录不存在");
var decision = Clean(Text(body["decision"]), 30);
var note = Clean(Text(body["note"]), 500);
if (decision is not ("accept" or "withdraw")) return Error(400, "请选择接收或申请退档");
if (decision == "withdraw" && note.Length < 8)
return Error(400, "申请退档必须填写至少 8 个字的特殊理由");
var updated = ReviewedPlacement(placement, decision, note, NowIso());
await repository.SaveRecordsAsync(
[updated],
Audit(user, decision == "accept" ? "接收投档考生" : "申请退档", $"{school.Name} · {placement.Id}"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["placement"] = RecordJson(updated) });
}
private async Task<ResolvedReporting> ResolveReportingAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken,
string? requiredStatus = null)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return ResolvedReporting.Failed(context.Error);
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return ResolvedReporting.Failed(Error(403, "招生学校账号未绑定有效学校"));
var cleanExamId = Clean(examId, 64);
var plan = Records(data, "plan", cleanExamId)
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, cleanExamId, school.Id);
var valid = requiredStatus is null
? record?.Status is "draft" or "rejected"
: record?.Status == requiredStatus;
if (plan is null || record is null || !valid)
{
var message = requiredStatus == "submitted"
? "请先提交本轮考生报到情况"
: "当前报到批次不能修改暂存状态";
return ResolvedReporting.Failed(Error(409, message));
}
return new(context.User, school, data, plan, record, null);
}
private async Task<ResolvedScan> ResolveScanTargetAsync(
string sessionToken,
string rawCode,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return ResolvedScan.Failed(context.Error);
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return ResolvedScan.Failed(Error(403, "招生学校账号未绑定有效学校"));
var match = AdmissionNoticePattern.Match(rawCode.ToUpperInvariant());
if (!match.Success) return ResolvedScan.Failed(Error(400, "未识别到有效的录取通知书防伪码"));
var code = match.Value.ToUpperInvariant();
var placement = Records(data, "placement").FirstOrDefault(item =>
item.SchoolId == school.Id && item.Status == "final" &&
DocumentVerificationCodeService.SafeEquals(code, NoticeCode(item)));
if (placement is null) return ResolvedScan.Failed(Error(404, "该二维码不属于本校有效录取通知书"));
var plan = Records(data, "plan", placement.ExamId)
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, placement.ExamId, school.Id);
if (plan is null || record is null || record.Status is not ("draft" or "rejected") ||
!(record.Payload["rows"] as JsonArray ?? []).Any(item => Text(item?["placementId"]) == placement.Id))
return ResolvedScan.Failed(Error(409, "该考生不在当前可维护的报到批次"));
return new(context.User, school, data, plan, record, placement, code, null);
}
private string NoticeCode(CandidateAdmissionRecord placement) =>
documentCodes.AdmissionNoticeCode(
placement.Id,
placement.UserId ?? "",
placement.SchoolId ?? "",
placement.ExamId,
Text(placement.Payload["categoryCode"]),
Text(placement.Payload["noticeNumber"]),
placement.UpdatedAt);
private static Dictionary<string, JsonObject> ReportingRowMap(CandidateAdmissionRecord record) =>
(record.Payload["rows"] as JsonArray ?? []).OfType<JsonObject>()
.ToDictionary(item => Text(item["placementId"]), item => item.DeepClone().AsObject(), StringComparer.Ordinal);
private static CandidateAdmissionRecord UpdateReportingRecord(
CandidateAdmissionRecord record,
string status,
IEnumerable<JsonObject> rows,
string actor,
string now,
params (string Key, JsonNode? Value)[] additions)
{
var payload = record.Payload.DeepClone().AsObject();
payload["rows"] = new JsonArray(rows.Select(item => (JsonNode)item).ToArray());
foreach (var addition in additions) payload[addition.Key] = addition.Value;
return record with { Status = status, UpdatedAt = now, Payload = payload };
}
private static CandidateAdmissionRecord ReviewedPlacement(
CandidateAdmissionRecord placement,
string decision,
string note,
string now)
{
var payload = placement.Payload.DeepClone().AsObject();
payload["schoolDecisionNote"] = note;
if (decision == "withdraw") payload["withdrawalReason"] = note;
return placement with
{
Status = decision == "accept" ? "admitted" : "withdrawal_pending",
Payload = payload,
UpdatedAt = now
};
}
private static string ReportingCode(string status) =>
status switch { "reported" => "Y", "not_reported" => "N", _ => "P" };
private sealed record ResolvedReporting(
AuthenticationUser? User,
AdminSchool? School,
AdmissionData? Data,
CandidateAdmissionRecord? Plan,
CandidateAdmissionRecord? Record,
AdminEndpointResult? Error)
{
public static ResolvedReporting Failed(AdminEndpointResult error) =>
new(null, null, null, null, null, error);
}
private sealed record ResolvedScan(
AuthenticationUser? User,
AdminSchool? School,
AdmissionData? Data,
CandidateAdmissionRecord? Plan,
CandidateAdmissionRecord? Record,
CandidateAdmissionRecord? Placement,
string Code,
AdminEndpointResult? Error)
{
public static ResolvedScan Failed(AdminEndpointResult error) =>
new(null, null, null, null, null, null, "", error);
}
}
@@ -2,8 +2,10 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Administration;
@@ -15,7 +17,9 @@ internal sealed partial class AdminAdmissionService(
AdminOperationalSnapshotLoader operationalLoader,
AdminAccountBatchSnapshotLoader directoryLoader,
AdminWorkflowResultSnapshotLoader resultLoader,
AdminAdmissionRepository repository) : IAdminAdmissionService
AdminAdmissionRepository repository,
DocumentVerificationCodeService documentCodes,
IPublicQueryService publicQueries) : IAdminAdmissionService
{
private static readonly HashSet<string> Phases =
["draft", "filling", "closed", "matching", "school_review", "reporting", "supplementary", "completed"];
Loaded 3 of 36 files, more files were not shown because too many files have changed in this diff. Show more