using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Store.Inventory.Groups; internal sealed class Handler(IAppDbContext db) : IQueryHandler { public async Task Handle(Query request, CancellationToken ct) { // 1단계: ProductID 단위 그룹 + aggregations (EF Core 가 SQL 로 깔끔히 번역). // anonymous key + 다중 join + Response 생성자 projection 을 한 번에 시도하면 // translation 실패하므로, 그룹 aggregations 만 먼저 가져온다. var groupedQuery = db.MemberInventory.AsNoTracking() .Where(i => i.MemberID == request.MemberID && i.DeletedAt == null) .GroupBy(i => i.OrderItem!.ProductID) .Select(g => new { ProductID = g.Key, TotalCount = g.Count(), UnusedCount = g.Count(x => x.UsedAt == null), EarliestAcquiredAt = g.Min(x => x.AcquiredAt) }); var total = await groupedQuery.CountAsync(ct); var pagedGroups = await groupedQuery .OrderByDescending(g => g.EarliestAcquiredAt) .Skip((request.Page - 1) * request.PerPage) .Take(request.PerPage) .ToListAsync(ct); if (pagedGroups.Count == 0) { return new Response(total, []); } // 2단계: 표시 페이지의 Product + Game 메타데이터 한 번에 fetch. var productIDs = pagedGroups.Select(g => g.ProductID).ToList(); var products = await db.Product.AsNoTracking() .Where(p => productIDs.Contains(p.ID)) .Select(p => new { p.ID, p.Name, p.Thumbnail, GameName = p.Game!.KorName }) .ToListAsync(ct); var productMap = products.ToDictionary(p => p.ID); // 3단계: 정렬 순서 유지하면서 stitch. var list = pagedGroups.Select(g => { var meta = productMap.GetValueOrDefault(g.ProductID); return new Response.GroupRow( g.ProductID, meta?.Name ?? string.Empty, meta?.Thumbnail, meta?.GameName ?? string.Empty, g.TotalCount, g.UnusedCount, g.EarliestAcquiredAt ); }).ToList(); return new Response(total, list); } }