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

正式编排会事务性替换整场计划、准考证、座位和审计记录。
支持三级管理员范围读取、批量 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:
2026-07-23 11:25:09 +08:00 Unverified
parent 25501651e1
commit 8f83adbc1e
18 changed files with 2138 additions and 11 deletions
+130
View File
@@ -148,6 +148,28 @@ function Assert-CandidateAdmissionsEquivalent {
}
}
function Assert-ArrangementPreviewEquivalent {
param(
[Parameter(Mandatory)]
[string] $Expected,
[Parameter(Mandatory)]
[string] $Actual
)
$expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected)
$actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual)
foreach ($node in @($expectedNode, $actualNode)) {
foreach ($sample in $node['samples'].AsArray()) {
$sample.AsObject().Remove('generatedAt') | Out-Null
}
}
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) {
$difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$'
throw "Arrangement preview JSON payload differs from the legacy API at $difference"
}
}
function Find-JsonDifference {
param(
[AllowNull()]
@@ -249,6 +271,20 @@ try {
}
$registrationSwitchProcess.Dispose()
$arrangementCapacityProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
'tests/helpers/prepare-admin-arrangement-smoke.mjs', $smokeDatabasePath
) -Environment $nodeEnvironment
if (-not $arrangementCapacityProcess.WaitForExit(10000)) {
$arrangementCapacityProcess.Kill($true)
throw 'Timed out while preparing arrangement capacity in the smoke-test database'
}
$arrangementCapacityOutput = $arrangementCapacityProcess.StandardOutput.ReadToEnd()
$arrangementCapacityError = $arrangementCapacityProcess.StandardError.ReadToEnd()
if ($arrangementCapacityProcess.ExitCode -ne 0) {
throw "Could not prepare arrangement capacity in the smoke-test database`n$arrangementCapacityOutput`n$arrangementCapacityError"
}
$arrangementCapacityProcess.Dispose()
$projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
$buildProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
'build', $projectPath,
@@ -447,6 +483,7 @@ try {
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true'
ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true'
ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true'
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl
@@ -807,6 +844,94 @@ try {
throw 'Native admins route did not preserve the class-admin boundary'
}
$arrangementSessions = @(
@{ Level = 'super'; Legacy = $session; Native = $nativeSession },
@{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession },
@{ Level = 'class'; Legacy = $legacyClassSession; Native = $nativeClassSession }
)
foreach ($arrangementSession in $arrangementSessions) {
$legacyArrangementRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Legacy
$nativeArrangementRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Native
if ($nativeArrangementRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Arrangement read for $($arrangementSession.Level) admin did not use ASP.NET Core"
}
Assert-JsonEquivalent -Expected $legacyArrangementRead.Content -Actual $nativeArrangementRead.Content -Label "Arrangement read for $($arrangementSession.Level) admin"
}
$arrangementState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admission-arrangements" -WebSession $nativeSession
$arrangementExam = @($arrangementState.exams | Where-Object { $_.approvedCount -gt 0 -and -not $_.archivedAt })[0]
$arrangementRule = @($arrangementState.rules | Where-Object { $_.code -eq 'district_room_seat' })[0]
if ($null -eq $arrangementExam -or $null -eq $arrangementRule) {
throw 'Seed data did not provide an exam and number rule for arrangement smoke testing'
}
$arrangementBody = @{
mixingScope = 'province'
numberRuleId = $arrangementRule.id
seed = 'native-arrangement-parity'
} | ConvertTo-Json -Compress
$schoolArrangementForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck
if ($schoolArrangementForbidden.StatusCode -ne 403 -or $schoolArrangementForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native arrangement preview did not preserve the super-admin boundary'
}
$invalidArrangementBody = @{
mixingScope = 'invalid'
numberRuleId = $arrangementRule.id
seed = 'native-arrangement-parity'
} | ConvertTo-Json -Compress
$invalidArrangement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $invalidArrangementBody -WebSession $nativeSession -SkipHttpErrorCheck
if ($invalidArrangement.StatusCode -ne 400) {
throw 'Native arrangement preview accepted an invalid mixing scope'
}
$legacyArrangementPreview = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $session
$nativeArrangementPreview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSession
if ($nativeArrangementPreview.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native arrangement preview did not use ASP.NET Core'
}
Assert-ArrangementPreviewEquivalent -Expected $legacyArrangementPreview.Content -Actual $nativeArrangementPreview.Content
$arrangementApplyResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSession
$appliedArrangement = $arrangementApplyResponse.Content | ConvertFrom-Json
if ($arrangementApplyResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$appliedArrangement.plan.examId -ne $arrangementExam.id -or
$appliedArrangement.summary.candidateCount -le 0 -or
@($appliedArrangement.cards).Count -ne $appliedArrangement.summary.candidateCount) {
throw 'Native arrangement application did not persist the complete exam plan'
}
foreach ($arrangementSession in $arrangementSessions) {
$legacyArrangementAfterApply = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Legacy
$nativeArrangementAfterApply = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Native
Assert-JsonEquivalent -Expected $legacyArrangementAfterApply.Content -Actual $nativeArrangementAfterApply.Content -Label "Arrangement application follow-up for $($arrangementSession.Level) admin"
}
$legacySingleAdmit = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($appliedArrangement.cards[0].registrationId)/admit-card" -Method Post -WebSession $nativeSession -SkipHttpErrorCheck
if ($legacySingleAdmit.StatusCode -ne 410 -or $legacySingleAdmit.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native arrangement routes did not retire single-candidate admit-card generation'
}
$anonymousSingleAdmit = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($appliedArrangement.cards[0].registrationId)/admit-card" -Method Post -SkipHttpErrorCheck
if ($anonymousSingleAdmit.StatusCode -ne 401) {
throw 'Native retired admit-card route did not preserve administrator authentication'
}
$batchAdmitExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/admit-cards?examId=$($arrangementExam.id)" -WebSession $nativeSession
if ($batchAdmitExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$batchAdmitExport.Headers['Content-Type'] -notmatch '^text/html' -or
$batchAdmitExport.Content -notmatch '衡准 · 准考证') {
throw 'Native batch admit-card HTML export is invalid'
}
$admitInfoExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/info?examId=$($arrangementExam.id)" -WebSession $nativeSession
if ($admitInfoExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$admitInfoExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or
$admitInfoExport.RawContentLength -lt 1000) {
throw 'Native admit-card information workbook export is invalid'
}
$superCenterMaterialsForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/center-materials?examId=$($arrangementExam.id)" -WebSession $nativeSession -SkipHttpErrorCheck
if ($superCenterMaterialsForbidden.StatusCode -ne 403) {
throw 'Native center-material export did not preserve the school-admin boundary'
}
$centerMaterialsExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/center-materials?examId=$($arrangementExam.id)" -WebSession $nativeSchoolSession
if ($centerMaterialsExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$centerMaterialsExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or
$centerMaterialsExport.RawContentLength -lt 1000) {
throw 'Native center-material workbook export is invalid'
}
$operationalSessions = @(
@{ Level = 'super'; Legacy = $session; Native = $nativeSession },
@{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession },
@@ -1443,6 +1568,10 @@ try {
if ($archivedExamUpdate.StatusCode -ne 409) {
throw 'Native exam update did not keep archived exams locked'
}
$archivedExamArrangement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSession -SkipHttpErrorCheck
if ($archivedExamArrangement.StatusCode -ne 409) {
throw 'Native arrangement preview did not keep archived exams locked'
}
$legacyExamsAfterArchive = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams" -WebSession $session
$nativeExamsAfterArchive = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -WebSession $nativeSession
Assert-JsonEquivalent -Expected $legacyExamsAfterArchive.Content -Actual $nativeExamsAfterArchive.Content -Label 'Exam archive follow-up'
@@ -1582,6 +1711,7 @@ try {
NativeAdminRegistrationPaymentWrites = 'passed'
NativeAdminWorkflowOperations = 'passed'
NativeAdminExamManagement = 'passed'
NativeAdminArrangements = 'passed'
} | Format-List
}
finally {