using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Membership.Entities; using TakeoutSaaS.Domain.Membership.Enums; using TakeoutSaaS.Domain.Membership.Repositories; using TakeoutSaaS.Infrastructure.App.Persistence; namespace TakeoutSaaS.Infrastructure.App.Repositories; /// /// 会员积分商城 EF Core 仓储实现。 /// public sealed class EfPointMallRepository(TakeoutAppDbContext context) : IPointMallRepository { /// public Task GetRuleByStoreAsync( long tenantId, long storeId, CancellationToken cancellationToken = default) { return context.MemberPointMallRules .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId) .FirstOrDefaultAsync(cancellationToken); } /// public Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default) { return context.MemberPointMallRules.AddAsync(entity, cancellationToken).AsTask(); } /// public Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default) { context.MemberPointMallRules.Update(entity); return Task.CompletedTask; } /// public async Task> SearchProductsAsync( long tenantId, long storeId, MemberPointMallProductStatus? status, string? keyword, CancellationToken cancellationToken = default) { var query = context.MemberPointMallProducts .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId); if (status.HasValue) { query = query.Where(item => item.Status == status.Value); } var normalizedKeyword = (keyword ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(normalizedKeyword)) { var like = $"%{normalizedKeyword}%"; query = query.Where(item => EF.Functions.ILike(item.Name, like) || (item.PhysicalName != null && EF.Functions.ILike(item.PhysicalName, like))); } return await query .OrderByDescending(item => item.Status) .ThenByDescending(item => item.UpdatedAt ?? item.CreatedAt) .ThenByDescending(item => item.Id) .ToListAsync(cancellationToken); } /// public Task FindProductByIdAsync( long tenantId, long storeId, long productId, CancellationToken cancellationToken = default) { return context.MemberPointMallProducts .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == productId) .FirstOrDefaultAsync(cancellationToken); } /// public Task GetProductByIdAsync( long tenantId, long storeId, long productId, CancellationToken cancellationToken = default) { return context.MemberPointMallProducts .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == productId) .FirstOrDefaultAsync(cancellationToken); } /// public Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default) { return context.MemberPointMallProducts.AddAsync(entity, cancellationToken).AsTask(); } /// public Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default) { context.MemberPointMallProducts.Update(entity); return Task.CompletedTask; } /// public Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default) { context.MemberPointMallProducts.Remove(entity); return Task.CompletedTask; } /// public Task HasRecordsByProductIdAsync( long tenantId, long storeId, long pointMallProductId, CancellationToken cancellationToken = default) { return context.MemberPointMallRecords .AsNoTracking() .AnyAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.PointMallProductId == pointMallProductId, cancellationToken); } /// public Task CountMemberRedeemsByProductAsync( long tenantId, long storeId, long pointMallProductId, long memberId, CancellationToken cancellationToken = default) { return context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.PointMallProductId == pointMallProductId && item.MemberId == memberId && item.Status != MemberPointMallRecordStatus.Canceled) .CountAsync(cancellationToken); } /// public async Task<(IReadOnlyList Items, int TotalCount)> SearchRecordsAsync( long tenantId, long storeId, MemberPointMallRedeemType? redeemType, MemberPointMallRecordStatus? status, DateTime? startUtc, DateTime? endUtc, string? keyword, int page, int pageSize, CancellationToken cancellationToken = default) { var normalizedPage = Math.Max(1, page); var normalizedPageSize = Math.Clamp(pageSize, 1, 500); var query = BuildRecordQuery( tenantId, storeId, redeemType, status, startUtc, endUtc, keyword); var totalCount = await query.CountAsync(cancellationToken); if (totalCount == 0) { return ([], 0); } var items = await query .OrderByDescending(item => item.RedeemedAt) .ThenByDescending(item => item.Id) .Skip((normalizedPage - 1) * normalizedPageSize) .Take(normalizedPageSize) .ToListAsync(cancellationToken); return (items, totalCount); } /// public Task GetRecordByIdAsync( long tenantId, long storeId, long recordId, CancellationToken cancellationToken = default) { return context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == recordId) .FirstOrDefaultAsync(cancellationToken); } /// public Task FindRecordByIdAsync( long tenantId, long storeId, long recordId, CancellationToken cancellationToken = default) { return context.MemberPointMallRecords .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == recordId) .FirstOrDefaultAsync(cancellationToken); } /// public async Task> ListRecordsForExportAsync( long tenantId, long storeId, MemberPointMallRedeemType? redeemType, MemberPointMallRecordStatus? status, DateTime? startUtc, DateTime? endUtc, string? keyword, CancellationToken cancellationToken = default) { return await BuildRecordQuery( tenantId, storeId, redeemType, status, startUtc, endUtc, keyword) .OrderByDescending(item => item.RedeemedAt) .ThenByDescending(item => item.Id) .Take(20_000) .ToListAsync(cancellationToken); } /// public Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default) { return context.MemberPointMallRecords.AddAsync(entity, cancellationToken).AsTask(); } /// public Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default) { context.MemberPointMallRecords.Update(entity); return Task.CompletedTask; } /// public Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default) { return context.MemberPointLedgers.AddAsync(entity, cancellationToken).AsTask(); } /// public async Task GetRuleStatsAsync( long tenantId, long storeId, CancellationToken cancellationToken = default) { var redeemedPoints = await context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId) .Select(item => (int?)item.UsedPoints) .SumAsync(cancellationToken) ?? 0; var memberIds = await context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId) .Select(item => item.MemberId) .Distinct() .ToListAsync(cancellationToken); var pointMembers = memberIds.Count; if (pointMembers == 0) { return new MemberPointMallRuleStatsSnapshot { TotalIssuedPoints = 0, RedeemedPoints = 0, PointMembers = 0, RedeemRate = 0m }; } var currentPoints = await context.MemberProfiles .AsNoTracking() .Where(item => item.TenantId == tenantId && memberIds.Contains(item.Id)) .Select(item => (int?)item.PointsBalance) .SumAsync(cancellationToken) ?? 0; var totalIssuedPoints = Math.Max(redeemedPoints + Math.Max(0, currentPoints), 0); var redeemRate = totalIssuedPoints <= 0 ? 0m : decimal.Round((decimal)redeemedPoints * 100m / totalIssuedPoints, 1, MidpointRounding.AwayFromZero); return new MemberPointMallRuleStatsSnapshot { TotalIssuedPoints = totalIssuedPoints, RedeemedPoints = redeemedPoints, PointMembers = pointMembers, RedeemRate = redeemRate }; } /// public async Task GetRecordStatsAsync( long tenantId, long storeId, DateTime nowUtc, CancellationToken cancellationToken = default) { var normalizedNow = NormalizeUtc(nowUtc); var dayStart = new DateTime( normalizedNow.Year, normalizedNow.Month, normalizedNow.Day, 0, 0, 0, DateTimeKind.Utc); var monthStart = new DateTime( normalizedNow.Year, normalizedNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); var todayRedeemCount = await context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.RedeemedAt >= dayStart && item.RedeemedAt <= normalizedNow) .CountAsync(cancellationToken); var pendingPhysicalCount = await context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.RedeemType == MemberPointMallRedeemType.Physical && item.Status == MemberPointMallRecordStatus.PendingPickup) .CountAsync(cancellationToken); var currentMonthUsedPoints = await context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.RedeemedAt >= monthStart && item.RedeemedAt <= normalizedNow) .Select(item => (int?)item.UsedPoints) .SumAsync(cancellationToken) ?? 0; return new MemberPointMallRecordStatsSnapshot { TodayRedeemCount = todayRedeemCount, PendingPhysicalCount = pendingPhysicalCount, CurrentMonthUsedPoints = currentMonthUsedPoints }; } /// public async Task> GetProductAggregatesAsync( long tenantId, long storeId, IReadOnlyCollection pointMallProductIds, CancellationToken cancellationToken = default) { if (pointMallProductIds.Count == 0) { return []; } var aggregates = await context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId && pointMallProductIds.Contains(item.PointMallProductId)) .GroupBy(item => item.PointMallProductId) .Select(group => new MemberPointMallProductAggregateSnapshot { PointMallProductId = group.Key, RedeemedCount = group.Count() }) .ToListAsync(cancellationToken); return aggregates.ToDictionary(item => item.PointMallProductId, item => item); } /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return context.SaveChangesAsync(cancellationToken); } private IQueryable BuildRecordQuery( long tenantId, long storeId, MemberPointMallRedeemType? redeemType, MemberPointMallRecordStatus? status, DateTime? startUtc, DateTime? endUtc, string? keyword) { var query = context.MemberPointMallRecords .AsNoTracking() .Where(item => item.TenantId == tenantId && item.StoreId == storeId); if (redeemType.HasValue) { query = query.Where(item => item.RedeemType == redeemType.Value); } if (status.HasValue) { query = query.Where(item => item.Status == status.Value); } if (startUtc.HasValue) { var normalizedStart = NormalizeUtc(startUtc.Value); query = query.Where(item => item.RedeemedAt >= normalizedStart); } if (endUtc.HasValue) { var normalizedEnd = NormalizeUtc(endUtc.Value); query = query.Where(item => item.RedeemedAt <= normalizedEnd); } var normalizedKeyword = (keyword ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(normalizedKeyword)) { var like = $"%{normalizedKeyword}%"; query = query.Where(item => EF.Functions.ILike(item.RecordNo, like) || EF.Functions.ILike(item.MemberName, like) || EF.Functions.ILike(item.MemberMobileMasked, like) || EF.Functions.ILike(item.ProductName, like)); } return query; } private static DateTime NormalizeUtc(DateTime value) { return value.Kind switch { DateTimeKind.Utc => value, DateTimeKind.Local => value.ToUniversalTime(), _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) }; } }