feat: 完成营销中心优惠券后端模块
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m54s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m54s
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券列表请求。
|
||||
/// </summary>
|
||||
public sealed class CouponListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 券类型筛选(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string? CouponType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券详情请求。
|
||||
/// </summary>
|
||||
public sealed class CouponDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string CouponId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券请求。
|
||||
/// </summary>
|
||||
public sealed class SaveCouponRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(当前操作上下文)。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(fixed/days)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID 集合(stores 模式必传)。
|
||||
/// </summary>
|
||||
public List<string>? StoreIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改优惠券状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeCouponStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string CouponId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除优惠券请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteCouponRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string CouponId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券列表结果。
|
||||
/// </summary>
|
||||
public sealed class CouponListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表数据。
|
||||
/// </summary>
|
||||
public List<CouponListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计信息。
|
||||
/// </summary>
|
||||
public CouponStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券列表项。
|
||||
/// </summary>
|
||||
public sealed class CouponListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
public int ClaimedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已核销数量。
|
||||
/// </summary>
|
||||
public int RedeemedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券详情。
|
||||
/// </summary>
|
||||
public sealed class CouponDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; set; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
public int ClaimedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(fixed/days)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public string? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public string? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; set; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID 列表。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券统计响应。
|
||||
/// </summary>
|
||||
public sealed class CouponStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取总数。
|
||||
/// </summary>
|
||||
public int ClaimedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已核销总数。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心优惠券模板管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/coupon")]
|
||||
public sealed class MarketingCouponController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取优惠券列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponListResultResponse>> List(
|
||||
[FromQuery] CouponListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验当前门店上下文
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层查询
|
||||
var result = await mediator.Send(new GetCouponTemplateListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
CouponType = request.CouponType,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 映射响应
|
||||
return ApiResponse<CouponListResultResponse>.Ok(new CouponListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.TotalCount,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize,
|
||||
Stats = new CouponStatsResponse
|
||||
{
|
||||
TotalCount = result.Stats.TotalCount,
|
||||
OngoingCount = result.Stats.OngoingCount,
|
||||
ClaimedCount = result.Stats.ClaimedCount,
|
||||
RedeemedCount = result.Stats.RedeemedCount,
|
||||
RedeemRate = result.Stats.RedeemRate
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取优惠券详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponDetailResponse>> Detail(
|
||||
[FromQuery] CouponDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店访问权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 查询详情
|
||||
var result = await mediator.Send(new GetCouponTemplateDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId))
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 处理不存在场景
|
||||
if (result is null)
|
||||
{
|
||||
return ApiResponse<CouponDetailResponse>.Error(ErrorCodes.NotFound, "优惠券不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券模板(新增/编辑)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponDetailResponse>> Save(
|
||||
[FromBody] SaveCouponRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验操作门店
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
|
||||
// 2. 解析适用门店范围
|
||||
var resolvedStoreIds = await ResolveStoreScopeStoreIdsAsync(
|
||||
request.StoreScopeMode,
|
||||
request.StoreIds,
|
||||
tenantId,
|
||||
merchantId,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 调用应用层保存
|
||||
var result = await mediator.Send(new SaveCouponTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||
Name = request.Name,
|
||||
CouponType = request.CouponType,
|
||||
Value = request.Value,
|
||||
MinimumSpend = request.MinimumSpend,
|
||||
TotalQuantity = request.TotalQuantity,
|
||||
PerUserLimit = request.PerUserLimit,
|
||||
ValidityType = request.ValidityType,
|
||||
ValidFrom = request.ValidFrom,
|
||||
ValidTo = request.ValidTo,
|
||||
RelativeValidDays = request.RelativeValidDays,
|
||||
Channels = request.Channels ?? [],
|
||||
StoreScopeMode = NormalizeStoreScopeMode(request.StoreScopeMode),
|
||||
StoreScopeStoreIds = resolvedStoreIds,
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改优惠券状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CouponDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<CouponDetailResponse>> ChangeStatus(
|
||||
[FromBody] ChangeCouponStatusRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店访问权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层修改状态
|
||||
var result = await mediator.Send(new ChangeCouponTemplateStatusCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId)),
|
||||
Status = request.Status
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<CouponDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除优惠券模板。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Delete(
|
||||
[FromBody] DeleteCouponRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 解析并校验门店访问权限
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
|
||||
// 2. 调用应用层删除
|
||||
await mediator.Send(new DeleteCouponTemplateCommand
|
||||
{
|
||||
StoreId = storeId,
|
||||
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.CouponId, nameof(request.CouponId))
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<long>> ResolveStoreScopeStoreIdsAsync(
|
||||
string? storeScopeMode,
|
||||
IEnumerable<string>? storeIds,
|
||||
long tenantId,
|
||||
long merchantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 标准化 mode
|
||||
var normalizedMode = NormalizeStoreScopeMode(storeScopeMode);
|
||||
|
||||
// 2. all 模式下展开为租户商户可见全门店
|
||||
if (string.Equals(normalizedMode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
var allStoreIds = await dbContext.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.Select(x => x.Id)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||
}
|
||||
|
||||
return allStoreIds;
|
||||
}
|
||||
|
||||
// 3. stores 模式下严格校验输入门店集合
|
||||
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
|
||||
if (parsedStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||
dbContext,
|
||||
tenantId,
|
||||
merchantId,
|
||||
parsedStoreIds,
|
||||
cancellationToken);
|
||||
|
||||
if (accessibleStoreIds.Count != parsedStoreIds.Count)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
|
||||
}
|
||||
|
||||
return accessibleStoreIds.OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeStoreScopeMode(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalized is not ("all" or "stores"))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static CouponListItemResponse MapListItem(CouponTemplateListItemDto source)
|
||||
{
|
||||
return new CouponListItemResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
RelativeValidDays = source.RelativeValidDays,
|
||||
TotalQuantity = source.TotalQuantity,
|
||||
ClaimedQuantity = source.ClaimedQuantity,
|
||||
RedeemedQuantity = source.RedeemedQuantity,
|
||||
PerUserLimit = source.PerUserLimit,
|
||||
DisplayStatus = source.DisplayStatus,
|
||||
IsDimmed = source.IsDimmed,
|
||||
StoreScopeMode = source.StoreScopeMode,
|
||||
StoreIds = source.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Channels = source.Channels.ToList(),
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static CouponDetailResponse MapDetail(CouponTemplateDetailDto source)
|
||||
{
|
||||
return new CouponDetailResponse
|
||||
{
|
||||
Id = source.Id.ToString(),
|
||||
Name = source.Name,
|
||||
CouponType = source.CouponType,
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
TotalQuantity = source.TotalQuantity,
|
||||
ClaimedQuantity = source.ClaimedQuantity,
|
||||
PerUserLimit = source.PerUserLimit,
|
||||
ValidityType = source.ValidityType,
|
||||
ValidFrom = ToDateOnly(source.ValidFrom),
|
||||
ValidTo = ToDateOnly(source.ValidTo),
|
||||
RelativeValidDays = source.RelativeValidDays,
|
||||
Channels = source.Channels.ToList(),
|
||||
StoreScopeMode = source.StoreScopeMode,
|
||||
StoreIds = source.StoreIds.Select(item => item.ToString()).ToList(),
|
||||
Status = source.Status,
|
||||
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ToDateOnly(DateTime? value)
|
||||
{
|
||||
return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string ToDateTime(DateTime value)
|
||||
{
|
||||
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 修改优惠券模板状态命令。
|
||||
/// </summary>
|
||||
public sealed class ChangeCouponTemplateStatusCommand : IRequest<CouponTemplateDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前筛选门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 编辑态状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除优惠券模板命令。
|
||||
/// </summary>
|
||||
public sealed class DeleteCouponTemplateCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前筛选门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券模板命令。
|
||||
/// </summary>
|
||||
public sealed class SaveCouponTemplateCommand : IRequest<CouponTemplateDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public long? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(fixed/days)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; init; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; init; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID 集合(已完成可访问性校验)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> StoreScopeStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑态状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板 DTO 映射工厂。
|
||||
/// </summary>
|
||||
internal static class CouponTemplateDtoFactory
|
||||
{
|
||||
public static CouponTemplateListItemDto ToListItemDto(
|
||||
CouponTemplate template,
|
||||
CouponStoreScopeModel storeScope,
|
||||
IReadOnlyList<string> channels,
|
||||
int redeemedQuantity,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var displayStatus = CouponTemplateMapping.ResolveDisplayStatus(template, nowUtc);
|
||||
return new CouponTemplateListItemDto
|
||||
{
|
||||
Id = template.Id,
|
||||
Name = template.Name,
|
||||
CouponType = CouponTemplateMapping.ToCouponTypeText(template.CouponType),
|
||||
Value = template.Value,
|
||||
MinimumSpend = template.MinimumSpend,
|
||||
ValidFrom = template.ValidFrom,
|
||||
ValidTo = template.ValidTo,
|
||||
RelativeValidDays = template.RelativeValidDays,
|
||||
TotalQuantity = template.TotalQuantity ?? 0,
|
||||
ClaimedQuantity = template.ClaimedQuantity,
|
||||
RedeemedQuantity = redeemedQuantity,
|
||||
PerUserLimit = template.PerUserLimit,
|
||||
DisplayStatus = displayStatus,
|
||||
IsDimmed = CouponTemplateMapping.IsDimmedDisplayStatus(displayStatus),
|
||||
StoreScopeMode = storeScope.Mode,
|
||||
StoreIds = storeScope.StoreIds,
|
||||
Channels = channels,
|
||||
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static CouponTemplateDetailDto ToDetailDto(
|
||||
CouponTemplate template,
|
||||
CouponStoreScopeModel storeScope,
|
||||
IReadOnlyList<string> channels)
|
||||
{
|
||||
return new CouponTemplateDetailDto
|
||||
{
|
||||
Id = template.Id,
|
||||
Name = template.Name,
|
||||
CouponType = CouponTemplateMapping.ToCouponTypeText(template.CouponType),
|
||||
Value = template.Value,
|
||||
MinimumSpend = template.MinimumSpend,
|
||||
TotalQuantity = template.TotalQuantity ?? 0,
|
||||
ClaimedQuantity = template.ClaimedQuantity,
|
||||
PerUserLimit = template.PerUserLimit,
|
||||
ValidityType = CouponTemplateMapping.ResolveValidityType(template),
|
||||
ValidFrom = template.ValidFrom,
|
||||
ValidTo = template.ValidTo,
|
||||
RelativeValidDays = template.RelativeValidDays,
|
||||
Channels = channels,
|
||||
StoreScopeMode = storeScope.Mode,
|
||||
StoreIds = storeScope.StoreIds,
|
||||
Status = CouponTemplateMapping.ToEditorStatus(template.Status),
|
||||
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public static CouponTemplateStatsDto ToStatsDto(
|
||||
int totalCount,
|
||||
int ongoingCount,
|
||||
int claimedCount,
|
||||
int redeemedCount)
|
||||
{
|
||||
var redeemRate = claimedCount <= 0
|
||||
? 0m
|
||||
: Math.Round((decimal)redeemedCount * 100m / claimedCount, 1, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new CouponTemplateStatsDto
|
||||
{
|
||||
TotalCount = totalCount,
|
||||
OngoingCount = ongoingCount,
|
||||
ClaimedCount = claimedCount,
|
||||
RedeemedCount = redeemedCount,
|
||||
RedeemRate = redeemRate
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板映射辅助。
|
||||
/// </summary>
|
||||
internal static class CouponTemplateMapping
|
||||
{
|
||||
private const string StatusDisabled = "disabled";
|
||||
private const string StatusEnded = "ended";
|
||||
private const string StatusOngoing = "ongoing";
|
||||
private const string StatusUpcoming = "upcoming";
|
||||
|
||||
private static readonly HashSet<string> AllowedChannels = ["delivery", "pickup", "dine_in"];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static bool TryParseCouponType(string? value, out CouponType couponType)
|
||||
{
|
||||
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "amount_off":
|
||||
couponType = CouponType.AmountOff;
|
||||
return true;
|
||||
case "discount":
|
||||
couponType = CouponType.Percentage;
|
||||
return true;
|
||||
case "free_delivery":
|
||||
couponType = CouponType.DeliveryFee;
|
||||
return true;
|
||||
default:
|
||||
couponType = CouponType.AmountOff;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ToCouponTypeText(CouponType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
CouponType.AmountOff => "amount_off",
|
||||
CouponType.Percentage => "discount",
|
||||
CouponType.DeliveryFee => "free_delivery",
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "存在不支持的券类型数据")
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryParseEditorStatus(string? value, out CouponTemplateStatus status)
|
||||
{
|
||||
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "enabled":
|
||||
status = CouponTemplateStatus.Active;
|
||||
return true;
|
||||
case "disabled":
|
||||
status = CouponTemplateStatus.Archived;
|
||||
return true;
|
||||
default:
|
||||
status = CouponTemplateStatus.Archived;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ToEditorStatus(CouponTemplateStatus value)
|
||||
{
|
||||
return value == CouponTemplateStatus.Active ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
public static bool TryNormalizeDisplayStatusFilter(string? value, out string? normalized)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
normalized = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate is StatusOngoing or StatusUpcoming or StatusEnded or StatusDisabled)
|
||||
{
|
||||
normalized = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
normalized = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string ResolveDisplayStatus(CouponTemplate template, DateTime nowUtc)
|
||||
{
|
||||
if (template.Status != CouponTemplateStatus.Active)
|
||||
{
|
||||
return StatusDisabled;
|
||||
}
|
||||
|
||||
if (template.ValidFrom.HasValue && nowUtc < template.ValidFrom.Value)
|
||||
{
|
||||
return StatusUpcoming;
|
||||
}
|
||||
|
||||
if (template.ValidTo.HasValue && nowUtc > template.ValidTo.Value)
|
||||
{
|
||||
return StatusEnded;
|
||||
}
|
||||
|
||||
return StatusOngoing;
|
||||
}
|
||||
|
||||
public static bool IsDimmedDisplayStatus(string displayStatus)
|
||||
{
|
||||
return string.Equals(displayStatus, StatusDisabled, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(displayStatus, StatusEnded, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string ResolveValidityType(CouponTemplate template)
|
||||
{
|
||||
return template.RelativeValidDays.HasValue && template.RelativeValidDays.Value > 0
|
||||
? "days"
|
||||
: "fixed";
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> DeserializeChannels(string? channelsJson, long templateId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channelsJson))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]渠道配置缺失");
|
||||
}
|
||||
|
||||
var channels = JsonSerializer.Deserialize<List<string>>(channelsJson, JsonOptions)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]渠道配置异常");
|
||||
|
||||
var normalized = channels
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0 || normalized.Any(item => !AllowedChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]渠道配置异常");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeChannelsForSave(IReadOnlyCollection<string>? channels)
|
||||
{
|
||||
var normalized = (channels ?? Array.Empty<string>())
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static CouponStoreScopeModel DeserializeStoreScope(string? storeScopeJson, long templateId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storeScopeJson))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置缺失");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Deserialize<CouponStoreScopePayload>(storeScopeJson, JsonOptions)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置异常");
|
||||
|
||||
var mode = (payload.Mode ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var storeIds = (payload.StoreIds ?? [])
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (mode is not ("all" or "stores"))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置异常");
|
||||
}
|
||||
|
||||
if (storeIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"优惠券[{templateId}]门店范围配置异常");
|
||||
}
|
||||
|
||||
return new CouponStoreScopeModel(mode, storeIds);
|
||||
}
|
||||
|
||||
public static string SerializeStoreScope(string mode, IReadOnlyCollection<long> storeIds)
|
||||
{
|
||||
var normalizedMode = (mode ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalizedMode is not ("all" or "stores"))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 非法");
|
||||
}
|
||||
|
||||
var normalizedStoreIds = storeIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (normalizedStoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(new CouponStoreScopePayload
|
||||
{
|
||||
Mode = normalizedMode,
|
||||
StoreIds = normalizedStoreIds
|
||||
}, JsonOptions);
|
||||
}
|
||||
|
||||
public static string SerializeChannels(IReadOnlyCollection<string> channels)
|
||||
{
|
||||
return JsonSerializer.Serialize(channels, JsonOptions);
|
||||
}
|
||||
|
||||
private sealed class CouponStoreScopePayload
|
||||
{
|
||||
public string Mode { get; set; } = "stores";
|
||||
|
||||
public List<long> StoreIds { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券门店范围快照。
|
||||
/// </summary>
|
||||
internal sealed record CouponStoreScopeModel(string Mode, IReadOnlyList<long> StoreIds);
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class CouponTemplateDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
public int ClaimedQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期类型(fixed/days)。
|
||||
/// </summary>
|
||||
public string ValidityType { get; init; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期开始。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定有效期结束。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; init; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> StoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed class CouponTemplateListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 券名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定时间开始。
|
||||
/// </summary>
|
||||
public DateTime? ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定时间结束。
|
||||
/// </summary>
|
||||
public DateTime? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取后有效天数。
|
||||
/// </summary>
|
||||
public int? RelativeValidDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放总量。
|
||||
/// </summary>
|
||||
public int TotalQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
public int ClaimedQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已核销数量。
|
||||
/// </summary>
|
||||
public int RedeemedQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限领。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; init; } = "stores";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> StoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 渠道列表(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板列表结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class CouponTemplateListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<CouponTemplateListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计信息。
|
||||
/// </summary>
|
||||
public CouponTemplateStatsDto Stats { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class CouponTemplateStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取总数。
|
||||
/// </summary>
|
||||
public int ClaimedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已核销总数。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销率(百分比)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 修改优惠券模板状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangeCouponTemplateStatusCommandHandler(
|
||||
ICouponRepository couponRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ChangeCouponTemplateStatusCommand, CouponTemplateDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CouponTemplateDetailDto> Handle(ChangeCouponTemplateStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var template = await couponRepository.FindTemplateByIdAsync(request.TemplateId, tenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
|
||||
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
|
||||
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
}
|
||||
|
||||
if (!CouponTemplateMapping.TryParseEditorStatus(request.Status, out var status))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
|
||||
}
|
||||
|
||||
template.Status = status;
|
||||
await couponRepository.UpdateTemplateAsync(template, cancellationToken);
|
||||
await couponRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
|
||||
return CouponTemplateDtoFactory.ToDetailDto(template, scope, channels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除优惠券模板命令处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteCouponTemplateCommandHandler(
|
||||
ICouponRepository couponRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeleteCouponTemplateCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(DeleteCouponTemplateCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var template = await couponRepository.FindTemplateByIdAsync(request.TemplateId, tenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
|
||||
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
|
||||
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
}
|
||||
|
||||
if (template.ClaimedQuantity > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "优惠券已被领取,禁止删除");
|
||||
}
|
||||
|
||||
var issuedCount = await couponRepository.CountIssuedCouponsByTemplateIdAsync(tenantId, template.Id, cancellationToken);
|
||||
if (issuedCount > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "优惠券已被领取,禁止删除");
|
||||
}
|
||||
|
||||
await couponRepository.DeleteTemplateAsync(template, cancellationToken);
|
||||
await couponRepository.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetCouponTemplateDetailQueryHandler(
|
||||
ICouponRepository couponRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCouponTemplateDetailQuery, CouponTemplateDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CouponTemplateDetailDto?> Handle(GetCouponTemplateDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var template = await couponRepository.FindTemplateByIdAsync(request.TemplateId, tenantId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
|
||||
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
}
|
||||
|
||||
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
|
||||
return CouponTemplateDtoFactory.ToDetailDto(template, scope, channels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券模板列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetCouponTemplateListQueryHandler(
|
||||
ICouponRepository couponRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetCouponTemplateListQuery, CouponTemplateListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CouponTemplateListResultDto> Handle(GetCouponTemplateListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
if (!CouponTemplateMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
|
||||
}
|
||||
|
||||
CouponType? couponTypeFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.CouponType))
|
||||
{
|
||||
if (!CouponTemplateMapping.TryParseCouponType(request.CouponType, out var parsedType))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法");
|
||||
}
|
||||
|
||||
couponTypeFilter = parsedType;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var templates = await couponRepository.GetTemplatesAsync(tenantId, cancellationToken);
|
||||
if (templates.Count == 0)
|
||||
{
|
||||
return new CouponTemplateListResultDto
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Stats = CouponTemplateDtoFactory.ToStatsDto(0, 0, 0, 0)
|
||||
};
|
||||
}
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var visibleTemplates = new List<ResolvedTemplateItem>(templates.Count);
|
||||
foreach (var template in templates)
|
||||
{
|
||||
var scope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
|
||||
if (!scope.StoreIds.Contains(request.StoreId) && !string.Equals(scope.Mode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
|
||||
visibleTemplates.Add(new ResolvedTemplateItem(
|
||||
template,
|
||||
scope,
|
||||
channels,
|
||||
CouponTemplateMapping.ResolveDisplayStatus(template, nowUtc)));
|
||||
}
|
||||
|
||||
if (visibleTemplates.Count == 0)
|
||||
{
|
||||
return new CouponTemplateListResultDto
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Stats = CouponTemplateDtoFactory.ToStatsDto(0, 0, 0, 0)
|
||||
};
|
||||
}
|
||||
|
||||
var templateIds = visibleTemplates.Select(item => item.Template.Id).ToArray();
|
||||
var redeemedMap = await couponRepository.CountRedeemedCouponsByTemplateIdsAsync(tenantId, templateIds, cancellationToken);
|
||||
|
||||
var stats = CouponTemplateDtoFactory.ToStatsDto(
|
||||
visibleTemplates.Count,
|
||||
visibleTemplates.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)),
|
||||
visibleTemplates.Sum(item => item.Template.ClaimedQuantity),
|
||||
visibleTemplates.Sum(item => redeemedMap.GetValueOrDefault(item.Template.Id)));
|
||||
|
||||
IEnumerable<ResolvedTemplateItem> filtered = visibleTemplates;
|
||||
var normalizedKeyword = request.Keyword?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
filtered = filtered.Where(item => item.Template.Name.ToLowerInvariant().Contains(normalizedKeyword));
|
||||
}
|
||||
|
||||
if (couponTypeFilter.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(item => item.Template.CouponType == couponTypeFilter.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStatus))
|
||||
{
|
||||
filtered = filtered.Where(item => string.Equals(item.DisplayStatus, normalizedStatus, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = filtered
|
||||
.OrderByDescending(item => item.Template.UpdatedAt ?? item.Template.CreatedAt)
|
||||
.ThenByDescending(item => item.Template.Id)
|
||||
.ToList();
|
||||
|
||||
var total = ordered.Count;
|
||||
var paged = ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(item => CouponTemplateDtoFactory.ToListItemDto(
|
||||
item.Template,
|
||||
item.Scope,
|
||||
item.Channels,
|
||||
redeemedMap.GetValueOrDefault(item.Template.Id),
|
||||
nowUtc))
|
||||
.ToList();
|
||||
|
||||
return new CouponTemplateListResultDto
|
||||
{
|
||||
Items = paged,
|
||||
TotalCount = total,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Stats = stats
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record ResolvedTemplateItem(
|
||||
CouponTemplate Template,
|
||||
CouponStoreScopeModel Scope,
|
||||
IReadOnlyList<string> Channels,
|
||||
string DisplayStatus);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存优惠券模板命令处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveCouponTemplateCommandHandler(
|
||||
ICouponRepository couponRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveCouponTemplateCommand, CouponTemplateDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CouponTemplateDetailDto> Handle(SaveCouponTemplateCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedName = request.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "券名称不能为空");
|
||||
}
|
||||
|
||||
if (normalizedName.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "券名称长度不能超过 64");
|
||||
}
|
||||
|
||||
if (!CouponTemplateMapping.TryParseCouponType(request.CouponType, out var couponType))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法");
|
||||
}
|
||||
|
||||
if (!CouponTemplateMapping.TryParseEditorStatus(request.Status, out var status))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
|
||||
}
|
||||
|
||||
if (request.TotalQuantity <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "发放总量必须大于 0");
|
||||
}
|
||||
|
||||
if (request.PerUserLimit.HasValue && request.PerUserLimit.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "每人限领必须大于 0");
|
||||
}
|
||||
|
||||
var normalizedChannels = CouponTemplateMapping.NormalizeChannelsForSave(request.Channels);
|
||||
var storeScopeJson = CouponTemplateMapping.SerializeStoreScope(request.StoreScopeMode, request.StoreScopeStoreIds);
|
||||
var channelsJson = CouponTemplateMapping.SerializeChannels(normalizedChannels);
|
||||
|
||||
var (value, minimumSpend, validFrom, validTo, relativeValidDays) = NormalizeBusinessFields(request, couponType);
|
||||
|
||||
CouponTemplate template;
|
||||
if (!request.TemplateId.HasValue)
|
||||
{
|
||||
template = new CouponTemplate
|
||||
{
|
||||
Name = normalizedName,
|
||||
CouponType = couponType,
|
||||
Value = value,
|
||||
MinimumSpend = minimumSpend,
|
||||
ValidFrom = validFrom,
|
||||
ValidTo = validTo,
|
||||
RelativeValidDays = relativeValidDays,
|
||||
TotalQuantity = request.TotalQuantity,
|
||||
ClaimedQuantity = 0,
|
||||
PerUserLimit = request.PerUserLimit,
|
||||
StoreScopeJson = storeScopeJson,
|
||||
ProductScopeJson = null,
|
||||
ChannelsJson = channelsJson,
|
||||
AllowStack = false,
|
||||
Status = status,
|
||||
Description = null
|
||||
};
|
||||
|
||||
await couponRepository.AddTemplateAsync(template, cancellationToken);
|
||||
await couponRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
template = await couponRepository.FindTemplateByIdAsync(request.TemplateId.Value, tenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
|
||||
var existingScope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
|
||||
if (!existingScope.StoreIds.Contains(request.StoreId) && !string.Equals(existingScope.Mode, "all", StringComparison.Ordinal))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "优惠券不存在");
|
||||
}
|
||||
|
||||
if (request.TotalQuantity < template.ClaimedQuantity)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "发放总量不能小于已领取数量");
|
||||
}
|
||||
|
||||
if (template.TotalQuantity.HasValue && request.TotalQuantity < template.TotalQuantity.Value)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "发放总量不可减少,只能增加");
|
||||
}
|
||||
|
||||
template.Name = normalizedName;
|
||||
template.CouponType = couponType;
|
||||
template.Value = value;
|
||||
template.MinimumSpend = minimumSpend;
|
||||
template.ValidFrom = validFrom;
|
||||
template.ValidTo = validTo;
|
||||
template.RelativeValidDays = relativeValidDays;
|
||||
template.TotalQuantity = request.TotalQuantity;
|
||||
template.PerUserLimit = request.PerUserLimit;
|
||||
template.StoreScopeJson = storeScopeJson;
|
||||
template.ChannelsJson = channelsJson;
|
||||
template.Status = status;
|
||||
|
||||
await couponRepository.UpdateTemplateAsync(template, cancellationToken);
|
||||
await couponRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var storeScope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
|
||||
var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
|
||||
return CouponTemplateDtoFactory.ToDetailDto(template, storeScope, channels);
|
||||
}
|
||||
|
||||
private static (decimal Value, decimal? MinimumSpend, DateTime? ValidFrom, DateTime? ValidTo, int? RelativeValidDays)
|
||||
NormalizeBusinessFields(SaveCouponTemplateCommand request, CouponType couponType)
|
||||
{
|
||||
var value = couponType switch
|
||||
{
|
||||
CouponType.AmountOff => NormalizeAmountOffValue(request.Value),
|
||||
CouponType.Percentage => NormalizeDiscountValue(request.Value),
|
||||
CouponType.DeliveryFee => 0m,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "不支持的券类型")
|
||||
};
|
||||
|
||||
var minimumSpend = couponType switch
|
||||
{
|
||||
CouponType.AmountOff => NormalizeRequiredMinimumSpend(request.MinimumSpend),
|
||||
CouponType.Percentage => NormalizeOptionalMinimumSpend(request.MinimumSpend),
|
||||
CouponType.DeliveryFee => null,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (couponType == CouponType.DeliveryFee && request.MinimumSpend.HasValue && request.MinimumSpend.Value > 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "免配送费券不支持设置使用门槛");
|
||||
}
|
||||
|
||||
var validityType = (request.ValidityType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return validityType switch
|
||||
{
|
||||
"fixed" => NormalizeFixedValidity(value, minimumSpend, request.ValidFrom, request.ValidTo),
|
||||
"days" => (
|
||||
value,
|
||||
minimumSpend,
|
||||
null,
|
||||
null,
|
||||
NormalizeRelativeValidDays(request.RelativeValidDays)),
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal NormalizeAmountOffValue(decimal value)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减券面额必须大于 0");
|
||||
}
|
||||
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizeDiscountValue(decimal value)
|
||||
{
|
||||
if (value <= 0 || value >= 10)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "折扣券面额必须在 0 到 10 之间");
|
||||
}
|
||||
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizeRequiredMinimumSpend(decimal? value)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减券必须设置使用门槛");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal? NormalizeOptionalMinimumSpend(decimal? value)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "使用门槛必须大于 0");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static DateTime NormalizeDateStart(DateTime? value, string fieldName)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
var utc = value.Value.Kind == DateTimeKind.Utc
|
||||
? value.Value
|
||||
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
||||
return utc.Date;
|
||||
}
|
||||
|
||||
private static DateTime NormalizeDateEnd(DateTime? value, string fieldName)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
var utc = value.Value.Kind == DateTimeKind.Utc
|
||||
? value.Value
|
||||
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
||||
return utc.Date.AddDays(1).AddTicks(-1);
|
||||
}
|
||||
|
||||
private static int NormalizeRelativeValidDays(int? value)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "领取后有效天数必须大于 0");
|
||||
}
|
||||
|
||||
return value.Value;
|
||||
}
|
||||
|
||||
private static (decimal Value, decimal? MinimumSpend, DateTime? ValidFrom, DateTime? ValidTo, int? RelativeValidDays)
|
||||
NormalizeFixedValidity(decimal value, decimal? minimumSpend, DateTime? validFrom, DateTime? validTo)
|
||||
{
|
||||
var normalizedStart = NormalizeDateStart(validFrom, "validFrom");
|
||||
var normalizedEnd = NormalizeDateEnd(validTo, "validTo");
|
||||
if (normalizedStart > normalizedEnd)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "固定有效期开始时间不能晚于结束时间");
|
||||
}
|
||||
|
||||
return (value, minimumSpend, normalizedStart, normalizedEnd, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询优惠券模板详情。
|
||||
/// </summary>
|
||||
public sealed class GetCouponTemplateDetailQuery : IRequest<CouponTemplateDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前筛选门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板 ID。
|
||||
/// </summary>
|
||||
public long TemplateId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询优惠券模板列表。
|
||||
/// </summary>
|
||||
public sealed class GetCouponTemplateListQuery : IRequest<CouponTemplateListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前筛选门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态筛选(ongoing/upcoming/ended/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 券类型筛选(amount_off/discount/free_delivery)。
|
||||
/// </summary>
|
||||
public string? CouponType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -53,6 +53,11 @@ public sealed class CouponTemplate : MultiTenantEntityBase
|
||||
/// </summary>
|
||||
public int? TotalQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每位用户可领取上限。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已领取数量。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券聚合仓储契约。
|
||||
/// </summary>
|
||||
public interface ICouponRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询租户下的券模板列表。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CouponTemplate>> GetTemplatesAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询券模板。
|
||||
/// </summary>
|
||||
Task<CouponTemplate?> FindTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增券模板。
|
||||
/// </summary>
|
||||
Task AddTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新券模板。
|
||||
/// </summary>
|
||||
Task UpdateTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除券模板。
|
||||
/// </summary>
|
||||
Task DeleteTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 统计模板下已发放券总数。
|
||||
/// </summary>
|
||||
Task<int> CountIssuedCouponsByTemplateIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按模板统计已核销券数量。
|
||||
/// </summary>
|
||||
Task<Dictionary<long, int>> CountRedeemedCouponsByTemplateIdsAsync(
|
||||
long tenantId,
|
||||
IReadOnlyCollection<long> templateIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化变更。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
@@ -44,6 +45,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
|
||||
services.AddScoped<IStoreRepository, EfStoreRepository>();
|
||||
services.AddScoped<IProductRepository, EfProductRepository>();
|
||||
services.AddScoped<ICouponRepository, EfCouponRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
|
||||
@@ -1590,6 +1590,7 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.CouponType).HasConversion<int>();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.TotalQuantity);
|
||||
builder.Property(x => x.PerUserLimit);
|
||||
builder.Property(x => x.StoreScopeJson).HasColumnType("text");
|
||||
builder.Property(x => x.ProductScopeJson).HasColumnType("text");
|
||||
builder.Property(x => x.ChannelsJson).HasColumnType("text");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfCouponRepository(TakeoutAppDbContext context) : ICouponRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CouponTemplate>> GetTemplatesAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.CouponTemplates
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CouponTemplate?> FindTemplateByIdAsync(long templateId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.CouponTemplates
|
||||
.Where(x => x.TenantId == tenantId && x.Id == templateId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.CouponTemplates.AddAsync(template, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.CouponTemplates.Update(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteTemplateAsync(CouponTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.CouponTemplates.Remove(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountIssuedCouponsByTemplateIdAsync(long tenantId, long templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Coupons
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.CouponTemplateId == templateId)
|
||||
.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<long, int>> CountRedeemedCouponsByTemplateIdsAsync(
|
||||
long tenantId,
|
||||
IReadOnlyCollection<long> templateIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (templateIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await context.Coupons
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
templateIds.Contains(x.CouponTemplateId) &&
|
||||
x.Status == CouponStatus.Redeemed)
|
||||
.GroupBy(x => x.CouponTemplateId)
|
||||
.Select(group => new { CouponTemplateId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(item => item.CouponTemplateId, item => item.Count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCouponTemplatePerUserLimit : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PerUserLimit",
|
||||
table: "coupon_templates",
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
comment: "每位用户可领取上限。");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PerUserLimit",
|
||||
table: "coupon_templates");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,6 +548,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("模板名称。");
|
||||
|
||||
b.Property<int?>("PerUserLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("每位用户可领取上限。");
|
||||
|
||||
b.Property<string>("ProductScopeJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("适用品类或商品范围(JSON)。");
|
||||
|
||||
Reference in New Issue
Block a user