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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user