Product.cs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  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 는 선택 — null 이면 플랫폼 직접 판매(소모품 ItemKind 상품 등).
  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>제휴 게임 (선택). null 이면 플랫폼 직접 판매(소모품 등) — 게임 수익배분·정산 없음.</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. /// <summary>소모품 아이템 종류. null 이면 일반 상품(실물/쿠폰). 값이 있으면 구매 시 계정 귀속 MemberItem 발급.</summary>
  36. public ItemKind? ItemKind { get; private set; }
  37. /// <summary>소모품 효과 지속일 (ExpBooster 활성 창 등). 0 = 즉시 소모형. ItemKind 상품에만 유효.</summary>
  38. public int DurationDays { get; private set; }
  39. public bool IsActive { get; private set; } = true;
  40. public int Order { get; private set; }
  41. public DateTime? SaleStartAt { get; private set; }
  42. public DateTime? SaleEndAt { get; private set; }
  43. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  44. public DateTime? UpdatedAt { get; private set; }
  45. [Timestamp]
  46. public byte[] RowVersion { get; private set; } = default!;
  47. private Product() { }
  48. public static Product Create(
  49. int? gameID,
  50. string name,
  51. ProductType type,
  52. int price,
  53. DiscountType discountType,
  54. int discountValue,
  55. int stock,
  56. string? description = null,
  57. string? thumbnail = null,
  58. int order = 0,
  59. int minPurchase = 1,
  60. int maxPurchase = 999,
  61. DateTime? saleStartAt = null,
  62. DateTime? saleEndAt = null,
  63. bool requireDonationChannel = false,
  64. ItemKind? itemKind = null,
  65. int durationDays = 0
  66. ) {
  67. Validate(gameID, name, price, discountType, discountValue, stock, minPurchase, maxPurchase, saleStartAt, saleEndAt);
  68. return new Product
  69. {
  70. GameID = gameID,
  71. Name = name,
  72. Type = type,
  73. Price = price,
  74. DiscountType = discountType,
  75. DiscountValue = discountType == DiscountType.None ? 0 : discountValue,
  76. Stock = stock,
  77. Description = description,
  78. Thumbnail = thumbnail,
  79. Order = order,
  80. MinPurchase = minPurchase,
  81. MaxPurchase = maxPurchase,
  82. SaleStartAt = saleStartAt,
  83. SaleEndAt = saleEndAt,
  84. RequireDonationChannel = requireDonationChannel,
  85. ItemKind = itemKind,
  86. DurationDays = itemKind is null ? 0 : (durationDays < 0 ? 0 : durationDays)
  87. };
  88. }
  89. public void Update(
  90. int? gameID,
  91. string name,
  92. ProductType type,
  93. int price,
  94. DiscountType discountType,
  95. int discountValue,
  96. int stock,
  97. string? description,
  98. string? thumbnail,
  99. int order,
  100. int minPurchase,
  101. int maxPurchase,
  102. DateTime? saleStartAt,
  103. DateTime? saleEndAt,
  104. bool isActive,
  105. bool requireDonationChannel
  106. ) {
  107. Validate(gameID, name, price, discountType, discountValue, stock, minPurchase, maxPurchase, saleStartAt, saleEndAt);
  108. GameID = gameID;
  109. Name = name;
  110. Type = type;
  111. Price = price;
  112. DiscountType = discountType;
  113. DiscountValue = discountType == DiscountType.None ? 0 : discountValue;
  114. Stock = stock;
  115. Description = description;
  116. Thumbnail = thumbnail;
  117. Order = order;
  118. MinPurchase = minPurchase;
  119. MaxPurchase = maxPurchase;
  120. SaleStartAt = saleStartAt;
  121. SaleEndAt = saleEndAt;
  122. IsActive = isActive;
  123. RequireDonationChannel = requireDonationChannel;
  124. UpdatedAt = DateTime.UtcNow;
  125. }
  126. /// <summary>소모품 아이템 정보 설정 (Admin). ItemKind null 이면 일반 상품으로 되돌린다.</summary>
  127. public void SetItemInfo(ItemKind? itemKind, int durationDays)
  128. {
  129. ItemKind = itemKind;
  130. DurationDays = itemKind is null ? 0 : (durationDays < 0 ? 0 : durationDays);
  131. UpdatedAt = DateTime.UtcNow;
  132. }
  133. /// <summary>
  134. /// 할인이 반영된 최종 판매가 (P).
  135. /// 결제/주문 금액 계산은 반드시 이 값을 사용 (정가 Price 가 아님).
  136. /// </summary>
  137. public int GetSalePrice()
  138. {
  139. return DiscountType switch
  140. {
  141. DiscountType.Percent => Math.Max(0, Price - (Price * DiscountValue / 100)),
  142. DiscountType.Amount => Math.Max(0, Price - DiscountValue),
  143. _ => Price
  144. };
  145. }
  146. /// <summary>
  147. /// 결제 시 재고 차감. -1(무제한)이면 차감하지 않음.
  148. /// 동시성: EF Core RowVersion 으로 충돌 감지 → DbUpdateConcurrencyException.
  149. /// </summary>
  150. public void DecreaseStock(int quantity)
  151. {
  152. if (quantity <= 0)
  153. {
  154. throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
  155. }
  156. if (Stock == -1)
  157. {
  158. return; // 무제한
  159. }
  160. if (Stock < quantity)
  161. {
  162. throw new InvalidOperationException("재고가 부족합니다.");
  163. }
  164. Stock -= quantity;
  165. UpdatedAt = DateTime.UtcNow;
  166. }
  167. /// <summary>환불 시 재고 복구. -1 이면 변경 없음.</summary>
  168. public void RestoreStock(int quantity)
  169. {
  170. if (quantity <= 0)
  171. {
  172. throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
  173. }
  174. if (Stock == -1)
  175. {
  176. return;
  177. }
  178. Stock += quantity;
  179. UpdatedAt = DateTime.UtcNow;
  180. }
  181. public void Deactivate()
  182. {
  183. IsActive = false;
  184. UpdatedAt = DateTime.UtcNow;
  185. }
  186. private static void Validate(int? gameID, string name, int price, DiscountType discountType, int discountValue, int stock, int minPurchase, int maxPurchase, DateTime? saleStartAt, DateTime? saleEndAt)
  187. {
  188. if (gameID.HasValue && gameID.Value <= 0)
  189. {
  190. throw new ArgumentOutOfRangeException(nameof(gameID), "GameID must be positive when specified.");
  191. }
  192. if (string.IsNullOrWhiteSpace(name))
  193. {
  194. throw new ArgumentException("Name is required.", nameof(name));
  195. }
  196. if (name.Length > 200)
  197. {
  198. throw new ArgumentOutOfRangeException(nameof(name));
  199. }
  200. if (price < 0)
  201. {
  202. throw new ArgumentOutOfRangeException(nameof(price), "Price must be non-negative.");
  203. }
  204. if (discountType == DiscountType.Percent && (discountValue < 0 || discountValue > 99))
  205. {
  206. throw new ArgumentOutOfRangeException(nameof(discountValue), "Percent discount must be 0..99.");
  207. }
  208. if (discountType == DiscountType.Amount && (discountValue < 0 || discountValue > price))
  209. {
  210. throw new ArgumentOutOfRangeException(nameof(discountValue), "Amount discount must be 0..Price.");
  211. }
  212. if (stock < -1)
  213. {
  214. throw new ArgumentOutOfRangeException(nameof(stock), "Stock must be -1 (unlimited) or >= 0.");
  215. }
  216. if (minPurchase < 1)
  217. {
  218. throw new ArgumentOutOfRangeException(nameof(minPurchase), "MinPurchase must be >= 1.");
  219. }
  220. if (maxPurchase < minPurchase)
  221. {
  222. throw new ArgumentOutOfRangeException(nameof(maxPurchase), "MaxPurchase must be >= MinPurchase.");
  223. }
  224. if (maxPurchase > 999)
  225. {
  226. throw new ArgumentOutOfRangeException(nameof(maxPurchase), "MaxPurchase must be <= 999.");
  227. }
  228. if (saleStartAt.HasValue && saleEndAt.HasValue && saleStartAt.Value >= saleEndAt.Value)
  229. {
  230. throw new ArgumentException("SaleStartAt must be before SaleEndAt.");
  231. }
  232. }
  233. }