480 lines
16 KiB
C#
480 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 会员积分商城 EF Core 仓储实现。
|
|
/// </summary>
|
|
public sealed class EfPointMallRepository(TakeoutAppDbContext context) : IPointMallRepository
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<MemberPointMallRule?> GetRuleByStoreAsync(
|
|
long tenantId,
|
|
long storeId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return context.MemberPointMallRules
|
|
.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default)
|
|
{
|
|
return context.MemberPointMallRules.AddAsync(entity, cancellationToken).AsTask();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default)
|
|
{
|
|
context.MemberPointMallRules.Update(entity);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<MemberPointMallProduct>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MemberPointMallProduct?> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MemberPointMallProduct?> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
|
|
{
|
|
return context.MemberPointMallProducts.AddAsync(entity, cancellationToken).AsTask();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
|
|
{
|
|
context.MemberPointMallProducts.Update(entity);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
|
|
{
|
|
context.MemberPointMallProducts.Remove(entity);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<(IReadOnlyList<MemberPointMallRecord> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MemberPointMallRecord?> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MemberPointMallRecord?> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<MemberPointMallRecord>> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default)
|
|
{
|
|
return context.MemberPointMallRecords.AddAsync(entity, cancellationToken).AsTask();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default)
|
|
{
|
|
context.MemberPointMallRecords.Update(entity);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default)
|
|
{
|
|
return context.MemberPointLedgers.AddAsync(entity, cancellationToken).AsTask();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<MemberPointMallRuleStatsSnapshot> 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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<MemberPointMallRecordStatsSnapshot> 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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Dictionary<long, MemberPointMallProductAggregateSnapshot>> GetProductAggregatesAsync(
|
|
long tenantId,
|
|
long storeId,
|
|
IReadOnlyCollection<long> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private IQueryable<MemberPointMallRecord> 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)
|
|
};
|
|
}
|
|
}
|