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>
|
/// </summary>
|
||||||
public int? TotalQuantity { get; set; }
|
public int? TotalQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每位用户可领取上限。
|
||||||
|
/// </summary>
|
||||||
|
public int? PerUserLimit { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 已领取数量。
|
/// 已领取数量。
|
||||||
/// </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.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using TakeoutSaaS.Application.App.Stores.Services;
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||||
@@ -44,6 +45,7 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
|
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
|
||||||
services.AddScoped<IStoreRepository, EfStoreRepository>();
|
services.AddScoped<IStoreRepository, EfStoreRepository>();
|
||||||
services.AddScoped<IProductRepository, EfProductRepository>();
|
services.AddScoped<IProductRepository, EfProductRepository>();
|
||||||
|
services.AddScoped<ICouponRepository, EfCouponRepository>();
|
||||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||||
|
|||||||
@@ -1590,6 +1590,7 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.Property(x => x.CouponType).HasConversion<int>();
|
builder.Property(x => x.CouponType).HasConversion<int>();
|
||||||
builder.Property(x => x.Description).HasMaxLength(512);
|
builder.Property(x => x.Description).HasMaxLength(512);
|
||||||
builder.Property(x => x.TotalQuantity);
|
builder.Property(x => x.TotalQuantity);
|
||||||
|
builder.Property(x => x.PerUserLimit);
|
||||||
builder.Property(x => x.StoreScopeJson).HasColumnType("text");
|
builder.Property(x => x.StoreScopeJson).HasColumnType("text");
|
||||||
builder.Property(x => x.ProductScopeJson).HasColumnType("text");
|
builder.Property(x => x.ProductScopeJson).HasColumnType("text");
|
||||||
builder.Property(x => x.ChannelsJson).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)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasComment("模板名称。");
|
.HasComment("模板名称。");
|
||||||
|
|
||||||
|
b.Property<int?>("PerUserLimit")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasComment("每位用户可领取上限。");
|
||||||
|
|
||||||
b.Property<string>("ProductScopeJson")
|
b.Property<string>("ProductScopeJson")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasComment("适用品类或商品范围(JSON)。");
|
.HasComment("适用品类或商品范围(JSON)。");
|
||||||
|
|||||||
Reference in New Issue
Block a user