using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Members; namespace Domain.Entities.Forum.Posts; /// /// 예측글 (d2 §③) — 종목 방향/기간 예측을 작성 시점에 확정 기록하고, 채점 배치(M4)가 상태를 확정한다. /// 작성 시 BasePrice(작성 시점 최신 T+1 종가)와 DueDate(영업일 + HorizonDays)를 스냅샷으로 고정한다. /// 예측글이 삭제(IsDeleted)돼도 Prediction 은 유지·채점된다(삭제 회피 어뷰징 차단). /// Stock FK 는 걸지 않는다(D1 소유). PostID 는 1:1(UQ). /// M2 는 Pending 생성만 — SettledPrice/SettledAt/상태 확정은 M4 PredictionSettlementService 소관. /// public class PostPrediction { [ForeignKey(nameof(PostID))] public virtual Post Post { get; private set; } = null!; [ForeignKey(nameof(MemberID))] public virtual Member Member { get; private set; } = null!; [Key] public int ID { get; private set; } /// 예측이 속한 게시글 (1:1) public int PostID { get; private set; } /// 작성 회원 public int MemberID { get; private set; } /// 예측 종목 단축코드 (char 6) public string StockCode { get; private set; } = default!; /// 예측 방향 (1=상승, 2=하락) public PredictionDirection Direction { get; private set; } /// 작성 시점 기준가 (최신 T+1 종가 스냅샷) public decimal BasePrice { get; private set; } /// 목표가 (선택) — 지정 시 도달 여부로 채점 public decimal? TargetPrice { get; private set; } /// 예측 기간 (영업일 수: 5 또는 20) public short HorizonDays { get; private set; } /// 채점 예정일 (작성일 + HorizonDays 영업일) public DateOnly DueDate { get; private set; } /// 상태 (0=Pending/1=Hit/2=Miss/3=Void) public PredictionStatus Status { get; private set; } = PredictionStatus.Pending; /// 채점 시점 종가 (M4 확정) public decimal? SettledPrice { get; private set; } /// 채점 일시 (M4 확정) public DateTime? SettledAt { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; private PostPrediction() { } /// Pending 예측 생성 (d2 §③ M2). 채점은 M4. public static PostPrediction Create( int postID, int memberID, string stockCode, PredictionDirection direction, decimal basePrice, short horizonDays, DateOnly dueDate, decimal? targetPrice = null) { if (postID <= 0) { throw new ArgumentOutOfRangeException(nameof(postID)); } if (memberID <= 0) { throw new ArgumentOutOfRangeException(nameof(memberID)); } if (stockCode is not { Length: 6 } || stockCode.Any(c => !char.IsAsciiDigit(c))) { throw new ArgumentException("stockCode must be 6 digits", nameof(stockCode)); } if (!Enum.IsDefined(direction)) { throw new ArgumentOutOfRangeException(nameof(direction)); } if (basePrice <= 0) { throw new ArgumentOutOfRangeException(nameof(basePrice)); } if (horizonDays <= 0) { throw new ArgumentOutOfRangeException(nameof(horizonDays)); } return new PostPrediction { PostID = postID, MemberID = memberID, StockCode = stockCode, Direction = direction, BasePrice = basePrice, TargetPrice = targetPrice is > 0 ? targetPrice : null, HorizonDays = horizonDays, DueDate = dueDate, Status = PredictionStatus.Pending }; } }