Apply council seat table by sigungu
This commit is contained in:
@@ -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<IReadOnlyList<DistrictSelectionOption>> GetCouncilSeatRegionOptionsAsync(
|
||||
string electionType,
|
||||
IEnumerable<string> regionFilters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
|
||||
!IsBasicCouncilCountingType(configuration.SungerType))
|
||||
{
|
||||
return Array.Empty<DistrictSelectionOption>();
|
||||
}
|
||||
|
||||
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<IReadOnlyList<DistrictSelectionOption>> GetCouncilSeatSigunguOptionsAsync(
|
||||
string electionType,
|
||||
IEnumerable<string> regionFilters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
|
||||
!IsBasicCouncilCountingType(configuration.SungerType))
|
||||
{
|
||||
return Array.Empty<DistrictSelectionOption>();
|
||||
}
|
||||
|
||||
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<IReadOnlyList<CouncilSeatPartySummary>> GetCouncilSeatSummariesAsync(
|
||||
string electionType,
|
||||
IReadOnlyList<string> sidoCodes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await GetCouncilSeatSummariesAsync(
|
||||
electionType,
|
||||
sidoCodes,
|
||||
Array.Empty<string>(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CouncilSeatPartySummary>> GetCouncilSeatSummariesAsync(
|
||||
string electionType,
|
||||
IReadOnlyList<string> sidoCodes,
|
||||
IReadOnlyList<string> sigunguCodes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
|
||||
!IsBasicCouncilCountingType(configuration.SungerType))
|
||||
{
|
||||
return Array.Empty<CouncilSeatPartySummary>();
|
||||
}
|
||||
|
||||
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<CouncilPanseResult?> 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<SbsCouncilPanseItem>(json, SerializerOptions);
|
||||
return item is null
|
||||
? null
|
||||
: new CouncilPanseResult(
|
||||
item.Minju,
|
||||
item.Kukhim,
|
||||
item.Etc,
|
||||
DateTimeOffset.Now,
|
||||
$"GET /{path}");
|
||||
}
|
||||
|
||||
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
|
||||
string electionType,
|
||||
IReadOnlyList<DistrictSelectionOption> 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<IReadOnlyList<SbsCouncilSeatSidoItem>> GetCouncilSeatItemsAsync(
|
||||
SbsElectionConfiguration configuration,
|
||||
IReadOnlyList<string> sidoCodes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedSidoCodes = NormalizeSidoCodes(sidoCodes);
|
||||
if (configuration.SungerType == 5 && normalizedSidoCodes.Count == 0)
|
||||
{
|
||||
return await GetArrayAsync<SbsCouncilSeatSidoItem>(
|
||||
configuration.BaseUri,
|
||||
BuildCouncilSeatPath(configuration, string.Empty),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var querySidoCodes = normalizedSidoCodes.Count > 0
|
||||
? normalizedSidoCodes
|
||||
: ResolveAllBasicApiSidoCodes();
|
||||
var items = new List<SbsCouncilSeatSidoItem>();
|
||||
var failures = 0;
|
||||
foreach (var sidoCode in querySidoCodes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = BuildCouncilSeatPath(
|
||||
configuration,
|
||||
$"sidos={Uri.EscapeDataString(sidoCode)}");
|
||||
items.AddRange(await GetArrayAsync<SbsCouncilSeatSidoItem>(
|
||||
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<string> NormalizeSidoCodes(IEnumerable<string> sidoCodes)
|
||||
{
|
||||
return (sidoCodes ?? Array.Empty<string>())
|
||||
.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<string> ResolveAllBasicApiSidoCodes()
|
||||
{
|
||||
return
|
||||
[
|
||||
"11",
|
||||
"26",
|
||||
"27",
|
||||
"28",
|
||||
"29",
|
||||
"30",
|
||||
"31",
|
||||
"41",
|
||||
"43",
|
||||
"44",
|
||||
"46",
|
||||
"47",
|
||||
"48",
|
||||
"49",
|
||||
"51",
|
||||
"52",
|
||||
"53"
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveCouncilSeatSidoCodes(int sungerType, IEnumerable<string> regionNames)
|
||||
{
|
||||
return (regionNames ?? Array.Empty<string>())
|
||||
.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<SbsCouncilSeatPartyItem> ResolveCouncilSeatPartyItems(SbsCouncilSeatSidoItem item)
|
||||
{
|
||||
if (item.Parties is { Count: > 0 })
|
||||
{
|
||||
return item.Parties;
|
||||
}
|
||||
|
||||
return item.Sigungus?
|
||||
.SelectMany(sigungu => sigungu.Parties ?? [])
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private static IEnumerable<SbsCouncilSeatPartyItem> ResolveCouncilSeatPartyItems(
|
||||
SbsCouncilSeatSidoItem item,
|
||||
ISet<string> 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<string> NormalizeCouncilSeatSigunguCodes(IEnumerable<string> sigunguCodes)
|
||||
{
|
||||
return (sigunguCodes ?? Array.Empty<string>())
|
||||
.Select(code => code?.Trim() ?? string.Empty)
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> 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<SbsCouncilSeatSigunguItem>? Sigungus { get; set; }
|
||||
|
||||
[JsonPropertyName("parties")]
|
||||
public List<SbsCouncilSeatPartyItem>? 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<SbsCouncilSeatPartyItem>? 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")]
|
||||
|
||||
@@ -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
|
||||
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<SbsElectionApiClient.CouncilSeatPartySummary> councilSeats)
|
||||
{
|
||||
var candidates = new List<CandidateEntry>(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<SbsElectionApiClient.CouncilSeatPartySummary> 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<SbsElectionApiClient.SbsElectionRefreshResult> 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 &&
|
||||
|
||||
Reference in New Issue
Block a user