diff --git a/Tornado3_2026Election/Services/SbsElectionApiClient.cs b/Tornado3_2026Election/Services/SbsElectionApiClient.cs index 8db0c78..1e5c4a5 100644 --- a/Tornado3_2026Election/Services/SbsElectionApiClient.cs +++ b/Tornado3_2026Election/Services/SbsElectionApiClient.cs @@ -301,10 +301,11 @@ public sealed class SbsElectionApiClient : IDisposable foreach (var responseItem in countingItems) { var item = responseItem.Item; - var regionId = item.Region?.Id; - if (string.IsNullOrWhiteSpace(regionId) || - !districtMap.TryGetValue(regionId, out var districtOption) || - !orderMap.TryGetValue(regionId, out var order)) + var matchedRegionCode = GetCountingRegionMatchKeys(item.Region) + .FirstOrDefault(key => districtMap.ContainsKey(key)); + if (string.IsNullOrWhiteSpace(matchedRegionCode) || + !districtMap.TryGetValue(matchedRegionCode, out var districtOption) || + !orderMap.TryGetValue(matchedRegionCode, out var order)) { continue; } @@ -322,6 +323,179 @@ public sealed class SbsElectionApiClient : IDisposable .ToArray(); } + public async Task> GetCouncilSeatRegionOptionsAsync( + string electionType, + IEnumerable regionFilters, + CancellationToken cancellationToken) + { + if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || + !IsBasicCouncilCountingType(configuration.SungerType)) + { + return Array.Empty(); + } + + var scopedSidoCodes = ResolveCouncilSeatSidoCodes(configuration.SungerType, regionFilters); + var items = await GetCouncilSeatItemsAsync( + configuration, + scopedSidoCodes, + cancellationToken) + .ConfigureAwait(false); + + return items + .Where(item => item.Sido is not null) + .GroupBy(item => FormatSidoCode(item.Sido!.Code), StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var item = group.First(); + var code = FormatSidoCode(item.Sido!.Code); + var regionName = BuildOutputRegionName(ExpandRegionName(item.Sido.Name)); + return new + { + Order = item.Sido.Order, + Option = new DistrictSelectionOption( + DisplayName: regionName, + DistrictCode: code, + RegionName: regionName, + DistrictName: regionName, + ParentRegionCode: code) + }; + }) + .OrderBy(item => item.Order > 0 ? item.Order : int.MaxValue) + .ThenBy(item => item.Option.RegionName, StringComparer.Ordinal) + .Select(item => item.Option) + .ToArray(); + } + + public async Task> GetCouncilSeatSigunguOptionsAsync( + string electionType, + IEnumerable regionFilters, + CancellationToken cancellationToken) + { + if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || + !IsBasicCouncilCountingType(configuration.SungerType)) + { + return Array.Empty(); + } + + var scopedSidoCodes = ResolveCouncilSeatSidoCodes(configuration.SungerType, regionFilters); + var items = await GetCouncilSeatItemsAsync( + configuration, + scopedSidoCodes, + cancellationToken) + .ConfigureAwait(false); + + return items + .Where(item => item.Sido is not null) + .SelectMany(item => + { + var sidoCode = FormatSidoCode(item.Sido!.Code); + var regionName = BuildOutputRegionName(ExpandRegionName(item.Sido.Name)); + return (item.Sigungus ?? []) + .Where(sigungu => sigungu.Sigungu is not null && + !string.IsNullOrWhiteSpace(sigungu.Sigungu.Id)) + .Select(sigungu => + { + var districtName = sigungu.Sigungu!.Name.Trim(); + return new + { + SidoOrder = item.Sido.Order, + SigunguOrder = sigungu.Sigungu.Order, + Option = new DistrictSelectionOption( + DisplayName: BuildFullDistrictDisplayName(regionName, districtName), + DistrictCode: sigungu.Sigungu.Id.Trim(), + RegionName: regionName, + DistrictName: districtName, + ParentRegionCode: sidoCode) + }; + }); + }) + .GroupBy(item => item.Option.DistrictCode, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(item => item.SidoOrder > 0 ? item.SidoOrder : int.MaxValue) + .ThenBy(item => item.SigunguOrder > 0 ? item.SigunguOrder : int.MaxValue) + .ThenBy(item => item.Option.DisplayName, StringComparer.Ordinal) + .Select(item => item.Option) + .ToArray(); + } + + public async Task> GetCouncilSeatSummariesAsync( + string electionType, + IReadOnlyList sidoCodes, + CancellationToken cancellationToken) + { + return await GetCouncilSeatSummariesAsync( + electionType, + sidoCodes, + Array.Empty(), + cancellationToken) + .ConfigureAwait(false); + } + + public async Task> GetCouncilSeatSummariesAsync( + string electionType, + IReadOnlyList sidoCodes, + IReadOnlyList sigunguCodes, + CancellationToken cancellationToken) + { + if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || + !IsBasicCouncilCountingType(configuration.SungerType)) + { + return Array.Empty(); + } + + var items = await GetCouncilSeatItemsAsync( + configuration, + sidoCodes, + cancellationToken) + .ConfigureAwait(false); + + var sigunguCodeSet = NormalizeCouncilSeatSigunguCodes(sigunguCodes); + return items + .SelectMany(item => ResolveCouncilSeatPartyItems(item, sigunguCodeSet)) + .GroupBy(item => string.IsNullOrWhiteSpace(item.Id) ? item.Name : item.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var first = group.First(); + return new CouncilSeatPartySummary( + PartyId: first.Id ?? string.Empty, + PartyName: string.IsNullOrWhiteSpace(first.Name) ? "무기타" : first.Name.Trim(), + RegionalSeats: group.Sum(item => Math.Max(0, item.Regional)), + ProportionalSeats: group.Sum(item => Math.Max(0, item.Proportional)), + TotalSeats: group.Sum(item => Math.Max(0, item.Total))); + }) + .Where(row => row.TotalSeats > 0 || row.RegionalSeats > 0 || row.ProportionalSeats > 0) + .OrderByDescending(row => row.TotalSeats) + .ThenBy(row => row.PartyName, StringComparer.Ordinal) + .ToArray(); + } + + public async Task GetCouncilPanseAsync( + string electionType, + CancellationToken cancellationToken) + { + if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || + !IsBasicCouncilCountingType(configuration.SungerType)) + { + return null; + } + + var path = BuildPansePath(configuration); + var json = await GetJsonAsync( + configuration.BaseUri, + path, + cancellationToken) + .ConfigureAwait(false); + var item = JsonSerializer.Deserialize(json, SerializerOptions); + return item is null + ? null + : new CouncilPanseResult( + item.Minju, + item.Kukhim, + item.Etc, + DateTimeOffset.Now, + $"GET /{path}"); + } + public async Task GetTurnoutOverviewAsync( string electionType, IReadOnlyList districts, @@ -1224,6 +1398,186 @@ public sealed class SbsElectionApiClient : IDisposable ? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus" : $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}"; + private static string BuildPansePath(SbsElectionConfiguration configuration) + => $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/panse"; + + private static string BuildCouncilSeatPath(SbsElectionConfiguration configuration, string query) + => string.IsNullOrWhiteSpace(query) + ? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/uiseok" + : $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/uiseok?{query}"; + + private async Task> GetCouncilSeatItemsAsync( + SbsElectionConfiguration configuration, + IReadOnlyList sidoCodes, + CancellationToken cancellationToken) + { + var normalizedSidoCodes = NormalizeSidoCodes(sidoCodes); + if (configuration.SungerType == 5 && normalizedSidoCodes.Count == 0) + { + return await GetArrayAsync( + configuration.BaseUri, + BuildCouncilSeatPath(configuration, string.Empty), + cancellationToken) + .ConfigureAwait(false); + } + + var querySidoCodes = normalizedSidoCodes.Count > 0 + ? normalizedSidoCodes + : ResolveAllBasicApiSidoCodes(); + var items = new List(); + var failures = 0; + foreach (var sidoCode in querySidoCodes) + { + try + { + var path = BuildCouncilSeatPath( + configuration, + $"sidos={Uri.EscapeDataString(sidoCode)}"); + items.AddRange(await GetArrayAsync( + configuration.BaseUri, + path, + cancellationToken) + .ConfigureAwait(false)); + } + catch (HttpRequestException) + { + failures++; + } + } + + if (items.Count == 0 && failures > 0) + { + throw new HttpRequestException("SBS API 의석표 데이터를 불러오지 못했습니다."); + } + + return items + .GroupBy(item => FormatSidoCode(item.Sido?.Code ?? 0), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .Where(item => item.Sido is not null) + .ToArray(); + } + + private static IReadOnlyList NormalizeSidoCodes(IEnumerable sidoCodes) + { + return (sidoCodes ?? Array.Empty()) + .Select(code => code?.Trim() ?? string.Empty) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.PadLeft(2, '0')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(code => code, StringComparer.Ordinal) + .ToArray(); + } + + private static IReadOnlyList ResolveAllBasicApiSidoCodes() + { + return + [ + "11", + "26", + "27", + "28", + "29", + "30", + "31", + "41", + "43", + "44", + "46", + "47", + "48", + "49", + "51", + "52", + "53" + ]; + } + + private static IReadOnlyList ResolveCouncilSeatSidoCodes(int sungerType, IEnumerable regionNames) + { + return (regionNames ?? Array.Empty()) + .Select(regionName => ResolveCouncilSeatSidoCode(sungerType, regionName)) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(code => code, StringComparer.Ordinal) + .ToArray(); + } + + private static string ResolveCouncilSeatSidoCode(int sungerType, string? regionName) + { + if (string.IsNullOrWhiteSpace(regionName)) + { + return string.Empty; + } + + var normalized = NormalizeRegionName(regionName); + return normalized switch + { + "세종" => "51", + "강원" => "52", + "전북" => "53", + "광주" when sungerType == 5 => "29", + "전남" when sungerType == 5 => "29", + "전남광주" when sungerType == 5 => "29", + "광주전남" when sungerType == 5 => "29", + _ => ResolveBasicApiSidoCode(regionName) + }; + } + + private static IEnumerable ResolveCouncilSeatPartyItems(SbsCouncilSeatSidoItem item) + { + if (item.Parties is { Count: > 0 }) + { + return item.Parties; + } + + return item.Sigungus? + .SelectMany(sigungu => sigungu.Parties ?? []) + .ToArray() ?? []; + } + + private static IEnumerable ResolveCouncilSeatPartyItems( + SbsCouncilSeatSidoItem item, + ISet sigunguCodes) + { + if (sigunguCodes.Count == 0) + { + return ResolveCouncilSeatPartyItems(item); + } + + return item.Sigungus? + .Where(sigungu => sigungu.Sigungu is not null && + sigunguCodes.Contains(sigungu.Sigungu.Id.Trim())) + .SelectMany(sigungu => sigungu.Parties ?? []) + .ToArray() ?? []; + } + + private static ISet NormalizeCouncilSeatSigunguCodes(IEnumerable sigunguCodes) + { + return (sigunguCodes ?? Array.Empty()) + .Select(code => code?.Trim() ?? string.Empty) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable GetCountingRegionMatchKeys(SbsTurnoutRegion? region) + { + if (region is null) + { + yield break; + } + + foreach (var key in new[] { region.Id, region.Name4Id, region.Name3Id, region.Name2Id }) + { + if (!string.IsNullOrWhiteSpace(key)) + { + yield return key.Trim(); + } + } + } + + private static string FormatSidoCode(int code) + => code <= 0 ? string.Empty : code.ToString("00", CultureInfo.InvariantCulture); + private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration) => Uri.Compare( configuration.BaseUri, @@ -1711,6 +2065,20 @@ public sealed class SbsElectionApiClient : IDisposable string JudgementBadgeText = "", string JudgementDetailText = ""); + public sealed record CouncilSeatPartySummary( + string PartyId, + string PartyName, + int RegionalSeats, + int ProportionalSeats, + int TotalSeats); + + public sealed record CouncilPanseResult( + int Minju, + int Kukhim, + int Etc, + DateTimeOffset ReceivedAt, + string SourcePath); + public sealed record TurnoutOverviewItem( string DisplayName, string RegionName, @@ -1832,6 +2200,81 @@ public sealed class SbsElectionApiClient : IDisposable public int Tupyosu { get; set; } } + private sealed class SbsCouncilPanseItem + { + [JsonPropertyName("minju")] + public int Minju { get; set; } + + [JsonPropertyName("kukhim")] + public int Kukhim { get; set; } + + [JsonPropertyName("etc")] + public int Etc { get; set; } + } + + private sealed class SbsCouncilSeatSidoItem + { + [JsonPropertyName("sido")] + public SbsCouncilSeatSido? Sido { get; set; } + + [JsonPropertyName("sigungus")] + public List? Sigungus { get; set; } + + [JsonPropertyName("parties")] + public List? Parties { get; set; } + } + + private sealed class SbsCouncilSeatSido + { + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public int Order { get; set; } + } + + private sealed class SbsCouncilSeatSigunguItem + { + [JsonPropertyName("sigungu")] + public SbsCouncilSeatSigungu? Sigungu { get; set; } + + [JsonPropertyName("parties")] + public List? Parties { get; set; } + } + + private sealed class SbsCouncilSeatSigungu + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public int Order { get; set; } + } + + private sealed class SbsCouncilSeatPartyItem + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("regional")] + public int Regional { get; set; } + + [JsonPropertyName("proportional")] + public int Proportional { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + } + private sealed class SbsCountingItem { [JsonPropertyName("region")] diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs index 7a889dc..e2167bb 100644 --- a/Tornado3_2026Election/ViewModels/DataViewModel.cs +++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs @@ -1165,9 +1165,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa } }; - if (IsByElectionTemplate(template) || - IsNormalPreElectionTurnoutDistrictBoardTemplate(template) || - ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name)) + if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name)) + { + regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType)); + regionOptions.AddRange(options.Select(option => new ScheduleRegionOption + { + Scope = ScheduleRegionScope.Single, + Label = option.DisplayName, + ElectionType = electionType, + RegionName = option.RegionName, + DistrictName = option.DistrictName, + DistrictCode = option.DistrictCode + })); + } + else if (IsByElectionTemplate(template) || + IsNormalPreElectionTurnoutDistrictBoardTemplate(template)) { regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType)); } @@ -3684,6 +3696,25 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa FormatTemplateDefinition? template, CancellationToken cancellationToken) { + if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name) && + IsBasicCouncilElectionType(electionType)) + { + try + { + var councilSeatRegions = await _apiClient + .GetCouncilSeatSigunguOptionsAsync(electionType, ResolveApiDistrictRegionScope(electionType), cancellationToken) + .ConfigureAwait(false); + if (councilSeatRegions.Count > 0) + { + return councilSeatRegions; + } + } + catch (Exception ex) + { + _logService.Warning($"{electionType} 의석표 지역 목록 수신 실패: {ex.Message}"); + } + } + if (UsesHistoricalScheduleOptions(template)) { var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType); @@ -4166,31 +4197,73 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다."); } + var sidoCodes = selectedTargets + .Select(ResolveCouncilSeatTargetSidoCode) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (sidoCodes.Length == 0) + { + throw new InvalidOperationException("의석 집계 대상 시도코드가 없습니다."); + } + + var sigunguCodes = selectedTargets + .Select(ResolveCouncilSeatTargetSigunguCode) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var councilSeats = await _apiClient + .GetCouncilSeatSummariesAsync(electionType, sidoCodes, sigunguCodes, cancellationToken) + .ConfigureAwait(false); + if (councilSeats.Count == 0) + { + throw new InvalidOperationException("의석 집계용 SBS API 의석표 데이터가 없습니다."); + } + + var seatCandidates = BuildCouncilSeatSummaryCandidates(councilSeats); + if (seatCandidates.Length == 0) + { + throw new InvalidOperationException("의석표에 반영할 정당별 의석 데이터가 없습니다."); + } + var refreshResults = await GetCountingSnapshotsForScheduleTargetsAsync( electionType, selectedTargets, cancellationToken) .ConfigureAwait(false); - if (refreshResults.Count == 0) - { - throw new InvalidOperationException("의석 집계용 개표 데이터가 없습니다."); - } - - var seatCandidates = BuildCouncilSeatSummaryCandidates(electionType, refreshResults); - if (seatCandidates.Length == 0) - { - throw new InvalidOperationException("의석으로 집계할 현재 득표 기준 후보가 없습니다."); - } - var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes)); var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes)); var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0)); var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0)); - var countedRate = ResolveCouncilSeatAggregateCountedRate(refreshResults); + var countedRate = refreshResults.Count == 0 + ? 0d + : ResolveAggregateCountedRate(totalVotes, countedVotes, refreshResults); + var totalSeats = councilSeats.Sum(row => Math.Max(row.TotalSeats, row.RegionalSeats + row.ProportionalSeats)); var regionName = ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets); - var districtName = selectedTargets.Length == 1 - ? selectedTargets[0].DisplayName - : regionName; + var isSingleSigunguTarget = selectedTargets.Length == 1 && sigunguCodes.Length == 1; + var districtName = isSingleSigunguTarget + ? FirstNonWhiteSpace(selectedTargets[0].DistrictName, selectedTargets[0].DisplayName) + : selectedTargets.Length == 1 + ? selectedTargets[0].DisplayName + : regionName; + var receivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(); + var countedVotesForSnapshot = refreshResults.Count == 0 + ? totalSeats + : countedVotes; + var remainingVotesForSnapshot = refreshResults.Count == 0 + ? 0 + : remainingVotes; + var totalExpectedVotesForSnapshot = totalVotes > 0 + ? totalVotes + : totalSeats; + var turnoutVotesForSnapshot = turnoutVotes > 0 + ? turnoutVotes + : totalSeats; + var electionDistrictName = isSingleSigunguTarget + ? districtName + : selectedTargets.Length == 1 + ? selectedTargets[0].DistrictName + : regionName; var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName); return new ElectionDataSnapshot @@ -4200,14 +4273,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa DistrictName = districtName, DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty, RegionName = regionName, - ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName, + ElectionDistrictName = electionDistrictName, Candidates = seatCandidates, - TotalExpectedVotes = totalVotes, - TurnoutVotes = turnoutVotes, - CountedVotesFromApi = countedVotes, - RemainingVotesFromApi = remainingVotes, + TotalExpectedVotes = totalExpectedVotesForSnapshot, + TurnoutVotes = turnoutVotesForSnapshot, + CountedVotesFromApi = countedVotesForSnapshot, + RemainingVotesFromApi = remainingVotesForSnapshot, CountedRateFromApi = countedRate, - ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(), + ReceivedAt = receivedAt, HistoricalTurnoutHistory = history.TurnoutHistory, HistoricalWinnerHistory = history.WinnerHistory }; @@ -4716,6 +4789,95 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character))); } + private static CandidateEntry[] BuildCouncilSeatSummaryCandidates( + IReadOnlyList councilSeats) + { + var candidates = new List(councilSeats.Count * 2); + for (var index = 0; index < councilSeats.Count; index++) + { + var row = councilSeats[index]; + if (row.RegionalSeats > 0) + { + candidates.Add(CreateCouncilSeatSummaryCandidate( + $"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}", + row.PartyName, + row.RegionalSeats, + index + 1)); + } + + if (row.ProportionalSeats > 0) + { + candidates.Add(CreateCouncilSeatSummaryCandidate( + $"{CouncilSeatProportionalCandidateCodePrefix}{index + 1:00}", + row.PartyName, + row.ProportionalSeats, + index + 1)); + } + + if (row.RegionalSeats <= 0 && row.ProportionalSeats <= 0 && row.TotalSeats > 0) + { + candidates.Add(CreateCouncilSeatSummaryCandidate( + $"{CouncilSeatCandidateCodePrefix}{index + 1:00}", + row.PartyName, + row.TotalSeats, + index + 1)); + } + } + + return candidates.ToArray(); + } + + private static CandidateEntry[] BuildProportionalCouncilCandidates( + IReadOnlyList councilSeats) + { + var totalProportionalSeats = councilSeats.Sum(row => Math.Max(0, row.ProportionalSeats)); + return councilSeats + .OrderByDescending(row => row.ProportionalSeats) + .ThenBy(row => row.PartyName, StringComparer.Ordinal) + .Select((row, index) => + { + var seatCount = Math.Max(0, row.ProportionalSeats); + return new CandidateEntry + { + CandidateCode = $"{CouncilSeatProportionalCandidateCodePrefix}{index + 1:00}", + BallotNumber = (index + 1).ToString(), + Name = row.PartyName, + Party = row.PartyName, + ColorParty = row.PartyName, + VoteCount = seatCount, + VoteRate = totalProportionalSeats <= 0 + ? 0 + : Math.Round(seatCount * 100d / totalProportionalSeats, 1, MidpointRounding.AwayFromZero), + HasImage = false, + ManualJudgement = CandidateJudgement.None, + AutomaticJudgement = seatCount > 0 ? CandidateJudgement.Elected : CandidateJudgement.None + }; + }) + .ToArray(); + } + + private static CandidateEntry CreateCouncilSeatSummaryCandidate( + string candidateCode, + string partyName, + int seatCount, + int ballotNumber) + { + var normalizedParty = string.IsNullOrWhiteSpace(partyName) ? "무기타" : partyName.Trim(); + return new CandidateEntry + { + CandidateCode = candidateCode, + BallotNumber = ballotNumber.ToString(), + Name = normalizedParty, + Party = normalizedParty, + ColorParty = normalizedParty, + VoteCount = seatCount, + VoteRate = seatCount, + HasImage = false, + ManualJudgement = CandidateJudgement.None, + AutomaticJudgement = CandidateJudgement.Elected + }; + } + private static CandidateEntry[] BuildCouncilSeatSummaryCandidates( string electionType, IReadOnlyList refreshResults) @@ -5084,6 +5246,49 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName); } + private string ResolveCouncilSeatTargetSidoCode(ScheduleRegionTarget target) + { + if (!string.IsNullOrWhiteSpace(target.DistrictCode) && + target.DistrictCode.Length == 2 && + target.DistrictCode.All(char.IsDigit)) + { + return target.DistrictCode; + } + + return ResolveScheduleTargetParentRegionCode(target); + } + + private static string ResolveCouncilSeatTargetSigunguCode(ScheduleRegionTarget target) + { + if (string.IsNullOrWhiteSpace(target.DistrictCode) || + target.DistrictCode.Length == 2 && target.DistrictCode.All(char.IsDigit)) + { + return string.Empty; + } + + return target.DistrictCode.Trim(); + } + + private string ResolveSelectedCouncilSeatSidoCode() + { + if (!string.IsNullOrWhiteSpace(DistrictCode) && + DistrictCode.Length == 2 && + DistrictCode.All(char.IsDigit)) + { + return DistrictCode; + } + + if (!string.IsNullOrWhiteSpace(DistrictName) && + _districtOptionMap.TryGetValue(DistrictName, out var option)) + { + return !string.IsNullOrWhiteSpace(option.ParentRegionCode) + ? option.ParentRegionCode + : option.DistrictCode; + } + + return SbsElectionApiClient.ResolveBasicApiSidoCode(FirstNonWhiteSpace(RegionName, DistrictName)); + } + private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.Bottom &&