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 3) — 기본 07:20 KST(SupplySync 07:00 다음 슬롯), `Seibro:CorpActionSync` 게이트. /// 총회·상호변경·대금·단주 4단계는 기업(Corp) 예산(Seibro:CorpBudget), CB/BW 행사 2단계는 주식(Stock) 예산(Seibro:StockBudget)을 공유한다. /// /// a) 총회 이벤트 구동: RightsBaseDate(Wave 1 적재)의 RGT_RACD∈{001,002,003,009} 인 (회사번호, 기준일) → /// getGmeetInfo + getGmeetMeasureInfo(Corp). rolling(미수집 (회사,기준일) 우선). /// b) 상호변경 이벤트 구동: RightsBaseDate RGT_RACD=204 인 (회사번호, 기준일) → getFmnmAltInfo(Corp) rolling. /// c) PaymentSchedule: getCostPaySchedul 날짜 스윕(TH1_PAY_TERM_BEGIN_DT) × PAY_COST_TPCD{1,3}(Corp) — 지급일+구분 window delete+insert. /// d) OddLotPayment: PaymentSchedule 중 PayCostTpcd=3(단주) 인 (회사번호, 지급일) → getOddLotInfo(Corp) rolling. /// e) ConvertibleExercise: getXrcStkOptionXrcInfo 날짜 스윕(RGT_STD_DT=권리행사일)(Stock) — 권리행사일 window delete+insert. /// 반환된 BOND_ISIN 이 (f)의 발견 소스. /// f) ConvertibleBondTarget: (e)에서 발견된 DISTINCT BondIsin → getXrcStkStatInfo(BOND_ISIN)(Stock) rolling. /// (Wave 4 BondIssuance 는 아직 없으니 (e) 결과만 시드 — 발견 소스 단일.) /// /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip. /// ⚠️ getXrcStk* 실샘플이 result="0" 이라 CB/BW 필드 매핑은 스펙 기준 — 운영 첫 수집 시 로그로 확인. /// internal sealed class SeibroCorpActionSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, SeibroQuota quota, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { // 총회계열 권리사유코드 (RGT_RACD) — getGmeetInfo/getGmeetMeasureInfo 트리거 private static readonly string[] MeetingRacds = ["001", "002", "003", "009"]; // 상호변경 권리사유코드 (RGT_RACD) — getFmnmAltInfo 트리거 private const string NameChangeRacd = "204"; // 대금지급구분 (PAY_COST_TPCD) — 1배당금 3단주대금 private static readonly string[] PayCostTpcds = ["1", "3"]; private const string OddLotPayCostTpcd = "3"; // 마커 job 키 (리뷰 결함 #1) — 정상 0행/조용한 날 재조회·starvation 해소 private const string MeetingJobKey = "corp-meeting"; // rolling (FetchMarker, key=custno|stdDt) private const string NameChangeJobKey = "corp-namechange"; // rolling (FetchMarker, key=custno|stdDt) private const string OddLotJobKey = "corp-oddlot"; // rolling (FetchMarker, key=custno|payDate) private const string BondTargetJobKey = "cb-target"; // rolling (FetchMarker, key=bondIsin) private const string PaySchedJobKey = "corp-paysched"; // date-sweep (SweptDate, Discriminator=tpcd) private const string ExerciseJobKey = "cb-exercise"; // date-sweep (SweptDate) protected override string JobName => "SeibroCorpActionSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.CorpActionSyncTime, new TimeOnly(7, 20)); 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; using var scope = scopeFactory.CreateScope(); var collectorSettings = scope.ServiceProvider.GetRequiredService(); if (!await collectorSettings.IsEnabledAsync(CollectorFlag.SeibroCorpAction, ct)) { return true; } cfg = cfg with { ApiKey = await collectorSettings.GetKeyAsync(CollectorKey.Seibro, ct) ?? cfg.ApiKey }; if (string.IsNullOrWhiteSpace(cfg.ApiKey)) { Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName); return true; } var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(SeibroHttp.ClientName); var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3; var startDate = todayKst.AddYears(-years); // 단계별 독립 실행 — 한 단계의 SEIBro 오류가 다른 단계를 막지 않도록 개별 try/catch. // 반환 false = 예산 소진(전체 중단). 파싱/HTTP 오류 = 해당 단계만 skip(마커 미기록 → 다음 실행 재시도). async Task Phase(string name, Func> body) { try { return await body(ct); } catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException) { Logger.LogWarning(ex, "[{Job}] {Phase} SEIBro 오류 — 단계 skip(다음 실행 재시도)", JobName, name); return true; } } // a) 총회 → b) 상호변경 → c) 대금지급일정 → d) 단주대금 → e) CB/BW 행사 스윕 → f) CB/BW 행사대상 rolling if (!await Phase("총회", t => RollMeetingsAsync(db, client, cfg, t))) { return true; } if (!await Phase("상호변경", t => RollNameChangesAsync(db, client, cfg, t))) { return true; } if (!await Phase("대금지급일정", t => SweepPaymentSchedulesAsync(db, client, cfg, startDate, todayKst, t))) { return true; } if (!await Phase("단주대금", t => RollOddLotsAsync(db, client, cfg, t))) { return true; } if (!await Phase("CB/BW행사", t => SweepExercisesAsync(db, client, cfg, startDate, todayKst, t))) { return true; } // f) CB/BW 행사대상 rolling (void 반환) — 개별 try/catch await Phase("CB/BW행사대상", async t => { await RollBondTargetsAsync(db, client, cfg, t); return true; }); return true; } /// /// a) 총회 이벤트 구동 — RightsBaseDate RGT_RACD∈{001,002,003,009} 의 (회사번호, 기준일) 중 GeneralMeeting 미수집·stale 우선. /// (회사, 기준일) 당 getGmeetInfo + getGmeetMeasureInfo 2콜(각각 Corp 예산 소비). 예산 소진 시 false. /// private async Task RollMeetingsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var triggers = await db.RightsBaseDate.AsNoTracking() .Where(c => MeetingRacds.Contains(c.RgtRacd)) .Select(c => new { c.IssucoCustno, c.RgtStdDt }) .Distinct() .ToListAsync(ct); if (triggers.Count == 0) { Logger.LogInformation("[{Job}] 총회 트리거 없음 (RightsBaseDate 총회계열 미적재)", JobName); return true; } // 조회 시도(0행 포함) 마커 기준 정렬(키=custno|stdDt) — 총회 없는 (회사,기준일)도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, MeetingJobKey, ct); var targets = triggers.Select(t => ( Key: (t.IssucoCustno, t.RgtStdDt), LastUpdatedAt: markers.TryGetValue(MeetingKey(t.IssucoCustno, t.RgtStdDt), out var last) ? (DateTime?)last : null )).ToList(); // getGmeetInfo + getGmeetMeasureInfo = 2콜/트리거 → maxPerRun 은 잔여예산/2 로 산정(각 콜이 개별 TryConsume). var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget) / 2; var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (key, token) => { var (custno, stdDt) = key; var stdDtStr = stdDt.ToString("yyyyMMdd"); if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { quotaExhausted = true; return; } var gmeetXml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getGmeetInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("RGT_STD_DT", stdDtStr)], Logger, token); var gmeetRows = SeibroGmeetParser.Parse(SeibroXml.Parse(gmeetXml)); var (gi, gu) = await SeibroCorpActionImport.UpsertGeneralMeetingsAsync(db, gmeetRows, token); if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { quotaExhausted = true; return; } if (cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, token); } var measureXml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getGmeetMeasureInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("RGT_STD_DT", stdDtStr)], Logger, token); var measureRows = SeibroGmeetMeasureParser.Parse(SeibroXml.Parse(measureXml)); var (mi, mu) = await SeibroCorpActionImport.UpsertMeetingAgendasAsync(db, measureRows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, MeetingJobKey, MeetingKey(custno, stdDt), token); // 2콜 완료(0행 포함) 후 스탬핑 Logger.LogInformation("[{Job}] 총회 ISSUCO_CUSTNO={Custno} RGT_STD_DT={Std} — 총회 ins={Gi}/upd={Gu}, 안건 ins={Mi}/upd={Mu}", JobName, custno, stdDtStr, gi, gu, mi, mu); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 총회 rolling 완료 — 트리거={Triggers}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// /// b) 상호변경 이벤트 구동 — RightsBaseDate RGT_RACD=204 의 (회사번호, 기준일) 중 CorpNameChange 미수집·stale 우선. /// getFmnmAltInfo(ISSUCO_CUSTNO, RGT_STD_DT) 1콜(Corp). 예산 소진 시 false. /// private async Task RollNameChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var triggers = await db.RightsBaseDate.AsNoTracking() .Where(c => c.RgtRacd == NameChangeRacd) .Select(c => new { c.IssucoCustno, c.RgtStdDt }) .Distinct() .ToListAsync(ct); if (triggers.Count == 0) { Logger.LogInformation("[{Job}] 상호변경 트리거 없음 (RightsBaseDate RGT_RACD=204 미적재)", JobName); return true; } // 조회 시도(0행 포함) 마커 기준 정렬(키=custno|stdDt) — 상호변경 없는 (회사,기준일)도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, NameChangeJobKey, ct); var targets = triggers.Select(t => ( Key: (t.IssucoCustno, t.RgtStdDt), LastUpdatedAt: markers.TryGetValue(MeetingKey(t.IssucoCustno, t.RgtStdDt), out var last) ? (DateTime?)last : null )).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (key, token) => { var (custno, stdDt) = key; if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getFmnmAltInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("RGT_STD_DT", stdDt.ToString("yyyyMMdd"))], Logger, token); var rows = SeibroFmnmAltParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroCorpActionImport.UpsertNameChangesAsync(db, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, NameChangeJobKey, MeetingKey(custno, stdDt), token); Logger.LogInformation("[{Job}] 상호변경 ISSUCO_CUSTNO={Custno} RGT_STD_DT={Std} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, stdDt, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 상호변경 rolling 완료 — 트리거={Triggers}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// /// c) 대금지급일정 날짜 스윕(전 캘린더일, 최신→과거) × 구분{1,3} — 지급일+구분 window delete+insert. 미적재 (일,구분)만. /// ⚠️ 대금지급일은 배당·단주 지급일이라 주말/휴장 가능성 있어(SeibroDividendSync 판단 동일) 전 캘린더일 스윕. 예산 소진 시 false. /// private async Task SweepPaymentSchedulesAsync(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)) { foreach (var tpcd in PayCostTpcds) { ct.ThrowIfCancellationRequested(); // 데이터 존재 OR 조회완료 마커(축=tpcd) → 스킵 (지급 없는 (일,구분)도 재조회 회피, 리뷰 결함 #1) if (await db.PaymentSchedule.AsNoTracking().AnyAsync(c => c.Th1PayTermBeginDt == day && c.PayCostTpcd == tpcd, ct) || await SeibroMarkers.IsSweptAsync(db, PaySchedJobKey, day, tpcd, ct)) { continue; } if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { Logger.LogWarning("[{Job}] 기업 예산({Budget}) 소진 — getCostPaySchedul {Day}/{Tpcd} 부터 중단", JobName, cfg.CorpBudget, day, tpcd); 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, "getCostPaySchedul", [new("TH1_PAY_TERM_BEGIN_DT", day.ToString("yyyyMMdd")), new("PAY_COST_TPCD", tpcd)], Logger, ct); var rows = SeibroCostPaySchedulParser.Parse(SeibroXml.Parse(xml)); var (inserted, deleted) = await SeibroCorpActionImport.ReplacePaymentScheduleWindowAsync(db, day, tpcd, rows, ct); await SeibroMarkers.MarkSweptAsync(db, PaySchedJobKey, day, tpcd, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getCostPaySchedul {Day}/{Tpcd} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, tpcd, rows.Count, inserted, deleted); fetched++; } } Logger.LogInformation("[{Job}] 대금지급일정 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched); return true; } /// /// d) 단주대금 이벤트 구동 — PaymentSchedule 중 PAY_COST_TPCD=3(단주) 의 (회사번호, 지급일) 중 OddLotPayment 미수집·stale 우선. /// getOddLotInfo(ISSUCO_CUSTNO, TH1_PAY_TERM_BEGIN_DT) 1콜(Corp). 예산 소진 시 false. /// private async Task RollOddLotsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var triggers = await db.PaymentSchedule.AsNoTracking() .Where(c => c.PayCostTpcd == OddLotPayCostTpcd) .Select(c => new { c.IssucoCustno, c.Th1PayTermBeginDt }) .Distinct() .ToListAsync(ct); if (triggers.Count == 0) { Logger.LogInformation("[{Job}] 단주대금 트리거 없음 (PaymentSchedule 단주(구분3) 미적재)", JobName); return true; } // 조회 시도(0행 포함) 마커 기준 정렬(키=custno|payDate) — 단주 없는 (회사,지급일)도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, OddLotJobKey, ct); var targets = triggers.Select(t => ( Key: (t.IssucoCustno, PayDate: t.Th1PayTermBeginDt), LastUpdatedAt: markers.TryGetValue(MeetingKey(t.IssucoCustno, t.Th1PayTermBeginDt), out var last) ? (DateTime?)last : null )).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (key, token) => { var (custno, payDate) = key; if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getOddLotInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("TH1_PAY_TERM_BEGIN_DT", payDate.ToString("yyyyMMdd"))], Logger, token); var rows = SeibroOddLotParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroCorpActionImport.UpsertOddLotsAsync(db, custno, payDate, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, OddLotJobKey, MeetingKey(custno, payDate), token); Logger.LogInformation("[{Job}] 단주대금 ISSUCO_CUSTNO={Custno} 지급일={Pay} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, payDate, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 단주대금 rolling 완료 — 트리거={Triggers}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// /// e) CB/BW 행사 날짜 스윕(전 캘린더일, 최신→과거) — 권리행사일 window delete+insert. 미적재일만. Stock 예산. 예산 소진 시 false. /// ⚠️ 권리행사일(RGT_STD_DT)은 영업일 성격이나 스펙상 확정 불가라 전 캘린더일 스윕(보수적). 반환 BOND_ISIN 이 (f) 발견 소스. /// private async Task SweepExercisesAsync(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(); // 데이터 존재 OR 조회완료 마커 → 스킵 (행사 없는 날도 재조회 회피, 리뷰 결함 #1) if (await db.ConvertibleExercise.AsNoTracking().AnyAsync(c => c.RgtStdDt == day, ct) || await SeibroMarkers.IsSweptAsync(db, ExerciseJobKey, day, null, ct)) { continue; } if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getXrcStkOptionXrcInfo {Day} 부터 중단", JobName, cfg.StockBudget, 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, "getXrcStkOptionXrcInfo", [new("RGT_STD_DT", day.ToString("yyyyMMdd"))], Logger, ct); var rows = SeibroXrcStkOptionXrcParser.Parse(SeibroXml.Parse(xml)); var (inserted, deleted) = await SeibroCorpActionImport.ReplaceExerciseWindowAsync(db, day, day, rows, ct); await SeibroMarkers.MarkSweptAsync(db, ExerciseJobKey, day, null, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getXrcStkOptionXrcInfo {Day} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, rows.Count, inserted, deleted); fetched++; } Logger.LogInformation("[{Job}] CB/BW 행사 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched); return true; } /// /// f) CB/BW 행사대상 rolling per-채권ISIN — (e) ConvertibleExercise 에서 발견된 DISTINCT BondIsin 중 ConvertibleBondTarget 미수집·stale 우선. /// getXrcStkStatInfo(BOND_ISIN) 1콜(Stock). Wave 4 BondIssuance 미도입이라 발견 소스는 (e) 결과뿐. /// private async Task RollBondTargetsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var bondIsins = await db.ConvertibleExercise.AsNoTracking().Select(c => c.BondIsin).Distinct().ToListAsync(ct); if (bondIsins.Count == 0) { Logger.LogInformation("[{Job}] getXrcStkStatInfo 대상 없음 (ConvertibleExercise 비어 있음 — 발견 소스 없음)", JobName); return; } // 조회 시도(0행 포함) 마커 기준 정렬(키=bondIsin) — getXrcStkStatInfo 실샘플 result=0 이라 대상 행이 안 남아 재조회되던 결함(#1) 해소 var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, BondTargetJobKey, ct); var targets = bondIsins.Select(isin => (Key: isin, LastUpdatedAt: markers.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget); var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (bondIsin, token) => { if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getXrcStkStatInfo", [new("BOND_ISIN", bondIsin)], Logger, token); var rows = SeibroXrcStkStatParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroCorpActionImport.UpsertBondTargetsAsync(db, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, BondTargetJobKey, bondIsin, token); Logger.LogInformation("[{Job}] getXrcStkStatInfo BOND_ISIN={Bond} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, bondIsin, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] getXrcStkStatInfo rolling 완료 — 대상채권={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); } /// 복합키(회사번호 + 날짜)를 마커 EntityKey 문자열로 결합 — 총회/상호변경/단주 rolling 공용. private static string MeetingKey(int custno, DateOnly date) { return $"{custno}|{date:yyyyMMdd}"; } }