支持五级混编、稳定种子、容量预检、固定考点、逐科考场/座位及四种准考证号规则。
正式编排会事务性替换整场计划、准考证、座位和审计记录。 支持三级管理员范围读取、批量 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:
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
Reference in new issue
Block a user