using Application.Abstractions.Data; using Domain.Entities.Stocks; using Microsoft.EntityFrameworkCore; namespace Infrastructure.StockData; /// /// SeibroCorpActionSyncService 의 DB 반영 로직 분리 (테스트 가능) — HTTP 없이 파서 출력(fixture)으로 검증한다 /// (SeibroSupplyImport 패턴 미러링). 일곱 엔티티(총회·안건·상호변경·대금·단주·CB대상·CB행사) upsert / window delete+insert 담당. /// public static class SeibroCorpActionImport { /// /// getGmeetInfo 결과를 GeneralMeeting 로 upsert — UQ (IssucoCustno, RgtStdDt, GmetKacd). /// rows 는 (회사, 기준일) per 콜 결과. 같은 콜 내 자연키 중복은 첫 행 채택. /// public static async Task<(int Inserted, int Updated)> UpsertGeneralMeetingsAsync(IAppDbContext db, IReadOnlyList rows, CancellationToken ct) { if (rows.Count == 0) { return (0, 0); } var custnos = rows.Select(c => c.IssucoCustno).Distinct().ToList(); var stdDts = rows.Select(c => c.RgtStdDt).Distinct().ToList(); var existing = await db.GeneralMeeting.Where(c => custnos.Contains(c.IssucoCustno) && stdDts.Contains(c.RgtStdDt)).ToListAsync(ct); var byKey = existing.ToDictionary(c => (c.IssucoCustno, c.RgtStdDt, c.GmetKacd)); var seen = new HashSet<(int, DateOnly, string)>(); var inserted = 0; var updated = 0; foreach (var row in rows) { var key = (row.IssucoCustno, row.RgtStdDt, row.GmetKacd); if (!seen.Add(key)) { continue; } if (byKey.TryGetValue(key, out var entity)) { var was = entity.UpdatedAt; entity.Update(row.IssuinNo, row.RgtRsnDetailSortCd, row.RostCloseBeginDt, row.RostCloseExpryDt, row.GmetOmeetYn, row.GmetPlaceTpcd, row.CouncilDterminPubnDt, row.GmetPlace, row.StkBuyreqRlYn, row.GmetDt, row.GmetTtm, row.NotcDlineDt, row.DferYn, row.EvoteYn, row.IsElectronicSecurity); if (entity.UpdatedAt != was) { updated++; } } else { var created = GeneralMeeting.Create(row.IssucoCustno, row.IssuinNo, row.GmetKacd, row.RgtRsnDetailSortCd, row.RostCloseBeginDt, row.RostCloseExpryDt, row.GmetOmeetYn, row.GmetPlaceTpcd, row.CouncilDterminPubnDt, row.GmetPlace, row.StkBuyreqRlYn, row.RgtStdDt, row.GmetDt, row.GmetTtm, row.NotcDlineDt, row.DferYn, row.EvoteYn, row.IsElectronicSecurity); await db.GeneralMeeting.AddAsync(created, ct); byKey[key] = created; inserted++; } } await db.SaveChangesAsync(ct); return (inserted, updated); } /// /// getGmeetMeasureInfo 결과를 MeetingAgenda 로 upsert — UQ (IssucoCustno, RgtStdDt, AgendaSeq). /// rows 는 (회사, 기준일) per 콜 결과(여러 안건). /// public static async Task<(int Inserted, int Updated)> UpsertMeetingAgendasAsync(IAppDbContext db, IReadOnlyList rows, CancellationToken ct) { if (rows.Count == 0) { return (0, 0); } var custnos = rows.Select(c => c.IssucoCustno).Distinct().ToList(); var stdDts = rows.Select(c => c.RgtStdDt).Distinct().ToList(); var existing = await db.MeetingAgenda.Where(c => custnos.Contains(c.IssucoCustno) && stdDts.Contains(c.RgtStdDt)).ToListAsync(ct); var byKey = existing.ToDictionary(c => (c.IssucoCustno, c.RgtStdDt, c.AgendaSeq)); var seen = new HashSet<(int, DateOnly, int)>(); var inserted = 0; var updated = 0; foreach (var row in rows) { var key = (row.IssucoCustno, row.RgtStdDt, row.AgendaSeq); if (!seen.Add(key)) { continue; } if (byKey.TryGetValue(key, out var entity)) { var was = entity.UpdatedAt; entity.Update(row.IssuinNo, row.GmetDt, row.GmetKacd, row.AgendaXpresNo, row.AgendaSchSeqo, row.AgendaNo, row.DtailAgendaNo, row.AgendaNm, row.EngAgendaNm, row.DtailAgendaNm, row.DtailEngAgendaNm, row.VoteMannTpcd, row.GmetResultTpcd); if (entity.UpdatedAt != was) { updated++; } } else { var created = MeetingAgenda.Create(row.IssucoCustno, row.IssuinNo, row.RgtStdDt, row.GmetDt, row.GmetKacd, row.AgendaSeq, row.AgendaXpresNo, row.AgendaSchSeqo, row.AgendaNo, row.DtailAgendaNo, row.AgendaNm, row.EngAgendaNm, row.DtailAgendaNm, row.DtailEngAgendaNm, row.VoteMannTpcd, row.GmetResultTpcd); await db.MeetingAgenda.AddAsync(created, ct); byKey[key] = created; inserted++; } } await db.SaveChangesAsync(ct); return (inserted, updated); } /// /// getFmnmAltInfo 결과를 CorpNameChange 로 upsert — UQ (IssucoCustno, RgtStdDt). /// rows 는 (회사, 기준일) per 콜 결과(보통 1행). /// public static async Task<(int Inserted, int Updated)> UpsertNameChangesAsync(IAppDbContext db, IReadOnlyList rows, CancellationToken ct) { if (rows.Count == 0) { return (0, 0); } var custnos = rows.Select(c => c.IssucoCustno).Distinct().ToList(); var stdDts = rows.Select(c => c.RgtStdDt).Distinct().ToList(); var existing = await db.CorpNameChange.Where(c => custnos.Contains(c.IssucoCustno) && stdDts.Contains(c.RgtStdDt)).ToListAsync(ct); var byKey = existing.ToDictionary(c => (c.IssucoCustno, c.RgtStdDt)); var seen = new HashSet<(int, DateOnly)>(); var inserted = 0; var updated = 0; foreach (var row in rows) { var key = (row.IssucoCustno, row.RgtStdDt); if (!seen.Add(key)) { continue; } if (byKey.TryGetValue(key, out var entity)) { var was = entity.UpdatedAt; entity.Update(row.FmnmBfaltKorNm, row.FmnmBfaltEngNm, row.FmnmAltKorNm, row.FmnmAltEngNm, row.MixusCirclBeginDt, row.MixusCirclExpryDt, row.UniCirclDt); if (entity.UpdatedAt != was) { updated++; } } else { var created = CorpNameChange.Create(row.IssucoCustno, row.RgtStdDt, row.FmnmBfaltKorNm, row.FmnmBfaltEngNm, row.FmnmAltKorNm, row.FmnmAltEngNm, row.MixusCirclBeginDt, row.MixusCirclExpryDt, row.UniCirclDt); await db.CorpNameChange.AddAsync(created, ct); byKey[key] = created; inserted++; } } await db.SaveChangesAsync(ct); return (inserted, updated); } /// /// getCostPaySchedul 결과를 PaymentSchedule 로 window(지급일+구분) delete+insert 반영. /// 날짜 스윕(TH1_PAY_TERM_BEGIN_DT × PAY_COST_TPCD)이라 자연키 대신 (지급일, 구분) 창 재구성. 응답에 없는 지급일/구분은 요청값 스탬핑. /// 같은 창 내 중복 (회사, ISIN, 권리기준일) 방어. /// public static async Task<(int Inserted, int Deleted)> ReplacePaymentScheduleWindowAsync(IAppDbContext db, DateOnly payDate, string payCostTpcd, IReadOnlyList rows, CancellationToken ct) { var deleted = await db.PaymentSchedule.Where(c => c.Th1PayTermBeginDt == payDate && c.PayCostTpcd == payCostTpcd).ExecuteDeleteAsync(ct); var seen = new HashSet<(int, string, DateOnly?)>(); var inserted = 0; foreach (var row in rows) { var key = (row.IssucoCustno, row.Isin, row.RgtStdDt); if (!seen.Add(key)) { continue; } var created = PaymentSchedule.Create(row.IssucoCustno, row.Isin, row.KorSecnNm, row.SecnKacd, row.RgtStdDt, row.RgtRacdNm, row.RgtRsnDetailSortCd, payCostTpcd, payDate); await db.PaymentSchedule.AddAsync(created, ct); inserted++; } await db.SaveChangesAsync(ct); return (inserted, deleted); } /// /// getOddLotInfo 결과를 OddLotPayment 로 upsert — UQ (IssucoCustno, PayDate, RgtStdDt). /// 응답에 없는 회사번호·지급일은 요청값을 스탬핑. rows 는 (회사, 지급일) per 콜 결과. /// public static async Task<(int Inserted, int Updated)> UpsertOddLotsAsync(IAppDbContext db, int issucoCustno, DateOnly payDate, IReadOnlyList rows, CancellationToken ct) { if (rows.Count == 0) { return (0, 0); } var existing = await db.OddLotPayment.Where(c => c.IssucoCustno == issucoCustno && c.PayDate == payDate).ToListAsync(ct); var byKey = existing.ToDictionary(c => c.RgtStdDt); var seen = new HashSet(); var inserted = 0; var updated = 0; foreach (var row in rows) { if (!seen.Add(row.RgtStdDt)) { continue; } if (byKey.TryGetValue(row.RgtStdDt, out var entity)) { var was = entity.UpdatedAt; entity.Update(row.RgtRacd, row.RgtRacdNm, row.RgtRsnDetailSortCd, row.EstmStdTpcd, row.EstmStdDt, row.EstmStdprc, row.RostCloseBeginDt, row.RostCloseExpryDt, row.XrgtDt); if (entity.UpdatedAt != was) { updated++; } } else { var created = OddLotPayment.Create(issucoCustno, payDate, row.RgtStdDt, row.RgtRacd, row.RgtRacdNm, row.RgtRsnDetailSortCd, row.EstmStdTpcd, row.EstmStdDt, row.EstmStdprc, row.RostCloseBeginDt, row.RostCloseExpryDt, row.XrgtDt); await db.OddLotPayment.AddAsync(created, ct); byKey[row.RgtStdDt] = created; inserted++; } } await db.SaveChangesAsync(ct); return (inserted, updated); } /// /// getXrcStkStatInfo 결과를 ConvertibleBondTarget 로 upsert — UQ (BondIsin, XrcStkIsin). rolling per-채권ISIN 1콜 결과. /// public static async Task<(int Inserted, int Updated)> UpsertBondTargetsAsync(IAppDbContext db, IReadOnlyList rows, CancellationToken ct) { if (rows.Count == 0) { return (0, 0); } var bondIsins = rows.Select(c => c.BondIsin).Distinct().ToList(); var existing = await db.ConvertibleBondTarget.Where(c => bondIsins.Contains(c.BondIsin)).ToListAsync(ct); var byKey = existing.ToDictionary(c => (c.BondIsin, c.XrcStkIsin)); var seen = new HashSet<(string, string)>(); var inserted = 0; var updated = 0; foreach (var row in rows) { var key = (row.BondIsin, row.XrcStkIsin); if (!seen.Add(key)) { continue; } if (byKey.TryGetValue(key, out var entity)) { var was = entity.UpdatedAt; entity.Update(row.BondSecnNm, row.BondKindNm, row.StkSecnNm, row.WrtbIsin, row.WrtbSecnNm, row.XrcRatio, row.XrcPrice, row.SetaccEndtermLimitDays, row.NewstkAllocDdBfLimitDays, row.DmanLimitRsnContent); if (entity.UpdatedAt != was) { updated++; } } else { var created = ConvertibleBondTarget.Create(row.BondIsin, row.BondSecnNm, row.BondKindNm, row.XrcStkIsin, row.StkSecnNm, row.WrtbIsin, row.WrtbSecnNm, row.XrcRatio, row.XrcPrice, row.SetaccEndtermLimitDays, row.NewstkAllocDdBfLimitDays, row.DmanLimitRsnContent); await db.ConvertibleBondTarget.AddAsync(created, ct); byKey[key] = created; inserted++; } } await db.SaveChangesAsync(ct); return (inserted, updated); } /// /// getXrcStkOptionXrcInfo 결과를 ConvertibleExercise 로 window(권리행사일 begin~end) delete+insert 반영. /// 날짜 스윕(RGT_STD_DT=권리행사일)이라 창 재구성으로 삭제분 반영. begin/end 는 이번 스윕 창. /// public static async Task<(int Inserted, int Deleted)> ReplaceExerciseWindowAsync(IAppDbContext db, DateOnly begin, DateOnly end, IReadOnlyList rows, CancellationToken ct) { var deleted = await db.ConvertibleExercise.Where(c => c.RgtStdDt >= begin && c.RgtStdDt <= end).ExecuteDeleteAsync(ct); var inserted = 0; foreach (var row in rows) { if (row.RgtStdDt < begin || row.RgtStdDt > end) { continue; // 스윕 창 밖 — 다른 창이 담당 } var created = ConvertibleExercise.Create(row.BondIsin, row.BondKorSecnNm, row.BondKindNm, row.XrcStkIsin, row.StkKorSecnNm, row.RgtStdDt, row.XrcPossBeginDt, row.XrcPossExpryDt, row.XrcAmt, row.XrcQty, row.XrcPrice, row.ListDt); await db.ConvertibleExercise.AddAsync(created, ct); inserted++; } await db.SaveChangesAsync(ct); return (inserted, deleted); } }