forked from xiongyuxing/tiku-backend.net
feat: add tenant points and coupon operations
This commit is contained in:
@@ -277,6 +277,291 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
||||
return ToActivationCodeItem(code);
|
||||
}
|
||||
|
||||
public async Task<TenantPointTaskList> GetPointTasksAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var tasks = dbContext.PointActivityTasks.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await tasks
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointTaskList(items);
|
||||
}
|
||||
|
||||
public async Task<PointActivityTask> UpsertPointTaskAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertPointTaskCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.Points <= 0 || command.MaxClaimsPerUser <= 0)
|
||||
{
|
||||
throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task");
|
||||
}
|
||||
|
||||
var task = command.Id.HasValue
|
||||
? await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(),
|
||||
cancellationToken);
|
||||
if (task is null)
|
||||
{
|
||||
task = new PointActivityTask { TenantId = actor.TenantId };
|
||||
dbContext.PointActivityTasks.Add(task);
|
||||
}
|
||||
|
||||
task.TaskKey = command.TaskKey.Trim();
|
||||
task.Title = command.Title.Trim();
|
||||
task.Description = command.Description?.Trim();
|
||||
task.TaskType = command.TaskType;
|
||||
task.Status = command.Status;
|
||||
task.Points = command.Points;
|
||||
task.MaxClaimsPerUser = command.MaxClaimsPerUser;
|
||||
task.StartsAt = command.StartsAt;
|
||||
task.EndsAt = command.EndsAt;
|
||||
task.SortOrder = command.SortOrder;
|
||||
task.Rules = JsonObjectOrDefault(command.Rules);
|
||||
task.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TenantPointClaimList> GetPointClaimsAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var claims = dbContext.PointActivityClaims.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
claims = claims.Where(item => item.UserId == query.UserId.Value);
|
||||
}
|
||||
|
||||
var items = await claims
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointClaimList(items);
|
||||
}
|
||||
|
||||
public async Task<TenantPointExchangeItemList> GetPointExchangeItemsAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var items = dbContext.PointExchangeItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status));
|
||||
}
|
||||
|
||||
if (query.RegionId.HasValue)
|
||||
{
|
||||
items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value);
|
||||
}
|
||||
|
||||
var result = await items
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointExchangeItemList(result);
|
||||
}
|
||||
|
||||
public async Task<PointExchangeItem> UpsertPointExchangeItemAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertPointExchangeItemCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.PointsCost <= 0)
|
||||
{
|
||||
throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item");
|
||||
}
|
||||
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||
entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||
entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(),
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new PointExchangeItem { TenantId = actor.TenantId };
|
||||
dbContext.PointExchangeItems.Add(item);
|
||||
}
|
||||
|
||||
item.RegionId = command.RegionId;
|
||||
item.ItemKey = command.ItemKey.Trim();
|
||||
item.Name = command.Name.Trim();
|
||||
item.Description = command.Description?.Trim();
|
||||
item.ItemType = command.ItemType;
|
||||
item.Status = command.Status;
|
||||
item.PointsCost = command.PointsCost;
|
||||
item.Stock = command.Stock;
|
||||
item.Days = command.Days;
|
||||
item.SortOrder = command.SortOrder;
|
||||
item.StartsAt = command.StartsAt;
|
||||
item.EndsAt = command.EndsAt;
|
||||
item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload);
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<TenantPointExchangeOrderList> GetPointExchangeOrdersAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var orders = dbContext.PointExchangeOrders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
orders = orders.Where(item => item.UserId == query.UserId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var result = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointExchangeOrderList(result);
|
||||
}
|
||||
|
||||
public async Task<PointExchangeOrder> UpdatePointExchangeOrderStatusAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdatePointExchangeOrderStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var order = await dbContext.PointExchangeOrders
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken)
|
||||
?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found");
|
||||
order.Status = command.Status;
|
||||
if (command.Status == PointExchangeOrderStatus.Completed)
|
||||
{
|
||||
order.CompletedAt ??= DateTimeOffset.UtcNow;
|
||||
order.CancelledAt = null;
|
||||
}
|
||||
else if (command.Status == PointExchangeOrderStatus.Cancelled)
|
||||
{
|
||||
order.CancelledAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return order;
|
||||
}
|
||||
|
||||
public async Task<TenantCouponList> GetCouponsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var coupons = dbContext.Coupons.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
var items = await coupons
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantCouponList(items);
|
||||
}
|
||||
|
||||
public async Task<Coupon> UpsertCouponAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertCouponCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var coupon = command.Id.HasValue
|
||||
? await dbContext.Coupons.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.Coupons.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(),
|
||||
cancellationToken);
|
||||
if (coupon is null)
|
||||
{
|
||||
coupon = new Coupon { TenantId = actor.TenantId };
|
||||
dbContext.Coupons.Add(coupon);
|
||||
}
|
||||
|
||||
coupon.Code = command.Code.Trim();
|
||||
coupon.PlanId = command.PlanId;
|
||||
coupon.DiscountType = command.DiscountType;
|
||||
coupon.DiscountValue = command.DiscountValue;
|
||||
coupon.ValidFrom = command.ValidFrom;
|
||||
coupon.ValidTo = command.ValidTo;
|
||||
coupon.MaxUses = command.MaxUses;
|
||||
coupon.Source = command.Source?.Trim();
|
||||
coupon.Remark = command.Remark;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return coupon;
|
||||
}
|
||||
|
||||
public async Task<TenantCouponRedemptionList> GetCouponRedemptionsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await redemptions
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantCouponRedemptionList(items);
|
||||
}
|
||||
|
||||
public async Task<TenantCouponReport> GetCouponReportAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
var claimedCount = await redemptions.CountAsync(cancellationToken);
|
||||
var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken);
|
||||
var discountApplied = await redemptions
|
||||
.Where(item => item.Status == CouponRedemptionStatus.Used)
|
||||
.SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0;
|
||||
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
@@ -321,6 +606,26 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
||||
? parsed
|
||||
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
|
||||
|
||||
private static PointActivityTaskStatus ParsePointTaskStatus(string? status) =>
|
||||
Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
|
||||
|
||||
private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) =>
|
||||
Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status");
|
||||
|
||||
private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) =>
|
||||
Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status");
|
||||
|
||||
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) =>
|
||||
Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user