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)
|
foreach (var responseItem in countingItems)
|
||||||
{
|
{
|
||||||
var item = responseItem.Item;
|
var item = responseItem.Item;
|
||||||
var regionId = item.Region?.Id;
|
var matchedRegionCode = GetCountingRegionMatchKeys(item.Region)
|
||||||
if (string.IsNullOrWhiteSpace(regionId) ||
|
.FirstOrDefault(key => districtMap.ContainsKey(key));
|
||||||
!districtMap.TryGetValue(regionId, out var districtOption) ||
|
if (string.IsNullOrWhiteSpace(matchedRegionCode) ||
|
||||||
!orderMap.TryGetValue(regionId, out var order))
|
!districtMap.TryGetValue(matchedRegionCode, out var districtOption) ||
|
||||||
|
!orderMap.TryGetValue(matchedRegionCode, out var order))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -322,6 +323,179 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
.ToArray();
|
.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(
|
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
|
||||||
string electionType,
|
string electionType,
|
||||||
IReadOnlyList<DistrictSelectionOption> districts,
|
IReadOnlyList<DistrictSelectionOption> districts,
|
||||||
@@ -1224,6 +1398,186 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
|
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
|
||||||
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";
|
: $"{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)
|
private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
|
||||||
=> Uri.Compare(
|
=> Uri.Compare(
|
||||||
configuration.BaseUri,
|
configuration.BaseUri,
|
||||||
@@ -1711,6 +2065,20 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
string JudgementBadgeText = "",
|
string JudgementBadgeText = "",
|
||||||
string JudgementDetailText = "");
|
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(
|
public sealed record TurnoutOverviewItem(
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string RegionName,
|
string RegionName,
|
||||||
@@ -1832,6 +2200,81 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
public int Tupyosu { get; set; }
|
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
|
private sealed class SbsCountingItem
|
||||||
{
|
{
|
||||||
[JsonPropertyName("region")]
|
[JsonPropertyName("region")]
|
||||||
|
|||||||
@@ -1165,9 +1165,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (IsByElectionTemplate(template) ||
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
|
||||||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
|
{
|
||||||
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));
|
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
|
||||||
}
|
}
|
||||||
@@ -3684,6 +3696,25 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
FormatTemplateDefinition? template,
|
FormatTemplateDefinition? template,
|
||||||
CancellationToken cancellationToken)
|
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))
|
if (UsesHistoricalScheduleOptions(template))
|
||||||
{
|
{
|
||||||
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
|
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
|
||||||
@@ -4166,31 +4197,73 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다.");
|
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(
|
var refreshResults = await GetCountingSnapshotsForScheduleTargetsAsync(
|
||||||
electionType,
|
electionType,
|
||||||
selectedTargets,
|
selectedTargets,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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 totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
|
||||||
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
||||||
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
||||||
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 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 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
|
? selectedTargets[0].DisplayName
|
||||||
: regionName;
|
: 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);
|
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
|
||||||
|
|
||||||
return new ElectionDataSnapshot
|
return new ElectionDataSnapshot
|
||||||
@@ -4200,14 +4273,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
DistrictName = districtName,
|
DistrictName = districtName,
|
||||||
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
|
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
|
||||||
RegionName = regionName,
|
RegionName = regionName,
|
||||||
ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
|
ElectionDistrictName = electionDistrictName,
|
||||||
Candidates = seatCandidates,
|
Candidates = seatCandidates,
|
||||||
TotalExpectedVotes = totalVotes,
|
TotalExpectedVotes = totalExpectedVotesForSnapshot,
|
||||||
TurnoutVotes = turnoutVotes,
|
TurnoutVotes = turnoutVotesForSnapshot,
|
||||||
CountedVotesFromApi = countedVotes,
|
CountedVotesFromApi = countedVotesForSnapshot,
|
||||||
RemainingVotesFromApi = remainingVotes,
|
RemainingVotesFromApi = remainingVotesForSnapshot,
|
||||||
CountedRateFromApi = countedRate,
|
CountedRateFromApi = countedRate,
|
||||||
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
|
ReceivedAt = receivedAt,
|
||||||
HistoricalTurnoutHistory = history.TurnoutHistory,
|
HistoricalTurnoutHistory = history.TurnoutHistory,
|
||||||
HistoricalWinnerHistory = history.WinnerHistory
|
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)));
|
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(
|
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
|
||||||
string electionType,
|
string electionType,
|
||||||
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
||||||
@@ -5084,6 +5246,49 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName);
|
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)
|
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||||
|
|||||||
Reference in New Issue
Block a user