using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Domain.Entities.Store; using Domain.Entities.Store.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Admin.Store.Order.RequestCouponsRefund; public sealed class Handler(IAppDbContext db) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { if (request.Lines is null || request.Lines.Count == 0) { return Result.Failure(Error.Problem("Refund.NoCoupons", "환불할 쿠폰을 1개 이상 선택해 주세요.")); } if (string.IsNullOrWhiteSpace(request.Reason)) { return Result.Failure(Error.Problem("Refund.ReasonRequired", "환불 사유는 필수입니다.")); } var order = await db.Order .Include(o => o.Items) .FirstOrDefaultAsync(o => o.ID == request.OrderID, ct); if (order is null) { return Result.Failure(Error.NotFound("Order.NotFound", "주문을 찾을 수 없습니다.")); } // 입력 line 들의 OrderItem/CouponCode 가 모두 해당 Order 에 속하는지 + 상태 검증. var couponCodeIds = request.Lines.Select(l => l.CouponCodeID).Distinct().ToList(); var orderItemIds = order.Items.Select(i => i.ID).ToHashSet(); var inventories = await db.MemberInventory.AsNoTracking() .Include(m => m.CouponCode) .Where(m => couponCodeIds.Contains(m.CouponCodeID)) .ToListAsync(ct); var inventoryByCouponCode = inventories.ToDictionary(m => m.CouponCodeID); // 이미 다른 OrderRefundItem 에 등록된 CouponCodeID — unique index 위반 사전 차단. var alreadyRefundedCouponIds = await db.OrderRefundItem.AsNoTracking() .Where(x => couponCodeIds.Contains(x.CouponCodeID)) .Select(x => x.CouponCodeID) .ToListAsync(ct); if (alreadyRefundedCouponIds.Count > 0) { return Result.Failure(Error.Problem("Refund.CouponAlreadyRequested", $"이미 환불 요청에 포함된 쿠폰이 있습니다: {alreadyRefundedCouponIds.Count}건")); } // OrderItem.UnitPrice 캐싱 (Line 별 단가) var unitPriceByItem = order.Items.ToDictionary(i => i.ID, i => i.UnitPrice); var totalAmount = 0; foreach (var line in request.Lines) { if (!inventoryByCouponCode.TryGetValue(line.CouponCodeID, out var inv) || inv.CouponCode is null) { return Result.Failure(Error.NotFound("Refund.CouponNotFound", $"쿠폰 코드 #{line.CouponCodeID} 의 발급 정보가 없습니다.")); } if (inv.OrderItemID != line.OrderItemID) { return Result.Failure(Error.Problem("Refund.CouponItemMismatch", $"쿠폰 코드 #{line.CouponCodeID} 가 OrderItem #{line.OrderItemID} 에 속하지 않습니다.")); } if (!orderItemIds.Contains(line.OrderItemID)) { return Result.Failure(Error.Problem("Refund.OrderItemMismatch", $"OrderItem #{line.OrderItemID} 가 주문 #{request.OrderID} 에 속하지 않습니다.")); } // PostRefundAction=Restore 인데 이미 Used → 거부 (도메인 메서드와 동일 정책) if (line.PostRefundAction == CouponPostRefundAction.Restore && inv.CouponCode.Status == CouponCodeStatus.Used) { return Result.Failure(Error.Problem("Refund.CannotRestoreUsed", $"이미 사용된 쿠폰 코드는 '회수' 처리할 수 없습니다: {inv.CouponCode.Code}")); } // PostRefundAction=Expire 는 Available/Issued 만 가능 (CouponCode.Expire() 참조) if (line.PostRefundAction == CouponPostRefundAction.Expire && inv.CouponCode.Status != CouponCodeStatus.Available && inv.CouponCode.Status != CouponCodeStatus.Issued) { return Result.Failure(Error.Problem("Refund.CannotExpire", $"쿠폰 코드 상태가 '{inv.CouponCode.Status}' 라서 '만료' 처리할 수 없습니다: {inv.CouponCode.Code}")); } totalAmount += unitPriceByItem[line.OrderItemID]; } if (totalAmount <= 0 || totalAmount > order.TotalAmount) { return Result.Failure(Error.Problem("Refund.InvalidAmount", $"환불 금액 합계({totalAmount:N0}원)가 비정상입니다.")); } var refund = OrderRefund.Create(request.OrderID, RefundType.Refund, request.ReasonType, totalAmount, request.Reason); db.OrderRefund.Add(refund); await db.SaveChangesAsync(ct); // refund.ID 확보 foreach (var line in request.Lines) { var unit = unitPriceByItem[line.OrderItemID]; var item = OrderRefundItem.Create(refund.ID, line.OrderItemID, line.CouponCodeID, unit, line.PostRefundAction); db.OrderRefundItem.Add(item); } await db.SaveChangesAsync(ct); return Result.Success(refund.ID); } }