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("");
foreach (var idx in indices)
{
var rate = (idx.FlucRateBp / 100m).ToString("0.00", CultureInfo.InvariantCulture);
var sign = idx.FlucRateBp >= 0 ? "+" : "";
sb.Append(CultureInfo.InvariantCulture, $"- {Escape(idx.IndexName)}: {idx.Close.ToString("N2", CultureInfo.InvariantCulture)} ({sign}{idx.ChangeVal.ToString("N2", CultureInfo.InvariantCulture)}, {sign}{rate}%)
");
}
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("");
foreach (var d in disclosures)
{
sb.Append(CultureInfo.InvariantCulture, $"- [{d.RceptDt:yyyy-MM-dd}] {Escape(d.CorpName)} — {Escape(d.ReportNm)}
");
}
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);
}
}