feat(member): implement points mall backend module
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user