Handler.cs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Domain.Entities.Store;
  4. using Domain.Entities.Store.ValueObject;
  5. using Microsoft.EntityFrameworkCore;
  6. using SharedKernel.Results;
  7. namespace Application.Features.Admin.Store.Order.RequestCouponsRefund;
  8. public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<int>>
  9. {
  10. public async Task<Result<int>> Handle(Command request, CancellationToken ct)
  11. {
  12. if (request.Lines is null || request.Lines.Count == 0)
  13. {
  14. return Result.Failure<int>(Error.Problem("Refund.NoCoupons", "환불할 쿠폰을 1개 이상 선택해 주세요."));
  15. }
  16. if (string.IsNullOrWhiteSpace(request.Reason))
  17. {
  18. return Result.Failure<int>(Error.Problem("Refund.ReasonRequired", "환불 사유는 필수입니다."));
  19. }
  20. var order = await db.Order
  21. .Include(o => o.Items)
  22. .FirstOrDefaultAsync(o => o.ID == request.OrderID, ct);
  23. if (order is null)
  24. {
  25. return Result.Failure<int>(Error.NotFound("Order.NotFound", "주문을 찾을 수 없습니다."));
  26. }
  27. // 입력 line 들의 OrderItem/CouponCode 가 모두 해당 Order 에 속하는지 + 상태 검증.
  28. var couponCodeIds = request.Lines.Select(l => l.CouponCodeID).Distinct().ToList();
  29. var orderItemIds = order.Items.Select(i => i.ID).ToHashSet();
  30. var inventories = await db.MemberInventory.AsNoTracking()
  31. .Include(m => m.CouponCode)
  32. .Where(m => couponCodeIds.Contains(m.CouponCodeID))
  33. .ToListAsync(ct);
  34. var inventoryByCouponCode = inventories.ToDictionary(m => m.CouponCodeID);
  35. // 이미 다른 OrderRefundItem 에 등록된 CouponCodeID — unique index 위반 사전 차단.
  36. var alreadyRefundedCouponIds = await db.OrderRefundItem.AsNoTracking()
  37. .Where(x => couponCodeIds.Contains(x.CouponCodeID))
  38. .Select(x => x.CouponCodeID)
  39. .ToListAsync(ct);
  40. if (alreadyRefundedCouponIds.Count > 0)
  41. {
  42. return Result.Failure<int>(Error.Problem("Refund.CouponAlreadyRequested", $"이미 환불 요청에 포함된 쿠폰이 있습니다: {alreadyRefundedCouponIds.Count}건"));
  43. }
  44. // OrderItem.UnitPrice 캐싱 (Line 별 단가)
  45. var unitPriceByItem = order.Items.ToDictionary(i => i.ID, i => i.UnitPrice);
  46. var totalAmount = 0;
  47. foreach (var line in request.Lines)
  48. {
  49. if (!inventoryByCouponCode.TryGetValue(line.CouponCodeID, out var inv) || inv.CouponCode is null)
  50. {
  51. return Result.Failure<int>(Error.NotFound("Refund.CouponNotFound", $"쿠폰 코드 #{line.CouponCodeID} 의 발급 정보가 없습니다."));
  52. }
  53. if (inv.OrderItemID != line.OrderItemID)
  54. {
  55. return Result.Failure<int>(Error.Problem("Refund.CouponItemMismatch", $"쿠폰 코드 #{line.CouponCodeID} 가 OrderItem #{line.OrderItemID} 에 속하지 않습니다."));
  56. }
  57. if (!orderItemIds.Contains(line.OrderItemID))
  58. {
  59. return Result.Failure<int>(Error.Problem("Refund.OrderItemMismatch", $"OrderItem #{line.OrderItemID} 가 주문 #{request.OrderID} 에 속하지 않습니다."));
  60. }
  61. // PostRefundAction=Restore 인데 이미 Used → 거부 (도메인 메서드와 동일 정책)
  62. if (line.PostRefundAction == CouponPostRefundAction.Restore && inv.CouponCode.Status == CouponCodeStatus.Used)
  63. {
  64. return Result.Failure<int>(Error.Problem("Refund.CannotRestoreUsed", $"이미 사용된 쿠폰 코드는 '회수' 처리할 수 없습니다: {inv.CouponCode.Code}"));
  65. }
  66. // PostRefundAction=Expire 는 Available/Issued 만 가능 (CouponCode.Expire() 참조)
  67. if (line.PostRefundAction == CouponPostRefundAction.Expire
  68. && inv.CouponCode.Status != CouponCodeStatus.Available
  69. && inv.CouponCode.Status != CouponCodeStatus.Issued)
  70. {
  71. return Result.Failure<int>(Error.Problem("Refund.CannotExpire", $"쿠폰 코드 상태가 '{inv.CouponCode.Status}' 라서 '만료' 처리할 수 없습니다: {inv.CouponCode.Code}"));
  72. }
  73. totalAmount += unitPriceByItem[line.OrderItemID];
  74. }
  75. if (totalAmount <= 0 || totalAmount > order.TotalAmount)
  76. {
  77. return Result.Failure<int>(Error.Problem("Refund.InvalidAmount", $"환불 금액 합계({totalAmount:N0}원)가 비정상입니다."));
  78. }
  79. var refund = OrderRefund.Create(request.OrderID, RefundType.Refund, request.ReasonType, totalAmount, request.Reason);
  80. db.OrderRefund.Add(refund);
  81. await db.SaveChangesAsync(ct); // refund.ID 확보
  82. foreach (var line in request.Lines)
  83. {
  84. var unit = unitPriceByItem[line.OrderItemID];
  85. var item = OrderRefundItem.Create(refund.ID, line.OrderItemID, line.CouponCodeID, unit, line.PostRefundAction);
  86. db.OrderRefundItem.Add(item);
  87. }
  88. await db.SaveChangesAsync(ct);
  89. return Result.Success(refund.ID);
  90. }
  91. }