Add aggregate voter registration management

This commit is contained in:
biss committed 2026-09-08 08:36:05 +08:00
1 parent 6d765ac8f3
commit e96a56314f
13 files changed
+1334 -7

No files matched your search

+1 -1
View File
@@ -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`:建立辖区计票所,并向已获地域角色的人员分配具体计票所;列表同样使用游标分页及四级级联筛选。
@@ -15,7 +15,7 @@
{
<div class="admin-stat-grid">
<article><span>可管理地区</span><strong>@regionCount</strong><small>含下级辖区</small></article>
<article><span>有效选民</span><strong>@activeVoterCount</strong><small>可参加后续资格核验</small></article>
<article><span>已登记选民人数</span><strong>@registeredVoterCount</strong><small>具名有效选民 @activeVoterCount;汇总数不用于线上资格核验</small></article>
<article><span>候选人资料</span><strong>@candidateCount</strong><small>含待审核与已公示</small></article>
<article><span>启用计票所</span><strong>@stationCount</strong><small>须单独分配工作人员</small></article>
</div>
@@ -24,10 +24,11 @@
</section>
@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<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
@@ -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<ActionResult<PagedResult<object>>> 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<ActionResult> ConfirmImport(Guid token, CancellationToken cancellationToken) => Ok(new { importedCount = await importService.ConfirmAsync(ActorId, token, cancellationToken) });
[HttpGet("aggregate-registrations")]
public async Task<ActionResult> 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<ActionResult> 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);
Loaded 3 of 13 files, more files were not shown because too many files have changed in this diff. Show more