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 배당·권리 수집 (Wave 1 ★P0) — 기본 06:40 KST(IssuerSync 06:30 다음 슬롯), `Seibro:DividendSync` 게이트. /// 세 단계가 하나의 기업(Corp) 카테고리 예산(SeibroQuota, Seibro:CorpBudget)을 공유한다. /// /// a) DividendSchedule 날짜 스윕: getDivSchedulInfo(BEGIN_STD_DT=day, EXPRY 생략 → 그날만, custno 미지정 → 전체 회사). /// 3년 창을 최신일→과거로 훑으며 미적재일만 수집한다. /// ⚠️ 날짜 스윕에 KrxBackfill(영업일·휴장 스킵)을 쓰지 않는다 — 배당 권리기준일 상당수가 분기말/연말(0331·0630·0930·1231)이며 /// 이 날짜가 토·일(예: 20161231 토, 20180331 토, 20180630 토)인 경우가 실제 샘플에 존재한다. 영업일만 훑으면 이런 기준일을 통째로 놓친다. /// → 주말·휴장 포함 전 캘린더일을 훑는다(3년 ≈ 1,095콜, Corp 예산 40,000 내 충분). /// b) Dividend rolling: 대상 = DividendSchedule 에 등장한 DISTINCT IssucoCustno 중 Dividend 미수집(우선)·stale 순. /// getDivInfo(ISSUCO_CUSTNO, BEGIN_STD_DT=3년전, EXPRY_STD_DT=오늘) 1콜 → 여러 배당 반환 → Dividend upsert(Isin+RgtStdDt). /// getDivInfo 응답엔 ISSUCO_CUSTNO 가 없으므로(샘플 확인) Isin 으로만 upsert 한다. /// c) RightsBaseDate rolling: 대상 = Stock.IssucoCustno non-null 중 RightsBaseDate 미수집(우선)·stale 순. /// getStddtInfo(ISSUCO_CUSTNO, BEGIN_STD_DT=3년전, EXPRY_STD_DT=오늘) → RightsBaseDate upsert. /// /// 실패/0행 = 정상 빈결과(다음 실행 재시도). HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). /// quota 소진 = 정상 종료(true) — 일일 예산은 KST 자정 롤오버로만 회복. ApiKey 미설정 시 로그만 남기고 skip. /// internal sealed class SeibroDividendSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, SeibroQuota quota, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { protected override string JobName => "SeibroDividendSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.DividendSyncTime, new TimeOnly(6, 40)); 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); var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3; var startDate = todayKst.AddYears(-years); try { if (!await SweepSchedulesAsync(db, client, cfg, startDate, todayKst, ct)) { return true; // quota 소진 — 오늘은 더 진행 불가 (정상 종료) } if (!await RollDividendsAsync(db, client, cfg, startDate, todayKst, ct)) { return true; } await RollRightsBaseDatesAsync(db, client, cfg, startDate, todayKst, 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) getDivSchedulInfo 날짜 스윕 (전 캘린더일, 최신일→과거) — 미적재일만 수집. quota 소진 시 false. private async Task SweepSchedulesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var fetched = 0; var first = true; for (var day = today; day >= startDate; day = day.AddDays(-1)) { ct.ThrowIfCancellationRequested(); if (await db.DividendSchedule.AsNoTracking().AnyAsync(c => c.RgtStdDt == day, ct)) { continue; // 이미 적재된 날 — resumable } if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { Logger.LogWarning("[{Job}] 기업 카테고리 일일 예산({Budget}) 소진 — getDivSchedulInfo {Day} 부터 중단", JobName, cfg.CorpBudget, day); 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, "getDivSchedulInfo", [new("BEGIN_STD_DT", day.ToString("yyyyMMdd"))], Logger, ct); var rows = SeibroDivSchedulParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDividendImport.UpsertSchedulesAsync(db, rows, ct); Logger.LogInformation("[{Job}] getDivSchedulInfo {Day} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, rows.Count, inserted, updated); fetched++; } Logger.LogInformation("[{Job}] 배당일정 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched); return true; } /// b) getDivInfo rolling — DividendSchedule 에 등장한 회사번호 중 미수집·stale 우선. quota 잔여분 내. 예산 소진 시 false. private async Task RollDividendsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var scheduleCustnos = await db.DividendSchedule.AsNoTracking().Select(c => c.IssucoCustno).Distinct().ToListAsync(ct); if (scheduleCustnos.Count == 0) { Logger.LogInformation("[{Job}] getDivInfo 대상 없음 (DividendSchedule 비어 있음)", JobName); return true; } // 회사번호 → 해당 회사 ISIN 들의 Dividend 최신 UpdatedAt (미수집이면 null → 최우선) var custnoIsins = await db.Stock.AsNoTracking().Where(c => c.IssucoCustno != null && c.ISIN != null) .Select(c => new { Custno = c.IssucoCustno!.Value, c.ISIN }) .ToListAsync(ct); var isinsByCustno = custnoIsins.GroupBy(c => c.Custno).ToDictionary(g => g.Key, g => g.Select(c => c.ISIN!).ToList()); var dividendUpdatedByIsin = await db.Dividend.AsNoTracking().GroupBy(c => c.Isin).Select(g => new { Isin = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.Isin, c => c.Last, ct); var targets = scheduleCustnos.Select(custno => { DateTime? last = null; if (isinsByCustno.TryGetValue(custno, out var isins)) { var times = isins.Where(dividendUpdatedByIsin.ContainsKey).Select(i => dividendUpdatedByIsin[i]).ToList(); // 회사의 모든 ISIN 이 최소 1회 수집됐을 때만 stale(가장 오래된 시각) 로 본다 — 하나라도 미수집이면 미수집(null=최우선) last = isins.Count > 0 && times.Count == isins.Count ? times.Min() : null; } return (Key: custno, LastUpdatedAt: last); }).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget); var beginStr = startDate.ToString("yyyyMMdd"); var expiryStr = today.ToString("yyyyMMdd"); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (custno, token) => { if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDivInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("BEGIN_STD_DT", beginStr), new("EXPRY_STD_DT", expiryStr)], Logger, token); var rows = SeibroDivInfoParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDividendImport.UpsertDividendsAsync(db, rows, token); Logger.LogInformation("[{Job}] getDivInfo ISSUCO_CUSTNO={Custno} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] getDivInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// c) getStddtInfo rolling — Stock.IssucoCustno non-null 중 미수집·stale 우선. quota 잔여분 내. private async Task RollRightsBaseDatesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var custnos = await db.Stock.AsNoTracking().Where(c => c.IssucoCustno != null).Select(c => c.IssucoCustno!.Value).Distinct().ToListAsync(ct); if (custnos.Count == 0) { Logger.LogInformation("[{Job}] getStddtInfo 대상 없음 (Stock.IssucoCustno 미스탬핑)", JobName); return; } var lastByCustno = await db.RightsBaseDate.AsNoTracking().GroupBy(c => c.IssucoCustno).Select(g => new { Custno = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.Custno, c => c.Last, ct); var targets = custnos.Select(custno => (Key: custno, LastUpdatedAt: lastByCustno.TryGetValue(custno, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget); var beginStr = startDate.ToString("yyyyMMdd"); var expiryStr = today.ToString("yyyyMMdd"); var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (custno, token) => { if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStddtInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("BEGIN_STD_DT", beginStr), new("EXPRY_STD_DT", expiryStr)], Logger, token); var rows = SeibroStddtParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDividendImport.UpsertRightsBaseDatesAsync(db, rows, token); Logger.LogInformation("[{Job}] getStddtInfo ISSUCO_CUSTNO={Custno} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] getStddtInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); } }