Product.cs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  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. /// 상점 판매 상품. 실물(Physical) 또는 무형 쿠폰(Digital).
  7. /// 게임사(GameID) 필수 — 사용자 정책: 상품 등록 시 게임 필수 선택.
  8. /// </summary>
  9. public class Product
  10. {
  11. [ForeignKey(nameof(GameID))]
  12. public virtual Game? Game { get; private set; }
  13. [Key]
  14. public int ID { get; private set; }
  15. /// <summary>제휴 게임 (필수). Game.Commission/ChannelCommission 으로 수익 배분 결정.</summary>
  16. public int GameID { get; private set; }
  17. public string Name { get; private set; } = default!;
  18. public string? Description { get; private set; }
  19. public string? Thumbnail { get; private set; }
  20. public ProductType Type { get; private set; }
  21. /// <summary>정가 (P 단위, 내부는 정수). PgCharged 잔액에서 차감.</summary>
  22. public int Price { get; private set; }
  23. /// <summary>할인 모드 (None/Percent/Amount).</summary>
  24. public DiscountType DiscountType { get; private set; } = DiscountType.None;
  25. /// <summary>할인 값. Percent: 0..99, Amount: 0..Price, None: 0.</summary>
  26. public int DiscountValue { get; private set; }
  27. /// <summary>재고. -1 = 무제한 (Digital 상품 + 사전 입력 쿠폰 코드 풀로 관리하는 경우).</summary>
  28. public int Stock { get; private set; }
  29. /// <summary>1회 주문 시 최소 구매 수량 (기본 1, 하한 1).</summary>
  30. public int MinPurchase { get; private set; } = 1;
  31. /// <summary>1회 주문 시 최대 구매 수량 (기본 999, 하드 상한 999).</summary>
  32. public int MaxPurchase { get; private set; } = 999;
  33. /// <summary>구매 시 후원 채널 선택 필수 여부. true 면 채널 미선택 주문을 거부한다.</summary>
  34. public bool RequireDonationChannel { get; private set; }
  35. public bool IsActive { get; private set; } = true;
  36. public int Order { get; private set; }
  37. public DateTime? SaleStartAt { get; private set; }
  38. public DateTime? SaleEndAt { get; private set; }
  39. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  40. public DateTime? UpdatedAt { get; private set; }
  41. [Timestamp]
  42. public byte[] RowVersion { get; private set; } = default!;
  43. private Product() { }
  44. public static Product Create(
  45. int gameID,
  46. string name,
  47. ProductType type,
  48. int price,
  49. DiscountType discountType,
  50. int discountValue,
  51. int stock,
  52. string? description = null,
  53. string? thumbnail = null,
  54. int order = 0,
  55. int minPurchase = 1,
  56. int maxPurchase = 999,
  57. DateTime? saleStartAt = null,
  58. DateTime? saleEndAt = null,
  59. bool requireDonationChannel = false
  60. ) {
  61. Validate(gameID, name, price, discountType, discountValue, stock, minPurchase, maxPurchase, saleStartAt, saleEndAt);
  62. return new Product
  63. {
  64. GameID = gameID,
  65. Name = name,
  66. Type = type,
  67. Price = price,
  68. DiscountType = discountType,
  69. DiscountValue = discountType == DiscountType.None ? 0 : discountValue,
  70. Stock = stock,
  71. Description = description,
  72. Thumbnail = thumbnail,
  73. Order = order,
  74. MinPurchase = minPurchase,
  75. MaxPurchase = maxPurchase,
  76. SaleStartAt = saleStartAt,
  77. SaleEndAt = saleEndAt,
  78. RequireDonationChannel = requireDonationChannel
  79. };
  80. }
  81. public void Update(
  82. int gameID,
  83. string name,
  84. ProductType type,
  85. int price,
  86. DiscountType discountType,
  87. int discountValue,
  88. int stock,
  89. string? description,
  90. string? thumbnail,
  91. int order,
  92. int minPurchase,
  93. int maxPurchase,
  94. DateTime? saleStartAt,
  95. DateTime? saleEndAt,
  96. bool isActive,
  97. bool requireDonationChannel
  98. ) {
  99. Validate(gameID, name, price, discountType, discountValue, stock, minPurchase, maxPurchase, saleStartAt, saleEndAt);
  100. GameID = gameID;
  101. Name = name;
  102. Type = type;
  103. Price = price;
  104. DiscountType = discountType;
  105. DiscountValue = discountType == DiscountType.None ? 0 : discountValue;
  106. Stock = stock;
  107. Description = description;
  108. Thumbnail = thumbnail;
  109. Order = order;
  110. MinPurchase = minPurchase;
  111. MaxPurchase = maxPurchase;
  112. SaleStartAt = saleStartAt;
  113. SaleEndAt = saleEndAt;
  114. IsActive = isActive;
  115. RequireDonationChannel = requireDonationChannel;
  116. UpdatedAt = DateTime.UtcNow;
  117. }
  118. /// <summary>
  119. /// 할인이 반영된 최종 판매가 (P).
  120. /// 결제/주문 금액 계산은 반드시 이 값을 사용 (정가 Price 가 아님).
  121. /// </summary>
  122. public int GetSalePrice()
  123. {
  124. return DiscountType switch
  125. {
  126. DiscountType.Percent => Math.Max(0, Price - (Price * DiscountValue / 100)),
  127. DiscountType.Amount => Math.Max(0, Price - DiscountValue),
  128. _ => Price
  129. };
  130. }
  131. /// <summary>
  132. /// 결제 시 재고 차감. -1(무제한)이면 차감하지 않음.
  133. /// 동시성: EF Core RowVersion 으로 충돌 감지 → DbUpdateConcurrencyException.
  134. /// </summary>
  135. public void DecreaseStock(int quantity)
  136. {
  137. if (quantity <= 0)
  138. {
  139. throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
  140. }
  141. if (Stock == -1)
  142. {
  143. return; // 무제한
  144. }
  145. if (Stock < quantity)
  146. {
  147. throw new InvalidOperationException("재고가 부족합니다.");
  148. }
  149. Stock -= quantity;
  150. UpdatedAt = DateTime.UtcNow;
  151. }
  152. /// <summary>환불 시 재고 복구. -1 이면 변경 없음.</summary>
  153. public void RestoreStock(int quantity)
  154. {
  155. if (quantity <= 0)
  156. {
  157. throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
  158. }
  159. if (Stock == -1)
  160. {
  161. return;
  162. }
  163. Stock += quantity;
  164. UpdatedAt = DateTime.UtcNow;
  165. }
  166. public void Deactivate()
  167. {
  168. IsActive = false;
  169. UpdatedAt = DateTime.UtcNow;
  170. }
  171. private static void Validate(int gameID, string name, int price, DiscountType discountType, int discountValue, int stock, int minPurchase, int maxPurchase, DateTime? saleStartAt, DateTime? saleEndAt)
  172. {
  173. if (gameID <= 0)
  174. {
  175. throw new ArgumentOutOfRangeException(nameof(gameID), "Game is required.");
  176. }
  177. if (string.IsNullOrWhiteSpace(name))
  178. {
  179. throw new ArgumentException("Name is required.", nameof(name));
  180. }
  181. if (name.Length > 200)
  182. {
  183. throw new ArgumentOutOfRangeException(nameof(name));
  184. }
  185. if (price < 0)
  186. {
  187. throw new ArgumentOutOfRangeException(nameof(price), "Price must be non-negative.");
  188. }
  189. if (discountType == DiscountType.Percent && (discountValue < 0 || discountValue > 99))
  190. {
  191. throw new ArgumentOutOfRangeException(nameof(discountValue), "Percent discount must be 0..99.");
  192. }
  193. if (discountType == DiscountType.Amount && (discountValue < 0 || discountValue > price))
  194. {
  195. throw new ArgumentOutOfRangeException(nameof(discountValue), "Amount discount must be 0..Price.");
  196. }
  197. if (stock < -1)
  198. {
  199. throw new ArgumentOutOfRangeException(nameof(stock), "Stock must be -1 (unlimited) or >= 0.");
  200. }
  201. if (minPurchase < 1)
  202. {
  203. throw new ArgumentOutOfRangeException(nameof(minPurchase), "MinPurchase must be >= 1.");
  204. }
  205. if (maxPurchase < minPurchase)
  206. {
  207. throw new ArgumentOutOfRangeException(nameof(maxPurchase), "MaxPurchase must be >= MinPurchase.");
  208. }
  209. if (maxPurchase > 999)
  210. {
  211. throw new ArgumentOutOfRangeException(nameof(maxPurchase), "MaxPurchase must be <= 999.");
  212. }
  213. if (saleStartAt.HasValue && saleEndAt.HasValue && saleStartAt.Value >= saleEndAt.Value)
  214. {
  215. throw new ArgumentException("SaleStartAt must be before SaleEndAt.");
  216. }
  217. }
  218. }