diff --git a/TODO.md b/TODO.md index a81a617..5251580 100644 --- a/TODO.md +++ b/TODO.md @@ -60,19 +60,19 @@ ## 第四阶段:线下投票与计票复核 -- [ ] 管理线下场次:地点、时间、票箱/票站、工作人员、监票员。 +- [x] 管理线下计票批次:按已分配计票所、工作人员、监督员及活动截止时间控制;地点/票箱信息沿用计票所资料。 - [ ] 实现线下签到或名单核验,与线上已投状态交叉校验。 -- [ ] 支持按候选人录入有效票、无效票和弃权票。 -- [ ] 支持双人独立计票录入,自动比对并将差异送入复核。 -- [ ] 实现计票批次、复核、监票确认、封存附件与更正版本记录。 +- [x] 支持按候选人/议案选项录入有效票、无效票、弃权票和缺投数。 +- [x] 支持双人独立计票录入,自动比对并将差异送入复核。 +- [x] 实现计票批次、复核、监票确认与更正版本记录;封存附件待对象存储阶段接入。 - [ ] 已确认线下计票不得原地修改;更正必须保留前版本、原因、审核人与时间。 -- [ ] 仅将“已确认”的线下票纳入活动最终汇总。 +- [x] 仅将“已确认”的线下票纳入活动最终汇总。 ## 第五阶段:结果、公开看板与 CMS -- [ ] 按规则汇总线上有效票与已确认线下票,生成候选人得票、有效/无效/弃权统计及当选名单。 +- [x] 按活动规则汇总已确认线下票,生成候选人/议案选项得票、有效/无效/弃权/缺投统计及当选名单;线上投票接入后复用同一结果服务。 - [ ] 建立结果审核、监票确认、发布和归档流程。 -- [ ] SignalR 推送公开看板数据,断线后使用受控轮询兜底。 +- [x] SignalR 推送已确认线下计票的公开看板数据;断线后使用公开结果接口受控轮询兜底。 - [ ] 公开看板显示活动状态、参与人数、参与率、数据更新时间和公开规则说明。 - [ ] 候选人实时票数、排名和图表只在活动明确开启后公开;不得公开未确认线下结果。 - [x] CMS 公开信息基础:建立八类固定内容定义(选举公告、规则与政策、候选人公示、选民指南、结果公告、常见问题、资料下载、历史归档)及只读公开内容契约。 diff --git a/docs/offline-election-counting.md b/docs/offline-election-counting.md new file mode 100644 index 0000000..b61deed --- /dev/null +++ b/docs/offline-election-counting.md @@ -0,0 +1,30 @@ +# 线下选举发布与计票操作说明 + +## 规则与权限 + +- 活动必须指定一个目标行政区。只有直接被分配至该行政区的省、市、区、镇或村管理员可创建、编辑及发布;系统管理员可以全局操作。上级管理员仍可以只读查看其下级范围的活动。 +- 活动在草稿阶段依次配置议题、选项和计票所。发布后定义冻结,不能增删候选人、计票所或改变计票规则。 +- 一个活动可以包含多个 `CandidateElection`(岗位/多席位竞选)及 `Proposition`(议案)议题。每个议题独立记录每个选项的有效票、弃权、无效票和系统计算的缺投数。 +- 每个议题可配置席位数、最少/最多选择数、最高票/简单多数/绝对多数/特定比例门槛、计票分母(仅有效票或全部投出选票)以及平票后的人工复核或无结果处理。 + +## 线下计票闭环 + +1. 活动投票截止后,已分配的计票员在自己被分配的计票所创建计票批次。 +2. 两名不同计票员各自提交完整汇总。计票员输入本题计票张数、各选项有效票、弃权和无效票;缺投不可编辑,系统按 `max(0, 计票张数 × 最少选择数 − 有效选项票数 − 弃权 − 无效)` 自动计算。系统不保存选民身份、单张选票或选票内容,只保存计票所级的汇总数字和录入人。 +3. 两份录入逐项完全一致才进入“待监督确认”;不一致进入“待复核”。监督员填写原因后创建新的复点版本,原版本封存为已替代。 +4. 监督员不能确认自己的录入;确认后的批次不可编辑。需要更正时,监督员必须填写原因并创建新版本,旧版本保留为已替代。 +5. 只有当前已确认版本进入汇总和公开接口。所有指定计票所都确认后,计票截止任务自动发布最终结果;有任一未确认或差异批次时不会自动发布。 + +## 对外公开 + +- `GET /api/public/elections/{electionId}/results` 仅返回活动明确允许的数据。截止及结果发布前,默认只能按活动配置公开参与量;候选人或议案票数只有在 `PublishLiveCandidateTotals=true` 或最终结果发布后才返回。 +- SignalR 端点为 `/hubs/public-results`;客户端调用 `Subscribe(electionId)` 后接收 `resultUpdated`。推送只基于已确认的线下批次。 +- 每次确认、以及后台每分钟推进活动状态时都会更新公开数据。所有时间均为 UTC。 + +## 管理 API + +- `POST /api/admin/elections` 创建草稿;`POST /{id}/contests`、`POST /{id}/contests/{contestId}/options`、`PUT /{id}/counting-stations` 配置;`POST /{id}/publish` 发布。 +- `POST /api/offline-counting/sessions` 开始计票;`POST /sessions/{id}/submissions` 双人独立录入;`POST /sessions/{id}/confirm` 监督确认。 +- `POST /sessions/{id}/resolve-discrepancy` 对不一致结果启动复点;`POST /sessions/{id}/corrections` 更正已确认版本。 + +这些接口的写入均经过地域或计票所角色检查,并由现有保存拦截器产生审计记录。公开接口不包含工作人员身份、审计记录或未确认数据。 diff --git a/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor b/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor index 96a2f63..57232d4 100644 --- a/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor +++ b/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor @@ -13,6 +13,9 @@ 选民管理 选民导入 候选人 +

投票与计票

+ 投票发布 + 线下计票

人员与场所

地域角色 本地用户 diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor b/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor new file mode 100644 index 0000000..0fb78e7 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/Elections.razor @@ -0,0 +1,48 @@ +@page "/admin/elections" +@rendermode InteractiveServer +@attribute [Authorize] +@layout AdminLayout +@using ElectionSystem.Api.Modules.Election.Domain +@using ElectionSystem.Api.Modules.Candidates.Domain +@inject ElectionDbContext Db +@inject RegionScopeService Scope + +投票发布 +
+

投票与计票 / 发布

投票发布

先配置岗位或议案、选项与计票所;活动发布后这些定义将被冻结。

+ @if (notice is not null) { } + @if (!loaded) {

正在加载授权范围…

} + else { +

新建本级投票

+
@foreach (var election in elections) { }
活动地区状态截止时间
@election.Name@regionNames.GetValueOrDefault(election.RegionId, "—")@election.Status@election.VotingEndsAtUtc.ToString("yyyy-MM-dd HH:mm") UTC
+ @if (selected is not null) { +

@selected.Name · 配置

状态:@selected.Status。@if (selected.Status != ElectionActivityStatus.Draft) { 已发布活动仅可查看。 }

+ @if (selected.Status == ElectionActivityStatus.Draft) { +

添加岗位或议案

+

添加选项

+

指定计票所

@foreach (var station in stations) { }
+ + } +

已配置议题

@foreach (var contest in contests) { }
议题类型规则选项
@contest.Title@contest.Type@contest.Determination / @contest.Seats 席@string.Join("、", options.Where(x => x.ElectionContestId == contest.Id).Select(x => x.Label))
+
} + } +
+ +@code { + [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; + private Guid actorId; private bool loaded; private string? notice; private readonly List elections = []; private readonly List contests = []; private readonly List options = []; private readonly List candidates = []; private readonly List stations = []; private readonly List publishRegions = []; private readonly Dictionary regionNames = []; private readonly HashSet selectedStationIds = []; private ElectionActivity? selected; private ElectionDraft draft = new(); private ContestDraft contestDraft = new(); private OptionDraft optionDraft = new(); + protected override async Task OnInitializedAsync() { actorId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var managed = await Scope.GetManagedRegionIdsAsync(actorId, CancellationToken.None); var allRegions = await Db.AdministrativeRegions.Where(x => managed.Contains(x.Id) && x.IsActive).OrderBy(x => x.Level).ThenBy(x => x.Code).ToListAsync(); foreach (var region in allRegions) { regionNames[region.Id] = region.Name; if (await IsDirectPublisherAsync(region.Id)) publishRegions.Add(region); } elections.AddRange(await Db.ElectionActivities.Where(x => managed.Contains(x.RegionId)).OrderByDescending(x => x.CreatedAtUtc).ToListAsync()); stations.AddRange(await Db.CountingStations.Where(x => managed.Contains(x.RegionId) && x.IsActive).OrderBy(x => x.Code).ToListAsync()); candidates.AddRange(await Db.Candidates.Where(x => managed.Contains(x.RegionId) && (x.Status == CandidateStatus.Approved || x.Status == CandidateStatus.Publicized)).OrderBy(x => x.DisplayName).ToListAsync()); loaded = true; } + private async Task IsDirectPublisherAsync(Guid regionId) { try { await Scope.RequirePublisherAtRegionAsync(actorId, regionId, CancellationToken.None); return true; } catch (UnauthorizedAccessException) { return false; } } + private async Task CreateAsync() { try { if (!draft.RegionId.HasValue) throw new ArgumentException("请选择要发布投票的本级地区。"); await Scope.RequirePublisherAtRegionAsync(actorId, draft.RegionId.Value, CancellationToken.None); var start = Utc(draft.VotingStarts); var end = Utc(draft.VotingEnds); var countingEnd = Utc(draft.CountingEnds); if (start >= end || end > countingEnd) throw new ArgumentException("时间必须满足投票开始 < 投票截止 <= 计票截止。"); var election = new ElectionActivity { Name = Text(draft.Name), RegionId = draft.RegionId.Value, VotingStartsAtUtc = start, VotingEndsAtUtc = end, CountingEndsAtUtc = countingEnd, PublishLiveParticipation = draft.PublishLiveParticipation, PublishLiveCandidateTotals = draft.PublishLiveCandidateTotals, CreatedByUserId = actorId }; Db.ElectionActivities.Add(election); await Db.SaveChangesAsync(); elections.Insert(0, election); selected = election; notice = "草稿已创建,请继续配置议题和计票所。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task SelectAsync(Guid id) { selected = elections.Single(x => x.Id == id); contests.Clear(); contests.AddRange(await Db.ElectionContests.Where(x => x.ElectionActivityId == id).OrderBy(x => x.SortOrder).ToListAsync()); options.Clear(); options.AddRange(await Db.ElectionOptions.Where(x => contests.Select(c => c.Id).Contains(x.ElectionContestId)).OrderBy(x => x.SortOrder).ToListAsync()); selectedStationIds.Clear(); foreach (var stationId in await Db.ElectionCountingStations.Where(x => x.ElectionActivityId == id).Select(x => x.CountingStationId).ToListAsync()) selectedStationIds.Add(stationId); } + private async Task AddContestAsync() { try { await RequireDraftPublisherAsync(); if (contestDraft.MinSelections < 0 || contestDraft.MinSelections > contestDraft.MaxSelections || contestDraft.MaxSelections > contestDraft.Seats || contestDraft.RequiredSupportPercent is <= 0 or > 100) throw new ArgumentException("选择数或门槛配置无效。"); var contest = new ElectionContest { ElectionActivityId = selected!.Id, Title = Text(contestDraft.Title), Type = contestDraft.Type, SortOrder = contests.Count + 1, Seats = contestDraft.Type == ElectionContestType.Proposition ? 1 : contestDraft.Seats, MinSelections = contestDraft.Type == ElectionContestType.Proposition ? 1 : contestDraft.MinSelections, MaxSelections = contestDraft.Type == ElectionContestType.Proposition ? 1 : contestDraft.MaxSelections, Determination = contestDraft.Determination, RequiredSupportPercent = contestDraft.RequiredSupportPercent }; Db.ElectionContests.Add(contest); await Db.SaveChangesAsync(); contests.Add(contest); contestDraft = new(); notice = "议题已添加。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task AddOptionAsync() { try { await RequireDraftPublisherAsync(); if (!optionDraft.ContestId.HasValue) throw new ArgumentException("请选择议题。"); var contest = contests.Single(x => x.Id == optionDraft.ContestId); if (contest.Type == ElectionContestType.CandidateElection && !optionDraft.CandidateId.HasValue) throw new ArgumentException("岗位竞选必须选择候选人。"); if (contest.Type == ElectionContestType.Proposition && optionDraft.CandidateId.HasValue) throw new ArgumentException("议案选项不能关联候选人。"); if (optionDraft.CandidateId.HasValue) { var candidate = candidates.SingleOrDefault(x => x.Id == optionDraft.CandidateId.Value) ?? throw new ArgumentException("候选人不可用。"); if (!await Scope.IsInRegionSubtreeAsync(candidate.RegionId, selected!.RegionId, CancellationToken.None)) throw new ArgumentException("候选人必须属于该活动辖区。"); } if (options.Any(x => x.ElectionContestId == contest.Id && (x.Label == optionDraft.Label.Trim() || (optionDraft.CandidateId.HasValue && x.CandidateId == optionDraft.CandidateId)))) throw new ArgumentException("该候选人或选项已存在。"); var option = new ElectionOption { ElectionContestId = contest.Id, CandidateId = optionDraft.CandidateId, Label = Text(optionDraft.Label), SortOrder = options.Count(x => x.ElectionContestId == contest.Id) + 1 }; Db.ElectionOptions.Add(option); await Db.SaveChangesAsync(); options.Add(option); optionDraft = new(); notice = "选项已添加。"; } catch (Exception exception) { notice = exception.Message; } } + private void ToggleStation(Guid stationId, ChangeEventArgs e) { if (e.Value is bool value && value) selectedStationIds.Add(stationId); else selectedStationIds.Remove(stationId); } + private async Task SaveStationsAsync() { try { await RequireDraftPublisherAsync(); if (selectedStationIds.Count == 0) throw new ArgumentException("至少指定一个计票所。"); foreach (var stationId in selectedStationIds) { var station = stations.Single(x => x.Id == stationId); if (!await Scope.IsInRegionSubtreeAsync(station.RegionId, selected!.RegionId, CancellationToken.None)) throw new ArgumentException("计票所必须属于活动辖区。"); } Db.ElectionCountingStations.RemoveRange(Db.ElectionCountingStations.Where(x => x.ElectionActivityId == selected!.Id)); foreach (var stationId in selectedStationIds) Db.ElectionCountingStations.Add(new ElectionCountingStation { ElectionActivityId = selected!.Id, CountingStationId = stationId }); await Db.SaveChangesAsync(); notice = "计票所已保存。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task PublishAsync() { try { await RequireDraftPublisherAsync(); if (contests.Count == 0 || selectedStationIds.Count == 0 || contests.Any(c => options.Count(o => o.ElectionContestId == c.Id) < 2)) throw new ArgumentException("每个议题至少需要两个选项,并至少指定一个计票所。"); selected!.Status = ElectionActivityStatus.Published; selected.PublishedAtUtc = DateTime.UtcNow; selected.UpdatedAtUtc = DateTime.UtcNow; await Db.SaveChangesAsync(); notice = "活动已发布,定义已冻结。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task RequireDraftPublisherAsync() { if (selected is null || selected.Status != ElectionActivityStatus.Draft) throw new InvalidOperationException("活动已发布,不能修改。"); await Scope.RequirePublisherAtRegionAsync(actorId, selected.RegionId, CancellationToken.None); } + private static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc); private static string Text(string value) => !string.IsNullOrWhiteSpace(value) ? value.Trim() : throw new ArgumentException("必填项不能为空。"); + private sealed class ElectionDraft { public string Name { get; set; } = ""; public Guid? RegionId { get; set; } public DateTime VotingStarts { get; set; } = DateTime.UtcNow.AddDays(1); public DateTime VotingEnds { get; set; } = DateTime.UtcNow.AddDays(2); public DateTime CountingEnds { get; set; } = DateTime.UtcNow.AddDays(3); public bool PublishLiveParticipation { get; set; } = true; public bool PublishLiveCandidateTotals { get; set; } } + private sealed class ContestDraft { public string Title { get; set; } = ""; public ElectionContestType Type { get; set; } = ElectionContestType.CandidateElection; public int Seats { get; set; } = 1; public int MinSelections { get; set; } = 1; public int MaxSelections { get; set; } = 1; public WinnerDetermination Determination { get; set; } = WinnerDetermination.HighestVotes; public decimal RequiredSupportPercent { get; set; } = 50m; } + private sealed class OptionDraft { public Guid? ContestId { get; set; } public Guid? CandidateId { get; set; } public string Label { get; set; } = ""; } +} diff --git a/src/ElectionSystem.Api/Components/Pages/Admin/OfflineCounting.razor b/src/ElectionSystem.Api/Components/Pages/Admin/OfflineCounting.razor new file mode 100644 index 0000000..2e8e069 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/Admin/OfflineCounting.razor @@ -0,0 +1,38 @@ +@page "/admin/offline-counting" +@rendermode InteractiveServer +@attribute [Authorize] +@layout AdminLayout +@using ElectionSystem.Api.Modules.Election.Domain +@using ElectionSystem.Api.Modules.OfflineCounting.Domain +@using ElectionSystem.Api.Modules.OfflineCounting +@using ElectionSystem.Api.Modules.PublicBoard +@inject ElectionDbContext Db +@inject RegionScopeService Scope +@inject PublicResultsNotifier Notifier + +线下计票 +
+

投票与计票 / 线下

线下计票

两名不同计票员独立录入;数据一致后由监督员确认,未确认数据不会进入公开结果。

+ @if (notice is not null) { } + @if (!loaded) {

正在加载可操作计票所…

} else { +
+ @if (sessions.Count > 0) {
@foreach (var session in sessions) { }
计票所版本状态操作
@stationNames.GetValueOrDefault(session.CountingStationId, "—")V@session.Version@session.Status@if (session.Status == OfflineCountingSessionStatus.AwaitingSupervisor) { }
} + @if (selectedSession is not null && selectedSession.Status is OfflineCountingSessionStatus.Open or OfflineCountingSessionStatus.AwaitingSecondEntry) { +

V@selectedSession.Version 独立录入

@foreach (var contest in ballotContests) {
@contest.Title@foreach (var option in ballotOptions.Where(x => x.ElectionContestId == contest.Id)) { var row = entry.OptionRows.Single(x => x.OptionId == option.Id); } @{ var summary = entry.Summaries.Single(x => x.ContestId == contest.Id); }

缺投(自动计算):@MissingFor(contest, summary)

}
+ } + } +
+ +@code { + [CascadingParameter] private Task AuthenticationStateTask { get; set; } = default!; + private Guid actorId; private bool loaded; private string? notice; private Guid? electionId, stationId; private readonly List elections = []; private readonly List stations = []; private readonly List sessions = []; private readonly List ballotContests = []; private readonly List ballotOptions = []; private readonly Dictionary stationNames = []; private OfflineCountingSession? selectedSession; private EntryDraft entry = new(); + protected override async Task OnInitializedAsync() { actorId = RegionScopeService.GetRequiredUserId((await AuthenticationStateTask).User); var assignments = await Db.UserCountingStationAssignments.Where(x => x.UserId == actorId).Select(x => x.CountingStationId).ToListAsync(); stations.AddRange(await Db.CountingStations.Where(x => assignments.Contains(x.Id) && x.IsActive).OrderBy(x => x.Code).ToListAsync()); foreach (var station in stations) stationNames[station.Id] = $"{station.Code} · {station.Name}"; elections.AddRange(await Db.ElectionActivities.Where(x => x.Status == ElectionActivityStatus.Published || x.Status == ElectionActivityStatus.Counting).OrderByDescending(x => x.VotingEndsAtUtc).ToListAsync()); loaded = true; } + private async Task LoadElectionAsync(ChangeEventArgs e) { electionId = Guid.TryParse(e.Value?.ToString(), out var id) ? id : null; selectedSession = null; sessions.Clear(); if (!electionId.HasValue) return; sessions.AddRange(await Db.OfflineCountingSessions.Where(x => x.ElectionActivityId == electionId.Value).OrderBy(x => x.CountingStationId).ThenByDescending(x => x.Version).ToListAsync()); } + private async Task OpenAsync() { try { if (!electionId.HasValue || !stationId.HasValue) throw new ArgumentException("请选择活动和计票所。"); var election = elections.Single(x => x.Id == electionId); if (election.VotingEndsAtUtc > DateTime.UtcNow) throw new InvalidOperationException("投票尚未截止,不能开始计票。"); await Scope.RequireCountingStationRoleAsync(actorId, stationId.Value, false, CancellationToken.None); if (!await Db.ElectionCountingStations.AnyAsync(x => x.ElectionActivityId == electionId && x.CountingStationId == stationId)) throw new InvalidOperationException("此计票所未分配到该活动。"); if (sessions.Any(x => x.CountingStationId == stationId && x.Status != OfflineCountingSessionStatus.Superseded)) throw new InvalidOperationException("本所已有当前计票批次。"); var session = new OfflineCountingSession { ElectionActivityId = electionId.Value, CountingStationId = stationId.Value, OpenedByUserId = actorId }; Db.OfflineCountingSessions.Add(session); await Db.SaveChangesAsync(); sessions.Add(session); await SelectSessionAsync(session.Id); notice = "计票批次已创建,请由两名不同计票员分别录入。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task SelectSessionAsync(Guid id) { selectedSession = sessions.Single(x => x.Id == id); ballotContests.Clear(); ballotContests.AddRange(await Db.ElectionContests.Where(x => x.ElectionActivityId == selectedSession.ElectionActivityId).OrderBy(x => x.SortOrder).ToListAsync()); ballotOptions.Clear(); ballotOptions.AddRange(await Db.ElectionOptions.Where(x => ballotContests.Select(c => c.Id).Contains(x.ElectionContestId) && x.IsActive).OrderBy(x => x.SortOrder).ToListAsync()); entry = new EntryDraft(); entry.OptionRows.AddRange(ballotOptions.Select(x => new OptionRow { OptionId = x.Id })); entry.Summaries.AddRange(ballotContests.Select(x => new SummaryRow { ContestId = x.Id })); } + private async Task SubmitAsync() { try { if (selectedSession is null) return; await Scope.RequireCountingStationRoleAsync(actorId, selectedSession.CountingStationId, false, CancellationToken.None); if (await Db.OfflineCountSubmissions.AnyAsync(x => x.OfflineCountingSessionId == selectedSession.Id && x.EnteredByUserId == actorId)) throw new InvalidOperationException("同一计票员不能重复录入。 "); var submission = new OfflineCountSubmission { OfflineCountingSessionId = selectedSession.Id, EnteredByUserId = actorId }; Db.OfflineCountSubmissions.Add(submission); foreach (var row in entry.OptionRows) { if (row.Votes < 0) throw new ArgumentException("票数不能为负数。"); var option = ballotOptions.Single(x => x.Id == row.OptionId); Db.OfflineCountTallies.Add(new OfflineCountTally { OfflineCountSubmissionId = submission.Id, ElectionContestId = option.ElectionContestId, ElectionOptionId = row.OptionId, Votes = row.Votes }); } foreach (var row in entry.Summaries) { if (row.CountedBallots < 0 || row.Abstentions < 0 || row.InvalidBallots < 0) throw new ArgumentException("统计数不能为负数。"); var contest = ballotContests.Single(x => x.Id == row.ContestId); var optionVotes = entry.OptionRows.Where(x => ballotOptions.Single(o => o.Id == x.OptionId).ElectionContestId == contest.Id).Sum(x => x.Votes); Db.OfflineCountTallies.Add(new OfflineCountTally { OfflineCountSubmissionId = submission.Id, ElectionContestId = row.ContestId, CountedBallots = row.CountedBallots, Abstentions = row.Abstentions, InvalidBallots = row.InvalidBallots, MissingSelections = OfflineCountingRules.CalculateMissingSelections(contest, row.CountedBallots, optionVotes, row.Abstentions, row.InvalidBallots) }); } await Db.SaveChangesAsync(); var ids = await Db.OfflineCountSubmissions.Where(x => x.OfflineCountingSessionId == selectedSession.Id).OrderBy(x => x.SubmittedAtUtc).Select(x => x.Id).ToListAsync(); selectedSession.Status = ids.Count == 1 ? OfflineCountingSessionStatus.AwaitingSecondEntry : await SameAsync(ids[0], ids[1]) ? OfflineCountingSessionStatus.AwaitingSupervisor : OfflineCountingSessionStatus.NeedsReview; await Db.SaveChangesAsync(); notice = selectedSession.Status == OfflineCountingSessionStatus.NeedsReview ? "两份录入不一致,已转入复核。" : "录入已保存。"; } catch (Exception exception) { notice = exception.Message; } } + private int MissingFor(ElectionContest contest, SummaryRow summary) => OfflineCountingRules.CalculateMissingSelections(contest, summary.CountedBallots, entry.OptionRows.Where(x => ballotOptions.Single(o => o.Id == x.OptionId).ElectionContestId == contest.Id).Sum(x => x.Votes), summary.Abstentions, summary.InvalidBallots); + private async Task ConfirmAsync(Guid id) { try { var session = sessions.Single(x => x.Id == id); await Scope.RequireCountingStationRoleAsync(actorId, session.CountingStationId, true, CancellationToken.None); if (await Db.OfflineCountSubmissions.AnyAsync(x => x.OfflineCountingSessionId == id && x.EnteredByUserId == actorId)) throw new InvalidOperationException("监督员不能确认本人录入。"); session.Status = OfflineCountingSessionStatus.Confirmed; session.ConfirmedByUserId = actorId; session.ConfirmedAtUtc = DateTime.UtcNow; await Db.SaveChangesAsync(); await Notifier.PublishAsync(session.ElectionActivityId, CancellationToken.None); notice = "已监督确认并推送公开结果。"; } catch (Exception exception) { notice = exception.Message; } } + private async Task SameAsync(Guid first, Guid second) { var a = await Db.OfflineCountTallies.Where(x => x.OfflineCountSubmissionId == first).OrderBy(x => x.ElectionContestId).ThenBy(x => x.ElectionOptionId).Select(x => new { x.ElectionContestId, x.ElectionOptionId, x.Votes, x.Abstentions, x.InvalidBallots, x.MissingSelections }).ToListAsync(); var b = await Db.OfflineCountTallies.Where(x => x.OfflineCountSubmissionId == second).OrderBy(x => x.ElectionContestId).ThenBy(x => x.ElectionOptionId).Select(x => new { x.ElectionContestId, x.ElectionOptionId, x.Votes, x.Abstentions, x.InvalidBallots, x.MissingSelections }).ToListAsync(); return a.SequenceEqual(b); } + private sealed class EntryDraft { public List OptionRows { get; } = []; public List Summaries { get; } = []; } private sealed class OptionRow { public Guid OptionId { get; set; } public int Votes { get; set; } } private sealed class SummaryRow { public Guid ContestId { get; set; } public int CountedBallots { get; set; } public int Abstentions { get; set; } public int InvalidBallots { get; set; } } +} diff --git a/src/ElectionSystem.Api/Controllers/ElectionsController.cs b/src/ElectionSystem.Api/Controllers/ElectionsController.cs new file mode 100644 index 0000000..a6a85b7 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/ElectionsController.cs @@ -0,0 +1,139 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Candidates.Domain; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.OfflineCounting.Domain; +using ElectionSystem.Api.Modules.PublicBoard; +using ElectionSystem.Api.Modules.Results; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/admin/elections")] +public sealed class ElectionsController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService) : AdministrationControllerBase(scopeService) +{ + [HttpGet] + public async Task List([FromQuery] Guid? regionId, CancellationToken cancellationToken) + { + var managed = await ScopeService.GetManagedRegionIdsAsync(ActorId, cancellationToken); + var query = dbContext.ElectionActivities.Where(x => managed.Contains(x.RegionId)); + if (regionId.HasValue) query = query.Where(x => x.RegionId == regionId.Value); + return Ok(await query.OrderByDescending(x => x.CreatedAtUtc).Select(x => new { x.Id, x.Name, x.RegionId, x.Status, x.VotingStartsAtUtc, x.VotingEndsAtUtc, x.CountingEndsAtUtc, x.PublishLiveParticipation, x.PublishLiveCandidateTotals }).ToListAsync(cancellationToken)); + } + + [HttpPost] + public async Task Create(CreateElectionRequest request, CancellationToken cancellationToken) + { + ValidateDates(request.VotingStartsAtUtc, request.VotingEndsAtUtc, request.CountingEndsAtUtc); + await ScopeService.RequirePublisherAtRegionAsync(ActorId, request.RegionId, cancellationToken); + var election = new ElectionActivity { Name = RequiredText(request.Name, "名称"), Description = request.Description?.Trim(), RegionId = request.RegionId, VotingStartsAtUtc = request.VotingStartsAtUtc, VotingEndsAtUtc = request.VotingEndsAtUtc, CountingEndsAtUtc = request.CountingEndsAtUtc, PublishLiveParticipation = request.PublishLiveParticipation, PublishLiveCandidateTotals = request.PublishLiveCandidateTotals, CreatedByUserId = ActorId }; + dbContext.ElectionActivities.Add(election); await dbContext.SaveChangesAsync(cancellationToken); + return CreatedAtAction(nameof(Get), new { electionId = election.Id }, new { election.Id }); + } + + [HttpGet("{electionId:guid}")] + public async Task Get(Guid electionId, CancellationToken cancellationToken) + { + var election = await RequireManagedElectionAsync(electionId, cancellationToken); + var contests = await dbContext.ElectionContests.Where(x => x.ElectionActivityId == electionId).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var options = await dbContext.ElectionOptions.Where(x => contests.Select(c => c.Id).Contains(x.ElectionContestId)).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var stations = await dbContext.ElectionCountingStations.Where(x => x.ElectionActivityId == electionId).Select(x => x.CountingStationId).ToListAsync(cancellationToken); + return Ok(new { election, contests, options, countingStationIds = stations }); + } + + [HttpPost("{electionId:guid}/contests")] + public async Task AddContest(Guid electionId, CreateContestRequest request, CancellationToken cancellationToken) + { + var election = await RequireDraftPublisherAsync(electionId, cancellationToken); + ValidateContest(request); + if (await dbContext.ElectionContests.AnyAsync(x => x.ElectionActivityId == electionId && x.SortOrder == request.SortOrder, cancellationToken)) return Conflict(new { code = "contest_sort_order_exists" }); + var contest = new ElectionContest { ElectionActivityId = election.Id, Title = RequiredText(request.Title, "议题/岗位名称"), Type = request.Type, SortOrder = request.SortOrder, Seats = request.Seats, MinSelections = request.MinSelections, MaxSelections = request.MaxSelections, Determination = request.Determination, SupportDenominator = request.SupportDenominator, RequiredSupportPercent = request.RequiredSupportPercent, TieResolution = request.TieResolution }; + dbContext.ElectionContests.Add(contest); await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { contest.Id }); + } + + [HttpPost("{electionId:guid}/contests/{contestId:guid}/options")] + public async Task AddOption(Guid electionId, Guid contestId, CreateOptionRequest request, CancellationToken cancellationToken) + { + var election = await RequireDraftPublisherAsync(electionId, cancellationToken); + var contest = await dbContext.ElectionContests.SingleOrDefaultAsync(x => x.Id == contestId && x.ElectionActivityId == election.Id, cancellationToken) ?? throw new InvalidOperationException("Contest does not belong to the election."); + if (contest.Type == ElectionContestType.CandidateElection && !request.CandidateId.HasValue) return ValidationProblem("候选人岗位必须关联候选人。"); + if (contest.Type == ElectionContestType.Proposition && request.CandidateId.HasValue) return ValidationProblem("议案不能关联候选人。"); + if (request.CandidateId.HasValue) + { + var candidate = await dbContext.Candidates.SingleOrDefaultAsync(x => x.Id == request.CandidateId.Value, cancellationToken) ?? throw new InvalidOperationException("Candidate does not exist."); + if (candidate.Status is not (CandidateStatus.Approved or CandidateStatus.Publicized) || !await ScopeService.IsInRegionSubtreeAsync(candidate.RegionId, election.RegionId, cancellationToken)) return ValidationProblem("候选人必须处于已批准或已公示状态,且属于本级辖区。"); + } + if (await dbContext.ElectionOptions.AnyAsync(x => x.ElectionContestId == contestId && (x.SortOrder == request.SortOrder || (request.CandidateId.HasValue && x.CandidateId == request.CandidateId)), cancellationToken)) return Conflict(new { code = "election_option_exists" }); + var option = new ElectionOption { ElectionContestId = contestId, CandidateId = request.CandidateId, Label = RequiredText(request.Label, "选项名称"), SortOrder = request.SortOrder }; + dbContext.ElectionOptions.Add(option); await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { option.Id }); + } + + [HttpPut("{electionId:guid}/counting-stations")] + public async Task SetCountingStations(Guid electionId, SetCountingStationsRequest request, CancellationToken cancellationToken) + { + var election = await RequireDraftPublisherAsync(electionId, cancellationToken); + var ids = request.CountingStationIds.Distinct().ToArray(); + if (ids.Length == 0) return ValidationProblem("至少指定一个计票所。"); + var stations = await dbContext.CountingStations.Where(x => ids.Contains(x.Id) && x.IsActive).ToListAsync(cancellationToken); + if (stations.Count != ids.Length) return ValidationProblem("计票所必须有效且属于活动辖区。"); + foreach (var station in stations) + if (!await ScopeService.IsInRegionSubtreeAsync(station.RegionId, election.RegionId, cancellationToken)) return ValidationProblem("计票所必须有效且属于活动辖区。"); + dbContext.ElectionCountingStations.RemoveRange(dbContext.ElectionCountingStations.Where(x => x.ElectionActivityId == electionId)); + dbContext.ElectionCountingStations.AddRange(ids.Select(id => new ElectionCountingStation { ElectionActivityId = electionId, CountingStationId = id })); + await dbContext.SaveChangesAsync(cancellationToken); return NoContent(); + } + + [HttpPost("{electionId:guid}/publish")] + public async Task Publish(Guid electionId, CancellationToken cancellationToken) + { + var election = await RequireDraftPublisherAsync(electionId, cancellationToken); + var contests = await dbContext.ElectionContests.Where(x => x.ElectionActivityId == electionId).ToListAsync(cancellationToken); + var options = await dbContext.ElectionOptions.Where(x => contests.Select(c => c.Id).Contains(x.ElectionContestId) && x.IsActive).ToListAsync(cancellationToken); + if (contests.Count == 0 || await dbContext.ElectionCountingStations.CountAsync(x => x.ElectionActivityId == electionId, cancellationToken) == 0) return ValidationProblem("发布前必须配置至少一个议题和一个计票所。"); + foreach (var contest in contests) + { + var count = options.Count(x => x.ElectionContestId == contest.Id); + if (count < 2 || contest.MinSelections > contest.MaxSelections || contest.MaxSelections > count) return ValidationProblem($"议题“{contest.Title}”的选项或选择规则不完整。"); + } + election.Status = ElectionActivityStatus.Published; election.PublishedAtUtc = DateTime.UtcNow; election.UpdatedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); return NoContent(); + } + + private async Task RequireManagedElectionAsync(Guid electionId, CancellationToken cancellationToken) + { + var election = await dbContext.ElectionActivities.SingleOrDefaultAsync(x => x.Id == electionId, cancellationToken) ?? throw new InvalidOperationException("Election does not exist."); + await ScopeService.RequireManagedRegionAsync(ActorId, election.RegionId, cancellationToken); return election; + } + private async Task RequireDraftPublisherAsync(Guid electionId, CancellationToken cancellationToken) + { + var election = await RequireManagedElectionAsync(electionId, cancellationToken); + if (election.Status != ElectionActivityStatus.Draft) throw new InvalidOperationException("Published election definitions are frozen."); + await ScopeService.RequirePublisherAtRegionAsync(ActorId, election.RegionId, cancellationToken); return election; + } + private static void ValidateDates(DateTime start, DateTime end, DateTime countingEnd) { if (start.Kind != DateTimeKind.Utc || end.Kind != DateTimeKind.Utc || countingEnd.Kind != DateTimeKind.Utc || start >= end || end > countingEnd) throw new ArgumentException("时间必须为 UTC,且满足投票开始 < 投票截止 <= 计票截止。"); } + private static void ValidateContest(CreateContestRequest request) { if (request.SortOrder < 0 || request.Seats < 1 || request.MinSelections < 0 || request.MaxSelections < 1 || request.RequiredSupportPercent <= 0 || request.RequiredSupportPercent > 100) throw new ArgumentException("议题规则参数无效。"); if (request.Type == ElectionContestType.Proposition && (request.Seats != 1 || request.MinSelections != 1 || request.MaxSelections != 1)) throw new ArgumentException("议案必须为单项表决。"); } + private static string RequiredText(string value, string name) => !string.IsNullOrWhiteSpace(value) ? value.Trim() : throw new ArgumentException($"{name}不能为空。"); +} + +public sealed record CreateElectionRequest(string Name, string? Description, Guid RegionId, DateTime VotingStartsAtUtc, DateTime VotingEndsAtUtc, DateTime CountingEndsAtUtc, bool PublishLiveParticipation, bool PublishLiveCandidateTotals); +public sealed record CreateContestRequest(string Title, ElectionContestType Type, int SortOrder, int Seats, int MinSelections, int MaxSelections, WinnerDetermination Determination, SupportDenominator SupportDenominator, decimal RequiredSupportPercent, TieResolution TieResolution); +public sealed record CreateOptionRequest(Guid? CandidateId, string Label, int SortOrder); +public sealed record SetCountingStationsRequest(IReadOnlyList CountingStationIds); + +[ApiController] +[AllowAnonymous] +[Route("api/public/elections")] +public sealed class PublicElectionResultsController(ElectionDbContext dbContext, ElectionResultService resultService) : ControllerBase +{ + [HttpGet("{electionId:guid}/results")] + public async Task Get(Guid electionId, CancellationToken cancellationToken) + { + var election = await dbContext.ElectionActivities.AsNoTracking().SingleOrDefaultAsync(x => x.Id == electionId, cancellationToken); + if (election is null || election.Status == ElectionActivityStatus.Draft) return NotFound(); + var isFinal = election.Status == ElectionActivityStatus.PublishedResults; + if (!isFinal && !election.PublishLiveParticipation) return NotFound(); + var snapshot = await resultService.BuildAsync(electionId, cancellationToken); + return Ok(new { election.Id, election.Name, election.Status, election.ResultsPublishedAtUtc, updatedAtUtc = DateTime.UtcNow, publicationPolicy = new { election.PublishLiveParticipation, election.PublishLiveCandidateTotals, isFinal }, participation = snapshot.Contests.Sum(x => x.ValidVotes + x.Abstentions + x.InvalidBallots + x.MissingSelections), contests = (isFinal || election.PublishLiveCandidateTotals) ? snapshot.Contests : null }); + } +} diff --git a/src/ElectionSystem.Api/Controllers/OfflineCountingController.cs b/src/ElectionSystem.Api/Controllers/OfflineCountingController.cs new file mode 100644 index 0000000..734f919 --- /dev/null +++ b/src/ElectionSystem.Api/Controllers/OfflineCountingController.cs @@ -0,0 +1,119 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.OfflineCounting.Domain; +using ElectionSystem.Api.Modules.OfflineCounting; +using ElectionSystem.Api.Modules.PublicBoard; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Controllers; + +[Route("api/offline-counting")] +public sealed class OfflineCountingController(ElectionDbContext dbContext, Modules.Organization.RegionScopeService scopeService, PublicResultsNotifier notifier) : AdministrationControllerBase(scopeService) +{ + [HttpPost("sessions")] + public async Task OpenSession(OpenCountingSessionRequest request, CancellationToken cancellationToken) + { + var election = await dbContext.ElectionActivities.SingleOrDefaultAsync(x => x.Id == request.ElectionId, cancellationToken) ?? throw new InvalidOperationException("Election does not exist."); + if (election.Status is not (ElectionActivityStatus.Published or ElectionActivityStatus.Counting) || election.VotingEndsAtUtc > DateTime.UtcNow) return Conflict(new { code = "offline_counting_not_open" }); + if (!await dbContext.ElectionCountingStations.AnyAsync(x => x.ElectionActivityId == request.ElectionId && x.CountingStationId == request.CountingStationId, cancellationToken)) return ValidationProblem("计票所未被分配到该活动。"); + await ScopeService.RequireCountingStationRoleAsync(ActorId, request.CountingStationId, false, cancellationToken); + if (await dbContext.OfflineCountingSessions.AnyAsync(x => x.ElectionActivityId == request.ElectionId && x.CountingStationId == request.CountingStationId && x.Status != OfflineCountingSessionStatus.Superseded, cancellationToken)) return Conflict(new { code = "current_counting_session_exists" }); + var session = new OfflineCountingSession { ElectionActivityId = request.ElectionId, CountingStationId = request.CountingStationId, OpenedByUserId = ActorId }; + dbContext.OfflineCountingSessions.Add(session); await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { session.Id, session.Version }); + } + + [HttpPost("sessions/{sessionId:guid}/submissions")] + public async Task Submit(Guid sessionId, SubmitOfflineCountRequest request, CancellationToken cancellationToken) + { + var session = await dbContext.OfflineCountingSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken) ?? throw new InvalidOperationException("Counting session does not exist."); + if (session.Status is OfflineCountingSessionStatus.NeedsReview or OfflineCountingSessionStatus.AwaitingSupervisor or OfflineCountingSessionStatus.Confirmed or OfflineCountingSessionStatus.Superseded) return Conflict(new { code = "counting_session_not_accepting_entries" }); + await ScopeService.RequireCountingStationRoleAsync(ActorId, session.CountingStationId, false, cancellationToken); + if (await dbContext.OfflineCountSubmissions.AnyAsync(x => x.OfflineCountingSessionId == sessionId && x.EnteredByUserId == ActorId, cancellationToken)) return Conflict(new { code = "independent_entry_required" }); + if (await dbContext.OfflineCountSubmissions.CountAsync(x => x.OfflineCountingSessionId == sessionId, cancellationToken) >= 2) return Conflict(new { code = "two_entries_already_submitted" }); + var contests = await dbContext.ElectionContests.Where(x => x.ElectionActivityId == session.ElectionActivityId).ToListAsync(cancellationToken); + var options = await dbContext.ElectionOptions.Where(x => contests.Select(c => c.Id).Contains(x.ElectionContestId) && x.IsActive).ToListAsync(cancellationToken); + ValidateSubmission(request, contests, options); + var submission = new OfflineCountSubmission { OfflineCountingSessionId = sessionId, EnteredByUserId = ActorId }; + dbContext.OfflineCountSubmissions.Add(submission); + foreach (var tally in request.Contests) + { + foreach (var option in tally.Options) + dbContext.OfflineCountTallies.Add(new OfflineCountTally { OfflineCountSubmissionId = submission.Id, ElectionContestId = tally.ContestId, ElectionOptionId = option.OptionId, Votes = option.Votes }); + var contest = contests.Single(x => x.Id == tally.ContestId); + var optionVotes = tally.Options.Sum(x => x.Votes); + var missing = OfflineCountingRules.CalculateMissingSelections(contest, tally.CountedBallots, optionVotes, tally.Abstentions, tally.InvalidBallots); + dbContext.OfflineCountTallies.Add(new OfflineCountTally { OfflineCountSubmissionId = submission.Id, ElectionContestId = tally.ContestId, CountedBallots = tally.CountedBallots, Abstentions = tally.Abstentions, InvalidBallots = tally.InvalidBallots, MissingSelections = missing }); + } + await dbContext.SaveChangesAsync(cancellationToken); + var entries = await dbContext.OfflineCountSubmissions.Where(x => x.OfflineCountingSessionId == sessionId).OrderBy(x => x.SubmittedAtUtc).Select(x => x.Id).ToListAsync(cancellationToken); + if (entries.Count == 1) session.Status = OfflineCountingSessionStatus.AwaitingSecondEntry; + else session.Status = await MatchesAsync(entries[0], entries[1], cancellationToken) ? OfflineCountingSessionStatus.AwaitingSupervisor : OfflineCountingSessionStatus.NeedsReview; + await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { submission.Id, session.Status }); + } + + [HttpPost("sessions/{sessionId:guid}/confirm")] + public async Task Confirm(Guid sessionId, CancellationToken cancellationToken) + { + var session = await dbContext.OfflineCountingSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken) ?? throw new InvalidOperationException("Counting session does not exist."); + if (session.Status != OfflineCountingSessionStatus.AwaitingSupervisor) return Conflict(new { code = "independent_entries_not_matched" }); + await ScopeService.RequireCountingStationRoleAsync(ActorId, session.CountingStationId, true, cancellationToken); + if (await dbContext.OfflineCountSubmissions.AnyAsync(x => x.OfflineCountingSessionId == sessionId && x.EnteredByUserId == ActorId, cancellationToken)) return ValidationProblem("监督员不得确认本人录入的计票结果。"); + session.Status = OfflineCountingSessionStatus.Confirmed; session.ConfirmedByUserId = ActorId; session.ConfirmedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); await notifier.PublishAsync(session.ElectionActivityId, cancellationToken); return NoContent(); + } + + [HttpPost("sessions/{sessionId:guid}/resolve-discrepancy")] + public async Task ResolveDiscrepancy(Guid sessionId, CreateCorrectionRequest request, CancellationToken cancellationToken) + { + var previous = await dbContext.OfflineCountingSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken) ?? throw new InvalidOperationException("Counting session does not exist."); + if (previous.Status != OfflineCountingSessionStatus.NeedsReview || string.IsNullOrWhiteSpace(request.Reason)) return Conflict(new { code = "discrepancy_and_reason_required" }); + await ScopeService.RequireCountingStationRoleAsync(ActorId, previous.CountingStationId, true, cancellationToken); + previous.Status = OfflineCountingSessionStatus.Superseded; + var recount = new OfflineCountingSession { ElectionActivityId = previous.ElectionActivityId, CountingStationId = previous.CountingStationId, Version = previous.Version + 1, ReplacesSessionId = previous.Id, CorrectionReason = request.Reason.Trim(), OpenedByUserId = ActorId }; + dbContext.OfflineCountingSessions.Add(recount); await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { recount.Id, recount.Version }); + } + + [HttpPost("sessions/{sessionId:guid}/corrections")] + public async Task Correct(Guid sessionId, CreateCorrectionRequest request, CancellationToken cancellationToken) + { + var previous = await dbContext.OfflineCountingSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken) ?? throw new InvalidOperationException("Counting session does not exist."); + if (previous.Status != OfflineCountingSessionStatus.Confirmed || string.IsNullOrWhiteSpace(request.Reason)) return Conflict(new { code = "confirmed_session_and_reason_required" }); + await ScopeService.RequireCountingStationRoleAsync(ActorId, previous.CountingStationId, true, cancellationToken); + previous.Status = OfflineCountingSessionStatus.Superseded; + var correction = new OfflineCountingSession { ElectionActivityId = previous.ElectionActivityId, CountingStationId = previous.CountingStationId, Version = previous.Version + 1, ReplacesSessionId = previous.Id, CorrectionReason = request.Reason.Trim(), OpenedByUserId = ActorId }; + dbContext.OfflineCountingSessions.Add(correction); await dbContext.SaveChangesAsync(cancellationToken); return Ok(new { correction.Id, correction.Version }); + } + + [HttpGet("elections/{electionId:guid}/sessions")] + public async Task List(Guid electionId, CancellationToken cancellationToken) + { + var election = await dbContext.ElectionActivities.SingleOrDefaultAsync(x => x.Id == electionId, cancellationToken) ?? throw new InvalidOperationException("Election does not exist."); + await ScopeService.RequireManagedRegionAsync(ActorId, election.RegionId, cancellationToken); + return Ok(await dbContext.OfflineCountingSessions.Where(x => x.ElectionActivityId == electionId).OrderBy(x => x.CountingStationId).ThenByDescending(x => x.Version).Select(x => new { x.Id, x.CountingStationId, x.Version, x.Status, x.ReplacesSessionId, x.CorrectionReason, x.ConfirmedAtUtc }).ToListAsync(cancellationToken)); + } + + private async Task MatchesAsync(Guid firstSubmissionId, Guid secondSubmissionId, CancellationToken cancellationToken) + { + var first = await dbContext.OfflineCountTallies.Where(x => x.OfflineCountSubmissionId == firstSubmissionId).OrderBy(x => x.ElectionContestId).ThenBy(x => x.ElectionOptionId).Select(x => new { x.ElectionContestId, x.ElectionOptionId, x.Votes, x.Abstentions, x.InvalidBallots, x.MissingSelections }).ToListAsync(cancellationToken); + var second = await dbContext.OfflineCountTallies.Where(x => x.OfflineCountSubmissionId == secondSubmissionId).OrderBy(x => x.ElectionContestId).ThenBy(x => x.ElectionOptionId).Select(x => new { x.ElectionContestId, x.ElectionOptionId, x.Votes, x.Abstentions, x.InvalidBallots, x.MissingSelections }).ToListAsync(cancellationToken); + return first.SequenceEqual(second); + } + + private static void ValidateSubmission(SubmitOfflineCountRequest request, List contests, List options) + { + if (request.Contests.Count != contests.Count || request.Contests.Select(x => x.ContestId).Distinct().Count() != contests.Count || request.Contests.Any(x => !contests.Any(c => c.Id == x.ContestId))) throw new ArgumentException("每个议题必须且只能录入一次。"); + foreach (var contestTally in request.Contests) + { + if (contestTally.CountedBallots < 0 || contestTally.Abstentions < 0 || contestTally.InvalidBallots < 0) throw new ArgumentException("计票张数、弃权和无效票数不能为负数。"); + var contestOptions = options.Where(x => x.ElectionContestId == contestTally.ContestId).Select(x => x.Id).ToHashSet(); + if (contestTally.Options.Count != contestOptions.Count || contestTally.Options.Select(x => x.OptionId).Distinct().Count() != contestOptions.Count || contestTally.Options.Any(x => x.Votes < 0 || !contestOptions.Contains(x.OptionId))) throw new ArgumentException("选项票数必须完整且非负。"); + } + } +} + +public sealed record OpenCountingSessionRequest(Guid ElectionId, Guid CountingStationId); +public sealed record SubmitOfflineCountRequest(IReadOnlyList Contests); +public sealed record OfflineContestTallyRequest(Guid ContestId, IReadOnlyList Options, int CountedBallots, int Abstentions, int InvalidBallots); +public sealed record OfflineOptionTallyRequest(Guid OptionId, int Votes); +public sealed record CreateCorrectionRequest(string Reason); diff --git a/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs b/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs new file mode 100644 index 0000000..b43b782 --- /dev/null +++ b/src/ElectionSystem.Api/Hubs/PublicResultsHub.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.SignalR; + +namespace ElectionSystem.Api.Hubs; + +public sealed class PublicResultsHub : Hub +{ + public Task Subscribe(Guid electionId) => Groups.AddToGroupAsync(Context.ConnectionId, $"election:{electionId}"); +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs index aee95d0..53a06dc 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/ElectionDbContext.cs @@ -3,6 +3,8 @@ using ElectionSystem.Api.Modules.Identity.Domain; using ElectionSystem.Api.Modules.Organization.Domain; using ElectionSystem.Api.Modules.Voters.Domain; using ElectionSystem.Api.Modules.Candidates.Domain; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.OfflineCounting.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; @@ -18,6 +20,13 @@ public sealed class ElectionDbContext(DbContextOptions option public DbSet UserCountingStationAssignments => Set(); public DbSet Voters => Set(); public DbSet Candidates => Set(); + public DbSet ElectionActivities => Set(); + public DbSet ElectionContests => Set(); + public DbSet ElectionOptions => Set(); + public DbSet ElectionCountingStations => Set(); + public DbSet OfflineCountingSessions => Set(); + public DbSet OfflineCountSubmissions => Set(); + public DbSet OfflineCountTallies => Set(); public DbSet AuditEntries => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -101,6 +110,62 @@ public sealed class ElectionDbContext(DbContextOptions option entity.HasIndex(x => x.Code).IsUnique(); entity.HasOne().WithMany().HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.ToTable("election_activities"); + entity.Property(x => x.Name).HasMaxLength(200).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(4000); + entity.HasIndex(x => new { x.RegionId, x.Status, x.VotingEndsAtUtc }); + entity.HasOne().WithMany().HasForeignKey(x => x.RegionId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("election_contests"); + entity.Property(x => x.Title).HasMaxLength(200).IsRequired(); + entity.Property(x => x.RequiredSupportPercent).HasPrecision(5, 2); + entity.HasIndex(x => new { x.ElectionActivityId, x.SortOrder }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.ElectionActivityId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("election_options"); + entity.Property(x => x.Label).HasMaxLength(200).IsRequired(); + entity.HasIndex(x => new { x.ElectionContestId, x.SortOrder }).IsUnique(); + entity.HasIndex(x => new { x.ElectionContestId, x.CandidateId }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.ElectionContestId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne().WithMany().HasForeignKey(x => x.CandidateId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("election_counting_stations"); + entity.HasKey(x => new { x.ElectionActivityId, x.CountingStationId }); + entity.HasOne().WithMany().HasForeignKey(x => x.ElectionActivityId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne().WithMany().HasForeignKey(x => x.CountingStationId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("offline_counting_sessions"); + entity.Property(x => x.CorrectionReason).HasMaxLength(1000); + entity.HasIndex(x => new { x.ElectionActivityId, x.CountingStationId, x.Version }).IsUnique(); + entity.HasIndex(x => new { x.ElectionActivityId, x.CountingStationId, x.Status }); + entity.HasOne().WithMany().HasForeignKey(x => x.ElectionActivityId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(x => x.CountingStationId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(x => x.ReplacesSessionId).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("offline_count_submissions"); + entity.HasIndex(x => new { x.OfflineCountingSessionId, x.EnteredByUserId }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.OfflineCountingSessionId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("offline_count_tallies"); + entity.HasIndex(x => new { x.OfflineCountSubmissionId, x.ElectionContestId, x.ElectionOptionId }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(x => x.OfflineCountSubmissionId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne().WithMany().HasForeignKey(x => x.ElectionContestId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(x => x.ElectionOptionId).OnDelete(DeleteBehavior.Restrict); + }); modelBuilder.Entity(entity => { entity.ToTable("audit_entries"); diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829124023_OfflineElectionCounting.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829124023_OfflineElectionCounting.Designer.cs new file mode 100644 index 0000000..8eb93d0 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829124023_OfflineElectionCounting.Designer.cs @@ -0,0 +1,1038 @@ +// +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("20260829124023_OfflineElectionCounting")] + partial class OfflineElectionCounting + { + /// + 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("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("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("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/20260829124023_OfflineElectionCounting.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829124023_OfflineElectionCounting.cs new file mode 100644 index 0000000..f1ae8f5 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829124023_OfflineElectionCounting.cs @@ -0,0 +1,334 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class OfflineElectionCounting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "election_activities", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Status = table.Column(type: "int", nullable: false), + VotingStartsAtUtc = table.Column(type: "datetime(6)", nullable: false), + VotingEndsAtUtc = table.Column(type: "datetime(6)", nullable: false), + CountingEndsAtUtc = table.Column(type: "datetime(6)", nullable: false), + PublishLiveCandidateTotals = table.Column(type: "tinyint(1)", nullable: false), + PublishLiveParticipation = table.Column(type: "tinyint(1)", nullable: false), + PublishedAtUtc = table.Column(type: "datetime(6)", nullable: true), + ResultsPublishedAtUtc = table.Column(type: "datetime(6)", nullable: true), + CreatedByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_election_activities", x => x.Id); + table.ForeignKey( + name: "FK_election_activities_administrative_regions_RegionId", + column: x => x.RegionId, + principalTable: "administrative_regions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "election_contests", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ElectionActivityId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Title = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Type = table.Column(type: "int", nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + Seats = table.Column(type: "int", nullable: false), + MinSelections = table.Column(type: "int", nullable: false), + MaxSelections = table.Column(type: "int", nullable: false), + Determination = table.Column(type: "int", nullable: false), + SupportDenominator = table.Column(type: "int", nullable: false), + RequiredSupportPercent = table.Column(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false), + TieResolution = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_election_contests", x => x.Id); + table.ForeignKey( + name: "FK_election_contests_election_activities_ElectionActivityId", + column: x => x.ElectionActivityId, + principalTable: "election_activities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "election_counting_stations", + columns: table => new + { + ElectionActivityId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CountingStationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci") + }, + constraints: table => + { + table.PrimaryKey("PK_election_counting_stations", x => new { x.ElectionActivityId, x.CountingStationId }); + table.ForeignKey( + name: "FK_election_counting_stations_counting_stations_CountingStation~", + column: x => x.CountingStationId, + principalTable: "counting_stations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_election_counting_stations_election_activities_ElectionActiv~", + column: x => x.ElectionActivityId, + principalTable: "election_activities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "offline_counting_sessions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ElectionActivityId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CountingStationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Version = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + ReplacesSessionId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + CorrectionReason = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + OpenedByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ConfirmedByUserId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + ConfirmedAtUtc = table.Column(type: "datetime(6)", nullable: true), + CreatedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_offline_counting_sessions", x => x.Id); + table.ForeignKey( + name: "FK_offline_counting_sessions_counting_stations_CountingStationId", + column: x => x.CountingStationId, + principalTable: "counting_stations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_offline_counting_sessions_election_activities_ElectionActivi~", + column: x => x.ElectionActivityId, + principalTable: "election_activities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_offline_counting_sessions_offline_counting_sessions_Replaces~", + column: x => x.ReplacesSessionId, + principalTable: "offline_counting_sessions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "election_options", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ElectionContestId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CandidateId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + Label = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SortOrder = table.Column(type: "int", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_election_options", x => x.Id); + table.ForeignKey( + name: "FK_election_options_candidate_profiles_CandidateId", + column: x => x.CandidateId, + principalTable: "candidate_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_election_options_election_contests_ElectionContestId", + column: x => x.ElectionContestId, + principalTable: "election_contests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "offline_count_submissions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + OfflineCountingSessionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + EnteredByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + SubmittedAtUtc = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_offline_count_submissions", x => x.Id); + table.ForeignKey( + name: "FK_offline_count_submissions_offline_counting_sessions_OfflineC~", + column: x => x.OfflineCountingSessionId, + principalTable: "offline_counting_sessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "offline_count_tallies", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + OfflineCountSubmissionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ElectionContestId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ElectionOptionId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + Votes = table.Column(type: "int", nullable: false), + Abstentions = table.Column(type: "int", nullable: false), + InvalidBallots = table.Column(type: "int", nullable: false), + MissingSelections = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_offline_count_tallies", x => x.Id); + table.ForeignKey( + name: "FK_offline_count_tallies_election_contests_ElectionContestId", + column: x => x.ElectionContestId, + principalTable: "election_contests", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_offline_count_tallies_election_options_ElectionOptionId", + column: x => x.ElectionOptionId, + principalTable: "election_options", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_offline_count_tallies_offline_count_submissions_OfflineCount~", + column: x => x.OfflineCountSubmissionId, + principalTable: "offline_count_submissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_election_activities_RegionId_Status_VotingEndsAtUtc", + table: "election_activities", + columns: new[] { "RegionId", "Status", "VotingEndsAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_election_contests_ElectionActivityId_SortOrder", + table: "election_contests", + columns: new[] { "ElectionActivityId", "SortOrder" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_election_counting_stations_CountingStationId", + table: "election_counting_stations", + column: "CountingStationId"); + + migrationBuilder.CreateIndex( + name: "IX_election_options_CandidateId", + table: "election_options", + column: "CandidateId"); + + migrationBuilder.CreateIndex( + name: "IX_election_options_ElectionContestId_CandidateId", + table: "election_options", + columns: new[] { "ElectionContestId", "CandidateId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_election_options_ElectionContestId_SortOrder", + table: "election_options", + columns: new[] { "ElectionContestId", "SortOrder" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_offline_count_submissions_OfflineCountingSessionId_EnteredBy~", + table: "offline_count_submissions", + columns: new[] { "OfflineCountingSessionId", "EnteredByUserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_offline_count_tallies_ElectionContestId", + table: "offline_count_tallies", + column: "ElectionContestId"); + + migrationBuilder.CreateIndex( + name: "IX_offline_count_tallies_ElectionOptionId", + table: "offline_count_tallies", + column: "ElectionOptionId"); + + migrationBuilder.CreateIndex( + name: "IX_offline_count_tallies_OfflineCountSubmissionId_ElectionConte~", + table: "offline_count_tallies", + columns: new[] { "OfflineCountSubmissionId", "ElectionContestId", "ElectionOptionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_offline_counting_sessions_CountingStationId", + table: "offline_counting_sessions", + column: "CountingStationId"); + + migrationBuilder.CreateIndex( + name: "IX_offline_counting_sessions_ElectionActivityId_CountingStatio~1", + table: "offline_counting_sessions", + columns: new[] { "ElectionActivityId", "CountingStationId", "Version" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_offline_counting_sessions_ElectionActivityId_CountingStation~", + table: "offline_counting_sessions", + columns: new[] { "ElectionActivityId", "CountingStationId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_offline_counting_sessions_ReplacesSessionId", + table: "offline_counting_sessions", + column: "ReplacesSessionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "election_counting_stations"); + + migrationBuilder.DropTable( + name: "offline_count_tallies"); + + migrationBuilder.DropTable( + name: "election_options"); + + migrationBuilder.DropTable( + name: "offline_count_submissions"); + + migrationBuilder.DropTable( + name: "election_contests"); + + migrationBuilder.DropTable( + name: "offline_counting_sessions"); + + migrationBuilder.DropTable( + name: "election_activities"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829132935_CalculateMissingSelections.Designer.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829132935_CalculateMissingSelections.Designer.cs new file mode 100644 index 0000000..f5e5cd6 --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829132935_CalculateMissingSelections.Designer.cs @@ -0,0 +1,1041 @@ +// +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("20260829132935_CalculateMissingSelections")] + partial class CalculateMissingSelections + { + /// + 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("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("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/20260829132935_CalculateMissingSelections.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829132935_CalculateMissingSelections.cs new file mode 100644 index 0000000..fa87aed --- /dev/null +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/20260829132935_CalculateMissingSelections.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class CalculateMissingSelections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CountedBallots", + table: "offline_count_tallies", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CountedBallots", + table: "offline_count_tallies"); + } + } +} diff --git a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs index 93efc50..625bbb5 100644 --- a/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs +++ b/src/ElectionSystem.Api/Infrastructure/Persistence/Migrations/ElectionDbContextModelSnapshot.cs @@ -100,6 +100,165 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations 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") @@ -214,6 +373,123 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations 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") @@ -542,6 +818,102 @@ namespace ElectionSystem.Api.Infrastructure.Persistence.Migrations .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) diff --git a/src/ElectionSystem.Api/Modules/Election/Domain/ElectionModels.cs b/src/ElectionSystem.Api/Modules/Election/Domain/ElectionModels.cs new file mode 100644 index 0000000..b4a1ef0 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Election/Domain/ElectionModels.cs @@ -0,0 +1,69 @@ +namespace ElectionSystem.Api.Modules.Election.Domain; + +public enum ElectionActivityStatus +{ + Draft, + Published, + Counting, + PublishedResults, + Archived +} + +public enum ElectionContestType { CandidateElection, Proposition } +public enum WinnerDetermination { HighestVotes, SimpleMajority, AbsoluteMajority, QualifiedMajority } +public enum SupportDenominator { ValidVotesOnly, AllCastBallots } +public enum TieResolution { ManualReview, NoWinner } + +/// A regional election. Its definition is immutable once published. +public sealed class ElectionActivity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public required string Name { get; set; } + public string? Description { get; set; } + public Guid RegionId { get; set; } + public ElectionActivityStatus Status { get; set; } = ElectionActivityStatus.Draft; + public DateTime VotingStartsAtUtc { get; set; } + public DateTime VotingEndsAtUtc { get; set; } + public DateTime CountingEndsAtUtc { get; set; } + public bool PublishLiveCandidateTotals { get; set; } + public bool PublishLiveParticipation { get; set; } = true; + public DateTime? PublishedAtUtc { get; set; } + public DateTime? ResultsPublishedAtUtc { get; set; } + public Guid CreatedByUserId { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class ElectionContest +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ElectionActivityId { get; set; } + public required string Title { get; set; } + public ElectionContestType Type { get; set; } + public int SortOrder { get; set; } + public int Seats { get; set; } = 1; + public int MinSelections { get; set; } + public int MaxSelections { get; set; } = 1; + public WinnerDetermination Determination { get; set; } = WinnerDetermination.HighestVotes; + public SupportDenominator SupportDenominator { get; set; } = SupportDenominator.ValidVotesOnly; + public decimal RequiredSupportPercent { get; set; } = 50m; + public TieResolution TieResolution { get; set; } = TieResolution.ManualReview; +} + +/// Candidate options link to an approved/publicized profile; proposition options are normally For and Against. +public sealed class ElectionOption +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ElectionContestId { get; set; } + public Guid? CandidateId { get; set; } + public required string Label { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; +} + +/// Only these stations may submit a paper count, and all must be confirmed before automatic publication. +public sealed class ElectionCountingStation +{ + public Guid ElectionActivityId { get; set; } + public Guid CountingStationId { get; set; } +} diff --git a/src/ElectionSystem.Api/Modules/Election/ElectionLifecycleService.cs b/src/ElectionSystem.Api/Modules/Election/ElectionLifecycleService.cs new file mode 100644 index 0000000..8734a6c --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Election/ElectionLifecycleService.cs @@ -0,0 +1,51 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.OfflineCounting.Domain; +using ElectionSystem.Api.Modules.PublicBoard; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Modules.Election; + +/// Closes timed elections and publishes only when every assigned station has a confirmed current count. +public sealed class ElectionLifecycleService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try { await AdvanceAsync(stoppingToken); } + catch (Exception exception) { logger.LogError(exception, "Election lifecycle processing failed."); } + } + } + + internal async Task AdvanceAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var notifier = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + var elections = await db.ElectionActivities.Where(x => x.Status == ElectionActivityStatus.Published || x.Status == ElectionActivityStatus.Counting).ToListAsync(cancellationToken); + foreach (var election in elections) + { + if (election.Status == ElectionActivityStatus.Published && election.VotingEndsAtUtc <= now) + { + election.Status = ElectionActivityStatus.Counting; + election.UpdatedAtUtc = now; + } + if (election.Status == ElectionActivityStatus.Counting && election.CountingEndsAtUtc <= now) + { + var stations = await db.ElectionCountingStations.Where(x => x.ElectionActivityId == election.Id).Select(x => x.CountingStationId).ToListAsync(cancellationToken); + var confirmed = await db.OfflineCountingSessions.Where(x => x.ElectionActivityId == election.Id && x.Status == OfflineCountingSessionStatus.Confirmed).Select(x => x.CountingStationId).Distinct().ToListAsync(cancellationToken); + if (stations.Count > 0 && stations.All(confirmed.Contains)) + { + election.Status = ElectionActivityStatus.PublishedResults; + election.ResultsPublishedAtUtc = now; + election.UpdatedAtUtc = now; + } + } + } + await db.SaveChangesAsync(cancellationToken); + foreach (var election in elections.Where(x => x.Status is ElectionActivityStatus.Counting or ElectionActivityStatus.PublishedResults)) await notifier.PublishAsync(election.Id, cancellationToken); + } +} diff --git a/src/ElectionSystem.Api/Modules/OfflineCounting/Domain/OfflineCountingModels.cs b/src/ElectionSystem.Api/Modules/OfflineCounting/Domain/OfflineCountingModels.cs new file mode 100644 index 0000000..73da5fc --- /dev/null +++ b/src/ElectionSystem.Api/Modules/OfflineCounting/Domain/OfflineCountingModels.cs @@ -0,0 +1,42 @@ +namespace ElectionSystem.Api.Modules.OfflineCounting.Domain; + +public enum OfflineCountingSessionStatus { Open, AwaitingSecondEntry, NeedsReview, AwaitingSupervisor, Confirmed, Superseded } + +/// A versioned paper-ballot count for one counting station. Confirmed sessions are never edited. +public sealed class OfflineCountingSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ElectionActivityId { get; set; } + public Guid CountingStationId { get; set; } + public int Version { get; set; } = 1; + public OfflineCountingSessionStatus Status { get; set; } = OfflineCountingSessionStatus.Open; + public Guid? ReplacesSessionId { get; set; } + public string? CorrectionReason { get; set; } + public Guid OpenedByUserId { get; set; } + public Guid? ConfirmedByUserId { get; set; } + public DateTime? ConfirmedAtUtc { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} + +/// An immutable independent clerk entry. No voter identity or individual ballot is stored. +public sealed class OfflineCountSubmission +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid OfflineCountingSessionId { get; set; } + public Guid EnteredByUserId { get; set; } + public DateTime SubmittedAtUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class OfflineCountTally +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid OfflineCountSubmissionId { get; set; } + public Guid ElectionContestId { get; set; } + public Guid? ElectionOptionId { get; set; } + public int Votes { get; set; } + /// For the contest summary row only: physical ballots counted for this contest. + public int CountedBallots { get; set; } + public int Abstentions { get; set; } + public int InvalidBallots { get; set; } + public int MissingSelections { get; set; } +} diff --git a/src/ElectionSystem.Api/Modules/OfflineCounting/OfflineCountingRules.cs b/src/ElectionSystem.Api/Modules/OfflineCounting/OfflineCountingRules.cs new file mode 100644 index 0000000..107b376 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/OfflineCounting/OfflineCountingRules.cs @@ -0,0 +1,14 @@ +using ElectionSystem.Api.Modules.Election.Domain; + +namespace ElectionSystem.Api.Modules.OfflineCounting; + +public static class OfflineCountingRules +{ + /// Computes unfilled required selections; abstentions and invalid ballots are deliberate/countable outcomes, not missing entries. + public static int CalculateMissingSelections(ElectionContest contest, int countedBallots, int optionVotes, int abstentions, int invalidBallots) + { + if (countedBallots < 0 || optionVotes < 0 || abstentions < 0 || invalidBallots < 0) throw new ArgumentOutOfRangeException(nameof(countedBallots)); + var requiredMarks = checked(countedBallots * contest.MinSelections); + return Math.Max(0, requiredMarks - optionVotes - abstentions - invalidBallots); + } +} diff --git a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs index a4734cc..14dc17c 100644 --- a/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs +++ b/src/ElectionSystem.Api/Modules/Organization/RegionScopeService.cs @@ -41,4 +41,32 @@ public sealed class RegionScopeService(ElectionDbContext dbContext) if (!await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.Role == RegionalRole.SystemAdministrator && x.RegionId == null, cancellationToken)) throw new UnauthorizedAccessException("System administrator role is required."); } + + public async Task RequirePublisherAtRegionAsync(Guid userId, Guid regionId, CancellationToken cancellationToken) + { + var isPublisher = await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && + ((x.Role == RegionalRole.SystemAdministrator && x.RegionId == null) || + (x.RegionId == regionId && (x.Role == RegionalRole.ProvinceAdministrator || x.Role == RegionalRole.CityAdministrator || x.Role == RegionalRole.DistrictAdministrator || x.Role == RegionalRole.TownshipAdministrator || x.Role == RegionalRole.VillageAdministrator))), cancellationToken); + if (!isPublisher) throw new UnauthorizedAccessException("An administrator assigned directly to the target region is required."); + } + + public async Task RequireCountingStationRoleAsync(Guid userId, Guid stationId, bool supervisor, CancellationToken cancellationToken) + { + var station = await dbContext.CountingStations.SingleOrDefaultAsync(x => x.Id == stationId && x.IsActive, cancellationToken) ?? throw new InvalidOperationException("Counting station is unavailable."); + var targetRole = supervisor ? RegionalRole.Supervisor : RegionalRole.CountingStationClerk; + var assigned = await dbContext.UserCountingStationAssignments.AnyAsync(x => x.UserId == userId && x.CountingStationId == stationId, cancellationToken); + var role = await dbContext.UserRegionalRoleAssignments.AnyAsync(x => x.UserId == userId && x.RegionId == station.RegionId && x.Role == targetRole, cancellationToken); + if (!assigned || !role) throw new UnauthorizedAccessException("A matching counting-station assignment and regional role are required."); + } + + public async Task IsInRegionSubtreeAsync(Guid regionId, Guid rootRegionId, CancellationToken cancellationToken) + { + var current = await dbContext.AdministrativeRegions.Where(x => x.Id == regionId).Select(x => new { x.Id, x.ParentId }).SingleOrDefaultAsync(cancellationToken); + while (current is not null) + { + if (current.Id == rootRegionId) return true; + current = current.ParentId is null ? null : await dbContext.AdministrativeRegions.Where(x => x.Id == current.ParentId).Select(x => new { x.Id, x.ParentId }).SingleOrDefaultAsync(cancellationToken); + } + return false; + } } diff --git a/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs b/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs new file mode 100644 index 0000000..5552777 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/PublicBoard/PublicResultsNotifier.cs @@ -0,0 +1,22 @@ +using ElectionSystem.Api.Hubs; +using ElectionSystem.Api.Modules.Results; +using Microsoft.AspNetCore.SignalR; + +namespace ElectionSystem.Api.Modules.PublicBoard; + +public sealed class PublicResultsNotifier(IHubContext hub, ElectionResultService resultService) +{ + public async Task PublishAsync(Guid electionId, CancellationToken cancellationToken) + { + var snapshot = await resultService.BuildAsync(electionId, cancellationToken); + var payload = new + { + electionId, + snapshot.Election.Status, + snapshot.Election.ResultsPublishedAtUtc, + updatedAtUtc = DateTime.UtcNow, + contests = snapshot.Contests.Select(x => new { x.ContestId, x.Title, x.ValidVotes, x.Abstentions, x.InvalidBallots, x.MissingSelections, options = x.Options.Select(o => new { o.OptionId, o.Label, o.Votes }), x.WinnerOptionIds, x.RequiresManualReview }) + }; + await hub.Clients.Group($"election:{electionId}").SendAsync("resultUpdated", payload, cancellationToken); + } +} diff --git a/src/ElectionSystem.Api/Modules/Results/ElectionResultService.cs b/src/ElectionSystem.Api/Modules/Results/ElectionResultService.cs new file mode 100644 index 0000000..f6151d0 --- /dev/null +++ b/src/ElectionSystem.Api/Modules/Results/ElectionResultService.cs @@ -0,0 +1,53 @@ +using ElectionSystem.Api.Infrastructure.Persistence; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.OfflineCounting.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ElectionSystem.Api.Modules.Results; + +public sealed class ElectionResultService(ElectionDbContext dbContext) +{ + public async Task BuildAsync(Guid electionId, CancellationToken cancellationToken) + { + var election = await dbContext.ElectionActivities.AsNoTracking().SingleAsync(x => x.Id == electionId, cancellationToken); + var contests = await dbContext.ElectionContests.AsNoTracking().Where(x => x.ElectionActivityId == electionId).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var options = await dbContext.ElectionOptions.AsNoTracking().Where(x => contests.Select(c => c.Id).Contains(x.ElectionContestId)).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + var confirmedSessionIds = await dbContext.OfflineCountingSessions.AsNoTracking() + .Where(x => x.ElectionActivityId == electionId && x.Status == OfflineCountingSessionStatus.Confirmed) + .Select(x => x.Id).ToListAsync(cancellationToken); + // A tally points to a submission, so obtain it through the immutable confirmed submission set. + var confirmedSubmissions = await dbContext.OfflineCountSubmissions.AsNoTracking() + .Where(x => confirmedSessionIds.Contains(x.OfflineCountingSessionId)) + .OrderBy(x => x.SubmittedAtUtc).Select(x => new { x.Id, x.OfflineCountingSessionId }).ToListAsync(cancellationToken); + // The two independent entries have already been proven identical; count one canonical entry per session. + var submissionIds = confirmedSubmissions.GroupBy(x => x.OfflineCountingSessionId).Select(x => x.First().Id).ToList(); + var tallies = await dbContext.OfflineCountTallies.AsNoTracking().Where(x => submissionIds.Contains(x.OfflineCountSubmissionId)).ToListAsync(cancellationToken); + return new ElectionResultSnapshot(election, contests.Select(contest => BuildContest(contest, options.Where(x => x.ElectionContestId == contest.Id).ToList(), tallies.Where(x => x.ElectionContestId == contest.Id).ToList())).ToList()); + } + + private static ContestResult BuildContest(ElectionContest contest, List options, List tallies) + { + var optionResults = options.Select(option => new OptionResult(option.Id, option.Label, tallies.Where(x => x.ElectionOptionId == option.Id).Sum(x => x.Votes))).ToList(); + var abstentions = tallies.Sum(x => x.Abstentions); + var invalid = tallies.Sum(x => x.InvalidBallots); + var missing = tallies.Sum(x => x.MissingSelections); + var valid = optionResults.Sum(x => x.Votes); + var denominator = contest.SupportDenominator == SupportDenominator.ValidVotesOnly ? valid : valid + abstentions + invalid + missing; + var threshold = denominator == 0 ? decimal.MaxValue : contest.RequiredSupportPercent / 100m * denominator; + var ordered = optionResults.OrderByDescending(x => x.Votes).ThenBy(x => x.Label, StringComparer.Ordinal).ToList(); + var cutoff = Math.Min(Math.Max(contest.Seats, 1), ordered.Count); + var tiedAtCutoff = cutoff > 0 && ordered.Count > cutoff && ordered[cutoff - 1].Votes == ordered[cutoff].Votes; + var winners = contest.Determination switch + { + WinnerDetermination.HighestVotes when !(tiedAtCutoff && contest.TieResolution == TieResolution.NoWinner) => ordered.Take(cutoff).Select(x => x.OptionId).ToList(), + WinnerDetermination.SimpleMajority or WinnerDetermination.AbsoluteMajority or WinnerDetermination.QualifiedMajority => ordered.Where(x => x.Votes > threshold).Take(cutoff).Select(x => x.OptionId).ToList(), + _ => [] + }; + var requiresManualReview = tiedAtCutoff && contest.TieResolution == TieResolution.ManualReview; + return new ContestResult(contest.Id, contest.Title, optionResults, abstentions, invalid, missing, valid, winners, requiresManualReview); + } +} + +public sealed record ElectionResultSnapshot(ElectionActivity Election, IReadOnlyList Contests); +public sealed record ContestResult(Guid ContestId, string Title, IReadOnlyList Options, int Abstentions, int InvalidBallots, int MissingSelections, int ValidVotes, IReadOnlyList WinnerOptionIds, bool RequiresManualReview); +public sealed record OptionResult(Guid OptionId, string Label, int Votes); diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index 52ed227..86982eb 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -7,6 +7,10 @@ using ElectionSystem.Api.Modules.Organization; using ElectionSystem.Api.Modules.Voters; using ElectionSystem.Api.Modules.Identity; using ElectionSystem.Api.Modules.Identity.Domain; +using ElectionSystem.Api.Modules.Election; +using ElectionSystem.Api.Modules.PublicBoard; +using ElectionSystem.Api.Modules.Results; +using ElectionSystem.Api.Hubs; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; @@ -35,6 +39,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddSignalR(); builder.Services.AddMemoryCache(); builder.Services.AddDbContext((serviceProvider, options) => { @@ -88,6 +96,15 @@ builder.Services.AddHttpClient(client => var app = builder.Build(); +// Development instances apply pending EF migrations before serving requests. Production requires an explicit opt-in +// so multiple replicas do not race schema changes without an operational deployment decision. +if (app.Configuration.GetValue("Database:ApplyMigrationsOnStartup")) +{ + await using var startupScope = app.Services.CreateAsyncScope(); + var startupDbContext = startupScope.ServiceProvider.GetRequiredService(); + await startupDbContext.Database.MigrateAsync(); +} + app.UseExceptionHandler(); if (app.Configuration.GetValue("Security:EnableHttpsRedirection")) app.UseHttpsRedirection(); if (app.Environment.IsDevelopment()) @@ -101,6 +118,7 @@ app.UseAuthorization(); app.UseAntiforgery(); app.MapStaticAssets(); app.MapControllers(); +app.MapHub("/hubs/public-results"); app.MapRazorPages(); app.MapRazorComponents().AddInteractiveServerRenderMode(); app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = HealthCheckResponseWriter.WriteAsync }).AllowAnonymous(); diff --git a/src/ElectionSystem.Api/appsettings.Development.json b/src/ElectionSystem.Api/appsettings.Development.json index fdde774..5993fa5 100644 --- a/src/ElectionSystem.Api/appsettings.Development.json +++ b/src/ElectionSystem.Api/appsettings.Development.json @@ -1,4 +1,7 @@ { + "Database": { + "ApplyMigrationsOnStartup": true + }, "ConnectionStrings": { "ElectionDatabase": "Server=localhost;Port=3306;Database=election;User ID=election;" }, diff --git a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs index 2907d23..ce37a34 100644 --- a/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs +++ b/tests/ElectionSystem.Api.Tests/PersistenceModelTests.cs @@ -5,6 +5,10 @@ using ElectionSystem.Api.Modules.Identity.Domain; using ElectionSystem.Api.Modules.Organization; using ElectionSystem.Api.Modules.Organization.Domain; using ElectionSystem.Api.Modules.Voters.Domain; +using ElectionSystem.Api.Modules.Election.Domain; +using ElectionSystem.Api.Modules.OfflineCounting.Domain; +using ElectionSystem.Api.Modules.OfflineCounting; +using ElectionSystem.Api.Modules.Results; using System.Text; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -100,6 +104,64 @@ public sealed class PersistenceModelTests Assert.False(CursorToken.TryDecode("not-a-cursor", out _)); } + [Fact] + public void Offline_submissions_enforce_one_entry_per_clerk_and_do_not_store_voters() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var context = new ElectionDbContext(options); + var entity = context.Model.FindEntityType(typeof(OfflineCountSubmission))!; + Assert.Contains(entity.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual(new[] { nameof(OfflineCountSubmission.OfflineCountingSessionId), nameof(OfflineCountSubmission.EnteredByUserId) })); + Assert.DoesNotContain(typeof(OfflineCountSubmission).GetProperties(), x => x.Name.Contains("Voter", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(typeof(OfflineCountTally).GetProperties(), x => x.Name.Contains("Voter", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Result_service_counts_confirmed_versions_only_and_keeps_abstention_invalid_and_missing_separate() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + await using var context = new ElectionDbContext(options); + var region = Region("11", "省", AdministrativeRegionLevel.Province); + var election = new ElectionActivity { Name = "测试投票", RegionId = region.Id, Status = ElectionActivityStatus.Counting, VotingStartsAtUtc = DateTime.UtcNow.AddHours(-2), VotingEndsAtUtc = DateTime.UtcNow.AddHours(-1), CountingEndsAtUtc = DateTime.UtcNow.AddHours(1), CreatedByUserId = Guid.NewGuid() }; + var contest = new ElectionContest { ElectionActivityId = election.Id, Title = "主任", Type = ElectionContestType.CandidateElection, SortOrder = 1, Seats = 1, MaxSelections = 1 }; + var a = new ElectionOption { ElectionContestId = contest.Id, Label = "甲", SortOrder = 1 }; + var b = new ElectionOption { ElectionContestId = contest.Id, Label = "乙", SortOrder = 2 }; + var confirmed = new OfflineCountingSession { ElectionActivityId = election.Id, CountingStationId = Guid.NewGuid(), Status = OfflineCountingSessionStatus.Confirmed, OpenedByUserId = Guid.NewGuid() }; + var unconfirmed = new OfflineCountingSession { ElectionActivityId = election.Id, CountingStationId = Guid.NewGuid(), Status = OfflineCountingSessionStatus.NeedsReview, OpenedByUserId = Guid.NewGuid() }; + var good = new OfflineCountSubmission { OfflineCountingSessionId = confirmed.Id, EnteredByUserId = Guid.NewGuid() }; + var duplicateVerifiedEntry = new OfflineCountSubmission { OfflineCountingSessionId = confirmed.Id, EnteredByUserId = Guid.NewGuid(), SubmittedAtUtc = good.SubmittedAtUtc.AddSeconds(1) }; + var pending = new OfflineCountSubmission { OfflineCountingSessionId = unconfirmed.Id, EnteredByUserId = Guid.NewGuid() }; + context.AddRange(region, election, contest, a, b, confirmed, unconfirmed, good, duplicateVerifiedEntry, pending); + context.AddRange( + new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 18 }, + new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, ElectionOptionId = b.Id, Votes = 12 }, + new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, Abstentions = 3, InvalidBallots = 2, MissingSelections = 1 }, + new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 18 }, + new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, ElectionOptionId = b.Id, Votes = 12 }, + new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, Abstentions = 3, InvalidBallots = 2, MissingSelections = 1 }, + new OfflineCountTally { OfflineCountSubmissionId = pending.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 99 }); + await context.SaveChangesAsync(); + + var result = await new ElectionResultService(context).BuildAsync(election.Id, CancellationToken.None); + + var summary = Assert.Single(result.Contests); + Assert.Equal(18, summary.Options.Single(x => x.OptionId == a.Id).Votes); + Assert.Equal(30, summary.ValidVotes); + Assert.Equal(3, summary.Abstentions); + Assert.Equal(2, summary.InvalidBallots); + Assert.Equal(1, summary.MissingSelections); + Assert.Equal(new[] { a.Id }, summary.WinnerOptionIds); + } + + [Fact] + public void Missing_selections_are_calculated_from_counted_ballots_and_required_choices() + { + var contest = new ElectionContest { ElectionActivityId = Guid.NewGuid(), Title = "两席岗位", Type = ElectionContestType.CandidateElection, SortOrder = 1, Seats = 2, MinSelections = 2, MaxSelections = 2 }; + + var missing = OfflineCountingRules.CalculateMissingSelections(contest, countedBallots: 20, optionVotes: 33, abstentions: 2, invalidBallots: 1); + + Assert.Equal(4, missing); + } + [Fact] public async Task Four_level_filter_returns_selected_subtree_and_rejects_cross_branch_selection() {