From e96a56314fdb54f09dd2b95c622d72fdaf0ed899 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 8 Sep 2026 08:36:05 +0800 Subject: [PATCH] Add aggregate voter registration management --- docs/admin-management.md | 2 +- .../Components/Pages/Admin/Dashboard.razor | 7 +- .../Controllers/VotersController.cs | 26 +- .../Persistence/ElectionDbContext.cs | 9 + ...AddVoterRegistrationAggregates.Designer.cs | 1089 +++++++++++++++++ ...08002906_AddVoterRegistrationAggregates.cs | 63 + .../ElectionDbContextModelSnapshot.cs | 48 + .../Domain/VoterRegistrationAggregate.cs | 15 + .../VoterRegistrationAggregateService.cs | 34 + .../Pages/Admin/VoterImport.cshtml | 3 +- .../Pages/Admin/VoterImport.cshtml.cs | 34 +- src/ElectionSystem.Api/Program.cs | 1 + .../PersistenceModelTests.cs | 10 + 13 files changed, 1334 insertions(+), 7 deletions(-) create mode 100644 src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.Designer.cs create mode 100644 src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.cs create mode 100644 src/ElectionSystem.Api/Modules/Voters/Domain/VoterRegistrationAggregate.cs create mode 100644 src/ElectionSystem.Api/Modules/Voters/VoterRegistrationAggregateService.cs diff --git a/docs/admin-management.md b/docs/admin-management.md index 63859c7..b906a12 100644 --- a/docs/admin-management.md +++ b/docs/admin-management.md @@ -26,7 +26,7 @@ dotnet user-secrets remove "Bootstrap:SystemAdministrator:Password" --project sr - `/admin/dashboard`:仅显示当前授权范围内的汇总。 - `/admin/regions`:首次启动会自动导入随应用发布的 AreaCity 四级目录;村/社区使用本地确认目录。`/admin/region-import` 仅用于受控版本更新。 -- `/admin/voters` 与 `/admin/voter-import`:按地域维护选民、预校验并确认 Excel 导入。选民列表使用每页 50 条的游标分页,并支持省→市→区县→镇街四级级联筛选。 +- `/admin/voters` 与 `/admin/voter-import`:按地域维护选民、预校验并确认 Excel 导入。若身份信息不能进入本系统,`/admin/voter-import` 可按有权辖区登记未具名的补充人数并填写原因;它独立于选民档案,只用于登记总量展示,绝不用于线上资格、已投状态或重复投票判断。与具名名单并存时,汇总数必须只填未具名部分,避免重复统计。选民列表使用每页 50 条的游标分页,并支持省→市→区县→镇街四级级联筛选。 - `/admin/candidates`:维护候选人资料和服务端允许的状态转换。 - `/admin/roles`:系统管理员授予地域管理员、计票员或监督员角色。 - `/admin/counting-stations`:建立辖区计票所,并向已获地域角色的人员分配具体计票所;列表同样使用游标分页及四级级联筛选。 diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor index a5e00a6..5bd9b52 100644 --- a/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Dashboard.razor @@ -15,7 +15,7 @@ {
可管理地区@regionCount含下级辖区
-
有效选民@activeVoterCount可参加后续资格核验
+
已登记选民人数@registeredVoterCount具名有效选民 @activeVoterCount;汇总数不用于线上资格核验
候选人资料@candidateCount含待审核与已公示
启用计票所@stationCount须单独分配工作人员
@@ -24,10 +24,11 @@ @code { - private bool loaded; private string? error; private int regionCount, activeVoterCount, candidateCount, stationCount; + private bool loaded; private string? error; private int regionCount, activeVoterCount, aggregateVoterCount, candidateCount, stationCount; + private int registeredVoterCount => activeVoterCount + aggregateVoterCount; protected override async Task OnInitializedAsync() { - try { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var scope = await Scope.GetManagedRegionIdsAsync(userId, CancellationToken.None); regionCount = scope.Count; activeVoterCount = await Db.Voters.CountAsync(x => scope.Contains(x.RegionId) && x.Status == VoterStatus.Active); candidateCount = await Db.Candidates.CountAsync(x => scope.Contains(x.RegionId)); stationCount = await Db.CountingStations.CountAsync(x => scope.Contains(x.RegionId) && x.IsActive); loaded = true; } + try { var userId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var scope = await Scope.GetManagedRegionIdsAsync(userId, CancellationToken.None); regionCount = scope.Count; activeVoterCount = await Db.Voters.CountAsync(x => scope.Contains(x.RegionId) && x.Status == VoterStatus.Active); aggregateVoterCount = await Db.VoterRegistrationAggregates.Where(x => scope.Contains(x.RegionId)).SumAsync(x => (int?)x.RegisteredCount) ?? 0; candidateCount = await Db.Candidates.CountAsync(x => scope.Contains(x.RegionId)); stationCount = await Db.CountingStations.CountAsync(x => scope.Contains(x.RegionId) && x.IsActive); loaded = true; } catch (Exception) { error = "无法读取管理范围。请确认此账号已完成地域角色分配。"; } } [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; diff --git a/src/ElectionSystem.Api/Controllers/VotersController.cs b/src/ElectionSystem.Api/Controllers/VotersController.cs index 867e7bd..e39b310 100644 --- a/src/ElectionSystem.Api/Controllers/VotersController.cs +++ b/src/ElectionSystem.Api/Controllers/VotersController.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Controllers; [Route("api/admin/voters")] -public sealed class VotersController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.RegionCascadeFilterService cascadeFilterService, VoterImportService importService) : AdministrationControllerBase(scopeService) +public sealed class VotersController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, Modules.Organization.RegionCascadeFilterService cascadeFilterService, VoterImportService importService, VoterRegistrationAggregateService aggregateService) : AdministrationControllerBase(scopeService) { [HttpGet] public async Task>> List([FromQuery] Guid? regionId, [FromQuery] VoterStatus? status, [FromQuery] string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) @@ -85,9 +85,33 @@ public sealed class VotersController(ElectionDbContext dbContext, Modules.Organi [HttpPost("import/{token:guid}/confirm")] public async Task ConfirmImport(Guid token, CancellationToken cancellationToken) => Ok(new { importedCount = await importService.ConfirmAsync(ActorId, token, cancellationToken) }); + + [HttpGet("aggregate-registrations")] + public async Task ListAggregateRegistrations(CancellationToken cancellationToken) + { + var allowed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var rows = await (from aggregate in dbContext.VoterRegistrationAggregates.AsNoTracking() + join region in dbContext.AdministrativeRegions.AsNoTracking() on aggregate.RegionId equals region.Id + where allowed.Contains(aggregate.RegionId) + orderby region.Code + select new { aggregate.RegionId, RegionCode = region.Code, RegionName = region.Name, aggregate.RegisteredCount, aggregate.Reason, aggregate.UpdatedAtUtc }).ToListAsync(cancellationToken); + return Ok(rows); + } + + [HttpPut("aggregate-registrations/{regionId:guid}")] + public async Task SetAggregateRegistration(Guid regionId, SetVoterRegistrationAggregateRequest request, CancellationToken cancellationToken) + { + try + { + var aggregate = await aggregateService.SetAsync(ActorId, regionId, request.RegisteredCount, request.Reason, cancellationToken); + return Ok(new { aggregate.RegionId, aggregate.RegisteredCount, aggregate.UpdatedAtUtc }); + } + catch (ArgumentException exception) { return ValidationProblem(exception.Message); } + } } public sealed record CreateVoterRequest(string VoterNumber, string DisplayName, Guid RegionId); public sealed record SetVoterStatusRequest(VoterStatus Status, string? Reason); +public sealed record SetVoterRegistrationAggregateRequest(int RegisteredCount, string? Reason); public sealed record VoterCursor(string DisplayName, Guid Id); internal sealed record VoterCursorRow(Guid Id, string VoterNumber, string DisplayName, Guid RegionId, VoterStatus Status, string? DisabledReason, DateTime UpdatedAtUtc); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs index 53a06dc..2b3350f 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs @@ -19,6 +19,7 @@ public sealed class ElectionDbContext(DbContextOptions option public DbSet CountingStations => Set(); public DbSet UserCountingStationAssignments => Set(); public DbSet Voters => Set(); + public DbSet VoterRegistrationAggregates => Set(); public DbSet Candidates => Set(); public DbSet ElectionActivities => Set(); public DbSet ElectionContests => Set(); @@ -93,6 +94,14 @@ public sealed class ElectionDbContext(DbContextOptions option entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); entity.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.ToTable("voter_registration_aggregates"); + entity.Property(x => x.Reason).HasMaxLength(500).IsRequired(); + entity.HasIndex(x => x.RegionId).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(x => x.SetByUserId).OnDelete(DeleteBehavior.Restrict); + }); modelBuilder.Entity(entity => { entity.ToTable("candidate_profiles"); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.Designer.cs new file mode 100644 index 0000000..700baa2 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.Designer.cs @@ -0,0 +1,1089 @@ +// +using System; +using ElectionSystem.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ElectionDbContext))] + [Migration("20260908002906_AddVoterRegistrationAggregates")] + partial class AddVoterRegistrationAggregates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ElectionSystem.Api.Infrastructure.Auditing.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("ActorId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("MetadataJson") + .HasColumnType("json"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ResourceId") + .HasColumnType("longtext"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("TraceId") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc", "ResourceType"); + + b.ToTable("audit_entries", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("NominationSummary") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StatusReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId", "Status", "DisplayName"); + + b.ToTable("candidate_profiles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CountingEndsAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PublishLiveCandidateTotals") + .HasColumnType("tinyint(1)"); + + b.Property("PublishLiveParticipation") + .HasColumnType("tinyint(1)"); + + b.Property("PublishedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("ResultsPublishedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("VotingEndsAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("VotingStartsAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId", "Status", "VotingEndsAtUtc"); + + b.ToTable("election_activities", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Determination") + .HasColumnType("int"); + + b.Property("ElectionActivityId") + .HasColumnType("char(36)"); + + b.Property("MaxSelections") + .HasColumnType("int"); + + b.Property("MinSelections") + .HasColumnType("int"); + + b.Property("RequiredSupportPercent") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SupportDenominator") + .HasColumnType("int"); + + b.Property("TieResolution") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ElectionActivityId", "SortOrder") + .IsUnique(); + + b.ToTable("election_contests", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionCountingStation", b => + { + b.Property("ElectionActivityId") + .HasColumnType("char(36)"); + + b.Property("CountingStationId") + .HasColumnType("char(36)"); + + b.HasKey("ElectionActivityId", "CountingStationId"); + + b.HasIndex("CountingStationId"); + + b.ToTable("election_counting_stations", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CandidateId") + .HasColumnType("char(36)"); + + b.Property("ElectionContestId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("ElectionContestId", "CandidateId") + .IsUnique(); + + b.HasIndex("ElectionContestId", "SortOrder") + .IsUnique(); + + b.ToTable("election_options", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("identity_roles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("ExternalSubject") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSubject") + .IsUnique(); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("identity_users", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EnteredByUserId") + .HasColumnType("char(36)"); + + b.Property("OfflineCountingSessionId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OfflineCountingSessionId", "EnteredByUserId") + .IsUnique(); + + b.ToTable("offline_count_submissions", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountTally", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Abstentions") + .HasColumnType("int"); + + b.Property("CountedBallots") + .HasColumnType("int"); + + b.Property("ElectionContestId") + .HasColumnType("char(36)"); + + b.Property("ElectionOptionId") + .HasColumnType("char(36)"); + + b.Property("InvalidBallots") + .HasColumnType("int"); + + b.Property("MissingSelections") + .HasColumnType("int"); + + b.Property("OfflineCountSubmissionId") + .HasColumnType("char(36)"); + + b.Property("Votes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ElectionContestId"); + + b.HasIndex("ElectionOptionId"); + + b.HasIndex("OfflineCountSubmissionId", "ElectionContestId", "ElectionOptionId") + .IsUnique(); + + b.ToTable("offline_count_tallies", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ConfirmedByUserId") + .HasColumnType("char(36)"); + + b.Property("CorrectionReason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CountingStationId") + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("ElectionActivityId") + .HasColumnType("char(36)"); + + b.Property("OpenedByUserId") + .HasColumnType("char(36)"); + + b.Property("ReplacesSessionId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CountingStationId"); + + b.HasIndex("ReplacesSessionId"); + + b.HasIndex("ElectionActivityId", "CountingStationId", "Status"); + + b.HasIndex("ElectionActivityId", "CountingStationId", "Version") + .IsUnique() + .HasDatabaseName("IX_offline_counting_sessions_ElectionActivityId_CountingStatio~1"); + + b.ToTable("offline_counting_sessions", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsSynthetic") + .HasColumnType("tinyint(1)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentId") + .HasColumnType("char(36)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SourceVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Code", "SourceVersion") + .IsUnique(); + + b.HasIndex("ParentId", "Level", "IsActive"); + + b.ToTable("administrative_regions", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("RegionId"); + + b.ToTable("counting_stations", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("organization_units", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserCountingStationAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CountingStationId") + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("CountingStationId"); + + b.HasIndex("UserId", "CountingStationId") + .IsUnique(); + + b.ToTable("identity_user_counting_stations", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserRegionalRoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.HasIndex("UserId", "Role", "RegionId") + .IsUnique(); + + b.ToTable("identity_user_regional_roles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("DisabledReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VoterNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("VoterNumber") + .IsUnique(); + + b.HasIndex("RegionId", "Status", "DisplayName"); + + b.ToTable("voter_profiles", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterRegistrationAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RegisteredCount") + .HasColumnType("int"); + + b.Property("SetByUserId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId") + .IsUnique(); + + b.HasIndex("SetByUserId"); + + b.ToTable("voter_registration_aggregates", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("identity_user_tokens", (string)null); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", b => + { + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", null) + .WithMany() + .HasForeignKey("ElectionActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionCountingStation", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", null) + .WithMany() + .HasForeignKey("CountingStationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", null) + .WithMany() + .HasForeignKey("ElectionActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Election.Domain.ElectionOption", b => + { + b.HasOne("ElectionSystem.Api.Modules.Candidates.Domain.CandidateProfile", null) + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", null) + .WithMany() + .HasForeignKey("ElectionContestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountSubmission", b => + { + b.HasOne("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", null) + .WithMany() + .HasForeignKey("OfflineCountingSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountTally", b => + { + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionContest", null) + .WithMany() + .HasForeignKey("ElectionContestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionOption", null) + .WithMany() + .HasForeignKey("ElectionOptionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountSubmission", null) + .WithMany() + .HasForeignKey("OfflineCountSubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", null) + .WithMany() + .HasForeignKey("CountingStationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Election.Domain.ElectionActivity", null) + .WithMany() + .HasForeignKey("ElectionActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.OfflineCounting.Domain.OfflineCountingSession", null) + .WithMany() + .HasForeignKey("ReplacesSessionId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserCountingStationAssignment", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.CountingStation", null) + .WithMany() + .HasForeignKey("CountingStationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Organization.Domain.UserRegionalRoleAssignment", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterProfile", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterRegistrationAggregate", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("SetByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.cs new file mode 100644 index 0000000..7e06d67 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260908002906_AddVoterRegistrationAggregates.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddVoterRegistrationAggregates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "voter_registration_aggregates", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RegisteredCount = table.Column(type: "int", nullable: false), + Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SetByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UpdatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_voter_registration_aggregates", x => x.Id); + table.ForeignKey( + name: "FK_voter_registration_aggregates_administrative_regions_RegionId", + column: x => x.RegionId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_voter_registration_aggregates_identity_users_SetByUserId", + column: x => x.SetByUserId, + principalTable: "identity_users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_voter_registration_aggregates_RegionId", + table: "voter_registration_aggregates", + column: "RegionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_voter_registration_aggregates_SetByUserId", + table: "voter_registration_aggregates", + column: "SetByUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "voter_registration_aggregates"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs index 625bbb5..daae6c9 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs @@ -706,6 +706,39 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations b.ToTable("voter_profiles", (string)null); }); + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterRegistrationAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RegisteredCount") + .HasColumnType("int"); + + b.Property("SetByUserId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RegionId") + .IsUnique(); + + b.HasIndex("SetByUserId"); + + b.ToTable("voter_registration_aggregates", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -982,6 +1015,21 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity("ElectionSystem.Api.Modules.Voters.Domain.VoterRegistrationAggregate", b => + { + b.HasOne("ElectionSystem.Api.Modules.Organization.Domain.AdministrativeRegion", null) + .WithMany() + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("SetByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("ElectionSystem.Api.Modules.Identity.Domain.ApplicationRole", null) diff --git a/src/ElectionSystem.Api/Modules/Voters/Domain/VoterRegistrationAggregate.cs b/src/ElectionSystem.Api/Modules/Voters/Domain/VoterRegistrationAggregate.cs new file mode 100644 index 0000000..f587206 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Voters/Domain/VoterRegistrationAggregate.cs @@ -0,0 +1,15 @@ +namespace ElectionSystem.Api.Modules.Voters.Domain; + +/// +/// A region-level registration total supplied when individual voter identity data must not enter this system. +/// It is intentionally separate from and cannot establish voting eligibility. +/// +public sealed class VoterRegistrationAggregate +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid RegionId { get; set; } + public int RegisteredCount { get; set; } + public required string Reason { get; set; } + public Guid SetByUserId { get; set; } + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/ElectionSystem.Api/Modules/Voters/VoterRegistrationAggregateService.cs b/src/ElectionSystem.Api/Modules/Voters/VoterRegistrationAggregateService.cs new file mode 100644 index 0000000..ff6b2e6 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Voters/VoterRegistrationAggregateService.cs @@ -0,0 +1,34 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Organization; +using ElectionSystem.Api.Modules.Voters.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Modules.Voters; + +public sealed class VoterRegistrationAggregateService(ElectionDbContext dbContext, RegionScopeService scopeService) +{ + public async Task SetAsync(Guid actorId, Guid regionId, int registeredCount, string? reason, CancellationToken cancellationToken) + { + if (registeredCount < 0) throw new ArgumentException("登记人数不能小于 0。", nameof(registeredCount)); + var normalizedReason = reason?.Trim(); + if (string.IsNullOrWhiteSpace(normalizedReason) || normalizedReason.Length > 500) throw new ArgumentException("请说明不上传身份信息的原因,且不得超过 500 个字符。", nameof(reason)); + + await scopeService.RequireManagedRegionAsync(actorId, regionId, cancellationToken); + var aggregate = await dbContext.VoterRegistrationAggregates.SingleOrDefaultAsync(x => x.RegionId == regionId, cancellationToken); + if (aggregate is null) + { + aggregate = new VoterRegistrationAggregate { RegionId = regionId, RegisteredCount = registeredCount, Reason = normalizedReason, SetByUserId = actorId }; + dbContext.VoterRegistrationAggregates.Add(aggregate); + } + else + { + aggregate.RegisteredCount = registeredCount; + aggregate.Reason = normalizedReason; + aggregate.SetByUserId = actorId; + aggregate.UpdatedAtUtc = DateTime.UtcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return aggregate; + } +} diff --git a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml index 764b844..411cf32 100644 --- a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml +++ b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml @@ -2,8 +2,9 @@ @model ElectionSystem.Api.Pages.Admin.VoterImportModel @{ ViewData["Title"] = "选民导入"; }
-

基础资料 / 批量导入

选民名单导入

Excel 首行必须依次为:选民编号、姓名、行政区划代码。系统先预校验,逐行错误清零后才可确认写入。

+

基础资料 / 批量导入

选民登记

可导入具名选民名单;如身份信息不能上传,则按辖区登记未具名的补充人数。汇总人数不会创建个人选民资料,也不能作为线上投票资格或防重复投票依据。

@if (Model.Message is not null) {
@Model.Message
}
+

不含身份信息的汇总登记

填写后将覆盖该辖区此前的汇总数,并记录操作审计。若同一辖区也已导入具名名单,请只填写未具名的补充人数,避免重复统计。

@if (Model.AggregateRegistrations.Count > 0) {
@foreach (var row in Model.AggregateRegistrations) { }
行政区划未具名补充人数原因更新时间(UTC)
@row.RegionCode @row.RegionName@row.RegisteredCount@row.Reason@row.UpdatedAtUtc.ToString("u")
}
@if (Model.Preview is not null) {

预校验结果

可导入 @Model.Preview.ValidCount 行;错误 @Model.Preview.ErrorCount 行。

@foreach (var row in Model.Preview.Rows) { }
行号选民编号姓名行政区划结果
@row.RowNumber@row.VoterNumber@row.DisplayName@row.RegionCode@(row.Error ?? "通过")
}
diff --git a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs index a22cfe7..6585f86 100644 --- a/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs +++ b/src/ElectionSystem.Api/Pages/Admin/VoterImport.cshtml.cs @@ -3,23 +3,55 @@ using ElectionSystem.Api.Modules.Voters; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; namespace ElectionSystem.Api.Pages.Admin; [Authorize] -public sealed class VoterImportModel(VoterImportService importService) : PageModel +public sealed class VoterImportModel(VoterImportService importService, VoterRegistrationAggregateService aggregateService, ElectionSystem.Api.Infrastructure.Persistence.ElectionDbContext dbContext, RegionScopeService scopeService) : PageModel { public VoterImportPreview? Preview { get; private set; } public string? Message { get; private set; } + public IReadOnlyList Regions { get; private set; } = []; + public IReadOnlyList AggregateRegistrations { get; private set; } = []; + [BindProperty] public Guid AggregateRegionId { get; set; } + [BindProperty] public int AggregateRegisteredCount { get; set; } + [BindProperty] public string? AggregateReason { get; set; } + public async Task OnGetAsync(CancellationToken cancellationToken) => await LoadRegionsAsync(cancellationToken); public async Task OnPostPreviewAsync(IFormFile? file, CancellationToken cancellationToken) { + await LoadRegionsAsync(cancellationToken); if (file is null || file.Length == 0 || !Path.GetExtension(file.FileName).Equals(".xlsx", StringComparison.OrdinalIgnoreCase)) { Message = "请选择 .xlsx 文件。"; return Page(); } await using var stream = file.OpenReadStream(); Preview = await importService.PreviewAsync(RegionScopeService.GetRequiredUserId(User), stream, cancellationToken); return Page(); } public async Task OnPostConfirmAsync(Guid token, CancellationToken cancellationToken) { + await LoadRegionsAsync(cancellationToken); try { var count = await importService.ConfirmAsync(RegionScopeService.GetRequiredUserId(User), token, cancellationToken); Message = $"已导入 {count} 名选民。"; } catch (Exception exception) when (exception is InvalidOperationException or UnauthorizedAccessException) { Message = exception.Message; } return Page(); } + public async Task OnPostSetAggregateAsync(CancellationToken cancellationToken) + { + await LoadRegionsAsync(cancellationToken); + try + { + await aggregateService.SetAsync(RegionScopeService.GetRequiredUserId(User), AggregateRegionId, AggregateRegisteredCount, AggregateReason, cancellationToken); + Message = "已保存不含身份信息的汇总登记人数。该数字不产生个人选民资料,也不能用于线上逐人资格核验。"; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or UnauthorizedAccessException) { Message = exception.Message; } + return Page(); + } + private async Task LoadRegionsAsync(CancellationToken cancellationToken) + { + var allowed = await scopeService.GetManagedRegionIdsAsync(RegionScopeService.GetRequiredUserId(User), cancellationToken); + Regions = await dbContext.AdministrativeRegions.AsNoTracking().Where(x => allowed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Code).Select(x => new RegionOption(x.Id, x.Code, x.Name)).ToListAsync(cancellationToken); + AggregateRegistrations = await (from aggregate in dbContext.VoterRegistrationAggregates.AsNoTracking() + join region in dbContext.AdministrativeRegions.AsNoTracking() on aggregate.RegionId equals region.Id + where allowed.Contains(aggregate.RegionId) + orderby region.Code + select new AggregateRegistrationRow(region.Code, region.Name, aggregate.RegisteredCount, aggregate.Reason, aggregate.UpdatedAtUtc)).ToListAsync(cancellationToken); + } + public sealed record RegionOption(Guid Id, string Code, string Name); + public sealed record AggregateRegistrationRow(string RegionCode, string RegionName, int RegisteredCount, string Reason, DateTime UpdatedAtUtc); } diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index 86982eb..0bb249b 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -38,6 +38,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs index ce37a34..e57959e 100644 --- a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs +++ b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs @@ -63,6 +63,16 @@ public sealed class PersistenceModelTests Assert.Contains(entity.GetIndexes(), x => x.Properties.Select(p => p.Name).SequenceEqual(new[] { nameof(VoterProfile.RegionId), nameof(VoterProfile.Status), nameof(VoterProfile.DisplayName) })); } + [Fact] + public void Aggregate_registration_is_region_unique_and_has_no_identity_fields() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var context = new ElectionDbContext(options); + var entity = context.Model.FindEntityType(typeof(VoterRegistrationAggregate))!; + Assert.Contains(entity.GetIndexes(), x => x.IsUnique && x.Properties.Single().Name == nameof(VoterRegistrationAggregate.RegionId)); + Assert.DoesNotContain(typeof(VoterRegistrationAggregate).GetProperties(), x => x.Name.Contains("Name", StringComparison.OrdinalIgnoreCase) || x.Name.Contains("Number", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task AreaCity_import_preserves_parent_chain_and_four_levels() {