using System.Globalization; using System.Text; using Application.Abstractions.Data; using Domain.Entities.Forum.Briefings; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; using PostEntity = Domain.Entities.Forum.Posts.Post; namespace Application.Helpers; /// /// 장전 브리핑 본문 조립 + 발행 엔진 (d2 §③·§⑥ M3 DailyBriefingPublisher). /// 전일 지수(IndexDailyPrice 최신 KOSPI/KOSDAQ), 등락 상위/하위 종목(StockDailyPrice 최신 거래일 ChangeRate), /// 주요 공시(Disclosure 최근)를 취합해 briefing 보드에 시스템 계정(MemberID=null)으로 IsNotice Post 를 발행하고 /// DailyBriefing 기록을 남긴다. BriefingDate 로 멱등(이미 발행됐으면 skip). /// /// 스케줄은 Infrastructure 의 HostedService(DailyBriefingPublisher)가 담당하고, 여기서는 순수 조립/발행 로직만 /// 다뤄 IAppDbContext 만으로 단위 테스트가 가능하도록 분리했다 (PredictionSettlementEngine 패턴). /// 시스템 작성자 정책: Post.MemberID 는 nullable 이므로 전용 Member 없이 null + Name="개미투자" 로 발행한다(가장 단순). /// public static class DailyBriefingComposer { /// 시스템 작성자 표시명 (Post.MemberID=null). public const string SystemAuthorName = "개미투자"; private const int TopMoversCount = 5; private const int DisclosureCount = 10; /// 발행 결과 (배치 로그/테스트용). public readonly record struct PublishResult(bool Published, int PostID, bool AlreadyExisted); /// /// briefingDate 기준 장전 브리핑을 조립·발행한다. 이미 해당 일자로 발행됐으면 skip(멱등). /// 발행 시 briefing 보드는 lazy 시드되며, 반환값으로 발행 여부/PostID 를 준다. /// public static async Task PublishAsync(IAppDbContext db, DateOnly briefingDate, CancellationToken ct) { var existing = await db.DailyBriefing.AsNoTracking() .Where(c => c.BriefingDate == briefingDate) .Select(c => c.PostID) .FirstOrDefaultAsync(ct); if (existing != 0) { return new PublishResult(false, existing, true); } var body = await ComposeBodyAsync(db, ct); var subject = $"[장전 브리핑] {briefingDate:yyyy-MM-dd}"; var boardID = await BoardSeeder.ResolveBriefingBoardIDAsync(db, ct); var post = new PostEntity { BoardID = boardID, MemberID = null, Name = SystemAuthorName, Subject = subject, Content = body, IsNotice = true }; await db.Post.AddAsync(post, ct); await db.SaveChangesAsync(ct); // Post.ID(IDENTITY) 확보 — DailyBriefing FK 에 필요 await db.DailyBriefing.AddAsync(DailyBriefing.Create(briefingDate, post.ID), ct); // Board 글 수 증가 (일반 작성 핸들러와 동일 계약) var boardEntity = await db.Board.FirstOrDefaultAsync(c => c.ID == boardID, ct); if (boardEntity is not null) { boardEntity.Posts++; boardEntity.UpdatedAt = DateTime.UtcNow; var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(c => c.ID == boardEntity.BoardGroupID, ct); if (boardGroup is not null) { boardGroup.Posts++; boardGroup.UpdatedAt = DateTime.UtcNow; } } await db.SaveChangesAsync(ct); return new PublishResult(true, post.ID, false); } /// 전일 지수 + 등락 상위/하위 + 주요 공시로 HTML 본문을 조립한다. private static async Task ComposeBodyAsync(IAppDbContext db, CancellationToken ct) { var sb = new StringBuilder(); // ── 전일 지수 (KOSPI/KOSDAQ 최신 종가·등락) ── sb.Append("

전일 지수

"); var indices = await LoadLatestIndicesAsync(db, ct); if (indices.Count > 0) { sb.Append(""); } else { sb.Append("

데이터 없음

"); } // ── 등락 상위/하위 종목 (최신 거래일 ChangeRate) ── var latestTradeDate = await db.StockDailyPrice.AsNoTracking() .OrderByDescending(c => c.TradingDate) .Select(c => (DateOnly?)c.TradingDate) .FirstOrDefaultAsync(ct); if (latestTradeDate is DateOnly tradeDate) { var gainers = await LoadMoversAsync(db, tradeDate, top: true, ct); var losers = await LoadMoversAsync(db, tradeDate, top: false, ct); sb.Append("

등락 상위

"); AppendMovers(sb, gainers); sb.Append("

등락 하위

"); AppendMovers(sb, losers); } // ── 주요 공시 (최근 접수) ── sb.Append("

주요 공시

"); var disclosures = await db.Disclosure.AsNoTracking() .OrderByDescending(c => c.RceptDt).ThenByDescending(c => c.RceptNo) .Take(DisclosureCount) .Select(c => new { c.CorpName, c.ReportNm, c.RceptDt }) .ToListAsync(ct); if (disclosures.Count > 0) { sb.Append(""); } else { sb.Append("

데이터 없음

"); } return sb.ToString(); } private sealed record IndexRow(string IndexName, decimal Close, decimal ChangeVal, int FlucRateBp); /// KOSPI/KOSDAQ 각 계열에서 최신 거래일 지수 1건씩 로드. private static async Task> LoadLatestIndicesAsync(IAppDbContext db, CancellationToken ct) { var rows = new List(); foreach (var series in new[] { MarketIndexSeries.KOSPI, MarketIndexSeries.KOSDAQ }) { var idx = await db.IndexDailyPrice.AsNoTracking() .Where(c => c.Series == series) .OrderByDescending(c => c.TradeDate).ThenBy(c => c.IndexName) .Select(c => new IndexRow(c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp)) .FirstOrDefaultAsync(ct); if (idx is not null) { rows.Add(idx); } } return rows; } private sealed record MoverRow(string Name, string Code, decimal ChangeRate, int Close); /// 최신 거래일 종목 시세를 ChangeRate 내림차순(top)/오름차순으로 N건 로드 — Stock 조인으로 종목명 표기. private static async Task> LoadMoversAsync(IAppDbContext db, DateOnly tradeDate, bool top, CancellationToken ct) { var query = db.StockDailyPrice.AsNoTracking().Where(c => c.TradingDate == tradeDate); var ordered = top ? query.OrderByDescending(c => c.ChangeRate) : query.OrderBy(c => c.ChangeRate); var rows = await ordered .Take(TopMoversCount) .Join(db.Stock.AsNoTracking(), price => price.StockID, stock => stock.ID, (price, stock) => new MoverRow(stock.Name, stock.Code, price.ChangeRate, price.Close)) .ToListAsync(ct); return rows; } private static void AppendMovers(StringBuilder sb, List movers) { if (movers.Count == 0) { sb.Append("

데이터 없음

"); return; } sb.Append("
    "); foreach (var m in movers) { var rate = m.ChangeRate.ToString("0.00", CultureInfo.InvariantCulture); var sign = m.ChangeRate >= 0 ? "+" : ""; sb.Append(CultureInfo.InvariantCulture, $"
  • {Escape(m.Name)} (${m.Code}): {m.Close.ToString("N0", CultureInfo.InvariantCulture)}원 ({sign}{rate}%)
  • "); } sb.Append("
"); } private static string Escape(string value) { return value.Replace("&", "&", StringComparison.Ordinal).Replace("<", "<", StringComparison.Ordinal).Replace(">", ">", StringComparison.Ordinal); } }