支持五级混编、稳定种子、容量预检、固定考点、逐科考场/座位及四种准考证号规则。

正式编排会事务性替换整场计划、准考证、座位和审计记录。
支持三级管理员范围读取、批量 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:
biss committed 2026-07-23 11:25:09 +08:00
1 parent 25501651e1
commit 8f83adbc1e
18 files changed
+2138 -11

No files matched your search

@@ -0,0 +1,37 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public sealed record AdminDocumentResult(
int StatusCode,
byte[]? Content,
string? ContentType,
string? FileName,
JsonObject? Error);
public interface IAdminArrangementService
{
Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> PreviewAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> ApplyAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> RetiredSingleAsync(
string sessionToken,
CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAsync(
string sessionToken,
string type,
string examId,
CancellationToken cancellationToken);
}
@@ -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);
}
Loaded 3 of 18 files, more files were not shown because too many files have changed in this diff. Show more