支持五级混编、稳定种子、容量预检、固定考点、逐科考场/座位及四种准考证号规则。
正式编排会事务性替换整场计划、准考证、座位和审计记录。 支持三级管理员范围读取、批量 HTML 准考证、准考证信息 Excel,以及校级考点桌贴、门贴和签名单。 核心实现见 [AdminArrangementPlanner.cs (line 7)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminArrangementPlanner.cs:7)、[AdminArrangementService.cs (line 11)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminArrangementService.cs:11) 和 [AdminArrangementExporter.cs (line 5)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminArrangementExporter.cs:5)。 新增开关 ADMIN_NATIVE_ARRANGEMENTS_ENABLED=true,说明已更新到 [MIGRATION.md (line 57)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:57)。 Excel 由 .NET 直接生成,使用 ClosedXML 0.105.0;该库支持服务端生成 Excel 2007+ 工作簿。ClosedXML NuGet 页面
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal static class AdminArrangementExporter
|
||||
{
|
||||
private static readonly string[] InformationHeaders =
|
||||
[
|
||||
"考生学校", "班级", "报名号", "姓名", "证件号码", "考试代码", "考试名称", "准考证号",
|
||||
"考点代码", "考点名称", "考点详细地址", "科目", "日期", "时间", "考试考场序号",
|
||||
"考场通用名称", "物理场地代码", "楼栋", "楼层", "座位号"
|
||||
];
|
||||
|
||||
public static byte[] AdmitCardInformation(
|
||||
IReadOnlyList<ArrangementExportRow> rows,
|
||||
string subtitle)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.Worksheets.Add("准考证信息");
|
||||
ConfigureTable(
|
||||
sheet,
|
||||
$"准考证信息台账|{subtitle}",
|
||||
InformationHeaders,
|
||||
rows.Select(InformationValues).ToArray());
|
||||
return Save(workbook);
|
||||
}
|
||||
|
||||
public static byte[] CenterMaterials(
|
||||
IReadOnlyList<ArrangementExportRow> rows,
|
||||
string subtitle)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var sorted = rows
|
||||
.OrderBy(item => item.CenterCode, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SubjectDate, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SubjectTime, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ExamRoomCode, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Seat, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
AddDeskStickers(workbook.Worksheets.Add("桌贴"), sorted);
|
||||
var doors = sorted
|
||||
.GroupBy(
|
||||
item => new
|
||||
{
|
||||
item.ExamCode,
|
||||
item.SubjectName,
|
||||
item.CenterCode,
|
||||
item.ExamRoomCode,
|
||||
item.RoomCode
|
||||
})
|
||||
.Select(group => (Row: group.First(), Count: group.Count()))
|
||||
.ToArray();
|
||||
ConfigureTable(
|
||||
workbook.Worksheets.Add("门贴"),
|
||||
$"考场门贴|{subtitle}",
|
||||
[
|
||||
"考试", "科目", "日期", "时间", "考点", "考试考场序号",
|
||||
"考场通用名称", "场地代码", "楼栋", "楼层", "人数"
|
||||
],
|
||||
doors.Select(item => new object[]
|
||||
{
|
||||
item.Row.ExamName,
|
||||
item.Row.SubjectName,
|
||||
item.Row.SubjectDate,
|
||||
item.Row.SubjectTime,
|
||||
item.Row.CenterName,
|
||||
item.Row.ExamRoomCode,
|
||||
item.Row.RoomName,
|
||||
item.Row.RoomCode,
|
||||
item.Row.Building,
|
||||
item.Row.Floor,
|
||||
item.Count
|
||||
}).ToArray());
|
||||
ConfigureTable(
|
||||
workbook.Worksheets.Add("考场签名单"),
|
||||
$"考场考生签名单|{subtitle}",
|
||||
[
|
||||
"考试", "科目", "日期", "时间", "考试考场序号", "考场通用名称",
|
||||
"楼栋", "楼层", "座位号", "准考证号", "姓名", "考生学校", "班级", "考生签名"
|
||||
],
|
||||
sorted.Select(item => new object[]
|
||||
{
|
||||
item.ExamName,
|
||||
item.SubjectName,
|
||||
item.SubjectDate,
|
||||
item.SubjectTime,
|
||||
item.ExamRoomCode,
|
||||
item.RoomName,
|
||||
item.Building,
|
||||
item.Floor,
|
||||
item.Seat,
|
||||
item.CardNumber,
|
||||
item.CandidateName,
|
||||
item.SchoolName,
|
||||
item.ClassName,
|
||||
""
|
||||
}).ToArray());
|
||||
return Save(workbook);
|
||||
}
|
||||
|
||||
private static void AddDeskStickers(
|
||||
IXLWorksheet sheet,
|
||||
IReadOnlyList<ArrangementExportRow> rows)
|
||||
{
|
||||
sheet.ShowGridLines = false;
|
||||
for (var column = 1; column <= 7; column++)
|
||||
sheet.Column(column).Width = column == 4 ? 3 : 14.5;
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var item = rows[index];
|
||||
var slot = index % 2;
|
||||
var group = index / 2;
|
||||
var startRow = group * 9 + 1;
|
||||
var startColumn = slot == 0 ? 1 : 5;
|
||||
var values = new[]
|
||||
{
|
||||
item.ExamName,
|
||||
$"{item.SubjectName}|{item.SubjectDate} {item.SubjectTime}",
|
||||
"考试考场序号",
|
||||
item.ExamRoomCode,
|
||||
$"座位号 {item.Seat}",
|
||||
$"{item.RoomName}(场地 {item.RoomCode})|{item.Building} {item.Floor}",
|
||||
$"{item.CandidateName}|{item.SchoolName} {item.ClassName}",
|
||||
$"{item.CenterName}|准考证号 {item.CardNumber}"
|
||||
};
|
||||
for (var offset = 0; offset < values.Length; offset++)
|
||||
{
|
||||
var range = sheet.Range(
|
||||
startRow + offset,
|
||||
startColumn,
|
||||
startRow + offset,
|
||||
startColumn + 2);
|
||||
range.Merge();
|
||||
range.Value = values[offset];
|
||||
range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
range.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
range.Style.Alignment.WrapText = true;
|
||||
range.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||
range.Style.Border.InsideBorder = XLBorderStyleValues.Hair;
|
||||
sheet.Row(startRow + offset).Height = 28;
|
||||
}
|
||||
var title = sheet.Range(startRow, startColumn, startRow, startColumn + 2);
|
||||
title.Style.Fill.BackgroundColor = XLColor.FromHtml("#173F60");
|
||||
title.Style.Font.FontColor = XLColor.White;
|
||||
title.Style.Font.Bold = true;
|
||||
var room = sheet.Range(startRow + 3, startColumn, startRow + 3, startColumn + 2);
|
||||
room.Style.Font.FontSize = 24;
|
||||
room.Style.Font.Bold = true;
|
||||
var seat = sheet.Range(startRow + 4, startColumn, startRow + 4, startColumn + 2);
|
||||
seat.Style.Fill.BackgroundColor = XLColor.FromHtml("#FFF1C7");
|
||||
seat.Style.Font.FontColor = XLColor.FromHtml("#8A4B08");
|
||||
seat.Style.Font.FontSize = 18;
|
||||
seat.Style.Font.Bold = true;
|
||||
}
|
||||
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.Margins.SetLeft(0.25);
|
||||
sheet.PageSetup.Margins.SetRight(0.25);
|
||||
sheet.PageSetup.Margins.SetTop(0.25);
|
||||
sheet.PageSetup.Margins.SetBottom(0.25);
|
||||
}
|
||||
|
||||
private static void ConfigureTable(
|
||||
IXLWorksheet sheet,
|
||||
string title,
|
||||
IReadOnlyList<string> headers,
|
||||
IReadOnlyList<object[]> rows)
|
||||
{
|
||||
sheet.ShowGridLines = false;
|
||||
var lastColumn = headers.Count;
|
||||
var titleRange = sheet.Range(1, 1, 1, lastColumn);
|
||||
titleRange.Merge();
|
||||
titleRange.Value = title;
|
||||
titleRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#173F60");
|
||||
titleRange.Style.Font.FontColor = XLColor.White;
|
||||
titleRange.Style.Font.FontSize = 16;
|
||||
titleRange.Style.Font.Bold = true;
|
||||
titleRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
sheet.Row(1).Height = 28;
|
||||
for (var column = 0; column < headers.Count; column++)
|
||||
{
|
||||
var cell = sheet.Cell(2, column + 1);
|
||||
cell.Value = headers[column];
|
||||
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#2C7080");
|
||||
cell.Style.Font.FontColor = XLColor.White;
|
||||
cell.Style.Font.Bold = true;
|
||||
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
cell.Style.Alignment.WrapText = true;
|
||||
}
|
||||
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
|
||||
{
|
||||
for (var column = 0; column < headers.Count; column++)
|
||||
{
|
||||
var value = column < rows[rowIndex].Length ? rows[rowIndex][column] : "";
|
||||
var cell = sheet.Cell(rowIndex + 3, column + 1);
|
||||
if (value is int integer) cell.Value = integer;
|
||||
else if (value is double number) cell.Value = number;
|
||||
else cell.Value = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? "";
|
||||
cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||
cell.Style.Alignment.WrapText = true;
|
||||
cell.Style.Border.BottomBorder = XLBorderStyleValues.Hair;
|
||||
cell.Style.Border.BottomBorderColor = XLColor.FromHtml("#D8E2E7");
|
||||
}
|
||||
}
|
||||
sheet.SheetView.FreezeRows(2);
|
||||
if (rows.Count > 0) sheet.Range(2, 1, rows.Count + 2, lastColumn).SetAutoFilter();
|
||||
sheet.Columns().AdjustToContents(8, 40);
|
||||
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
sheet.PageSetup.SetRowsToRepeatAtTop(1, 2);
|
||||
}
|
||||
|
||||
private static object[] InformationValues(ArrangementExportRow item) =>
|
||||
[
|
||||
item.SchoolName,
|
||||
item.ClassName,
|
||||
item.CandidateNumber,
|
||||
item.CandidateName,
|
||||
item.IdNumber,
|
||||
item.ExamCode,
|
||||
item.ExamName,
|
||||
item.CardNumber,
|
||||
item.CenterCode,
|
||||
item.CenterName,
|
||||
item.CenterAddress,
|
||||
item.SubjectName,
|
||||
item.SubjectDate,
|
||||
item.SubjectTime,
|
||||
item.ExamRoomCode,
|
||||
item.RoomName,
|
||||
item.RoomCode,
|
||||
item.Building,
|
||||
item.Floor,
|
||||
item.Seat
|
||||
];
|
||||
|
||||
private static byte[] Save(XLWorkbook workbook)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal static partial class AdminArrangementPlanner
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> MixingScopeNames =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["class"] = "班内混编",
|
||||
["school"] = "校内混编",
|
||||
["district"] = "县区内混编",
|
||||
["city"] = "市内混编",
|
||||
["province"] = "省内混编"
|
||||
};
|
||||
|
||||
public static ArrangementBuildResult Build(
|
||||
AdminOperationalSnapshot operational,
|
||||
AdminCenterSnapshot centers,
|
||||
AdminArrangementSnapshot arrangements,
|
||||
AdminAccountBatchSnapshot directory,
|
||||
string examId,
|
||||
string requestedScope,
|
||||
string numberRuleId,
|
||||
string requestedSeed,
|
||||
string generatedAt)
|
||||
{
|
||||
var exam = operational.Exams.FirstOrDefault(item => item.Id == examId)
|
||||
?? throw Error("考试不存在", 404);
|
||||
var mixingScope = requestedScope.Length == 0 ? "school" : requestedScope;
|
||||
if (!MixingScopeNames.ContainsKey(mixingScope)) throw Error("请选择有效的混编范围", 400);
|
||||
var rule = arrangements.Rules.FirstOrDefault(item => item.Id == numberRuleId && item.Active)
|
||||
?? throw Error("请选择有效的准考证号规则", 400);
|
||||
var seedSource = requestedSeed.Length > 0 ? requestedSeed : First(Text(exam.Data, "code"), exam.Id);
|
||||
var seed = seedSource[..Math.Min(seedSource.Length, 80)];
|
||||
var warnings = new List<string>();
|
||||
void Warn(string message)
|
||||
{
|
||||
if (!warnings.Contains(message, StringComparer.Ordinal)) warnings.Add(message);
|
||||
}
|
||||
|
||||
var subjectOrder = exam.Subjects
|
||||
.Select((subject, index) => new { Id = Text(subject, "id"), Index = index })
|
||||
.ToDictionary(item => item.Id, item => item.Index, StringComparer.Ordinal);
|
||||
var approvedRegistrations = operational.Registrations
|
||||
.Where(item => item.ExamId == exam.Id && item.Status == "approved")
|
||||
.ToArray();
|
||||
if (approvedRegistrations.Length == 0)
|
||||
throw Error("该考试没有已审核通过的报名,无法编排");
|
||||
|
||||
var candidates = approvedRegistrations.Select(registration =>
|
||||
{
|
||||
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId)
|
||||
?? throw Error($"报名 {registration.Id} 缺少考生档案");
|
||||
var boundary = ScopeValue(profile, mixingScope);
|
||||
if (boundary.Length == 0)
|
||||
{
|
||||
var scopeLabel = MixingScopeNames[mixingScope].Replace("内混编", "", StringComparison.Ordinal);
|
||||
throw Error($"{Text(profile.Data, "name")} 缺少{scopeLabel}信息");
|
||||
}
|
||||
var requestedSubjectIds = (registration.Data["subjectIds"] as JsonArray ?? [])
|
||||
.Select(item => item?.GetValue<string>() ?? "")
|
||||
.ToArray();
|
||||
var subjects = requestedSubjectIds
|
||||
.Select(id => exam.Subjects.FirstOrDefault(subject => Text(subject, "id") == id))
|
||||
.Where(item => item is not null)
|
||||
.Cast<JsonObject>()
|
||||
.ToArray();
|
||||
if (subjects.Length != requestedSubjectIds.Length || subjects.Length == 0)
|
||||
throw Error($"{Text(profile.Data, "name")} 的报考科目无效");
|
||||
var overlap = OverlappingSubjects(subjects);
|
||||
if (overlap is not null)
|
||||
throw Error($"{Text(profile.Data, "name")} 报考的“{Text(overlap.Value.Left, "name")}”与“{Text(overlap.Value.Right, "name")}”时间冲突");
|
||||
var orderedSubjectIds = subjects
|
||||
.OrderBy(subject => subjectOrder.GetValueOrDefault(Text(subject, "id")))
|
||||
.Select(subject => Text(subject, "id"))
|
||||
.ToArray();
|
||||
return new CandidateWork(
|
||||
registration,
|
||||
profile,
|
||||
orderedSubjectIds,
|
||||
string.Join('|', orderedSubjectIds),
|
||||
$"{mixingScope}:{boundary}");
|
||||
}).ToList();
|
||||
|
||||
var activeCenters = centers.Centers
|
||||
.Where(center => center.Status == "active")
|
||||
.Select(center =>
|
||||
{
|
||||
var allRooms = centers.Rooms
|
||||
.Where(room => room.CenterId == center.Id && room.Status == "active")
|
||||
.ToArray();
|
||||
var regularRooms = allRooms.Where(room => room.RoomType != "spare").ToArray();
|
||||
var rooms = regularRooms.Length > 0 ? regularRooms : allRooms;
|
||||
if (regularRooms.Length == 0 && allRooms.Length > 0)
|
||||
Warn($"{center.Name} 没有普通启用考场,本次将使用备用考场");
|
||||
return new CenterWork(center, rooms, allRooms);
|
||||
})
|
||||
.Where(center => center.Rooms.Count > 0)
|
||||
.ToArray();
|
||||
if (activeCenters.Length == 0) throw Error("没有可用的启用考点和考场");
|
||||
|
||||
var remaining = activeCenters.ToDictionary(
|
||||
center => center.Center.Id,
|
||||
center => exam.Subjects.ToDictionary(
|
||||
subject => Text(subject, "id"),
|
||||
_ => center.Rooms.Sum(room => room.Capacity),
|
||||
StringComparer.Ordinal),
|
||||
StringComparer.Ordinal);
|
||||
var assignedCounts = activeCenters.ToDictionary(item => item.Center.Id, _ => 0, StringComparer.Ordinal);
|
||||
var affinity = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var groups = candidates
|
||||
.GroupBy(candidate => $"{candidate.ScopeKey}:{candidate.Signature}", StringComparer.Ordinal)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ThenBy(group => group.Key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var ordered = group.ToList();
|
||||
ordered.Sort((left, right) => StableCompare(seed, left, right));
|
||||
foreach (var candidate in ordered)
|
||||
{
|
||||
var eligible = activeCenters.Where(center =>
|
||||
candidate.SubjectIds.All(subjectId =>
|
||||
remaining[center.Center.Id].GetValueOrDefault(subjectId) > 0)).ToArray();
|
||||
if (eligible.Length == 0)
|
||||
throw Error($"{Text(candidate.Profile.Data, "name")} 的全部报考科目无法在同一考点容纳;请增加考场容量或缩小本次报名范围");
|
||||
var local = eligible.Where(center => CenterMatchesProfile(center.Center, candidate.Profile, mixingScope)).ToArray();
|
||||
if (local.Length == 0)
|
||||
Warn($"{Text(candidate.Profile.Data, "name")} 所属范围内没有足够考点,已跨范围使用可用考点");
|
||||
var pool = local.Length > 0 ? local : eligible;
|
||||
var selected = pool
|
||||
.OrderByDescending(center => CenterScore(center, candidate, remaining, affinity, assignedCounts))
|
||||
.ThenBy(center => center.Center.Code, StringComparer.Ordinal)
|
||||
.First();
|
||||
candidate.CenterId = selected.Center.Id;
|
||||
foreach (var subjectId in candidate.SubjectIds) remaining[selected.Center.Id][subjectId]--;
|
||||
assignedCounts[selected.Center.Id]++;
|
||||
var affinityKey = $"{selected.Center.Id}:{candidate.Signature}";
|
||||
affinity[affinityKey] = affinity.GetValueOrDefault(affinityKey) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var assignments = candidates.ToDictionary(
|
||||
item => item.Registration.Id,
|
||||
_ => new List<ArrangementAssignment>(),
|
||||
StringComparer.Ordinal);
|
||||
var roomUses = new List<RoomUse>();
|
||||
foreach (var subject in exam.Subjects)
|
||||
{
|
||||
var subjectId = Text(subject, "id");
|
||||
foreach (var center in activeCenters)
|
||||
{
|
||||
var subjectCandidates = candidates
|
||||
.Where(candidate => candidate.CenterId == center.Center.Id && candidate.SubjectIds.Contains(subjectId, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
if (subjectCandidates.Count == 0) continue;
|
||||
subjectCandidates.Sort((left, right) =>
|
||||
{
|
||||
var scope = string.Compare(left.ScopeKey, right.ScopeKey, StringComparison.Ordinal);
|
||||
if (scope != 0) return scope;
|
||||
var signature = string.Compare(left.Signature, right.Signature, StringComparison.Ordinal);
|
||||
return signature != 0 ? signature : StableCompare(seed, left, right);
|
||||
});
|
||||
var roomIndex = 0;
|
||||
var seatIndex = 0;
|
||||
var currentScopeKey = "";
|
||||
var rooms = center.Rooms
|
||||
.OrderBy(room => room.Code, StringComparer.Ordinal)
|
||||
.ThenBy(room => room.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
foreach (var candidate in subjectCandidates)
|
||||
{
|
||||
if (currentScopeKey.Length > 0 && candidate.ScopeKey != currentScopeKey && seatIndex > 0)
|
||||
{
|
||||
roomIndex++;
|
||||
seatIndex = 0;
|
||||
}
|
||||
currentScopeKey = candidate.ScopeKey;
|
||||
while (roomIndex < rooms.Length && seatIndex >= rooms[roomIndex].Capacity)
|
||||
{
|
||||
roomIndex++;
|
||||
seatIndex = 0;
|
||||
}
|
||||
if (roomIndex >= rooms.Length)
|
||||
throw Error($"{center.Center.Name} 在“{Text(subject, "name")}”科目下按{MixingScopeNames[mixingScope]}隔离后考场不足;请增加考场或扩大混编范围");
|
||||
var room = rooms[roomIndex];
|
||||
var seat = (room.SeatStart + seatIndex).ToString("00", CultureInfo.InvariantCulture);
|
||||
assignments[candidate.Registration.Id].Add(new ArrangementAssignment(
|
||||
candidate.Registration.Id,
|
||||
subjectId,
|
||||
center.Center.Id,
|
||||
center.Center.Name,
|
||||
room.Id,
|
||||
room.Name,
|
||||
room.Code,
|
||||
room.Building,
|
||||
room.Floor,
|
||||
"",
|
||||
seat,
|
||||
candidate.Signature));
|
||||
roomUses.Add(new(center.Center.Code, room.Code, room.Id));
|
||||
seatIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var examRoomCodes = roomUses
|
||||
.GroupBy(item => item.RoomId, StringComparer.Ordinal)
|
||||
.Select(group => group.Last())
|
||||
.OrderBy(item => item.CenterCode, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.RoomCode, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.RoomId, StringComparer.Ordinal)
|
||||
.Select((item, index) => new { item.RoomId, Code = (index + 1).ToString("000", CultureInfo.InvariantCulture) })
|
||||
.ToDictionary(item => item.RoomId, item => item.Code, StringComparer.Ordinal);
|
||||
foreach (var registrationId in assignments.Keys.ToArray())
|
||||
{
|
||||
assignments[registrationId] = assignments[registrationId]
|
||||
.OrderBy(item => subjectOrder.GetValueOrDefault(item.SubjectId))
|
||||
.Select(item => item with { ExamRoomCode = examRoomCodes[item.RoomId] })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var sequenceCounters = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var numbers = new HashSet<string>(StringComparer.Ordinal);
|
||||
candidates.Sort((left, right) =>
|
||||
{
|
||||
var scope = string.Compare(left.ScopeKey, right.ScopeKey, StringComparison.Ordinal);
|
||||
return scope != 0 ? scope : StableCompare(seed, left, right);
|
||||
});
|
||||
var cards = candidates.Select(candidate =>
|
||||
{
|
||||
var center = activeCenters.First(item => item.Center.Id == candidate.CenterId);
|
||||
var primary = assignments[candidate.Registration.Id][0];
|
||||
var candidateSchool = directory.Schools.FirstOrDefault(item => item.Id == candidate.Profile.SchoolId);
|
||||
var centerSchool = directory.Schools.FirstOrDefault(item => item.Id == center.Center.SchoolId);
|
||||
var sources = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["district_code"] = NormalizedCode(First(Text(candidate.Profile.Data, "districtCode"), center.Center.DistrictCode)),
|
||||
["center_school_code"] = NormalizedCode(centerSchool?.Code ?? ""),
|
||||
["candidate_school_code"] = NormalizedCode(candidateSchool?.Code ?? ""),
|
||||
["exam_room_code"] = primary.ExamRoomCode,
|
||||
["seat"] = primary.Seat
|
||||
};
|
||||
var counterKey = SequenceGroup(rule, sources);
|
||||
var sequence = sequenceCounters.GetValueOrDefault(counterKey) + 1;
|
||||
sequenceCounters[counterKey] = sequence;
|
||||
var number = FormatAdmissionNumber(rule, sources, sequence);
|
||||
if (!numbers.Add(number))
|
||||
throw Error($"规则“{rule.Name}”生成了重复准考证号 {number},请检查规则组成");
|
||||
return new ArrangementCard(
|
||||
candidate.Registration.Id,
|
||||
number,
|
||||
center.Center.Id,
|
||||
center.Center.Name,
|
||||
center.Center.Code,
|
||||
string.Join(' ', new[]
|
||||
{
|
||||
center.Center.ProvinceName,
|
||||
center.Center.CityName,
|
||||
center.Center.DistrictName,
|
||||
center.Center.Address
|
||||
}.Where(value => value.Length > 0)),
|
||||
generatedAt,
|
||||
assignments[candidate.Registration.Id]);
|
||||
}).ToArray();
|
||||
|
||||
var usedCenters = cards.Select(card => card.CenterId).ToHashSet(StringComparer.Ordinal);
|
||||
var sameSchoolCenterCount = candidates.Count(candidate =>
|
||||
activeCenters.First(center => center.Center.Id == candidate.CenterId).Center.SchoolId == candidate.Profile.SchoolId);
|
||||
var sameSchoolRate = Math.Round(
|
||||
sameSchoolCenterCount * 100d / candidates.Count,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero);
|
||||
var reservedSpareRooms = activeCenters.Sum(center =>
|
||||
{
|
||||
var usedRoomIds = center.Rooms.Select(room => room.Id).ToHashSet(StringComparer.Ordinal);
|
||||
return center.AllRooms.Count(room => room.RoomType == "spare" && !usedRoomIds.Contains(room.Id));
|
||||
});
|
||||
var summary = new ArrangementSummary(
|
||||
candidates.Count,
|
||||
usedCenters.Count,
|
||||
cards.Sum(card => card.Assignments.Count),
|
||||
candidates.Select(candidate => candidate.Signature).Distinct(StringComparer.Ordinal).Count(),
|
||||
sameSchoolCenterCount,
|
||||
sameSchoolRate,
|
||||
reservedSpareRooms);
|
||||
return new(exam, rule, mixingScope, seed, warnings, summary, cards);
|
||||
}
|
||||
|
||||
private static long CenterScore(
|
||||
CenterWork center,
|
||||
CandidateWork candidate,
|
||||
IReadOnlyDictionary<string, Dictionary<string, int>> remaining,
|
||||
IReadOnlyDictionary<string, int> affinity,
|
||||
IReadOnlyDictionary<string, int> assignedCounts)
|
||||
{
|
||||
var affinityValue = affinity.GetValueOrDefault($"{center.Center.Id}:{candidate.Signature}");
|
||||
var capacity = candidate.SubjectIds.Min(subjectId => remaining[center.Center.Id][subjectId]);
|
||||
return affinityValue * 100000L + LocalityScore(center.Center, candidate.Profile) + capacity -
|
||||
assignedCounts.GetValueOrDefault(center.Center.Id);
|
||||
}
|
||||
|
||||
private static bool CenterMatchesProfile(AdminCenter center, OperationalProfile profile, string scope) => scope switch
|
||||
{
|
||||
"class" or "school" => center.SchoolId == profile.SchoolId,
|
||||
"district" => Text(profile.Data, "districtCode") is { Length: > 0 } district && center.DistrictCode == district,
|
||||
"city" => Text(profile.Data, "cityCode") is { Length: > 0 } city && center.CityCode == city,
|
||||
_ => Text(profile.Data, "provinceCode") is { Length: > 0 } province && center.ProvinceCode == province
|
||||
};
|
||||
|
||||
private static int LocalityScore(AdminCenter center, OperationalProfile profile)
|
||||
{
|
||||
if (center.SchoolId == profile.SchoolId) return 5000;
|
||||
if (Text(profile.Data, "districtCode") is { Length: > 0 } district && center.DistrictCode == district) return 1000;
|
||||
if (Text(profile.Data, "cityCode") is { Length: > 0 } city && center.CityCode == city) return 300;
|
||||
return Text(profile.Data, "provinceCode") is { Length: > 0 } province && center.ProvinceCode == province ? 100 : 0;
|
||||
}
|
||||
|
||||
private static string ScopeValue(OperationalProfile profile, string scope) => scope switch
|
||||
{
|
||||
"class" => profile.ClassId ?? "",
|
||||
"school" => profile.SchoolId ?? "",
|
||||
"district" => Text(profile.Data, "districtCode"),
|
||||
"city" => Text(profile.Data, "cityCode"),
|
||||
_ => Text(profile.Data, "provinceCode")
|
||||
};
|
||||
|
||||
private static (JsonObject Left, JsonObject Right)? OverlappingSubjects(IReadOnlyList<JsonObject> subjects)
|
||||
{
|
||||
for (var left = 0; left < subjects.Count; left++)
|
||||
{
|
||||
for (var right = left + 1; right < subjects.Count; right++)
|
||||
{
|
||||
var first = subjects[left];
|
||||
var second = subjects[right];
|
||||
if (Text(first, "date") != Text(second, "date")) continue;
|
||||
var firstStart = Minutes(Text(first, "start"));
|
||||
var firstEnd = Minutes(Text(first, "end"));
|
||||
var secondStart = Minutes(Text(second, "start"));
|
||||
var secondEnd = Minutes(Text(second, "end"));
|
||||
if (firstStart is not null && firstEnd is not null && secondStart is not null && secondEnd is not null &&
|
||||
firstStart < secondEnd && secondStart < firstEnd)
|
||||
return (first, second);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? Minutes(string value)
|
||||
{
|
||||
var parts = value.Split(':');
|
||||
return parts.Length >= 2 &&
|
||||
int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var hour) &&
|
||||
int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var minute)
|
||||
? hour * 60 + minute
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string FormatAdmissionNumber(
|
||||
AdmissionNumberRule rule,
|
||||
IReadOnlyDictionary<string, string> sources,
|
||||
int sequence)
|
||||
{
|
||||
if (rule.Segments.Count == 0) throw Error("所选准考证号规则没有可用的组成段");
|
||||
return string.Join(rule.Separator, rule.Segments.Select(segment =>
|
||||
{
|
||||
var value = segment.Source == "sequence"
|
||||
? sequence.ToString(CultureInfo.InvariantCulture)
|
||||
: sources.GetValueOrDefault(segment.Source) ?? "";
|
||||
if (value.Length == 0)
|
||||
throw Error($"准考证号规则需要“{First(segment.Label, segment.Source)}”,但考生或考点档案中缺少该值");
|
||||
return segment.Width is > 0 ? value.PadLeft(segment.Width.Value, '0') : value;
|
||||
}));
|
||||
}
|
||||
|
||||
private static string SequenceGroup(AdmissionNumberRule rule, IReadOnlyDictionary<string, string> sources)
|
||||
{
|
||||
if (rule.Code.StartsWith("district_", StringComparison.Ordinal)) return sources["district_code"];
|
||||
if (rule.Code.StartsWith("center_school_", StringComparison.Ordinal)) return sources["center_school_code"];
|
||||
if (rule.Code.StartsWith("candidate_school_", StringComparison.Ordinal)) return sources["candidate_school_code"];
|
||||
return "all";
|
||||
}
|
||||
|
||||
private static int StableCompare(string seed, CandidateWork left, CandidateWork right)
|
||||
{
|
||||
var leftHash = HashText($"{seed}:{left.Registration.Id}");
|
||||
var rightHash = HashText($"{seed}:{right.Registration.Id}");
|
||||
var comparison = leftHash.CompareTo(rightHash);
|
||||
return comparison != 0
|
||||
? comparison
|
||||
: string.Compare(left.Registration.Id, right.Registration.Id, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static uint HashText(string value)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
foreach (var character in value)
|
||||
{
|
||||
hash ^= character;
|
||||
hash *= 16777619;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizedCode(string value) =>
|
||||
NonAlphaNumeric().Replace(value, "").ToUpperInvariant();
|
||||
|
||||
private static string Text(JsonObject value, string property) =>
|
||||
value[property]?.ToString() ?? "";
|
||||
|
||||
private static string First(params string[] values) =>
|
||||
values.FirstOrDefault(value => value.Length > 0) ?? "";
|
||||
|
||||
private static AdminArrangementException Error(string message, int status = 409) => new(status, message);
|
||||
|
||||
[GeneratedRegex("[^0-9A-Z]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex NonAlphaNumeric();
|
||||
|
||||
private sealed class CandidateWork(
|
||||
OperationalRegistration registration,
|
||||
OperationalProfile profile,
|
||||
IReadOnlyList<string> subjectIds,
|
||||
string signature,
|
||||
string scopeKey)
|
||||
{
|
||||
public OperationalRegistration Registration { get; } = registration;
|
||||
public OperationalProfile Profile { get; } = profile;
|
||||
public IReadOnlyList<string> SubjectIds { get; } = subjectIds;
|
||||
public string Signature { get; } = signature;
|
||||
public string ScopeKey { get; } = scopeKey;
|
||||
public string? CenterId { get; set; }
|
||||
}
|
||||
|
||||
private sealed record CenterWork(
|
||||
AdminCenter Center,
|
||||
IReadOnlyList<AdminCenterRoom> Rooms,
|
||||
IReadOnlyList<AdminCenterRoom> AllRooms);
|
||||
|
||||
private sealed record RoomUse(string CenterCode, string RoomCode, string RoomId);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal sealed class AdminArrangementRepository(IRelationalConnectionFactory connectionFactory)
|
||||
{
|
||||
public async Task SaveAsync(
|
||||
StoredArrangementPlan plan,
|
||||
IReadOnlyList<ArrangementCard> cards,
|
||||
AdminAuditEntry audit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ExecuteAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"DELETE FROM exam_arrangement_plans WHERE exam_id = @examId",
|
||||
[new("@examId", plan.ExamId)],
|
||||
cancellationToken);
|
||||
await ExecuteAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
INSERT INTO exam_arrangement_plans (
|
||||
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
|
||||
subject_assignment_count, subject_combination_count, same_school_center_rate,
|
||||
warnings_json, generated_by, generated_at
|
||||
) VALUES (
|
||||
@id, @examId, @numberRuleId, @mixingScope, @randomSeed, @candidateCount, @centerCount,
|
||||
@subjectAssignmentCount, @subjectCombinationCount, @sameSchoolCenterRate,
|
||||
@warningsJson, @generatedBy, @generatedAt
|
||||
)
|
||||
""",
|
||||
[
|
||||
new("@id", plan.Id),
|
||||
new("@examId", plan.ExamId),
|
||||
new("@numberRuleId", plan.NumberRuleId),
|
||||
new("@mixingScope", plan.MixingScope),
|
||||
new("@randomSeed", plan.RandomSeed),
|
||||
new("@candidateCount", plan.CandidateCount),
|
||||
new("@centerCount", plan.CenterCount),
|
||||
new("@subjectAssignmentCount", plan.SubjectAssignmentCount),
|
||||
new("@subjectCombinationCount", plan.SubjectCombinationCount),
|
||||
new("@sameSchoolCenterRate", plan.SameSchoolCenterRate),
|
||||
new("@warningsJson", JsonSerializer.Serialize(plan.Warnings)),
|
||||
new("@generatedBy", plan.GeneratedBy),
|
||||
new("@generatedAt", plan.GeneratedAt)
|
||||
],
|
||||
cancellationToken);
|
||||
foreach (var card in cards)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
INSERT INTO admit_cards (
|
||||
registration_id, plan_id, card_number, center_id, test_center, center_code,
|
||||
center_address, generated_at
|
||||
) VALUES (
|
||||
@registrationId, @planId, @cardNumber, @centerId, @testCenter, @centerCode,
|
||||
@centerAddress, @generatedAt
|
||||
)
|
||||
""",
|
||||
[
|
||||
new("@registrationId", card.RegistrationId),
|
||||
new("@planId", plan.Id),
|
||||
new("@cardNumber", card.Number),
|
||||
new("@centerId", card.CenterId),
|
||||
new("@testCenter", card.TestCenter),
|
||||
new("@centerCode", card.CenterCode),
|
||||
new("@centerAddress", card.CenterAddress),
|
||||
new("@generatedAt", card.GeneratedAt)
|
||||
],
|
||||
cancellationToken);
|
||||
foreach (var assignment in card.Assignments)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
INSERT INTO admit_card_subjects (
|
||||
registration_id, subject_id, room_id, room, room_code, exam_room_code,
|
||||
building, floor, seat, subject_signature
|
||||
) VALUES (
|
||||
@registrationId, @subjectId, @roomId, @room, @roomCode, @examRoomCode,
|
||||
@building, @floor, @seat, @subjectSignature
|
||||
)
|
||||
""",
|
||||
[
|
||||
new("@registrationId", card.RegistrationId),
|
||||
new("@subjectId", assignment.SubjectId),
|
||||
new("@roomId", assignment.RoomId),
|
||||
new("@room", assignment.RoomName),
|
||||
new("@roomCode", assignment.RoomCode),
|
||||
new("@examRoomCode", assignment.ExamRoomCode),
|
||||
new("@building", assignment.Building),
|
||||
new("@floor", assignment.Floor),
|
||||
new("@seat", assignment.Seat),
|
||||
new("@subjectSignature", assignment.SubjectSignature)
|
||||
],
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
await ExecuteAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
|
||||
VALUES (@id, @actorId, @action, @detail, @createdAt)
|
||||
""",
|
||||
[
|
||||
new("@id", audit.Id),
|
||||
new("@actorId", audit.ActorId),
|
||||
new("@action", audit.Action),
|
||||
new("@detail", audit.Detail),
|
||||
new("@createdAt", audit.CreatedAt)
|
||||
],
|
||||
cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
string sql,
|
||||
IReadOnlyList<SqlParameterValue> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
foreach (var item in parameters)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = item.Name;
|
||||
parameter.Value = item.Value ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record SqlParameterValue(string Name, object? Value);
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Application.Administration;
|
||||
using Eis.Infrastructure.Authentication;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal sealed class AdminArrangementService(
|
||||
IAuthenticationStateStore authenticationState,
|
||||
AuthenticationRepository authenticationRepository,
|
||||
AdminOperationalSnapshotLoader operationalSnapshotLoader,
|
||||
AdminCenterSnapshotLoader centerSnapshotLoader,
|
||||
AdminArrangementSnapshotLoader arrangementSnapshotLoader,
|
||||
AdminAccountBatchSnapshotLoader directorySnapshotLoader,
|
||||
AdminArrangementRepository repository) : IAdminArrangementService
|
||||
{
|
||||
public async Task<AdminEndpointResult> GetAsync(
|
||||
string sessionToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
|
||||
if (context.Error is not null) return context.Error;
|
||||
var user = context.User!;
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var arrangements = await arrangementSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
|
||||
var scopedRegistrations = operational.Registrations
|
||||
.Where(item => item.Status == "approved")
|
||||
.Where(item =>
|
||||
{
|
||||
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
|
||||
return profile is not null && InScope(user, profile);
|
||||
})
|
||||
.ToArray();
|
||||
var registrations = scopedRegistrations.Select(registration =>
|
||||
{
|
||||
var output = RegistrationJson(directory, operational, registration);
|
||||
var profile = operational.Profiles.First(item => item.UserId == registration.UserId);
|
||||
var candidate = profile.Data.DeepClone().AsObject();
|
||||
candidate["idNumber"] = MaskId(Text(candidate, "idNumber"));
|
||||
output["candidate"] = candidate;
|
||||
return output;
|
||||
}).ToArray();
|
||||
var plans = arrangements.Plans.Select(plan => PlanJson(arrangements, operational, plan)).ToArray();
|
||||
var visibleExams = Level(user) == "super"
|
||||
? operational.Exams
|
||||
: operational.Exams.Where(exam => scopedRegistrations.Any(item => item.ExamId == exam.Id)).ToArray();
|
||||
var exams = visibleExams.Select(exam =>
|
||||
{
|
||||
var output = PublicExamJson(exam);
|
||||
output["approvedCount"] = scopedRegistrations.Count(item => item.ExamId == exam.Id);
|
||||
output["arrangedCount"] = scopedRegistrations.Count(item =>
|
||||
item.ExamId == exam.Id && item.Data["admitCard"] is not null);
|
||||
var plan = arrangements.Plans.FirstOrDefault(item => item.ExamId == exam.Id);
|
||||
output["plan"] = plan is null ? null : PlanJson(arrangements, operational, plan);
|
||||
return output;
|
||||
}).ToArray();
|
||||
var centerScope = Level(user) switch
|
||||
{
|
||||
"super" => centers.Centers,
|
||||
"school" => centers.Centers.Where(item => item.SchoolId == user.SchoolId).ToArray(),
|
||||
_ => []
|
||||
};
|
||||
var centerOutput = centerScope
|
||||
.Where(item => item.Status == "active")
|
||||
.Select(center => CenterJson(centers, center))
|
||||
.ToArray();
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["canArrange"] = Level(user) == "super",
|
||||
["canExportCenterMaterials"] = Level(user) == "school",
|
||||
["scopeLabel"] = ScopeLabel(user, directory),
|
||||
["exams"] = new JsonArray(exams),
|
||||
["registrations"] = new JsonArray(registrations),
|
||||
["plans"] = Level(user) == "super" ? new JsonArray(plans) : [],
|
||||
["rules"] = Level(user) == "super"
|
||||
? new JsonArray(arrangements.Rules.Where(item => item.Active).Select(RuleJson).ToArray())
|
||||
: [],
|
||||
["mixingScopes"] = Level(user) == "super" ? MixingScopesJson() : [],
|
||||
["centers"] = new JsonArray(centerOutput)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AdminEndpointResult> PreviewAsync(
|
||||
string sessionToken,
|
||||
string examId,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken) =>
|
||||
ArrangeAsync(sessionToken, examId, body, preview: true, cancellationToken);
|
||||
|
||||
public Task<AdminEndpointResult> ApplyAsync(
|
||||
string sessionToken,
|
||||
string examId,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken) =>
|
||||
ArrangeAsync(sessionToken, examId, body, preview: false, cancellationToken);
|
||||
|
||||
public async Task<AdminEndpointResult> RetiredSingleAsync(
|
||||
string sessionToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
|
||||
return context.Error ?? Error(
|
||||
410,
|
||||
"单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成");
|
||||
}
|
||||
|
||||
public async Task<AdminDocumentResult> ExportAsync(
|
||||
string sessionToken,
|
||||
string type,
|
||||
string examId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
|
||||
if (context.Error is not null) return DocumentError(context.Error);
|
||||
var user = context.User!;
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var arrangements = await arrangementSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
|
||||
var exam = operational.Exams.FirstOrDefault(item => item.Id == examId);
|
||||
if (exam is null) return DocumentError(Error(404, "请选择有效考试"));
|
||||
var scopeLabel = ScopeLabel(user, directory);
|
||||
if (type == "center-materials")
|
||||
{
|
||||
if (Level(user) != "school")
|
||||
return DocumentError(Error(403, "考点桌贴、门贴和签名单只能由维护该考点的校级管理员导出"));
|
||||
var centerIds = centers.Centers
|
||||
.Where(item => item.SchoolId == user.SchoolId)
|
||||
.Select(item => item.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var registrations = operational.Registrations
|
||||
.Where(item => item.ExamId == exam.Id && item.Data["admitCard"] is JsonObject card &&
|
||||
centerIds.Contains(Text(card, "centerId")))
|
||||
.ToArray();
|
||||
var rows = BuildExportRows(operational, centers, directory, registrations);
|
||||
if (rows.Count == 0)
|
||||
return DocumentError(Error(404, "本校维护考点暂无该考试的已编排考生"));
|
||||
var content = AdminArrangementExporter.CenterMaterials(rows, $"{Text(exam.Data, "name")}|{scopeLabel}");
|
||||
return Document(
|
||||
content,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
$"{Text(exam.Data, "name")}-{scopeLabel}-考点桌贴门贴签名单.xlsx");
|
||||
}
|
||||
|
||||
var scoped = operational.Registrations.Where(item =>
|
||||
{
|
||||
if (item.ExamId != exam.Id || item.Data["admitCard"] is null) return false;
|
||||
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
|
||||
return profile is not null && InScope(user, profile);
|
||||
}).ToArray();
|
||||
if (scoped.Length == 0) return DocumentError(Error(404, "当前范围暂无已生成的准考证"));
|
||||
if (type == "admit-cards")
|
||||
{
|
||||
var html = RenderAdmitCards(
|
||||
operational,
|
||||
centers,
|
||||
arrangements.OrganizationName,
|
||||
scoped,
|
||||
$"{Text(exam.Data, "name")}-{scopeLabel}-准考证");
|
||||
return Document(
|
||||
Encoding.UTF8.GetBytes(html),
|
||||
"text/html; charset=utf-8",
|
||||
$"{Text(exam.Data, "name")}-{scopeLabel}-准考证批量打印.html");
|
||||
}
|
||||
if (type != "info") return DocumentError(Error(404, "不支持的准考证导出类型"));
|
||||
var exportRows = BuildExportRows(operational, centers, directory, scoped);
|
||||
var workbook = AdminArrangementExporter.AdmitCardInformation(
|
||||
exportRows,
|
||||
$"{Text(exam.Data, "name")}|{scopeLabel}");
|
||||
return Document(
|
||||
workbook,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
$"{Text(exam.Data, "name")}-{scopeLabel}-准考证信息.xlsx");
|
||||
}
|
||||
|
||||
private async Task<AdminEndpointResult> ArrangeAsync(
|
||||
string sessionToken,
|
||||
string examId,
|
||||
JsonObject body,
|
||||
bool preview,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||
if (context.Error is not null) return context.Error;
|
||||
var user = context.User!;
|
||||
var generatedAt = NowIso();
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var exam = operational.Exams.FirstOrDefault(item => item.Id == examId);
|
||||
if (exam?.Data["archivedAt"] is not null)
|
||||
return Error(409, "该考试已归档,不能重新编排准考证");
|
||||
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var arrangements = await arrangementSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
|
||||
ArrangementBuildResult result;
|
||||
try
|
||||
{
|
||||
result = AdminArrangementPlanner.Build(
|
||||
operational,
|
||||
centers,
|
||||
arrangements,
|
||||
directory,
|
||||
examId,
|
||||
Clean(Text(body, "mixingScope"), 20),
|
||||
Clean(Text(body, "numberRuleId"), 64),
|
||||
Clean(Text(body, "seed"), 80),
|
||||
generatedAt);
|
||||
}
|
||||
catch (AdminArrangementException exception)
|
||||
{
|
||||
return Error(exception.StatusCode, exception.Message);
|
||||
}
|
||||
|
||||
if (preview)
|
||||
{
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["preview"] = true,
|
||||
["summary"] = SummaryJson(result.Summary),
|
||||
["warnings"] = StringArray(result.Warnings),
|
||||
["samples"] = new JsonArray(result.Cards.Take(5).Select(CardJson).ToArray())
|
||||
});
|
||||
}
|
||||
|
||||
var plan = new StoredArrangementPlan(
|
||||
Uid("arrangement"),
|
||||
result.Exam.Id,
|
||||
result.Rule.Id,
|
||||
result.MixingScope,
|
||||
result.Seed,
|
||||
result.Summary.CandidateCount,
|
||||
result.Summary.CenterCount,
|
||||
result.Summary.SubjectAssignmentCount,
|
||||
result.Summary.SubjectCombinationCount,
|
||||
result.Summary.SameSchoolCenterRate,
|
||||
result.Warnings,
|
||||
user.Id,
|
||||
generatedAt);
|
||||
var replacing = arrangements.Plans.Any(item => item.ExamId == result.Exam.Id);
|
||||
await repository.SaveAsync(
|
||||
plan,
|
||||
result.Cards,
|
||||
Audit(
|
||||
user,
|
||||
replacing ? "重新编排准考证" : "批量编排准考证",
|
||||
$"{Text(result.Exam.Data, "name")} · {plan.CandidateCount} 人 · {plan.CenterCount} 个考点 · {result.Rule.Name}"),
|
||||
cancellationToken);
|
||||
var planOutput = PlanJson(plan);
|
||||
AddSummaryFields(planOutput, result.Summary);
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["plan"] = planOutput,
|
||||
["summary"] = SummaryJson(result.Summary),
|
||||
["warnings"] = StringArray(result.Warnings),
|
||||
["cards"] = new JsonArray(result.Cards.Select(CardJson).ToArray())
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ResolvedAdmin> ResolveAsync(
|
||||
string token,
|
||||
bool superOnly,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||
var userId = await authenticationState.GetSessionUserIdAsync(token);
|
||||
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
|
||||
if (user is not { Active: true, ArchivedAt: null })
|
||||
return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||
if (user.Role != "admin") return ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
|
||||
if (superOnly && Level(user) != "super")
|
||||
return ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作"));
|
||||
return new(user, null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ArrangementExportRow> BuildExportRows(
|
||||
AdminOperationalSnapshot operational,
|
||||
AdminCenterSnapshot centers,
|
||||
AdminAccountBatchSnapshot directory,
|
||||
IReadOnlyList<OperationalRegistration> registrations)
|
||||
{
|
||||
var rows = new List<ArrangementExportRow>();
|
||||
foreach (var registration in registrations)
|
||||
{
|
||||
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
|
||||
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||
var card = registration.Data["admitCard"] as JsonObject;
|
||||
if (profile is null || exam is null || card is null) continue;
|
||||
var school = directory.Schools.FirstOrDefault(item => item.Id == profile.SchoolId);
|
||||
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
|
||||
var center = centers.Centers.FirstOrDefault(item => item.Id == Text(card, "centerId"));
|
||||
foreach (var assignment in (card["assignments"] as JsonArray ?? []).OfType<JsonObject>())
|
||||
{
|
||||
var subject = exam.Subjects.FirstOrDefault(item => Text(item, "id") == Text(assignment, "subjectId"));
|
||||
var room = centers.Rooms.FirstOrDefault(item => item.Id == Text(assignment, "roomId"));
|
||||
rows.Add(new ArrangementExportRow(
|
||||
school?.Name ?? Text(profile.Data, "school"),
|
||||
schoolClass?.Name ?? Text(profile.Data, "grade"),
|
||||
First(account?.CandidateNumber ?? "", Text(registration.Data, "registrationNumber")),
|
||||
First(Text(profile.Data, "name"), account?.DisplayName ?? ""),
|
||||
Text(profile.Data, "idNumber"),
|
||||
Text(exam.Data, "code"),
|
||||
Text(exam.Data, "name"),
|
||||
Text(card, "number"),
|
||||
First(Text(card, "centerCode"), center?.Code ?? ""),
|
||||
First(Text(card, "testCenter"), center?.Name ?? ""),
|
||||
First(
|
||||
Text(card, "centerAddress"),
|
||||
center is null
|
||||
? ""
|
||||
: string.Join(' ', new[]
|
||||
{
|
||||
center.ProvinceName,
|
||||
center.CityName,
|
||||
center.DistrictName,
|
||||
center.Address
|
||||
}.Where(value => value.Length > 0))),
|
||||
subject is null ? "" : Text(subject, "name"),
|
||||
subject is null ? "" : Text(subject, "date"),
|
||||
subject is null
|
||||
? ""
|
||||
: string.Join('—', new[] { Text(subject, "start"), Text(subject, "end") }.Where(value => value.Length > 0)),
|
||||
Text(assignment, "examRoomCode"),
|
||||
First(Text(assignment, "roomName"), Text(assignment, "room"), room?.Name ?? ""),
|
||||
First(Text(assignment, "roomCode"), room?.Code ?? ""),
|
||||
First(Text(assignment, "building"), room?.Building ?? ""),
|
||||
First(Text(assignment, "floor"), room?.Floor ?? ""),
|
||||
Text(assignment, "seat")));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string RenderAdmitCards(
|
||||
AdminOperationalSnapshot operational,
|
||||
AdminCenterSnapshot centers,
|
||||
string organizationName,
|
||||
IReadOnlyList<OperationalRegistration> registrations,
|
||||
string title)
|
||||
{
|
||||
var sections = new StringBuilder();
|
||||
foreach (var registration in registrations)
|
||||
{
|
||||
var user = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
|
||||
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||
var card = registration.Data["admitCard"] as JsonObject;
|
||||
if (profile is null || exam is null || card is null) continue;
|
||||
var assignments = (card["assignments"] as JsonArray ?? [])
|
||||
.OfType<JsonObject>()
|
||||
.ToDictionary(item => Text(item, "subjectId"), StringComparer.Ordinal);
|
||||
var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? [])
|
||||
.Select(item => item?.GetValue<string>() ?? "")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var rows = new StringBuilder();
|
||||
foreach (var subject in exam.Subjects.Where(item => subjectIds.Contains(Text(item, "id"))))
|
||||
{
|
||||
assignments.TryGetValue(Text(subject, "id"), out var assignment);
|
||||
var room = assignment is null
|
||||
? null
|
||||
: centers.Rooms.FirstOrDefault(item => item.Id == Text(assignment, "roomId"));
|
||||
rows.Append("<tr><td>").Append(E(Text(subject, "name")))
|
||||
.Append("</td><td>").Append(E(Text(subject, "date")))
|
||||
.Append("</td><td>").Append(E(Text(subject, "start"))).Append('—').Append(E(Text(subject, "end")))
|
||||
.Append("</td><td class=\"room-no\">").Append(E(First(Text(assignment, "examRoomCode"), "待定")))
|
||||
.Append("</td><td><strong>").Append(E(First(Text(assignment, "roomName"), Text(assignment, "room"), room?.Name ?? "", "待定")))
|
||||
.Append("</strong><small>场地代码:").Append(E(First(Text(assignment, "roomCode"), room?.Code ?? "", "—")))
|
||||
.Append("</small></td><td>").Append(E(First(Text(assignment, "building"), room?.Building ?? "", "—")))
|
||||
.Append("<small>").Append(E(First(Text(assignment, "floor"), room?.Floor ?? "", "楼层待定")))
|
||||
.Append("</small></td><td>").Append(E(First(Text(assignment, "seat"), "—"))).Append("</td></tr>");
|
||||
}
|
||||
var center = centers.Centers.FirstOrDefault(item => item.Id == Text(card, "centerId"));
|
||||
var centerCode = First(Text(card, "centerCode"), center?.Code ?? "");
|
||||
var centerAddress = First(
|
||||
Text(card, "centerAddress"),
|
||||
center is null
|
||||
? ""
|
||||
: string.Join(' ', new[]
|
||||
{
|
||||
center.ProvinceName,
|
||||
center.CityName,
|
||||
center.DistrictName,
|
||||
center.Address
|
||||
}.Where(value => value.Length > 0)));
|
||||
var generatedAt = DateTimeOffset.TryParse(
|
||||
Text(card, "generatedAt"),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal,
|
||||
out var parsed)
|
||||
? parsed.ToLocalTime().ToString("yyyy/M/d HH:mm:ss", CultureInfo.GetCultureInfo("zh-CN"))
|
||||
: Text(card, "generatedAt");
|
||||
sections.Append("<main class=\"card\"><header class=\"head\"><div><div class=\"brand\">衡准 · 准考证</div><p>")
|
||||
.Append(E(Text(exam.Data, "name")))
|
||||
.Append("</p></div><div><small>准考证号</small><div class=\"code\">").Append(E(Text(card, "number")))
|
||||
.Append("</div></div></header><section class=\"meta\"><div><span>姓名</span><strong>")
|
||||
.Append(E(First(Text(profile.Data, "name"), user?.DisplayName ?? "")))
|
||||
.Append("</strong></div><div><span>证件号码</span><strong>").Append(E(MaskId(Text(profile.Data, "idNumber"))))
|
||||
.Append("</strong></div><div><span>报名号</span><strong>")
|
||||
.Append(E(First(user?.CandidateNumber ?? "", Text(registration.Data, "registrationNumber"), "—")))
|
||||
.Append("</strong></div></section><section class=\"center\"><div><span>固定考点</span><strong>")
|
||||
.Append(E(Text(card, "testCenter"))).Append("</strong><small>考点代码:").Append(E(First(centerCode, "—")))
|
||||
.Append("</small></div><div><span>考点详细地址</span><strong>").Append(E(First(centerAddress, "地址待公布")))
|
||||
.Append("</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位号</th></tr></thead><tbody>")
|
||||
.Append(rows)
|
||||
.Append("</tbody></table><div class=\"explain\"><strong>编号说明</strong>“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。</div><div class=\"notice\">所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class=\"footer\"><span>")
|
||||
.Append(E(organizationName)).Append("</span><span>生成时间:").Append(E(generatedAt))
|
||||
.Append("</span></footer></main>");
|
||||
}
|
||||
const string styles = "@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{font-family:\"Microsoft YaHei\",sans-serif;color:#182552;margin:0;background:#f2f4f8}.card{max-width:1120px;min-height:720px;margin:24px auto;background:#fff;border:2px solid #182552;padding:26px;box-shadow:0 12px 35px #18255218;break-after:page}.card:last-child{break-after:auto}.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px double #182552;padding-bottom:14px}.head>div:last-child{text-align:right}.head small{color:#64748b}.brand{font-size:27px;font-weight:800}.head p{margin:6px 0}.code{font:700 22px Consolas,monospace;margin-top:5px}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.meta div,.center>div{border:1px solid #d9e0e7;padding:9px 12px}.meta span,.center span{display:block;color:#64748b;font-size:11px;margin-bottom:4px}.center{display:grid;grid-template-columns:1fr 1.5fr;gap:10px;margin-bottom:16px}.center small,td small{display:block;color:#64748b;margin-top:4px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #b9c5d2;padding:9px;text-align:left;vertical-align:middle}th{background:#eaf1f5}.room-no{font:700 15px Consolas,monospace;text-align:center}.explain,.notice{margin-top:12px;padding:10px 12px;font-size:12px}.explain{background:#eaf4f6;color:#245365}.explain strong{margin-right:8px}.notice{background:#fff7e6;color:#704b12}.footer{display:flex;justify-content:space-between;margin-top:14px;color:#64748b;font-size:11px}@media print{body{background:#fff}.card{margin:0;box-shadow:none}}";
|
||||
return $"<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><title>{E(title)}</title><style>{styles}</style></head><body>{sections}</body></html>";
|
||||
}
|
||||
|
||||
private static JsonObject RegistrationJson(
|
||||
AdminAccountBatchSnapshot directory,
|
||||
AdminOperationalSnapshot operational,
|
||||
OperationalRegistration registration)
|
||||
{
|
||||
var output = registration.Data.DeepClone().AsObject();
|
||||
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||
var subjectIds = (output["subjectIds"] as JsonArray ?? [])
|
||||
.Select(item => item?.GetValue<string>() ?? "")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
output["exam"] = exam?.Data.DeepClone();
|
||||
output["subjects"] = new JsonArray((exam?.Subjects ?? [])
|
||||
.Where(item => subjectIds.Contains(Text(item, "id")))
|
||||
.Select(item => item.DeepClone())
|
||||
.ToArray());
|
||||
output["amountDue"] = Math.Round(
|
||||
(output["subjects"] as JsonArray ?? []).OfType<JsonObject>().Sum(item => Number(item, "fee")),
|
||||
2,
|
||||
MidpointRounding.AwayFromZero);
|
||||
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
|
||||
var instance = directory.Instances.FirstOrDefault(item =>
|
||||
item.BusinessType == "registration_review" &&
|
||||
item.BusinessId == registration.Id &&
|
||||
item.Status == "pending") ??
|
||||
directory.Instances.FirstOrDefault(item =>
|
||||
item.BusinessType == "registration_review" && item.BusinessId == registration.Id);
|
||||
output["workflow"] = instance is null ? null : WorkflowJson(directory, instance);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static JsonObject WorkflowJson(
|
||||
AdminAccountBatchSnapshot snapshot,
|
||||
AccountWorkflowInstance instance)
|
||||
{
|
||||
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = instance.Id,
|
||||
["workflowId"] = instance.WorkflowId,
|
||||
["businessType"] = instance.BusinessType,
|
||||
["businessId"] = instance.BusinessId,
|
||||
["status"] = instance.Status,
|
||||
["currentStep"] = instance.CurrentStep,
|
||||
["assigneeId"] = JsonValue.Create(instance.AssigneeId),
|
||||
["createdAt"] = instance.CreatedAt,
|
||||
["completedAt"] = JsonValue.Create(instance.CompletedAt),
|
||||
["workflowName"] = workflow?.Name ?? "未命名流程",
|
||||
["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()),
|
||||
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } step
|
||||
? StepJson(step)
|
||||
: null,
|
||||
["assignee"] = assignee is null ? null : SafeUser(assignee),
|
||||
["actions"] = new JsonArray(snapshot.Actions
|
||||
.Where(item => item.InstanceId == instance.Id)
|
||||
.Select(item => ActionJson(snapshot, item))
|
||||
.ToArray())
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject PublicExamJson(OperationalExam exam)
|
||||
{
|
||||
var output = exam.Data.DeepClone().AsObject();
|
||||
output["totalScore"] = exam.Subjects.Sum(item => Number(item, "fullScore"));
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var state = output["archivedAt"] is not null
|
||||
? "archived"
|
||||
: TryDate(Text(output, "registrationStart"), out var start) && now < start
|
||||
? "upcoming"
|
||||
: TryDate(Text(output, "registrationEnd"), out var end) && now > end
|
||||
? "closed"
|
||||
: "open";
|
||||
output["registrationState"] = state;
|
||||
return output;
|
||||
}
|
||||
|
||||
private static JsonObject PlanJson(
|
||||
AdminArrangementSnapshot arrangements,
|
||||
AdminOperationalSnapshot operational,
|
||||
StoredArrangementPlan plan)
|
||||
{
|
||||
var output = PlanJson(plan);
|
||||
output["examName"] = operational.Exams.FirstOrDefault(item => item.Id == plan.ExamId) is { } exam
|
||||
? Text(exam.Data, "name")
|
||||
: "";
|
||||
output["ruleName"] = arrangements.Rules.FirstOrDefault(item => item.Id == plan.NumberRuleId)?.Name ?? "";
|
||||
output["mixingScopeName"] = MixingScopeName(plan.MixingScope);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static JsonObject PlanJson(StoredArrangementPlan plan) => new()
|
||||
{
|
||||
["id"] = plan.Id,
|
||||
["examId"] = plan.ExamId,
|
||||
["numberRuleId"] = plan.NumberRuleId,
|
||||
["mixingScope"] = plan.MixingScope,
|
||||
["randomSeed"] = plan.RandomSeed,
|
||||
["candidateCount"] = plan.CandidateCount,
|
||||
["centerCount"] = plan.CenterCount,
|
||||
["subjectAssignmentCount"] = plan.SubjectAssignmentCount,
|
||||
["subjectCombinationCount"] = plan.SubjectCombinationCount,
|
||||
["sameSchoolCenterRate"] = plan.SameSchoolCenterRate,
|
||||
["warnings"] = StringArray(plan.Warnings),
|
||||
["generatedBy"] = JsonValue.Create(plan.GeneratedBy),
|
||||
["generatedAt"] = plan.GeneratedAt
|
||||
};
|
||||
|
||||
private static JsonObject CenterJson(AdminCenterSnapshot snapshot, AdminCenter center) => new()
|
||||
{
|
||||
["id"] = center.Id,
|
||||
["schoolId"] = center.SchoolId,
|
||||
["code"] = center.Code,
|
||||
["name"] = center.Name,
|
||||
["provinceCode"] = center.ProvinceCode,
|
||||
["provinceName"] = center.ProvinceName,
|
||||
["cityCode"] = center.CityCode,
|
||||
["cityName"] = center.CityName,
|
||||
["districtCode"] = center.DistrictCode,
|
||||
["districtName"] = center.DistrictName,
|
||||
["address"] = center.Address,
|
||||
["contact"] = center.Contact,
|
||||
["managerName"] = center.ManagerName,
|
||||
["managerPhone"] = center.ManagerPhone,
|
||||
["emergencyPhone"] = center.EmergencyPhone,
|
||||
["gateOpenTime"] = center.GateOpenTime,
|
||||
["transport"] = center.Transport,
|
||||
["status"] = center.Status,
|
||||
["notes"] = center.Notes,
|
||||
["rooms"] = center.Rooms,
|
||||
["updatedAt"] = center.UpdatedAt,
|
||||
["roomCount"] = snapshot.Rooms.Count(item => item.CenterId == center.Id && item.Status == "active"),
|
||||
["capacity"] = snapshot.Rooms
|
||||
.Where(item => item.CenterId == center.Id && item.Status == "active" && item.RoomType != "spare")
|
||||
.Sum(item => item.Capacity)
|
||||
};
|
||||
|
||||
private static JsonObject RuleJson(AdmissionNumberRule rule) => new()
|
||||
{
|
||||
["id"] = rule.Id,
|
||||
["code"] = rule.Code,
|
||||
["name"] = rule.Name,
|
||||
["description"] = rule.Description,
|
||||
["separator"] = rule.Separator,
|
||||
["segments"] = new JsonArray(rule.Segments.Select(segment =>
|
||||
{
|
||||
var output = new JsonObject
|
||||
{
|
||||
["source"] = segment.Source,
|
||||
["label"] = segment.Label
|
||||
};
|
||||
if (segment.Width is not null) output["width"] = segment.Width;
|
||||
return output;
|
||||
}).ToArray()),
|
||||
["example"] = rule.Example,
|
||||
["active"] = rule.Active,
|
||||
["createdAt"] = rule.CreatedAt
|
||||
};
|
||||
|
||||
private static JsonArray MixingScopesJson() => new(
|
||||
new JsonObject
|
||||
{
|
||||
["code"] = "class",
|
||||
["name"] = "班内混编",
|
||||
["description"] = "以班级为边界,同班考生按科目组合穿插编排。"
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["code"] = "school",
|
||||
["name"] = "校内混编",
|
||||
["description"] = "同校跨班混编,优先安排在本校考点。"
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["code"] = "district",
|
||||
["name"] = "县区内混编",
|
||||
["description"] = "同县区跨学校混编,优先使用本县区考点。"
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["code"] = "city",
|
||||
["name"] = "市内混编",
|
||||
["description"] = "同市跨县区混编,优先使用本市考点。"
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["code"] = "province",
|
||||
["name"] = "省内混编",
|
||||
["description"] = "全省范围混编,按容量和科目组合选择考点。"
|
||||
});
|
||||
|
||||
private static JsonObject SummaryJson(ArrangementSummary summary) => new()
|
||||
{
|
||||
["candidateCount"] = summary.CandidateCount,
|
||||
["centerCount"] = summary.CenterCount,
|
||||
["subjectAssignmentCount"] = summary.SubjectAssignmentCount,
|
||||
["subjectCombinationCount"] = summary.SubjectCombinationCount,
|
||||
["sameSchoolCenterCount"] = summary.SameSchoolCenterCount,
|
||||
["sameSchoolCenterRate"] = summary.SameSchoolCenterRate,
|
||||
["reservedSpareRooms"] = summary.ReservedSpareRooms
|
||||
};
|
||||
|
||||
private static void AddSummaryFields(JsonObject output, ArrangementSummary summary)
|
||||
{
|
||||
output["sameSchoolCenterCount"] = summary.SameSchoolCenterCount;
|
||||
output["reservedSpareRooms"] = summary.ReservedSpareRooms;
|
||||
}
|
||||
|
||||
private static JsonObject CardJson(ArrangementCard card) => new()
|
||||
{
|
||||
["registrationId"] = card.RegistrationId,
|
||||
["number"] = card.Number,
|
||||
["centerId"] = card.CenterId,
|
||||
["testCenter"] = card.TestCenter,
|
||||
["centerCode"] = card.CenterCode,
|
||||
["centerAddress"] = card.CenterAddress,
|
||||
["generatedAt"] = card.GeneratedAt,
|
||||
["assignments"] = new JsonArray(card.Assignments.Select(AssignmentJson).ToArray())
|
||||
};
|
||||
|
||||
private static JsonObject AssignmentJson(ArrangementAssignment item) => new()
|
||||
{
|
||||
["registrationId"] = item.RegistrationId,
|
||||
["subjectId"] = item.SubjectId,
|
||||
["centerId"] = item.CenterId,
|
||||
["centerName"] = item.CenterName,
|
||||
["roomId"] = item.RoomId,
|
||||
["roomName"] = item.RoomName,
|
||||
["roomCode"] = item.RoomCode,
|
||||
["building"] = item.Building,
|
||||
["floor"] = item.Floor,
|
||||
["examRoomCode"] = item.ExamRoomCode,
|
||||
["seat"] = item.Seat,
|
||||
["subjectSignature"] = item.SubjectSignature
|
||||
};
|
||||
|
||||
private static JsonArray StringArray(IEnumerable<string> values) =>
|
||||
new(values.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray());
|
||||
|
||||
private static string ScopeLabel(AuthenticationUser user, AdminAccountBatchSnapshot directory)
|
||||
{
|
||||
if (Level(user) == "super") return "全部学校与班级";
|
||||
var school = directory.Schools.FirstOrDefault(item => item.Id == user.SchoolId)?.Name ?? "未绑定学校";
|
||||
if (Level(user) == "school") return school;
|
||||
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == user.ClassId)?.Name ?? "未绑定班级";
|
||||
return $"{school} · {schoolClass}";
|
||||
}
|
||||
|
||||
private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
|
||||
{
|
||||
"super" => true,
|
||||
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
|
||||
_ => user.ClassId is not null && profile.ClassId == user.ClassId
|
||||
};
|
||||
|
||||
private static JsonObject StepJson(AccountWorkflowStep item) => new()
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["name"] = item.Name,
|
||||
["adminLevel"] = item.AdminLevel,
|
||||
["position"] = item.Position
|
||||
};
|
||||
|
||||
private static JsonObject SafeUser(AdminUser item) => new()
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["username"] = item.Username,
|
||||
["role"] = item.Role,
|
||||
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
|
||||
["schoolId"] = JsonValue.Create(item.SchoolId),
|
||||
["classId"] = JsonValue.Create(item.ClassId),
|
||||
["displayName"] = item.DisplayName,
|
||||
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
|
||||
["mustChangePassword"] = item.MustChangePassword,
|
||||
["totpEnabled"] = item.TotpEnabled,
|
||||
["archived"] = item.ArchivedAt is not null
|
||||
};
|
||||
|
||||
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new()
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["instanceId"] = item.InstanceId,
|
||||
["actorId"] = JsonValue.Create(item.ActorId),
|
||||
["action"] = item.Action,
|
||||
["note"] = item.Note,
|
||||
["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId),
|
||||
["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId),
|
||||
["createdAt"] = item.CreatedAt,
|
||||
["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统",
|
||||
["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "",
|
||||
["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? ""
|
||||
};
|
||||
|
||||
private static string MixingScopeName(string value) => value switch
|
||||
{
|
||||
"class" => "班内混编",
|
||||
"school" => "校内混编",
|
||||
"district" => "县区内混编",
|
||||
"city" => "市内混编",
|
||||
"province" => "省内混编",
|
||||
_ => value
|
||||
};
|
||||
|
||||
private static bool TryDate(string value, out DateTimeOffset result) =>
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result);
|
||||
|
||||
private static double Number(JsonObject value, string property) =>
|
||||
double.TryParse(Text(value, property), NumberStyles.Float, CultureInfo.InvariantCulture, out var number)
|
||||
? number
|
||||
: 0;
|
||||
|
||||
private static string Text(JsonObject? value, string property) =>
|
||||
value?[property]?.ToString() ?? "";
|
||||
|
||||
private static string First(params string[] values) =>
|
||||
values.FirstOrDefault(value => value.Length > 0) ?? "";
|
||||
|
||||
private static string Clean(string value, int maximum)
|
||||
{
|
||||
var output = value.Trim();
|
||||
return output[..Math.Min(output.Length, maximum)];
|
||||
}
|
||||
|
||||
private static string MaskId(string value) =>
|
||||
value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : value;
|
||||
|
||||
private static string E(string? value) => WebUtility.HtmlEncode(value ?? "");
|
||||
|
||||
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
|
||||
|
||||
private static string NowIso() =>
|
||||
DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Uid(string prefix) =>
|
||||
$"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
|
||||
|
||||
private static string ToBase36(long value)
|
||||
{
|
||||
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
Span<char> buffer = stackalloc char[16];
|
||||
var position = buffer.Length;
|
||||
do
|
||||
{
|
||||
buffer[--position] = alphabet[(int)(value % 36)];
|
||||
value /= 36;
|
||||
} while (value > 0);
|
||||
return new(buffer[position..]);
|
||||
}
|
||||
|
||||
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) =>
|
||||
new(Uid("log"), user.Id, action, detail, NowIso());
|
||||
|
||||
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
|
||||
|
||||
private static AdminEndpointResult Error(int status, string message) =>
|
||||
new(status, new JsonObject { ["ok"] = false, ["message"] = message });
|
||||
|
||||
private static AdminDocumentResult Document(byte[] content, string contentType, string fileName) =>
|
||||
new(200, content, contentType, fileName, null);
|
||||
|
||||
private static AdminDocumentResult DocumentError(AdminEndpointResult error) =>
|
||||
new(error.StatusCode, null, null, null, error.Body);
|
||||
|
||||
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
|
||||
{
|
||||
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ArrangementExportRow(
|
||||
string SchoolName,
|
||||
string ClassName,
|
||||
string CandidateNumber,
|
||||
string CandidateName,
|
||||
string IdNumber,
|
||||
string ExamCode,
|
||||
string ExamName,
|
||||
string CardNumber,
|
||||
string CenterCode,
|
||||
string CenterName,
|
||||
string CenterAddress,
|
||||
string SubjectName,
|
||||
string SubjectDate,
|
||||
string SubjectTime,
|
||||
string ExamRoomCode,
|
||||
string RoomName,
|
||||
string RoomCode,
|
||||
string Building,
|
||||
string Floor,
|
||||
string Seat);
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal sealed record AdminArrangementSnapshot(
|
||||
IReadOnlyList<AdmissionNumberRule> Rules,
|
||||
IReadOnlyList<StoredArrangementPlan> Plans,
|
||||
string OrganizationName);
|
||||
|
||||
internal sealed record AdmissionNumberRule(
|
||||
string Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string Description,
|
||||
string Separator,
|
||||
IReadOnlyList<AdmissionNumberSegment> Segments,
|
||||
string Example,
|
||||
bool Active,
|
||||
string CreatedAt);
|
||||
|
||||
internal sealed record AdmissionNumberSegment(string Source, string Label, int? Width);
|
||||
|
||||
internal sealed record StoredArrangementPlan(
|
||||
string Id,
|
||||
string ExamId,
|
||||
string NumberRuleId,
|
||||
string MixingScope,
|
||||
string RandomSeed,
|
||||
int CandidateCount,
|
||||
int CenterCount,
|
||||
int SubjectAssignmentCount,
|
||||
int SubjectCombinationCount,
|
||||
double SameSchoolCenterRate,
|
||||
IReadOnlyList<string> Warnings,
|
||||
string? GeneratedBy,
|
||||
string GeneratedAt);
|
||||
|
||||
internal sealed class AdminArrangementSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||
{
|
||||
public async Task<AdminArrangementSnapshot> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var rules = await QueryAsync(
|
||||
connection,
|
||||
"""
|
||||
SELECT id, code, name, description, `separator`, segments_json, example, active, created_at
|
||||
FROM admission_number_rules ORDER BY created_at, id
|
||||
""",
|
||||
reader => new AdmissionNumberRule(
|
||||
Text(reader, "id"),
|
||||
Text(reader, "code"),
|
||||
Text(reader, "name"),
|
||||
Text(reader, "description"),
|
||||
Optional(reader, "separator") ?? "",
|
||||
ParseSegments(Optional(reader, "segments_json")),
|
||||
Optional(reader, "example") ?? "",
|
||||
Boolean(reader, "active"),
|
||||
Text(reader, "created_at")),
|
||||
cancellationToken);
|
||||
var plans = await QueryAsync(
|
||||
connection,
|
||||
"""
|
||||
SELECT id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
|
||||
subject_assignment_count, subject_combination_count, same_school_center_rate, warnings_json,
|
||||
generated_by, generated_at
|
||||
FROM exam_arrangement_plans ORDER BY generated_at DESC, id
|
||||
""",
|
||||
reader => new StoredArrangementPlan(
|
||||
Text(reader, "id"),
|
||||
Text(reader, "exam_id"),
|
||||
Text(reader, "number_rule_id"),
|
||||
Text(reader, "mixing_scope"),
|
||||
Text(reader, "random_seed"),
|
||||
Integer(reader, "candidate_count"),
|
||||
Integer(reader, "center_count"),
|
||||
Integer(reader, "subject_assignment_count"),
|
||||
Integer(reader, "subject_combination_count"),
|
||||
Number(reader, "same_school_center_rate"),
|
||||
ParseStrings(Optional(reader, "warnings_json")),
|
||||
Optional(reader, "generated_by"),
|
||||
Text(reader, "generated_at")),
|
||||
cancellationToken);
|
||||
await using var organizationCommand = connection.CreateCommand();
|
||||
organizationCommand.CommandText = "SELECT name FROM organization WHERE id = 1";
|
||||
var organizationValue = await organizationCommand.ExecuteScalarAsync(cancellationToken);
|
||||
var organizationName = organizationValue is null or DBNull
|
||||
? "考试服务平台"
|
||||
: Convert.ToString(organizationValue, CultureInfo.InvariantCulture) ?? "考试服务平台";
|
||||
return new(rules, plans, organizationName);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdmissionNumberSegment> ParseSegments(string? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonNode.Parse(value ?? "[]") as JsonArray ?? [])
|
||||
.OfType<JsonObject>()
|
||||
.Select(item => new AdmissionNumberSegment(
|
||||
item["source"]?.GetValue<string>() ?? "",
|
||||
item["label"]?.GetValue<string>() ?? "",
|
||||
item.ContainsKey("width") ? item["width"]?.GetValue<int>() ?? 0 : null))
|
||||
.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseStrings(string? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonNode.Parse(value ?? "[]") as JsonArray ?? [])
|
||||
.Select(item => item?.GetValue<string>() ?? "")
|
||||
.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<T>> QueryAsync<T>(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
Func<DbDataReader, T> map,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var output = new List<T>();
|
||||
while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader));
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string Text(DbDataReader reader, string name) =>
|
||||
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
private static string? Optional(DbDataReader reader, string name)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(name);
|
||||
return reader.IsDBNull(ordinal)
|
||||
? null
|
||||
: Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static int Integer(DbDataReader reader, string name) =>
|
||||
Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
|
||||
|
||||
private static double Number(DbDataReader reader, string name) =>
|
||||
Convert.ToDouble(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
|
||||
|
||||
private static bool Boolean(DbDataReader reader, string name) =>
|
||||
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
|
||||
}
|
||||
|
||||
internal sealed record ArrangementSummary(
|
||||
int CandidateCount,
|
||||
int CenterCount,
|
||||
int SubjectAssignmentCount,
|
||||
int SubjectCombinationCount,
|
||||
int SameSchoolCenterCount,
|
||||
double SameSchoolCenterRate,
|
||||
int ReservedSpareRooms);
|
||||
|
||||
internal sealed record ArrangementCard(
|
||||
string RegistrationId,
|
||||
string Number,
|
||||
string CenterId,
|
||||
string TestCenter,
|
||||
string CenterCode,
|
||||
string CenterAddress,
|
||||
string GeneratedAt,
|
||||
IReadOnlyList<ArrangementAssignment> Assignments);
|
||||
|
||||
internal sealed record ArrangementAssignment(
|
||||
string RegistrationId,
|
||||
string SubjectId,
|
||||
string CenterId,
|
||||
string CenterName,
|
||||
string RoomId,
|
||||
string RoomName,
|
||||
string RoomCode,
|
||||
string Building,
|
||||
string Floor,
|
||||
string ExamRoomCode,
|
||||
string Seat,
|
||||
string SubjectSignature);
|
||||
|
||||
internal sealed record ArrangementBuildResult(
|
||||
OperationalExam Exam,
|
||||
AdmissionNumberRule Rule,
|
||||
string MixingScope,
|
||||
string Seed,
|
||||
IReadOnlyList<string> Warnings,
|
||||
ArrangementSummary Summary,
|
||||
IReadOnlyList<ArrangementCard> Cards);
|
||||
|
||||
internal sealed class AdminArrangementException(int statusCode, string message) : Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
}
|
||||
@@ -11,7 +11,8 @@ public sealed record AdminMigrationOptions(
|
||||
bool NativeCandidateManagementEnabled = false,
|
||||
bool NativeRegistrationPaymentWritesEnabled = false,
|
||||
bool NativeWorkflowOperationsEnabled = false,
|
||||
bool NativeExamManagementEnabled = false)
|
||||
bool NativeExamManagementEnabled = false,
|
||||
bool NativeArrangementsEnabled = false)
|
||||
{
|
||||
public static AdminMigrationOptions FromEnvironment(
|
||||
bool configuredNativeReadsEnabled,
|
||||
@@ -26,7 +27,8 @@ public sealed record AdminMigrationOptions(
|
||||
bool configuredNativeCandidateManagementEnabled = false,
|
||||
bool configuredNativeRegistrationPaymentWritesEnabled = false,
|
||||
bool configuredNativeWorkflowOperationsEnabled = false,
|
||||
bool configuredNativeExamManagementEnabled = false)
|
||||
bool configuredNativeExamManagementEnabled = false,
|
||||
bool configuredNativeArrangementsEnabled = false)
|
||||
{
|
||||
var readsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||
@@ -62,7 +64,10 @@ public sealed record AdminMigrationOptions(
|
||||
var examManagementEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"),
|
||||
configuredNativeExamManagementEnabled);
|
||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled) && !readsEnabled)
|
||||
var arrangementsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
|
||||
configuredNativeArrangementsEnabled);
|
||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled) && !readsEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||
@@ -72,7 +77,7 @@ public sealed record AdminMigrationOptions(
|
||||
throw new InvalidOperationException(
|
||||
"启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true");
|
||||
}
|
||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled;
|
||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled;
|
||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -88,7 +93,7 @@ public sealed record AdminMigrationOptions(
|
||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||
}
|
||||
|
||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled);
|
||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled);
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||
|
||||
@@ -64,6 +64,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<AdminWorkflowResultSnapshotLoader>();
|
||||
services.AddScoped<AdminWorkflowRepository>();
|
||||
services.AddScoped<AdminExamRepository>();
|
||||
services.AddScoped<AdminArrangementSnapshotLoader>();
|
||||
services.AddScoped<AdminArrangementRepository>();
|
||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||
@@ -75,6 +77,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
|
||||
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
|
||||
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
|
||||
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
|
||||
services.AddSingleton<NoticeContentFormatter>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="ClosedXML" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="MySqlConnector" />
|
||||
<PackageReference Include="QRCoder" />
|
||||
|
||||
Reference in New Issue
Block a user