From 8f83adbc1e0930e6044aeb93a0c6209812e8ccc1 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 11:25:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BA=94=E7=BA=A7=E6=B7=B7?= =?UTF-8?q?=E7=BC=96=E3=80=81=E7=A8=B3=E5=AE=9A=E7=A7=8D=E5=AD=90=E3=80=81?= =?UTF-8?q?=E5=AE=B9=E9=87=8F=E9=A2=84=E6=A3=80=E3=80=81=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E8=80=83=E7=82=B9=E3=80=81=E9=80=90=E7=A7=91=E8=80=83=E5=9C=BA?= =?UTF-8?q?/=E5=BA=A7=E4=BD=8D=E5=8F=8A=E5=9B=9B=E7=A7=8D=E5=87=86?= =?UTF-8?q?=E8=80=83=E8=AF=81=E5=8F=B7=E8=A7=84=E5=88=99=E3=80=82=20?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E7=BC=96=E6=8E=92=E4=BC=9A=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E6=80=A7=E6=9B=BF=E6=8D=A2=E6=95=B4=E5=9C=BA=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E3=80=81=E5=87=86=E8=80=83=E8=AF=81=E3=80=81=E5=BA=A7=E4=BD=8D?= =?UTF-8?q?=E5=92=8C=E5=AE=A1=E8=AE=A1=E8=AE=B0=E5=BD=95=E3=80=82=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=89=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E8=AF=BB=E5=8F=96=E3=80=81=E6=89=B9=E9=87=8F?= =?UTF-8?q?=20HTML=20=E5=87=86=E8=80=83=E8=AF=81=E3=80=81=E5=87=86?= =?UTF-8?q?=E8=80=83=E8=AF=81=E4=BF=A1=E6=81=AF=20Excel=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E6=A0=A1=E7=BA=A7=E8=80=83=E7=82=B9=E6=A1=8C=E8=B4=B4?= =?UTF-8?q?=E3=80=81=E9=97=A8=E8=B4=B4=E5=92=8C=E7=AD=BE=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E3=80=82=20=E6=A0=B8=E5=BF=83=E5=AE=9E=E7=8E=B0=E8=A7=81=20[Ad?= =?UTF-8?q?minArrangementPlanner.cs=20(line=207)](C:/Users/BI/Documents/EI?= =?UTF-8?q?S-dotnet/src/Eis.Infrastructure/Administration/AdminArrangement?= =?UTF-8?q?Planner.cs:7)=E3=80=81[AdminArrangementService.cs=20(line=2011)?= =?UTF-8?q?](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Admin?= =?UTF-8?q?istration/AdminArrangementService.cs:11)=20=E5=92=8C=20[AdminAr?= =?UTF-8?q?rangementExporter.cs=20(line=205)](C:/Users/BI/Documents/EIS-do?= =?UTF-8?q?tnet/src/Eis.Infrastructure/Administration/AdminArrangementExpo?= =?UTF-8?q?rter.cs:5)=E3=80=82=20=E6=96=B0=E5=A2=9E=E5=BC=80=E5=85=B3=20AD?= =?UTF-8?q?MIN=5FNATIVE=5FARRANGEMENTS=5FENABLED=3Dtrue=EF=BC=8C=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=B7=B2=E6=9B=B4=E6=96=B0=E5=88=B0=20[MIGRATION.md?= =?UTF-8?q?=20(line=2057)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:5?= =?UTF-8?q?7)=E3=80=82=20Excel=20=E7=94=B1=20.NET=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=94=9F=E6=88=90=EF=BC=8C=E4=BD=BF=E7=94=A8=20ClosedXML=200.1?= =?UTF-8?q?05.0=EF=BC=9B=E8=AF=A5=E5=BA=93=E6=94=AF=E6=8C=81=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E7=94=9F=E6=88=90=20Excel=202007+=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=B0=BF=E3=80=82ClosedXML=20NuGet=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + Directory.Packages.props | 1 + MIGRATION.md | 7 +- scripts/smoke-dotnet-migration.ps1 | 130 +++ .../IAdminArrangementService.cs | 37 + .../AdminArrangementExporter.cs | 244 ++++++ .../Administration/AdminArrangementPlanner.cs | 447 ++++++++++ .../AdminArrangementRepository.cs | 154 ++++ .../Administration/AdminArrangementService.cs | 809 ++++++++++++++++++ .../AdminArrangementSnapshot.cs | 207 +++++ .../Administration/AdminMigrationOptions.cs | 15 +- src/Eis.Infrastructure/DependencyInjection.cs | 3 + .../Eis.Infrastructure.csproj | 1 + .../NativeAdminReadEndpoints.cs | 44 + src/Eis.Web/Program.cs | 11 +- src/Eis.Web/appsettings.json | 3 +- .../AdminMigrationOptionsTests.cs | 21 +- .../prepare-admin-arrangement-smoke.mjs | 13 + 18 files changed, 2138 insertions(+), 11 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminArrangementService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminArrangementExporter.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminArrangementPlanner.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminArrangementRepository.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminArrangementService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminArrangementSnapshot.cs create mode 100644 tests/helpers/prepare-admin-arrangement-smoke.mjs diff --git a/.env.example b/.env.example index 159a89f..7a0708f 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,8 @@ ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false # 考试创建、修改、发布状态及不可逆归档;必须同时启用管理端基础读取。 ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false +# 准考证全场预检、编排应用、批量打印及 Excel 考务材料;必须同时启用管理端基础读取。 +ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/Directory.Packages.props b/Directory.Packages.props index db38a4a..c04c9ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/MIGRATION.md b/MIGRATION.md index 31a95cf..9a2b2f7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入、工作流调度及考试维护已原生化) +- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入、工作流调度、考试维护及准考证编排导出已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -71,9 +71,10 @@ $env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' $env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' $env:ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true' +$env:ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true' ``` -旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled`、`administration.nativeCandidateManagementEnabled`、`administration.nativeRegistrationPaymentWritesEnabled`、`administration.nativeWorkflowOperationsEnabled`、`administration.nativeExamManagementEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled`、`administration.nativeCandidateManagementEnabled`、`administration.nativeRegistrationPaymentWritesEnabled`、`administration.nativeWorkflowOperationsEnabled`、`administration.nativeExamManagementEnabled`、`administration.nativeArrangementsEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 395b26f..6b7b800 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -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 { diff --git a/src/Eis.Application/Administration/IAdminArrangementService.cs b/src/Eis.Application/Administration/IAdminArrangementService.cs new file mode 100644 index 0000000..fbaa3c3 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminArrangementService.cs @@ -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 GetAsync(string sessionToken, CancellationToken cancellationToken); + + Task PreviewAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken); + + Task ApplyAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken); + + Task RetiredSingleAsync( + string sessionToken, + CancellationToken cancellationToken); + + Task ExportAsync( + string sessionToken, + string type, + string examId, + CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminArrangementExporter.cs b/src/Eis.Infrastructure/Administration/AdminArrangementExporter.cs new file mode 100644 index 0000000..98179cb --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminArrangementExporter.cs @@ -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 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 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 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 headers, + IReadOnlyList 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(); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminArrangementPlanner.cs b/src/Eis.Infrastructure/Administration/AdminArrangementPlanner.cs new file mode 100644 index 0000000..67585bc --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminArrangementPlanner.cs @@ -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 MixingScopeNames = + new Dictionary(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(); + 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() ?? "") + .ToArray(); + var subjects = requestedSubjectIds + .Select(id => exam.Subjects.FirstOrDefault(subject => Text(subject, "id") == id)) + .Where(item => item is not null) + .Cast() + .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(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(), + StringComparer.Ordinal); + var roomUses = new List(); + 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(StringComparer.Ordinal); + var numbers = new HashSet(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(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> remaining, + IReadOnlyDictionary affinity, + IReadOnlyDictionary 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 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 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 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 subjectIds, + string signature, + string scopeKey) + { + public OperationalRegistration Registration { get; } = registration; + public OperationalProfile Profile { get; } = profile; + public IReadOnlyList 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 Rooms, + IReadOnlyList AllRooms); + + private sealed record RoomUse(string CenterCode, string RoomCode, string RoomId); +} diff --git a/src/Eis.Infrastructure/Administration/AdminArrangementRepository.cs b/src/Eis.Infrastructure/Administration/AdminArrangementRepository.cs new file mode 100644 index 0000000..8206b18 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminArrangementRepository.cs @@ -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 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 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); +} diff --git a/src/Eis.Infrastructure/Administration/AdminArrangementService.cs b/src/Eis.Infrastructure/Administration/AdminArrangementService.cs new file mode 100644 index 0000000..11fbdfb --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminArrangementService.cs @@ -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 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 PreviewAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken) => + ArrangeAsync(sessionToken, examId, body, preview: true, cancellationToken); + + public Task ApplyAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken) => + ArrangeAsync(sessionToken, examId, body, preview: false, cancellationToken); + + public async Task RetiredSingleAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken); + return context.Error ?? Error( + 410, + "单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成"); + } + + public async Task 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 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 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 BuildExportRows( + AdminOperationalSnapshot operational, + AdminCenterSnapshot centers, + AdminAccountBatchSnapshot directory, + IReadOnlyList registrations) + { + var rows = new List(); + 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()) + { + 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 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() + .ToDictionary(item => Text(item, "subjectId"), StringComparer.Ordinal); + var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? []) + .Select(item => item?.GetValue() ?? "") + .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("").Append(E(Text(subject, "name"))) + .Append("").Append(E(Text(subject, "date"))) + .Append("").Append(E(Text(subject, "start"))).Append('—').Append(E(Text(subject, "end"))) + .Append("").Append(E(First(Text(assignment, "examRoomCode"), "待定"))) + .Append("").Append(E(First(Text(assignment, "roomName"), Text(assignment, "room"), room?.Name ?? "", "待定"))) + .Append("场地代码:").Append(E(First(Text(assignment, "roomCode"), room?.Code ?? "", "—"))) + .Append("").Append(E(First(Text(assignment, "building"), room?.Building ?? "", "—"))) + .Append("").Append(E(First(Text(assignment, "floor"), room?.Floor ?? "", "楼层待定"))) + .Append("").Append(E(First(Text(assignment, "seat"), "—"))).Append(""); + } + 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("
衡准 · 准考证

") + .Append(E(Text(exam.Data, "name"))) + .Append("

准考证号
").Append(E(Text(card, "number"))) + .Append("
姓名") + .Append(E(First(Text(profile.Data, "name"), user?.DisplayName ?? ""))) + .Append("
证件号码").Append(E(MaskId(Text(profile.Data, "idNumber")))) + .Append("
报名号") + .Append(E(First(user?.CandidateNumber ?? "", Text(registration.Data, "registrationNumber"), "—"))) + .Append("
固定考点") + .Append(E(Text(card, "testCenter"))).Append("考点代码:").Append(E(First(centerCode, "—"))) + .Append("
考点详细地址").Append(E(First(centerAddress, "地址待公布"))) + .Append("
") + .Append(rows) + .Append("
科目日期时间考试考场序号考场通用名称 / 场地代码楼栋 / 楼层座位号
编号说明“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。
所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
") + .Append(E(organizationName)).Append("生成时间:").Append(E(generatedAt)) + .Append("
"); + } + 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 $"{E(title)}{sections}"; + } + + 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() ?? "") + .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().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 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 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); diff --git a/src/Eis.Infrastructure/Administration/AdminArrangementSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminArrangementSnapshot.cs new file mode 100644 index 0000000..7ae32b2 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminArrangementSnapshot.cs @@ -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 Rules, + IReadOnlyList Plans, + string OrganizationName); + +internal sealed record AdmissionNumberRule( + string Id, + string Code, + string Name, + string Description, + string Separator, + IReadOnlyList 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 Warnings, + string? GeneratedBy, + string GeneratedAt); + +internal sealed class AdminArrangementSnapshotLoader(IRelationalConnectionFactory connectionFactory) +{ + public async Task 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 ParseSegments(string? value) + { + try + { + return (JsonNode.Parse(value ?? "[]") as JsonArray ?? []) + .OfType() + .Select(item => new AdmissionNumberSegment( + item["source"]?.GetValue() ?? "", + item["label"]?.GetValue() ?? "", + item.ContainsKey("width") ? item["width"]?.GetValue() ?? 0 : null)) + .ToArray(); + } + catch + { + return []; + } + } + + private static IReadOnlyList ParseStrings(string? value) + { + try + { + return (JsonNode.Parse(value ?? "[]") as JsonArray ?? []) + .Select(item => item?.GetValue() ?? "") + .ToArray(); + } + catch + { + return []; + } + } + + private static async Task> QueryAsync( + DbConnection connection, + string sql, + Func map, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var output = new List(); + 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 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 Warnings, + ArrangementSummary Summary, + IReadOnlyList Cards); + +internal sealed class AdminArrangementException(int statusCode, string message) : Exception(message) +{ + public int StatusCode { get; } = statusCode; +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index cd85cdc..3127a7b 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -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 diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index afccd68..020062f 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -64,6 +64,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -75,6 +77,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Eis.Infrastructure/Eis.Infrastructure.csproj b/src/Eis.Infrastructure/Eis.Infrastructure.csproj index f36838e..3c7f074 100644 --- a/src/Eis.Infrastructure/Eis.Infrastructure.csproj +++ b/src/Eis.Infrastructure/Eis.Infrastructure.csproj @@ -9,6 +9,7 @@
+ diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 662de83..6bc4316 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -139,6 +139,31 @@ public static class NativeAdminReadEndpoints endpoints.MapPost("/api/admin/exams/{examId}/archive", (HttpContext context, string examId, IAdminExamManagementService service, CancellationToken cancellationToken) => Execute(context, service.ArchiveAsync(Token(context), examId, cancellationToken))); } + + if (options.NativeArrangementsEnabled) + { + endpoints.MapGet("/api/admin/admission-arrangements", + (HttpContext context, IAdminArrangementService service, CancellationToken cancellationToken) => + Execute(context, service.GetAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/exams/{examId}/admission-arrangement/preview", + (HttpContext context, string examId, JsonObject body, IAdminArrangementService service, CancellationToken cancellationToken) => + Execute(context, service.PreviewAsync(Token(context), examId, body, cancellationToken))); + endpoints.MapPost("/api/admin/exams/{examId}/admission-arrangement", + (HttpContext context, string examId, JsonObject body, IAdminArrangementService service, CancellationToken cancellationToken) => + Execute(context, service.ApplyAsync(Token(context), examId, body, cancellationToken))); + endpoints.MapGet("/api/admin/admission-exports/{type:regex(^(admit-cards|info|center-materials)$)}", + (HttpContext context, string type, IAdminArrangementService service, CancellationToken cancellationToken) => + ExecuteDocument( + context, + service.ExportAsync( + Token(context), + type, + context.Request.Query["examId"].ToString(), + cancellationToken))); + endpoints.MapPost("/api/admin/registrations/{registrationId}/admit-card", + (HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) => + Execute(context, service.RetiredSingleAsync(Token(context), cancellationToken))); + } return endpoints; } @@ -150,6 +175,25 @@ public static class NativeAdminReadEndpoints return Results.Json(result.Body, statusCode: result.StatusCode); } + private static async Task ExecuteDocument( + HttpContext context, + Task operation) + { + var result = await operation; + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-EIS-Implementation"] = "aspnet-core"; + if (result.Error is not null) + { + return Results.Json(result.Error, statusCode: result.StatusCode); + } + + return Results.File( + result.Content ?? [], + result.ContentType ?? "application/octet-stream", + result.FileName, + enableRangeProcessing: false); + } + private static string Token(HttpContext context) => context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 7d0022f..3b7137b 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -55,7 +55,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeCandidateManagementEnabled"), builder.Configuration.GetValue("AdminMigration:NativeRegistrationPaymentWritesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeWorkflowOperationsEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeExamManagementEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeExamManagementEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeArrangementsEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -120,6 +121,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled, nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled, nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled, + nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -157,6 +159,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c .Concat(adminMigrationOptions.NativeExamManagementEnabled ? new[] { "POST/PATCH exams", "POST exam archive" } : []) + .Concat(adminMigrationOptions.NativeArrangementsEnabled + ? new[] + { + "GET admission-arrangements", "POST arrangement preview/apply", + "GET admission exports" + } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 8b3374d..eabb79b 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -20,7 +20,8 @@ "NativeCandidateManagementEnabled": false, "NativeRegistrationPaymentWritesEnabled": false, "NativeWorkflowOperationsEnabled": false, - "NativeExamManagementEnabled": false + "NativeExamManagementEnabled": false, + "NativeArrangementsEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index d663168..700b96f 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -271,6 +271,21 @@ public sealed class AdminMigrationOptionsTests }, examManagementEnabled: "true"); } + [Fact] + public void EnablesArrangementsWithBaseReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeArrangementsEnabled); + }, arrangementsEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -285,7 +300,8 @@ public sealed class AdminMigrationOptionsTests string? candidateManagementEnabled = null, string? registrationPaymentWritesEnabled = null, string? workflowOperationsEnabled = null, - string? examManagementEnabled = null) + string? examManagementEnabled = null, + string? arrangementsEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); @@ -299,6 +315,7 @@ public sealed class AdminMigrationOptionsTests var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"); var previousWorkflowOperationsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"); var previousExamManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"); + var previousArrangementsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -314,6 +331,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", examManagementEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED", arrangementsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -331,6 +349,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", previousExamManagementEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED", previousArrangementsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } } diff --git a/tests/helpers/prepare-admin-arrangement-smoke.mjs b/tests/helpers/prepare-admin-arrangement-smoke.mjs new file mode 100644 index 0000000..3a944a3 --- /dev/null +++ b/tests/helpers/prepare-admin-arrangement-smoke.mjs @@ -0,0 +1,13 @@ +import { DatabaseSync } from 'node:sqlite'; + +const databasePath = process.argv[2]; +if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径'); + +const database = new DatabaseSync(databasePath); +database.prepare(` + UPDATE test_rooms + SET capacity = 2000, + seat_end = seat_start + 1999 + WHERE status = 'active' +`).run(); +database.close();