feat(marketing): add full reduction campaign api module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s

This commit is contained in:
2026-02-28 15:46:21 +08:00
parent dda3f96d28
commit 5a6da9be0c
27 changed files with 2916 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
/// <summary>
/// 修改满减活动状态命令。
/// </summary>
public sealed class ChangeFullReductionCampaignStatusCommand : IRequest<FullReductionDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
/// <summary>
/// 状态active/completed
/// </summary>
public string Status { get; init; } = "completed";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
/// <summary>
/// 删除满减活动命令。
/// </summary>
public sealed class DeleteFullReductionCampaignCommand : IRequest<Unit>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
}

View File

@@ -0,0 +1,90 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
/// <summary>
/// 保存满减活动命令。
/// </summary>
public sealed class SaveFullReductionCampaignCommand : IRequest<FullReductionDetailDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID编辑时传
/// </summary>
public long? CampaignId { get; init; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 活动类型reduce/gift/second_half
/// </summary>
public string ActivityType { get; init; } = "reduce";
/// <summary>
/// 满减阶梯。
/// </summary>
public IReadOnlyCollection<FullReductionTierRuleDto> ReduceTiers { get; init; } = [];
/// <summary>
/// 满赠规则。
/// </summary>
public FullReductionGiftRuleDto? GiftRule { get; init; }
/// <summary>
/// 第二份半价规则。
/// </summary>
public FullReductionSecondHalfRuleDto? SecondHalfRule { get; init; }
/// <summary>
/// 活动开始时间。
/// </summary>
public DateTime StartAt { get; init; }
/// <summary>
/// 活动结束时间。
/// </summary>
public DateTime EndAt { get; init; }
/// <summary>
/// 适用渠道。
/// </summary>
public IReadOnlyCollection<string> Channels { get; init; } = [];
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; init; } = "all";
/// <summary>
/// 门店范围 ID。
/// </summary>
public IReadOnlyCollection<long> StoreScopeStoreIds { get; init; } = [];
/// <summary>
/// 选品基准门店 ID。
/// </summary>
public long ScopeStoreId { get; init; }
/// <summary>
/// 是否可叠加优惠券。
/// </summary>
public bool StackWithCoupon { get; init; }
/// <summary>
/// 活动说明。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 活动指标。
/// </summary>
public FullReductionMetricsDto? Metrics { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减活动详情 DTO。
/// </summary>
public sealed class FullReductionDetailDto
{
/// <summary>
/// 活动 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 活动开始时间。
/// </summary>
public DateTime StartAt { get; init; }
/// <summary>
/// 活动结束时间。
/// </summary>
public DateTime EndAt { get; init; }
/// <summary>
/// 活动状态active/completed
/// </summary>
public string Status { get; init; } = "active";
/// <summary>
/// 展示状态。
/// </summary>
public string DisplayStatus { get; init; } = "ongoing";
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
/// <summary>
/// 规则配置。
/// </summary>
public FullReductionRulesDto Rules { get; init; } = new();
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满赠规则 DTO。
/// </summary>
public sealed class FullReductionGiftRuleDto
{
/// <summary>
/// 购买数量门槛。
/// </summary>
public int BuyQuantity { get; init; }
/// <summary>
/// 赠送数量。
/// </summary>
public int GiftQuantity { get; init; }
/// <summary>
/// 赠品范围类型same_lowest/specified
/// </summary>
public string GiftScopeType { get; init; } = "same_lowest";
/// <summary>
/// 适用商品范围。
/// </summary>
public FullReductionScopeRuleDto ApplicableScope { get; init; } = new();
/// <summary>
/// 指定赠品范围。
/// </summary>
public FullReductionScopeRuleDto GiftScope { get; init; } = new();
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减活动列表项 DTO。
/// </summary>
public sealed class FullReductionListItemDto
{
/// <summary>
/// 活动 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 活动名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 活动开始时间。
/// </summary>
public DateTime StartAt { get; init; }
/// <summary>
/// 活动结束时间。
/// </summary>
public DateTime EndAt { get; init; }
/// <summary>
/// 展示状态。
/// </summary>
public string DisplayStatus { get; init; } = "ongoing";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
/// <summary>
/// 规则配置。
/// </summary>
public FullReductionRulesDto Rules { get; init; } = new();
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减活动列表结果 DTO。
/// </summary>
public sealed class FullReductionListResultDto
{
/// <summary>
/// 列表。
/// </summary>
public IReadOnlyList<FullReductionListItemDto> 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 FullReductionStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减活动指标 DTO。
/// </summary>
public sealed class FullReductionMetricsDto
{
/// <summary>
/// 参与订单数。
/// </summary>
public int ParticipatingOrderCount { get; init; }
/// <summary>
/// 优惠总额。
/// </summary>
public decimal DiscountTotalAmount { get; init; }
/// <summary>
/// 客单价提升。
/// </summary>
public decimal TicketIncreaseAmount { get; init; }
/// <summary>
/// 赠出商品数量。
/// </summary>
public int GiftedCount { get; init; }
/// <summary>
/// 带动销售额。
/// </summary>
public decimal DrivenSalesAmount { get; init; }
/// <summary>
/// 连带率提升百分比。
/// </summary>
public decimal AttachRateIncreasePercent { get; init; }
/// <summary>
/// 本月带动销售额。
/// </summary>
public decimal MonthlyDrivenSalesAmount { get; init; }
/// <summary>
/// 平均客单价提升。
/// </summary>
public decimal AverageTicketIncrease { get; init; }
}

View File

@@ -0,0 +1,62 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减活动规则 DTO。
/// </summary>
public sealed class FullReductionRulesDto
{
/// <summary>
/// 活动类型reduce/gift/second_half
/// </summary>
public string ActivityType { get; init; } = "reduce";
/// <summary>
/// 满减阶梯。
/// </summary>
public IReadOnlyList<FullReductionTierRuleDto> ReduceTiers { get; init; } = [];
/// <summary>
/// 满赠规则。
/// </summary>
public FullReductionGiftRuleDto? GiftRule { get; init; }
/// <summary>
/// 第二份半价规则。
/// </summary>
public FullReductionSecondHalfRuleDto? SecondHalfRule { get; init; }
/// <summary>
/// 适用渠道。
/// </summary>
public IReadOnlyList<string> Channels { get; init; } = [];
/// <summary>
/// 门店范围模式all/stores
/// </summary>
public string StoreScopeMode { get; init; } = "all";
/// <summary>
/// 门店范围 ID。
/// </summary>
public IReadOnlyList<long> StoreIds { get; init; } = [];
/// <summary>
/// 选品基准门店 ID。
/// </summary>
public long ScopeStoreId { get; init; }
/// <summary>
/// 是否可叠加优惠券。
/// </summary>
public bool StackWithCoupon { get; init; }
/// <summary>
/// 活动说明。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 统计指标。
/// </summary>
public FullReductionMetricsDto Metrics { get; init; } = new();
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 商品范围 DTO。
/// </summary>
public sealed class FullReductionScopeRuleDto
{
/// <summary>
/// 范围类型all/category/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 分类 ID。
/// </summary>
public IReadOnlyList<long> CategoryIds { get; init; } = [];
/// <summary>
/// 商品 ID。
/// </summary>
public IReadOnlyList<long> ProductIds { get; init; } = [];
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 第二份半价规则 DTO。
/// </summary>
public sealed class FullReductionSecondHalfRuleDto
{
/// <summary>
/// 折扣类型half/sixty/seventy/free
/// </summary>
public string DiscountType { get; init; } = "half";
/// <summary>
/// 适用商品范围。
/// </summary>
public FullReductionScopeRuleDto ApplicableScope { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减活动统计 DTO。
/// </summary>
public sealed class FullReductionStatsDto
{
/// <summary>
/// 活动总数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 进行中数量。
/// </summary>
public int OngoingCount { get; init; }
/// <summary>
/// 本月带动销售额。
/// </summary>
public decimal MonthlyDrivenSalesAmount { get; init; }
/// <summary>
/// 平均客单价提升。
/// </summary>
public decimal AverageTicketIncrease { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
/// <summary>
/// 满减阶梯 DTO。
/// </summary>
public sealed class FullReductionTierRuleDto
{
/// <summary>
/// 满足金额。
/// </summary>
public decimal MeetAmount { get; init; }
/// <summary>
/// 减免金额。
/// </summary>
public decimal ReduceAmount { get; init; }
}

View File

@@ -0,0 +1,108 @@
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
namespace TakeoutSaaS.Application.App.Coupons.FullReduction;
/// <summary>
/// 满减活动 DTO 映射工厂。
/// </summary>
internal static class FullReductionDtoFactory
{
/// <summary>
/// 构建列表项 DTO。
/// </summary>
public static FullReductionListItemDto ToListItemDto(
PromotionCampaign campaign,
FullReductionRulesDto rules,
DateTime nowUtc)
{
var displayStatus = FullReductionMapping.ResolveDisplayStatus(campaign, nowUtc);
return new FullReductionListItemDto
{
Id = campaign.Id,
Name = campaign.Name,
StartAt = campaign.StartAt,
EndAt = campaign.EndAt,
DisplayStatus = displayStatus,
IsDimmed = FullReductionMapping.IsDimmed(displayStatus),
UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt,
Rules = rules
};
}
/// <summary>
/// 构建详情 DTO。
/// </summary>
public static FullReductionDetailDto ToDetailDto(
PromotionCampaign campaign,
FullReductionRulesDto rules,
DateTime nowUtc)
{
return new FullReductionDetailDto
{
Id = campaign.Id,
Name = campaign.Name,
StartAt = campaign.StartAt,
EndAt = campaign.EndAt,
Status = FullReductionMapping.ToStatusText(campaign.Status),
DisplayStatus = FullReductionMapping.ResolveDisplayStatus(campaign, nowUtc),
UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt,
Rules = rules
};
}
/// <summary>
/// 构建统计 DTO。
/// </summary>
public static FullReductionStatsDto ToStatsDto(IReadOnlyCollection<FullReductionListItemDto> items)
{
if (items.Count == 0)
{
return new FullReductionStatsDto();
}
var monthlyDrivenSalesAmount = items.Sum(item => item.Rules.Metrics.MonthlyDrivenSalesAmount);
var metricsForAverage = items
.Select(item => item.Rules.Metrics.AverageTicketIncrease)
.Where(value => value > 0)
.ToList();
var averageTicketIncrease = metricsForAverage.Count == 0
? 0m
: decimal.Round(metricsForAverage.Average(), 2, MidpointRounding.AwayFromZero);
return new FullReductionStatsDto
{
TotalCount = items.Count,
OngoingCount = items.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)),
MonthlyDrivenSalesAmount = decimal.Round(monthlyDrivenSalesAmount, 2, MidpointRounding.AwayFromZero),
AverageTicketIncrease = averageTicketIncrease
};
}
/// <summary>
/// 构建默认新增活动实体。
/// </summary>
public static PromotionCampaign CreateNewCampaign(
string name,
DateTime startAt,
DateTime endAt,
string rulesJson,
string? description)
{
return new PromotionCampaign
{
Name = name,
PromotionType = PromotionType.FullReduction,
Status = PromotionStatus.Active,
StartAt = startAt,
EndAt = endAt,
RulesJson = rulesJson,
AudienceDescription = description,
Budget = null,
BannerUrl = null
};
}
}

View File

@@ -0,0 +1,606 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
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.FullReduction;
/// <summary>
/// 满减活动映射辅助。
/// </summary>
internal static class FullReductionMapping
{
private const string ActivityTypeGift = "gift";
private const string ActivityTypeReduce = "reduce";
private const string ActivityTypeSecondHalf = "second_half";
private const string DiscountTypeFree = "free";
private const string DiscountTypeHalf = "half";
private const string DiscountTypeSeventy = "seventy";
private const string DiscountTypeSixty = "sixty";
private const string DisplayStatusEnded = "ended";
private const string DisplayStatusOngoing = "ongoing";
private const string DisplayStatusUpcoming = "upcoming";
private const string GiftScopeSameLowest = "same_lowest";
private const string GiftScopeSpecified = "specified";
private const string ScopeTypeAll = "all";
private const string ScopeTypeCategory = "category";
private const string ScopeTypeProduct = "product";
private static readonly HashSet<string> AllowedActivityTypes =
[
ActivityTypeReduce,
ActivityTypeGift,
ActivityTypeSecondHalf
];
private static readonly HashSet<string> AllowedChannels =
[
"delivery",
"pickup",
"dine_in"
];
private static readonly HashSet<string> AllowedDiscountTypes =
[
DiscountTypeHalf,
DiscountTypeSixty,
DiscountTypeSeventy,
DiscountTypeFree
];
private static readonly HashSet<string> AllowedDisplayStatuses =
[
DisplayStatusOngoing,
DisplayStatusUpcoming,
DisplayStatusEnded
];
private static readonly HashSet<string> AllowedStoreScopeModes =
[
"all",
"stores"
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// 校验并标准化活动类型筛选。
/// </summary>
public static bool TryNormalizeActivityTypeFilter(string? value, out string? normalized)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(candidate))
{
normalized = null;
return true;
}
if (AllowedActivityTypes.Contains(candidate))
{
normalized = candidate;
return true;
}
normalized = null;
return false;
}
/// <summary>
/// 校验并标准化展示状态筛选。
/// </summary>
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 (AllowedDisplayStatuses.Contains(candidate))
{
normalized = candidate;
return true;
}
normalized = null;
return false;
}
/// <summary>
/// 解析状态文本为领域状态。
/// </summary>
public static PromotionStatus ParseStatus(string? value)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
return candidate switch
{
"active" => PromotionStatus.Active,
"completed" => PromotionStatus.Completed,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
/// <summary>
/// 输出状态文本。
/// </summary>
public static string ToStatusText(PromotionStatus status)
{
return status switch
{
PromotionStatus.Active => "active",
PromotionStatus.Completed => "completed",
_ => "completed"
};
}
/// <summary>
/// 解析活动规则。
/// </summary>
public static FullReductionRulesDto DeserializeRules(string? rulesJson, long campaignId)
{
if (string.IsNullOrWhiteSpace(rulesJson))
{
throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失");
}
var payload = JsonSerializer.Deserialize<FullReductionRulesDto>(rulesJson, JsonOptions)
?? throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则格式错误");
return NormalizeRulesForSave(
payload.ActivityType,
payload.ReduceTiers,
payload.GiftRule,
payload.SecondHalfRule,
payload.Channels,
payload.StoreScopeMode,
payload.StoreIds,
payload.ScopeStoreId,
payload.StackWithCoupon,
payload.Description,
payload.Metrics,
null);
}
/// <summary>
/// 序列化活动规则。
/// </summary>
public static string SerializeRules(FullReductionRulesDto rules)
{
return JsonSerializer.Serialize(rules, JsonOptions);
}
/// <summary>
/// 标准化并校验保存规则。
/// </summary>
public static FullReductionRulesDto NormalizeRulesForSave(
string? activityType,
IReadOnlyCollection<FullReductionTierRuleDto>? reduceTiers,
FullReductionGiftRuleDto? giftRule,
FullReductionSecondHalfRuleDto? secondHalfRule,
IReadOnlyCollection<string>? channels,
string? storeScopeMode,
IReadOnlyCollection<long>? storeIds,
long scopeStoreId,
bool stackWithCoupon,
string? description,
FullReductionMetricsDto? metrics,
FullReductionMetricsDto? fallbackMetrics)
{
var normalizedActivityType = NormalizeActivityType(activityType);
var normalizedChannels = NormalizeChannels(channels);
var normalizedStoreScopeMode = NormalizeStoreScopeMode(storeScopeMode);
var normalizedStoreIds = NormalizeStoreIds(storeIds);
if (scopeStoreId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "scopeStoreId 参数不合法");
}
if (!normalizedStoreIds.Contains(scopeStoreId))
{
throw new BusinessException(ErrorCodes.BadRequest, "scopeStoreId 必须在活动门店范围内");
}
var normalizedDescription = NormalizeDescription(description);
var normalizedMetrics = NormalizeMetrics(metrics ?? fallbackMetrics);
return normalizedActivityType switch
{
ActivityTypeReduce => new FullReductionRulesDto
{
ActivityType = ActivityTypeReduce,
ReduceTiers = NormalizeReduceTiers(reduceTiers),
GiftRule = null,
SecondHalfRule = null,
Channels = normalizedChannels,
StoreScopeMode = normalizedStoreScopeMode,
StoreIds = normalizedStoreIds,
ScopeStoreId = scopeStoreId,
StackWithCoupon = stackWithCoupon,
Description = normalizedDescription,
Metrics = normalizedMetrics
},
ActivityTypeGift => new FullReductionRulesDto
{
ActivityType = ActivityTypeGift,
ReduceTiers = [],
GiftRule = NormalizeGiftRule(giftRule),
SecondHalfRule = null,
Channels = normalizedChannels,
StoreScopeMode = normalizedStoreScopeMode,
StoreIds = normalizedStoreIds,
ScopeStoreId = scopeStoreId,
StackWithCoupon = stackWithCoupon,
Description = normalizedDescription,
Metrics = normalizedMetrics
},
ActivityTypeSecondHalf => new FullReductionRulesDto
{
ActivityType = ActivityTypeSecondHalf,
ReduceTiers = [],
GiftRule = null,
SecondHalfRule = NormalizeSecondHalfRule(secondHalfRule),
Channels = normalizedChannels,
StoreScopeMode = normalizedStoreScopeMode,
StoreIds = normalizedStoreIds,
ScopeStoreId = scopeStoreId,
StackWithCoupon = stackWithCoupon,
Description = normalizedDescription,
Metrics = normalizedMetrics
},
_ => throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法")
};
}
/// <summary>
/// 判断活动是否对可见门店集合可见。
/// </summary>
public static bool HasVisibleStore(FullReductionRulesDto rules, IReadOnlyCollection<long> visibleStoreIds)
{
if (visibleStoreIds.Count == 0)
{
return false;
}
return rules.StoreIds.Any(visibleStoreIds.Contains);
}
/// <summary>
/// 解析卡片展示状态。
/// </summary>
public static string ResolveDisplayStatus(PromotionCampaign campaign, DateTime nowUtc)
{
if (campaign.Status == PromotionStatus.Completed || campaign.Status == PromotionStatus.Paused)
{
return DisplayStatusEnded;
}
if (nowUtc < campaign.StartAt)
{
return DisplayStatusUpcoming;
}
if (nowUtc > campaign.EndAt)
{
return DisplayStatusEnded;
}
return DisplayStatusOngoing;
}
/// <summary>
/// 判断是否弱化展示。
/// </summary>
public static bool IsDimmed(string displayStatus)
{
return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal);
}
/// <summary>
/// 将结束时间规范为当天 23:59:59.9999999。
/// </summary>
public static DateTime NormalizeEndOfDay(DateTime dateTime)
{
var utc = dateTime.Kind == DateTimeKind.Utc
? dateTime
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
return utc.Date.AddDays(1).AddTicks(-1);
}
/// <summary>
/// 将开始时间规范为当天 00:00:00。
/// </summary>
public static DateTime NormalizeStartOfDay(DateTime dateTime)
{
var utc = dateTime.Kind == DateTimeKind.Utc
? dateTime
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
return utc.Date;
}
private static string NormalizeActivityType(string? value)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
if (!AllowedActivityTypes.Contains(candidate))
{
throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法");
}
return candidate;
}
private static string NormalizeStoreScopeMode(string? value)
{
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
if (!AllowedStoreScopeModes.Contains(candidate))
{
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
}
return candidate;
}
private static IReadOnlyList<long> NormalizeStoreIds(IReadOnlyCollection<long>? storeIds)
{
var normalized = (storeIds ?? Array.Empty<long>())
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
}
return normalized;
}
private static IReadOnlyList<string> NormalizeChannels(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;
}
private static string? NormalizeDescription(string? description)
{
var normalized = (description ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
}
return normalized;
}
private static IReadOnlyList<FullReductionTierRuleDto> NormalizeReduceTiers(IReadOnlyCollection<FullReductionTierRuleDto>? tiers)
{
var normalized = (tiers ?? Array.Empty<FullReductionTierRuleDto>())
.Select(item => new FullReductionTierRuleDto
{
MeetAmount = NormalizeMoney(item.MeetAmount, "满减门槛金额必须大于 0"),
ReduceAmount = NormalizeMoney(item.ReduceAmount, "满减优惠金额必须大于 0")
})
.OrderBy(item => item.MeetAmount)
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "满减规则不能为空");
}
for (var index = 0; index < normalized.Count; index++)
{
var current = normalized[index];
if (current.ReduceAmount >= current.MeetAmount)
{
throw new BusinessException(ErrorCodes.BadRequest, "满减优惠金额必须小于门槛金额");
}
if (index == 0)
{
continue;
}
var previous = normalized[index - 1];
if (current.MeetAmount <= previous.MeetAmount)
{
throw new BusinessException(ErrorCodes.BadRequest, "满减门槛金额必须严格递增");
}
}
return normalized;
}
private static FullReductionGiftRuleDto NormalizeGiftRule(FullReductionGiftRuleDto? giftRule)
{
if (giftRule is null)
{
throw new BusinessException(ErrorCodes.BadRequest, "giftRule 不能为空");
}
if (giftRule.BuyQuantity <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "购买数量门槛必须大于 0");
}
if (giftRule.GiftQuantity <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "赠送数量必须大于 0");
}
var normalizedGiftScopeType = (giftRule.GiftScopeType ?? string.Empty).Trim().ToLowerInvariant();
if (normalizedGiftScopeType is not (GiftScopeSameLowest or GiftScopeSpecified))
{
throw new BusinessException(ErrorCodes.BadRequest, "giftScopeType 参数不合法");
}
var normalizedApplicableScope = NormalizeScope(giftRule.ApplicableScope, true, "适用商品范围");
var normalizedGiftScope = normalizedGiftScopeType == GiftScopeSpecified
? NormalizeScope(giftRule.GiftScope, false, "指定赠品范围")
: new FullReductionScopeRuleDto
{
ScopeType = ScopeTypeAll,
CategoryIds = [],
ProductIds = []
};
return new FullReductionGiftRuleDto
{
BuyQuantity = giftRule.BuyQuantity,
GiftQuantity = giftRule.GiftQuantity,
GiftScopeType = normalizedGiftScopeType,
ApplicableScope = normalizedApplicableScope,
GiftScope = normalizedGiftScope
};
}
private static FullReductionSecondHalfRuleDto NormalizeSecondHalfRule(FullReductionSecondHalfRuleDto? secondHalfRule)
{
if (secondHalfRule is null)
{
throw new BusinessException(ErrorCodes.BadRequest, "secondHalfRule 不能为空");
}
var normalizedDiscountType = (secondHalfRule.DiscountType ?? string.Empty).Trim().ToLowerInvariant();
if (!AllowedDiscountTypes.Contains(normalizedDiscountType))
{
throw new BusinessException(ErrorCodes.BadRequest, "discountType 参数不合法");
}
var normalizedApplicableScope = NormalizeScope(secondHalfRule.ApplicableScope, false, "第二份半价适用范围");
return new FullReductionSecondHalfRuleDto
{
DiscountType = normalizedDiscountType,
ApplicableScope = normalizedApplicableScope
};
}
private static FullReductionScopeRuleDto NormalizeScope(
FullReductionScopeRuleDto? scope,
bool allowAll,
string scopeName)
{
if (scope is null)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}不能为空");
}
var normalizedScopeType = (scope.ScopeType ?? string.Empty).Trim().ToLowerInvariant();
if (normalizedScopeType is not (ScopeTypeAll or ScopeTypeCategory or ScopeTypeProduct))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}类型不合法");
}
if (!allowAll && normalizedScopeType == ScopeTypeAll)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}不支持全部商品");
}
var normalizedCategoryIds = scope.CategoryIds
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
var normalizedProductIds = scope.ProductIds
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
if (normalizedScopeType == ScopeTypeCategory && normalizedCategoryIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}缺少分类");
}
if (normalizedScopeType == ScopeTypeProduct && normalizedProductIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}缺少商品");
}
if (normalizedScopeType == ScopeTypeAll)
{
normalizedCategoryIds = [];
normalizedProductIds = [];
}
return new FullReductionScopeRuleDto
{
ScopeType = normalizedScopeType,
CategoryIds = normalizedCategoryIds,
ProductIds = normalizedProductIds
};
}
private static FullReductionMetricsDto NormalizeMetrics(FullReductionMetricsDto? metrics)
{
if (metrics is null)
{
return new FullReductionMetricsDto();
}
return new FullReductionMetricsDto
{
ParticipatingOrderCount = Math.Max(0, metrics.ParticipatingOrderCount),
DiscountTotalAmount = NormalizeNonNegativeMoney(metrics.DiscountTotalAmount),
TicketIncreaseAmount = NormalizeNonNegativeMoney(metrics.TicketIncreaseAmount),
GiftedCount = Math.Max(0, metrics.GiftedCount),
DrivenSalesAmount = NormalizeNonNegativeMoney(metrics.DrivenSalesAmount),
AttachRateIncreasePercent = NormalizeNonNegativeMoney(metrics.AttachRateIncreasePercent),
MonthlyDrivenSalesAmount = NormalizeNonNegativeMoney(metrics.MonthlyDrivenSalesAmount),
AverageTicketIncrease = NormalizeNonNegativeMoney(metrics.AverageTicketIncrease)
};
}
private static decimal NormalizeMoney(decimal value, string errorMessage)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, errorMessage);
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal NormalizeNonNegativeMoney(decimal value)
{
if (value <= 0)
{
return 0m;
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
}

View File

@@ -0,0 +1,54 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
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.FullReduction.Handlers;
/// <summary>
/// 修改满减活动状态命令处理器。
/// </summary>
public sealed class ChangeFullReductionCampaignStatusCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeFullReductionCampaignStatusCommand, FullReductionDetailDto>
{
/// <inheritdoc />
public async Task<FullReductionDetailDto> Handle(ChangeFullReductionCampaignStatusCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId,
tenantId,
PromotionType.FullReduction,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!rules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
var status = FullReductionMapping.ParseStatus(request.Status);
campaign.Status = status;
if (status == PromotionStatus.Completed)
{
var nowUtc = DateTime.UtcNow;
if (campaign.EndAt > nowUtc)
{
campaign.EndAt = nowUtc;
}
}
await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return FullReductionDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
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.FullReduction.Handlers;
/// <summary>
/// 删除满减活动命令处理器。
/// </summary>
public sealed class DeleteFullReductionCampaignCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteFullReductionCampaignCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> Handle(DeleteFullReductionCampaignCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId,
tenantId,
PromotionType.FullReduction,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!rules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
await promotionCampaignRepository.DeleteAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
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.FullReduction.Handlers;
/// <summary>
/// 满减活动详情查询处理器。
/// </summary>
public sealed class GetFullReductionCampaignDetailQueryHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFullReductionCampaignDetailQuery, FullReductionDetailDto?>
{
/// <inheritdoc />
public async Task<FullReductionDetailDto?> Handle(GetFullReductionCampaignDetailQuery request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId,
tenantId,
PromotionType.FullReduction,
cancellationToken);
if (campaign is null)
{
return null;
}
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!rules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
return FullReductionDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,122 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
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.FullReduction.Handlers;
/// <summary>
/// 满减活动列表查询处理器。
/// </summary>
public sealed class GetFullReductionCampaignListQueryHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFullReductionCampaignListQuery, FullReductionListResultDto>
{
/// <inheritdoc />
public async Task<FullReductionListResultDto> Handle(GetFullReductionCampaignListQuery request, CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
if (!FullReductionMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus))
{
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
}
if (!FullReductionMapping.TryNormalizeActivityTypeFilter(request.ActivityType, out var normalizedActivityType))
{
throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法");
}
var tenantId = tenantProvider.GetCurrentTenantId();
if (request.VisibleStoreIds.Count == 0)
{
return new FullReductionListResultDto
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize,
Stats = new FullReductionStatsDto()
};
}
var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync(
tenantId,
PromotionType.FullReduction,
cancellationToken);
if (campaigns.Count == 0)
{
return new FullReductionListResultDto
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize,
Stats = new FullReductionStatsDto()
};
}
var nowUtc = DateTime.UtcNow;
var visibleItems = new List<FullReductionListItemDto>(campaigns.Count);
foreach (var campaign in campaigns)
{
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!FullReductionMapping.HasVisibleStore(rules, request.VisibleStoreIds))
{
continue;
}
visibleItems.Add(FullReductionDtoFactory.ToListItemDto(campaign, rules, nowUtc));
}
var stats = FullReductionDtoFactory.ToStatsDto(visibleItems);
IEnumerable<FullReductionListItemDto> filtered = visibleItems;
if (!string.IsNullOrWhiteSpace(normalizedActivityType))
{
filtered = filtered.Where(item =>
string.Equals(item.Rules.ActivityType, normalizedActivityType, StringComparison.Ordinal));
}
if (!string.IsNullOrWhiteSpace(normalizedStatus))
{
filtered = filtered.Where(item =>
string.Equals(item.DisplayStatus, normalizedStatus, StringComparison.Ordinal));
}
var keyword = request.Keyword?.Trim();
if (!string.IsNullOrWhiteSpace(keyword))
{
filtered = filtered.Where(item =>
item.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
var ordered = filtered
.OrderByDescending(item => item.UpdatedAt)
.ThenByDescending(item => item.Id)
.ToList();
var total = ordered.Count;
var paged = ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return new FullReductionListResultDto
{
Items = paged,
TotalCount = total,
Page = page,
PageSize = pageSize,
Stats = stats
};
}
}

View File

@@ -0,0 +1,122 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
using TakeoutSaaS.Application.App.Coupons.FullReduction.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.FullReduction.Handlers;
/// <summary>
/// 保存满减活动命令处理器。
/// </summary>
public sealed class SaveFullReductionCampaignCommandHandler(
IPromotionCampaignRepository promotionCampaignRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveFullReductionCampaignCommand, FullReductionDetailDto>
{
/// <inheritdoc />
public async Task<FullReductionDetailDto> Handle(SaveFullReductionCampaignCommand request, CancellationToken cancellationToken)
{
var normalizedName = request.Name.Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
throw new BusinessException(ErrorCodes.BadRequest, "活动名称不能为空");
}
if (normalizedName.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "活动名称长度不能超过 64");
}
var normalizedStartAt = FullReductionMapping.NormalizeStartOfDay(request.StartAt);
var normalizedEndAt = FullReductionMapping.NormalizeEndOfDay(request.EndAt);
if (normalizedStartAt > normalizedEndAt)
{
throw new BusinessException(ErrorCodes.BadRequest, "活动开始时间不能晚于结束时间");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var existingMetrics = default(FullReductionMetricsDto);
PromotionCampaign campaign;
if (!request.CampaignId.HasValue)
{
var rules = FullReductionMapping.NormalizeRulesForSave(
request.ActivityType,
request.ReduceTiers,
request.GiftRule,
request.SecondHalfRule,
request.Channels,
request.StoreScopeMode,
request.StoreScopeStoreIds,
request.ScopeStoreId,
request.StackWithCoupon,
request.Description,
request.Metrics,
null);
campaign = FullReductionDtoFactory.CreateNewCampaign(
normalizedName,
normalizedStartAt,
normalizedEndAt,
FullReductionMapping.SerializeRules(rules),
rules.Description);
await promotionCampaignRepository.AddAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return FullReductionDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
}
campaign = await promotionCampaignRepository.FindByIdAsync(
request.CampaignId.Value,
tenantId,
PromotionType.FullReduction,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
var existingRules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
if (!existingRules.StoreIds.Contains(request.OperationStoreId))
{
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
}
existingMetrics = existingRules.Metrics;
var normalizedRules = FullReductionMapping.NormalizeRulesForSave(
request.ActivityType,
request.ReduceTiers,
request.GiftRule,
request.SecondHalfRule,
request.Channels,
request.StoreScopeMode,
request.StoreScopeStoreIds,
request.ScopeStoreId,
request.StackWithCoupon,
request.Description,
request.Metrics,
existingMetrics);
campaign.Name = normalizedName;
campaign.StartAt = normalizedStartAt;
campaign.EndAt = normalizedEndAt;
campaign.RulesJson = FullReductionMapping.SerializeRules(normalizedRules);
campaign.AudienceDescription = normalizedRules.Description;
if (campaign.Status != PromotionStatus.Completed)
{
campaign.Status = PromotionStatus.Active;
}
await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken);
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
return FullReductionDtoFactory.ToDetailDto(campaign, normalizedRules, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
/// <summary>
/// 查询满减活动详情。
/// </summary>
public sealed class GetFullReductionCampaignDetailQuery : IRequest<FullReductionDetailDto?>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long OperationStoreId { get; init; }
/// <summary>
/// 活动 ID。
/// </summary>
public long CampaignId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
/// <summary>
/// 查询满减活动列表。
/// </summary>
public sealed class GetFullReductionCampaignListQuery : IRequest<FullReductionListResultDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 活动类型筛选。
/// </summary>
public string? ActivityType { get; init; }
/// <summary>
/// 状态筛选。
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 4;
}