ProductLimitConfig.cs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using Domain.Entities.Store.ValueObject;
  4. namespace Domain.Entities.Store;
  5. /// <summary>
  6. /// 상품별 판매 정책 (1:1 with Product). fdmbox `tb_box_limit_config` 의 antooza 차용.
  7. /// 미설정 상품은 row 없음 (정책 미적용).
  8. ///
  9. /// 정책 동작:
  10. /// - UseQuantityLimit + MaxQuantity > 0 : 회원당 누적 구매 수량 제한.
  11. /// - UsePeriodLimit + PeriodType : 누적 윈도우 범위. 미사용 시 all-time 누적.
  12. /// - None/Day/Week/Month : 현재 시각 기준 rolling 윈도우.
  13. /// - Custom : PeriodStartAt ~ PeriodEndAt 의 절대 datetime 범위 (UTC).
  14. /// </summary>
  15. public class ProductLimitConfig
  16. {
  17. [ForeignKey(nameof(ProductID))]
  18. public virtual Product? Product { get; private set; }
  19. /// <summary>PK = Product.ID (1:1).</summary>
  20. [Key]
  21. public int ProductID { get; private set; }
  22. /// <summary>수량 제한 사용 여부.</summary>
  23. public bool UseQuantityLimit { get; private set; }
  24. /// <summary>회원당 누적 최대 구매 수량 (0 = 무제한, UseQuantityLimit=true 일 때 의미 있음).</summary>
  25. public int MaxQuantity { get; private set; }
  26. /// <summary>기간 제한 사용 여부. true 면 PeriodType 으로 윈도우 결정. false 면 all-time 누적.</summary>
  27. public bool UsePeriodLimit { get; private set; }
  28. /// <summary>누적 윈도우 단위. None/Day/Week/Month/Custom.</summary>
  29. public LimitPeriodType PeriodType { get; private set; } = LimitPeriodType.None;
  30. /// <summary>PeriodType=Custom 일 때 사용. 절대 윈도우 시작 시각 (UTC).</summary>
  31. public DateTime? PeriodStartAt { get; private set; }
  32. /// <summary>PeriodType=Custom 일 때 사용. 절대 윈도우 종료 시각 (UTC). 반드시 PeriodStartAt 보다 커야 함.</summary>
  33. public DateTime? PeriodEndAt { get; private set; }
  34. public DateTime? UpdatedAt { get; private set; }
  35. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  36. private ProductLimitConfig() { }
  37. public static ProductLimitConfig Create(int productID)
  38. {
  39. if (productID <= 0)
  40. {
  41. throw new ArgumentOutOfRangeException(nameof(productID));
  42. }
  43. return new ProductLimitConfig
  44. {
  45. ProductID = productID
  46. };
  47. }
  48. /// <summary>
  49. /// Admin 측에서 정책 전체 업서트. 모든 필드를 한번에 갱신.
  50. /// useQuantityLimit=false 면 maxQuantity 는 0 으로 정규화.
  51. /// usePeriodLimit=false 면 periodType=None, PeriodStartAt/EndAt 도 null 로 정규화.
  52. /// Custom 이 아니면 PeriodStartAt/EndAt 은 null 로 정규화.
  53. /// Custom 인 경우 두 값 모두 not null + end > start 검증.
  54. /// </summary>
  55. public void Update(
  56. bool useQuantityLimit,
  57. int maxQuantity,
  58. bool usePeriodLimit,
  59. LimitPeriodType periodType,
  60. DateTime? periodStartAt,
  61. DateTime? periodEndAt
  62. ) {
  63. if (maxQuantity < 0 || maxQuantity > 9999)
  64. {
  65. throw new ArgumentOutOfRangeException(nameof(maxQuantity), "MaxQuantity 는 0~9999 범위.");
  66. }
  67. var isCustom = usePeriodLimit && periodType == LimitPeriodType.Custom;
  68. if (isCustom)
  69. {
  70. if (!periodStartAt.HasValue || !periodEndAt.HasValue)
  71. {
  72. throw new ArgumentException("Custom 기간은 시작/종료 시각 모두 지정해야 합니다.", nameof(periodStartAt));
  73. }
  74. if (periodEndAt.Value <= periodStartAt.Value)
  75. {
  76. throw new ArgumentException("종료 시각은 시작 시각보다 이후여야 합니다.", nameof(periodEndAt));
  77. }
  78. }
  79. UseQuantityLimit = useQuantityLimit;
  80. MaxQuantity = useQuantityLimit ? maxQuantity : 0;
  81. UsePeriodLimit = usePeriodLimit;
  82. PeriodType = usePeriodLimit ? periodType : LimitPeriodType.None;
  83. PeriodStartAt = isCustom ? periodStartAt : null;
  84. PeriodEndAt = isCustom ? periodEndAt : null;
  85. UpdatedAt = DateTime.UtcNow;
  86. }
  87. /// <summary>
  88. /// 현재 정책에서 누적 윈도우 시작 시각을 반환. UTC 기준.
  89. /// UsePeriodLimit=false 또는 PeriodType=None 이면 null (=all-time).
  90. /// Custom 은 PeriodStartAt 절대값 반환.
  91. /// </summary>
  92. public DateTime? GetWindowStart(DateTime now)
  93. {
  94. if (!UsePeriodLimit || PeriodType == LimitPeriodType.None)
  95. {
  96. return null;
  97. }
  98. return PeriodType switch
  99. {
  100. LimitPeriodType.Day => now.AddDays(-1),
  101. LimitPeriodType.Week => now.AddDays(-7),
  102. LimitPeriodType.Month => now.AddDays(-30),
  103. LimitPeriodType.Custom => PeriodStartAt,
  104. _ => null
  105. };
  106. }
  107. /// <summary>
  108. /// 현재 정책에서 누적 윈도우 종료 시각을 반환. UTC 기준.
  109. /// rolling 유형(Day/Week/Month) 은 항상 현재 시각이 종료.
  110. /// Custom 은 PeriodEndAt 절대값 반환.
  111. /// 윈도우 미적용(All-time) 이면 null.
  112. /// </summary>
  113. public DateTime? GetWindowEnd(DateTime now)
  114. {
  115. if (!UsePeriodLimit || PeriodType == LimitPeriodType.None)
  116. {
  117. return null;
  118. }
  119. return PeriodType switch
  120. {
  121. LimitPeriodType.Day => now,
  122. LimitPeriodType.Week => now,
  123. LimitPeriodType.Month => now,
  124. LimitPeriodType.Custom => PeriodEndAt,
  125. _ => null
  126. };
  127. }
  128. }