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);
}
}