using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Domain.Entities.Store.ValueObject; namespace Domain.Entities.Store; /// /// 상품별 판매 정책 (1:1 with Product). fdmbox `tb_box_limit_config` 의 antooza 차용. /// 미설정 상품은 row 없음 (정책 미적용). /// /// 정책 동작: /// - UseQuantityLimit + MaxQuantity > 0 : 회원당 누적 구매 수량 제한. /// - UsePeriodLimit + PeriodType : 누적 윈도우 범위. 미사용 시 all-time 누적. /// - None/Day/Week/Month : 현재 시각 기준 rolling 윈도우. /// - Custom : PeriodStartAt ~ PeriodEndAt 의 절대 datetime 범위 (UTC). /// public class ProductLimitConfig { [ForeignKey(nameof(ProductID))] public virtual Product? Product { get; private set; } /// PK = Product.ID (1:1). [Key] public int ProductID { get; private set; } /// 수량 제한 사용 여부. public bool UseQuantityLimit { get; private set; } /// 회원당 누적 최대 구매 수량 (0 = 무제한, UseQuantityLimit=true 일 때 의미 있음). public int MaxQuantity { get; private set; } /// 기간 제한 사용 여부. true 면 PeriodType 으로 윈도우 결정. false 면 all-time 누적. public bool UsePeriodLimit { get; private set; } /// 누적 윈도우 단위. None/Day/Week/Month/Custom. public LimitPeriodType PeriodType { get; private set; } = LimitPeriodType.None; /// PeriodType=Custom 일 때 사용. 절대 윈도우 시작 시각 (UTC). public DateTime? PeriodStartAt { get; private set; } /// PeriodType=Custom 일 때 사용. 절대 윈도우 종료 시각 (UTC). 반드시 PeriodStartAt 보다 커야 함. public DateTime? PeriodEndAt { get; private set; } public DateTime? UpdatedAt { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; private ProductLimitConfig() { } public static ProductLimitConfig Create(int productID) { if (productID <= 0) { throw new ArgumentOutOfRangeException(nameof(productID)); } return new ProductLimitConfig { ProductID = productID }; } /// /// Admin 측에서 정책 전체 업서트. 모든 필드를 한번에 갱신. /// useQuantityLimit=false 면 maxQuantity 는 0 으로 정규화. /// usePeriodLimit=false 면 periodType=None, PeriodStartAt/EndAt 도 null 로 정규화. /// Custom 이 아니면 PeriodStartAt/EndAt 은 null 로 정규화. /// Custom 인 경우 두 값 모두 not null + end > start 검증. /// public void Update( bool useQuantityLimit, int maxQuantity, bool usePeriodLimit, LimitPeriodType periodType, DateTime? periodStartAt, DateTime? periodEndAt ) { if (maxQuantity < 0 || maxQuantity > 9999) { throw new ArgumentOutOfRangeException(nameof(maxQuantity), "MaxQuantity 는 0~9999 범위."); } var isCustom = usePeriodLimit && periodType == LimitPeriodType.Custom; if (isCustom) { if (!periodStartAt.HasValue || !periodEndAt.HasValue) { throw new ArgumentException("Custom 기간은 시작/종료 시각 모두 지정해야 합니다.", nameof(periodStartAt)); } if (periodEndAt.Value <= periodStartAt.Value) { throw new ArgumentException("종료 시각은 시작 시각보다 이후여야 합니다.", nameof(periodEndAt)); } } UseQuantityLimit = useQuantityLimit; MaxQuantity = useQuantityLimit ? maxQuantity : 0; UsePeriodLimit = usePeriodLimit; PeriodType = usePeriodLimit ? periodType : LimitPeriodType.None; PeriodStartAt = isCustom ? periodStartAt : null; PeriodEndAt = isCustom ? periodEndAt : null; UpdatedAt = DateTime.UtcNow; } /// /// 현재 정책에서 누적 윈도우 시작 시각을 반환. UTC 기준. /// UsePeriodLimit=false 또는 PeriodType=None 이면 null (=all-time). /// Custom 은 PeriodStartAt 절대값 반환. /// public DateTime? GetWindowStart(DateTime now) { if (!UsePeriodLimit || PeriodType == LimitPeriodType.None) { return null; } return PeriodType switch { LimitPeriodType.Day => now.AddDays(-1), LimitPeriodType.Week => now.AddDays(-7), LimitPeriodType.Month => now.AddDays(-30), LimitPeriodType.Custom => PeriodStartAt, _ => null }; } /// /// 현재 정책에서 누적 윈도우 종료 시각을 반환. UTC 기준. /// rolling 유형(Day/Week/Month) 은 항상 현재 시각이 종료. /// Custom 은 PeriodEndAt 절대값 반환. /// 윈도우 미적용(All-time) 이면 null. /// public DateTime? GetWindowEnd(DateTime now) { if (!UsePeriodLimit || PeriodType == LimitPeriodType.None) { return null; } return PeriodType switch { LimitPeriodType.Day => now, LimitPeriodType.Week => now, LimitPeriodType.Month => now, LimitPeriodType.Custom => PeriodEndAt, _ => null }; } }