投票发布功能

This commit is contained in:
biss committed 2026-08-29 21:42:31 +08:00
1 parent ae9e8e0c9d
commit 1e0652211b
24 files changed
+3633 -7

No files matched your search

@@ -13,6 +13,9 @@
<NavLink href="/admin/voters">选民管理</NavLink>
<NavLink href="/admin/voter-import">选民导入</NavLink>
<NavLink href="/admin/candidates">候选人</NavLink>
<p class="admin-sidebar__label">投票与计票</p>
<NavLink href="/admin/elections">投票发布</NavLink>
<NavLink href="/admin/offline-counting">线下计票</NavLink>
<p class="admin-sidebar__label">人员与场所</p>
<NavLink href="/admin/roles">地域角色</NavLink>
<NavLink href="/admin/users">本地用户</NavLink>
@@ -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
<PageTitle>投票发布</PageTitle>
<section class="admin-page">
<div class="admin-page__heading"><div><p class="eyebrow">投票与计票 / 发布</p><h1>投票发布</h1><p>先配置岗位或议案、选项与计票所;活动发布后这些定义将被冻结。</p></div></div>
@if (notice is not null) { <div class="admin-alert" role="alert">@notice</div> }
@if (!loaded) { <p class="admin-loading">正在加载授权范围…</p> }
else {
<EditForm Model="draft" OnValidSubmit="CreateAsync" class="admin-form"><h2>新建本级投票</h2><label>名称<input @bind="draft.Name" required /></label><label>目标地区<select @bind="draft.RegionId"><option value="">请选择本级地区</option>@foreach (var region in publishRegions) { <option value="@region.Id">@region.Name</option> }</select></label><label>投票开始(UTC)<input type="datetime-local" @bind="draft.VotingStarts" /></label><label>投票截止(UTC)<input type="datetime-local" @bind="draft.VotingEnds" /></label><label>计票截止(UTC)<input type="datetime-local" @bind="draft.CountingEnds" /></label><label><input type="checkbox" @bind="draft.PublishLiveParticipation" /> 实时公开参与量</label><label><input type="checkbox" @bind="draft.PublishLiveCandidateTotals" /> 实时公开候选人/议案票数</label><button class="admin-button" type="submit">创建草稿</button></EditForm>
<div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>活动</th><th>地区</th><th>状态</th><th>截止时间</th><th></th></tr></thead><tbody>@foreach (var election in elections) { <tr><td>@election.Name</td><td>@regionNames.GetValueOrDefault(election.RegionId, "—")</td><td>@election.Status</td><td>@election.VotingEndsAtUtc.ToString("yyyy-MM-dd HH:mm") UTC</td><td><button class="admin-link-button" @onclick="() => SelectAsync(election.Id)">配置</button></td></tr> }</tbody></table></div>
@if (selected is not null) {
<section class="admin-next"><h2>@selected.Name · 配置</h2><p>状态:@selected.Status。@if (selected.Status != ElectionActivityStatus.Draft) { <span>已发布活动仅可查看。</span> }</p>
@if (selected.Status == ElectionActivityStatus.Draft) {
<EditForm Model="contestDraft" OnValidSubmit="AddContestAsync" class="admin-form"><h3>添加岗位或议案</h3><label>名称<input @bind="contestDraft.Title" required /></label><label>类型<select @bind="contestDraft.Type"><option value="@ElectionContestType.CandidateElection">岗位竞选</option><option value="@ElectionContestType.Proposition">议案表决</option></select></label><label>席位数<input type="number" @bind="contestDraft.Seats" min="1" /></label><label>最少选择数<input type="number" @bind="contestDraft.MinSelections" min="0" /></label><label>最多选择数<input type="number" @bind="contestDraft.MaxSelections" min="1" /></label><label>判定<select @bind="contestDraft.Determination"><option value="@WinnerDetermination.HighestVotes">最高票</option><option value="@WinnerDetermination.SimpleMajority">简单多数</option><option value="@WinnerDetermination.AbsoluteMajority">绝对多数</option><option value="@WinnerDetermination.QualifiedMajority">比例门槛</option></select></label><label>门槛百分比<input type="number" @bind="contestDraft.RequiredSupportPercent" min="1" max="100" /></label><button class="admin-button" type="submit">添加议题</button></EditForm>
<EditForm Model="optionDraft" OnValidSubmit="AddOptionAsync" class="admin-form"><h3>添加选项</h3><label>议题<select @bind="optionDraft.ContestId"><option value="">请选择议题</option>@foreach (var contest in contests) { <option value="@contest.Id">@contest.Title</option> }</select></label><label>候选人(岗位投票可选)<select @bind="optionDraft.CandidateId"><option value="">议案选项不选候选人</option>@foreach (var candidate in candidates) { <option value="@candidate.Id">@candidate.DisplayName</option> }</select></label><label>显示名称<input @bind="optionDraft.Label" required /></label><button class="admin-button" type="submit">添加选项</button></EditForm>
<div class="admin-form"><h3>指定计票所</h3>@foreach (var station in stations) { <label><input type="checkbox" checked="@selectedStationIds.Contains(station.Id)" @onchange="e => ToggleStation(station.Id, e)" /> @station.Code · @station.Name</label> }<button class="admin-button" @onclick="SaveStationsAsync">保存计票所</button></div>
<button class="admin-button" @onclick="PublishAsync">发布并冻结活动定义</button>
}
<h3>已配置议题</h3><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>议题</th><th>类型</th><th>规则</th><th>选项</th></tr></thead><tbody>@foreach (var contest in contests) { <tr><td>@contest.Title</td><td>@contest.Type</td><td>@contest.Determination / @contest.Seats 席</td><td>@string.Join("、", options.Where(x => x.ElectionContestId == contest.Id).Select(x => x.Label))</td></tr> }</tbody></table></div>
</section> }
}
</section>
@code {
[CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
private Guid actorId; private bool loaded; private string? notice; private readonly List<ElectionActivity> elections = []; private readonly List<ElectionContest> contests = []; private readonly List<ElectionOption> options = []; private readonly List<CandidateProfile> candidates = []; private readonly List<CountingStation> stations = []; private readonly List<AdministrativeRegion> publishRegions = []; private readonly Dictionary<Guid, string> regionNames = []; private readonly HashSet<Guid> 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<bool> 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; } = ""; }
}
@@ -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
<PageTitle>线下计票</PageTitle>
<section class="admin-page">
<div class="admin-page__heading"><div><p class="eyebrow">投票与计票 / 线下</p><h1>线下计票</h1><p>两名不同计票员独立录入;数据一致后由监督员确认,未确认数据不会进入公开结果。</p></div></div>
@if (notice is not null) { <div class="admin-alert" role="alert">@notice</div> }
@if (!loaded) { <p class="admin-loading">正在加载可操作计票所…</p> } else {
<div class="admin-filter"><label>活动<select value="@electionId" @onchange="LoadElectionAsync"><option value="">请选择已截止活动</option>@foreach (var election in elections) { <option value="@election.Id">@election.Name(@election.Status)</option> }</select></label><label>计票所<select @bind="stationId"><option value="">请选择已分配计票所</option>@foreach (var station in stations) { <option value="@station.Id">@station.Code · @station.Name</option> }</select></label><button class="admin-button" @onclick="OpenAsync">开始本所计票</button></div>
@if (sessions.Count > 0) { <div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>计票所</th><th>版本</th><th>状态</th><th>操作</th></tr></thead><tbody>@foreach (var session in sessions) { <tr><td>@stationNames.GetValueOrDefault(session.CountingStationId, "—")</td><td>V@session.Version</td><td>@session.Status</td><td><button class="admin-link-button" @onclick="() => SelectSessionAsync(session.Id)">录入/查看</button>@if (session.Status == OfflineCountingSessionStatus.AwaitingSupervisor) { <button class="admin-link-button" @onclick="() => ConfirmAsync(session.Id)">监督确认</button> }</td></tr> }</tbody></table></div> }
@if (selectedSession is not null && selectedSession.Status is OfflineCountingSessionStatus.Open or OfflineCountingSessionStatus.AwaitingSecondEntry) {
<EditForm Model="entry" OnValidSubmit="SubmitAsync" class="admin-form"><h2>V@selectedSession.Version 独立录入</h2>@foreach (var contest in ballotContests) { <fieldset><legend>@contest.Title</legend>@foreach (var option in ballotOptions.Where(x => x.ElectionContestId == contest.Id)) { var row = entry.OptionRows.Single(x => x.OptionId == option.Id); <label>@option.Label <input type="number" min="0" @bind="row.Votes" /></label> } @{ var summary = entry.Summaries.Single(x => x.ContestId == contest.Id); }<label>本题计票张数<input type="number" min="0" @bind="summary.CountedBallots" /></label><label>弃权票<input type="number" min="0" @bind="summary.Abstentions" /></label><label>无效票<input type="number" min="0" @bind="summary.InvalidBallots" /></label><p>缺投(自动计算):@MissingFor(contest, summary)</p></fieldset> }<button class="admin-button" type="submit">提交独立录入</button></EditForm>
}
}
</section>
@code {
[CascadingParameter] private Task<AuthenticationState> AuthenticationStateTask { get; set; } = default!;
private Guid actorId; private bool loaded; private string? notice; private Guid? electionId, stationId; private readonly List<ElectionActivity> elections = []; private readonly List<CountingStation> stations = []; private readonly List<OfflineCountingSession> sessions = []; private readonly List<ElectionContest> ballotContests = []; private readonly List<ElectionOption> ballotOptions = []; private readonly Dictionary<Guid, string> 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<bool> 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<OptionRow> OptionRows { get; } = []; public List<SummaryRow> 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; } }
}
Loaded 3 of 24 files, more files were not shown because too many files have changed in this diff. Show more