using Application.Abstractions.Data; using Application.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.StockData; /// /// SEIBro 발행회사번호(ISSUCO_CUSTNO) 조인키 부트스트랩 + 종목 보강 (Wave 0) — 기본 06:30 KST, `Seibro:IssuerSync` 게이트. /// a) getShotnByMart × MART_TPCD {11,12,13,14} (주식 예산 4콜) → SeibroIssuerCode upsert(UQ Market+ShortCode) /// → Stock.IssucoCustno 스탬핑 (Code=단축코드 조인, 이미 같은 값이면 무변경) /// b) getStkStatInfo per-키 rolling — IssucoCustno 는 있는데 FaceValue 가 null 인 활성 Stock 우선(미보강 우선), /// SeibroRollingSweep + SeibroQuota(Stock 카테고리) 상한 내 SHOTN_ISIN 단건 호출 → Stock.EnrichFromSeibro. /// resultCount=0 은 정상 빈 결과(해당 종목만 미보강 유지 — 다음 실행에서 재시도), 종목별 SaveChanges 로 중단 시에도 진행분 보존. /// 실패 처리: HTTP·파싱 오류는 경고 로그 + 해당 run 중단(false 반환) → DailyScheduledService 가 RetryDelay 후 재시도. /// quota 소진은 정상 종료(true) — 일일 예산은 KST 자정 롤오버로만 회복되므로 재시도가 무의미하다. /// ApiKey 미설정 시 로그만 남기고 skip (다른 수집 배치와 동일 정책). /// internal sealed class SeibroIssuerSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, SeibroQuota quota, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { // 시장구분(MART_TPCD) — [11]유가 [12]코스닥 [13]K-OTC [14]코넥스. K-OTC 는 KRX Stock 미커버 시장의 유일 마스터. private static readonly string[] Markets = ["11", "12", "13", "14"]; protected override string JobName => "SeibroIssuerSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.IssuerSyncTime, new TimeOnly(6, 30)); protected override int MaxRetryCount => 2; protected override TimeSpan RetryDelay => TimeSpan.FromHours(2); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.Seibro; if (string.IsNullOrWhiteSpace(cfg.ApiKey)) { Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName); return true; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(SeibroHttp.ClientName); try { if (!await SyncIssuerCodesAsync(db, client, cfg, ct)) { return true; // quota 소진 — 오늘은 더 진행 불가 (정상 종료) } await EnrichStocksAsync(db, client, cfg, ct); return true; } catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException) { Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount); return false; } } /// a) 시장별 단축코드 스냅샷 4콜 → SeibroIssuerCode upsert + Stock.IssucoCustno 스탬핑. quota 소진 시 false. private async Task SyncIssuerCodesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var custnoByShortCode = new Dictionary(); var first = true; foreach (var market in Markets) { if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { Logger.LogWarning("[{Job}] 주식 카테고리 일일 예산({Budget}) 소진 — getShotnByMart {Market} 부터 중단", JobName, cfg.StockBudget, market); return false; } if (!first && cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } first = false; var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getShotnByMart", [new("MART_TPCD", market)], Logger, ct); var envelope = SeibroXml.Parse(xml); var rows = SeibroShotnByMartParser.Parse(envelope); var (inserted, updated) = await SeibroIssuerImport.UpsertIssuerCodesAsync(db, market, rows, ct); Logger.LogInformation("[{Job}] getShotnByMart MART_TPCD={Market} rows={Rows} (result={Result}) — inserted={Inserted}, updated={Updated}", JobName, market, rows.Count, envelope.ResultCount, inserted, updated); foreach (var row in rows) { custnoByShortCode[row.ShortCode] = row.IssucoCustno; } } var stamped = await SeibroIssuerImport.StampStockIssucoCustnoAsync(db, custnoByShortCode, ct); Logger.LogInformation("[{Job}] Stock.IssucoCustno 스탬핑 — {Stamped}건 (스냅샷 {Snapshot}코드)", JobName, stamped, custnoByShortCode.Count); return true; } // getStkStatInfo rolling 마커 job 키 (리뷰 결함 #1 — FaceValue null 판정은 정상 0행 종목을 매 실행 재조회) private const string EnrichJobKey = "stock-enrich"; /// /// b) getStkStatInfo rolling — 미보강(FaceValue null) 활성 종목부터 quota 잔여분 내에서 보강. /// 대상 정렬은 "FaceValue null" 이 아닌 SeibroFetchMarker(조회 시도 시각) 기준 — 정상 0행 종목이 매 실행 최우선 재조회되어 /// 나머지가 굶던 결함(#1) 해소. 조회 시도(0행 포함)마다 마커 Touch → 다음 실행에서 후순위로 밀려 나머지가 진행된다. /// private async Task EnrichStocksAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var targets = await db.Stock.Where(c => c.IsActive && c.IssucoCustno != null && c.FaceValue == null).OrderBy(c => c.ID).ToListAsync(ct); if (targets.Count == 0) { Logger.LogInformation("[{Job}] getStkStatInfo 보강 대상 없음", JobName); return; } var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, EnrichJobKey, ct); var ordered = targets.Select(c => (Key: c, LastUpdatedAt: markers.TryGetValue(c.Code, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget); var enriched = 0; var processed = await SeibroRollingSweep.RunAsync( targets: ordered, fetchAndUpsert: async (stock, token) => { if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { return; // 예산 경합 방어 — maxPerRun 산정 이후 다른 배치가 소비한 경우 skip } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStkStatInfo", [new("SHOTN_ISIN", stock.Code)], Logger, token); var rows = SeibroStkStatParser.Parse(SeibroXml.Parse(xml)); if (rows.Count == 0) { // 0행이어도 조회 시도 마커를 남겨 다음 실행에서 후순위로 밀린다 (starvation 방지) await SeibroMarkers.TouchFetchMarkerAsync(db, EnrichJobKey, stock.Code, token); Logger.LogInformation("[{Job}] getStkStatInfo SHOTN_ISIN={Code} 0행 — 미보강 유지, 마커 갱신 (다음 실행 후순위)", JobName, stock.Code); return; } var row = rows[0]; stock.EnrichFromSeibro(row.IssucoCustno, row.FaceValue, row.VotingRights, row.TotalIssuedShares, row.IsElectronicSecurity); await db.SaveChangesAsync(token); await SeibroMarkers.TouchFetchMarkerAsync(db, EnrichJobKey, stock.Code, token); enriched++; }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] getStkStatInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max}), 보강={Enriched}", JobName, targets.Count, processed, maxPerRun, enriched); } }