feat: implement marketing punch card backend module

This commit is contained in:
2026-03-02 21:43:09 +08:00
parent 6588c85f27
commit 3b3bdcee71
48 changed files with 14863 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 修改次卡模板状态命令。
/// </summary>
public sealed class ChangePunchCardTemplateStatusCommand : IRequest<PunchCardDetailDto>
{
/// <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; } = "disabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 删除次卡模板命令。
/// </summary>
public sealed class DeletePunchCardTemplateCommand : IRequest
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
}

View File

@@ -0,0 +1,130 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 保存次卡模板命令。
/// </summary>
public sealed class SavePunchCardTemplateCommand : IRequest<PunchCardDetailDto>
{
/// <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>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; init; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; init; }
/// <summary>
/// 固定开始日期。
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定结束日期。
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 指定分类 ID。
/// </summary>
public IReadOnlyCollection<long> ScopeCategoryIds { get; init; } = [];
/// <summary>
/// 指定标签 ID。
/// </summary>
public IReadOnlyCollection<long> ScopeTagIds { get; init; } = [];
/// <summary>
/// 指定商品 ID。
/// </summary>
public IReadOnlyCollection<long> ScopeProductIds { get; init; } = [];
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; init; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; init; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; init; }
/// <summary>
/// 每单限用次数。
/// </summary>
public int? PerOrderLimit { get; init; }
/// <summary>
/// 每人限购张数。
/// </summary>
public int? PerUserPurchaseLimit { get; init; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; init; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; init; } = "invalidate";
/// <summary>
/// 次卡说明。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
}

View File

@@ -0,0 +1,60 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
/// <summary>
/// 写入次卡使用记录命令。
/// </summary>
public sealed class WritePunchCardUsageRecordCommand : IRequest<PunchCardUsageRecordDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 次卡实例 ID可空
/// </summary>
public long? InstanceId { get; init; }
/// <summary>
/// 次卡实例编号(可空)。
/// </summary>
public string? InstanceNo { get; init; }
/// <summary>
/// 会员名称(当未指定实例时用于创建实例)。
/// </summary>
public string? MemberName { get; init; }
/// <summary>
/// 会员手机号(脱敏,当未指定实例时用于创建实例)。
/// </summary>
public string? MemberPhoneMasked { get; init; }
/// <summary>
/// 兑换商品名称。
/// </summary>
public string ProductName { get; init; } = string.Empty;
/// <summary>
/// 使用时间(可空,空则取当前 UTC
/// </summary>
public DateTime? UsedAt { get; init; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; init; } = 1;
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; init; }
}

View File

@@ -0,0 +1,137 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡详情。
/// </summary>
public sealed class PunchCardDetailDto
{
/// <summary>
/// 次卡 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; init; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; init; }
/// <summary>
/// 固定开始日期UTC
/// </summary>
public DateTime? ValidFrom { get; init; }
/// <summary>
/// 固定结束日期UTC
/// </summary>
public DateTime? ValidTo { get; init; }
/// <summary>
/// 适用范围。
/// </summary>
public PunchCardScopeDto Scope { get; init; } = new();
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; init; } = "free";
/// <summary>
/// 金额上限。
/// </summary>
public decimal? UsageCapAmount { get; init; }
/// <summary>
/// 每日限用。
/// </summary>
public int? DailyLimit { get; init; }
/// <summary>
/// 每单限用。
/// </summary>
public int? PerOrderLimit { get; init; }
/// <summary>
/// 每人限购。
/// </summary>
public int? PerUserPurchaseLimit { get; init; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; init; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; init; } = "invalidate";
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public IReadOnlyList<string> NotifyChannels { get; init; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; init; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; init; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,92 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡列表项。
/// </summary>
public sealed class PunchCardListItemDto
{
/// <summary>
/// 次卡 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; init; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 有效期展示文案。
/// </summary>
public string ValiditySummary { get; init; } = string.Empty;
/// <summary>
/// 适用范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; init; } = "free";
/// <summary>
/// 单次使用上限金额。
/// </summary>
public decimal? UsageCapAmount { get; init; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; init; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; init; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; init; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡模板列表结果。
/// </summary>
public sealed class PunchCardListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<PunchCardListItemDto> Items { get; init; } = [];
/// <summary>
/// 当前页。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 统计数据。
/// </summary>
public PunchCardStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡范围规则。
/// </summary>
public sealed class PunchCardScopeDto
{
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; init; } = "all";
/// <summary>
/// 指定分类 ID。
/// </summary>
public IReadOnlyList<long> CategoryIds { get; init; } = [];
/// <summary>
/// 指定标签 ID。
/// </summary>
public IReadOnlyList<long> TagIds { get; init; } = [];
/// <summary>
/// 指定商品 ID。
/// </summary>
public IReadOnlyList<long> ProductIds { get; init; } = [];
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡模板统计。
/// </summary>
public sealed class PunchCardStatsDto
{
/// <summary>
/// 在售次卡数量。
/// </summary>
public int OnSaleCount { get; init; }
/// <summary>
/// 累计售出数量。
/// </summary>
public int TotalSoldCount { get; init; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal TotalRevenueAmount { get; init; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveInUseCount { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡过滤选项。
/// </summary>
public sealed class PunchCardTemplateOptionDto
{
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,77 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录项。
/// </summary>
public sealed class PunchCardUsageRecordDto
{
/// <summary>
/// 使用记录 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 使用单号。
/// </summary>
public string RecordNo { get; init; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long PunchCardTemplateId { get; init; }
/// <summary>
/// 次卡名称。
/// </summary>
public string PunchCardName { get; init; } = string.Empty;
/// <summary>
/// 次卡实例 ID。
/// </summary>
public long PunchCardInstanceId { get; init; }
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; init; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberPhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 兑换商品名称。
/// </summary>
public string ProductName { get; init; } = string.Empty;
/// <summary>
/// 使用时间。
/// </summary>
public DateTime UsedAt { get; init; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; init; }
/// <summary>
/// 使用后剩余次数。
/// </summary>
public int RemainingTimesAfterUse { get; init; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; init; }
/// <summary>
/// 状态normal/almost_used_up/used_up/expired
/// </summary>
public string DisplayStatus { get; init; } = "normal";
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录导出结果。
/// </summary>
public sealed class PunchCardUsageRecordExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件内容Base64
/// </summary>
public string FileContentBase64 { get; init; } = string.Empty;
/// <summary>
/// 导出总条数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录分页结果。
/// </summary>
public sealed class PunchCardUsageRecordListResultDto
{
/// <summary>
/// 列表数据。
/// </summary>
public IReadOnlyList<PunchCardUsageRecordDto> Items { get; init; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 统计数据。
/// </summary>
public PunchCardUsageStatsDto Stats { get; init; } = new();
/// <summary>
/// 次卡筛选选项。
/// </summary>
public IReadOnlyList<PunchCardTemplateOptionDto> TemplateOptions { get; init; } = [];
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
/// <summary>
/// 次卡使用记录统计。
/// </summary>
public sealed class PunchCardUsageStatsDto
{
/// <summary>
/// 今日使用次数。
/// </summary>
public int TodayUsedCount { get; init; }
/// <summary>
/// 本月使用次数。
/// </summary>
public int MonthUsedCount { get; init; }
/// <summary>
/// 7 天内即将过期数量。
/// </summary>
public int ExpiringSoonCount { get; init; }
}

View File

@@ -0,0 +1,51 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.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.PunchCard.Handlers;
/// <summary>
/// 次卡状态变更处理器。
/// </summary>
public sealed class ChangePunchCardTemplateStatusCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangePunchCardTemplateStatusCommand, PunchCardDetailDto>
{
/// <inheritdoc />
public async Task<PunchCardDetailDto> Handle(
ChangePunchCardTemplateStatusCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedStatus = PunchCardMapping.ParseTemplateStatus(request.Status);
var entity = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
entity.Status = normalizedStatus;
await repository.UpdateTemplateAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.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.PunchCard.Handlers;
/// <summary>
/// 删除次卡模板处理器。
/// </summary>
public sealed class DeletePunchCardTemplateCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<DeletePunchCardTemplateCommand>
{
/// <inheritdoc />
public async Task Handle(DeletePunchCardTemplateCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
if (aggregate.TryGetValue(entity.Id, out var snapshot) && snapshot.SoldCount > 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "已售出的次卡不可删除");
}
await repository.DeleteTemplateAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,128 @@
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 导出次卡使用记录处理器。
/// </summary>
public sealed class ExportPunchCardUsageRecordCsvQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportPunchCardUsageRecordCsvQuery, PunchCardUsageRecordExportDto>
{
/// <inheritdoc />
public async Task<PunchCardUsageRecordExportDto> Handle(
ExportPunchCardUsageRecordCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
var records = await repository.ListUsageRecordsForExportAsync(
tenantId,
request.StoreId,
request.TemplateId,
request.Keyword,
normalizedStatus,
cancellationToken);
if (records.Count == 0)
{
return new PunchCardUsageRecordExportDto
{
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("\uFEFF使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态\n")),
TotalCount = 0
};
}
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
var instances = await repository.GetInstancesByIdsAsync(
tenantId,
request.StoreId,
instanceIds,
cancellationToken);
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
var templateIds = records.Select(item => item.PunchCardTemplateId)
.Concat(instances.Select(item => item.PunchCardTemplateId))
.Distinct()
.ToList();
var templates = await repository.GetTemplatesByIdsAsync(
tenantId,
request.StoreId,
templateIds,
cancellationToken);
var templateMap = templates.ToDictionary(item => item.Id, item => item);
var csv = BuildCsv(records, instanceMap, templateMap);
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
return new PunchCardUsageRecordExportDto
{
FileName = $"次卡使用记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = records.Count
};
}
private static string BuildCsv(
IReadOnlyCollection<Domain.Coupons.Entities.PunchCardUsageRecord> records,
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardInstance> instanceMap,
IReadOnlyDictionary<long, Domain.Coupons.Entities.PunchCardTemplate> templateMap)
{
var lines = new List<string>
{
"使用单号,会员,手机号,次卡,兑换商品,使用时间,剩余次数,总次数,状态"
};
var nowUtc = DateTime.UtcNow;
foreach (var record in records)
{
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
var dto = PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
var statusText = ResolveStatusText(dto.DisplayStatus);
lines.Add(string.Join(",",
Escape(dto.RecordNo),
Escape(dto.MemberName),
Escape(dto.MemberPhoneMasked),
Escape(dto.PunchCardName),
Escape(dto.ProductName),
Escape(dto.UsedAt.ToString("yyyy-MM-dd HH:mm:ss")),
dto.RemainingTimesAfterUse,
dto.TotalTimes,
Escape(statusText)));
}
return string.Join('\n', lines);
}
private static string ResolveStatusText(string value)
{
return value switch
{
"normal" => "正常使用",
"almost_used_up" => "即将用完",
"used_up" => "已用完",
"expired" => "已过期",
_ => "正常使用"
};
}
private static string Escape(string value)
{
var text = value.Replace("\"", "\"\"");
return $"\"{text}\"";
}
}

View File

@@ -0,0 +1,47 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡模板详情查询处理器。
/// </summary>
public sealed class GetPunchCardTemplateDetailQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPunchCardTemplateDetailQuery, PunchCardDetailDto?>
{
/// <inheritdoc />
public async Task<PunchCardDetailDto?> Handle(
GetPunchCardTemplateDetailQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var template = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken);
if (template is null)
{
return null;
}
var aggregate = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[request.TemplateId],
cancellationToken);
var snapshot = aggregate.TryGetValue(template.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(template.Id);
return PunchCardDtoFactory.ToDetailDto(template, snapshot);
}
}

View File

@@ -0,0 +1,67 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡模板列表查询处理器。
/// </summary>
public sealed class GetPunchCardTemplateListQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPunchCardTemplateListQuery, PunchCardListResultDto>
{
/// <inheritdoc />
public async Task<PunchCardListResultDto> Handle(
GetPunchCardTemplateListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var status = PunchCardMapping.ParseTemplateStatusFilter(request.Status);
var (items, totalCount) = await repository.SearchTemplatesAsync(
tenantId,
request.StoreId,
request.Keyword,
status,
page,
pageSize,
cancellationToken);
var templateIds = items.Select(item => item.Id).ToList();
var aggregates = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
templateIds,
cancellationToken);
var mappedItems = items
.Select(item =>
{
var aggregate = aggregates.TryGetValue(item.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(item.Id);
return PunchCardDtoFactory.ToListItemDto(item, aggregate);
})
.ToList();
var statsSnapshot = await repository.GetTemplateStatsAsync(
tenantId,
request.StoreId,
cancellationToken);
return new PunchCardListResultDto
{
Items = mappedItems,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
Stats = PunchCardDtoFactory.ToStatsDto(statsSnapshot)
};
}
}

View File

@@ -0,0 +1,104 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Handlers;
/// <summary>
/// 次卡使用记录列表查询处理器。
/// </summary>
public sealed class GetPunchCardUsageRecordListQueryHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetPunchCardUsageRecordListQuery, PunchCardUsageRecordListResultDto>
{
/// <inheritdoc />
public async Task<PunchCardUsageRecordListResultDto> Handle(
GetPunchCardUsageRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 500);
var normalizedStatus = PunchCardMapping.ParseUsageStatusFilter(request.Status);
var (records, totalCount) = await repository.SearchUsageRecordsAsync(
tenantId,
request.StoreId,
request.TemplateId,
request.Keyword,
normalizedStatus,
page,
pageSize,
cancellationToken);
var instanceIds = records.Select(item => item.PunchCardInstanceId).Distinct().ToList();
var instances = await repository.GetInstancesByIdsAsync(
tenantId,
request.StoreId,
instanceIds,
cancellationToken);
var instanceMap = instances.ToDictionary(item => item.Id, item => item);
var templateIds = records.Select(item => item.PunchCardTemplateId)
.Concat(instances.Select(item => item.PunchCardTemplateId))
.Distinct()
.ToList();
var templates = await repository.GetTemplatesByIdsAsync(
tenantId,
request.StoreId,
templateIds,
cancellationToken);
var templateMap = templates.ToDictionary(item => item.Id, item => item);
var nowUtc = DateTime.UtcNow;
var mappedRecords = records
.Select(record =>
{
instanceMap.TryGetValue(record.PunchCardInstanceId, out var instance);
templateMap.TryGetValue(record.PunchCardTemplateId, out var template);
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, nowUtc);
})
.ToList();
var usageStats = await repository.GetUsageStatsAsync(
tenantId,
request.StoreId,
request.TemplateId,
nowUtc,
cancellationToken);
var (templateRows, _) = await repository.SearchTemplatesAsync(
tenantId,
request.StoreId,
null,
null,
1,
500,
cancellationToken);
var templateOptions = templateRows
.OrderBy(item => item.Name, StringComparer.Ordinal)
.Select(item => new PunchCardTemplateOptionDto
{
TemplateId = item.Id,
Name = item.Name
})
.ToList();
return new PunchCardUsageRecordListResultDto
{
Items = mappedRecords,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
Stats = PunchCardDtoFactory.ToUsageStatsDto(usageStats),
TemplateOptions = templateOptions
};
}
}

View File

@@ -0,0 +1,158 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.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.PunchCard.Handlers;
/// <summary>
/// 次卡模板保存处理器。
/// </summary>
public sealed class SavePunchCardTemplateCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SavePunchCardTemplateCommand, PunchCardDetailDto>
{
/// <inheritdoc />
public async Task<PunchCardDetailDto> Handle(
SavePunchCardTemplateCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedName = PunchCardMapping.NormalizeName(request.Name);
var normalizedCoverImageUrl = PunchCardMapping.NormalizeOptionalCoverUrl(request.CoverImageUrl);
var normalizedSalePrice = PunchCardMapping.NormalizeAmount(request.SalePrice, "salePrice", false);
var normalizedOriginalPrice = PunchCardMapping.NormalizeOptionalAmount(request.OriginalPrice, "originalPrice", true);
var normalizedTotalTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.TotalTimes, "totalTimes", 10_000);
if (normalizedOriginalPrice.HasValue && normalizedOriginalPrice.Value < normalizedSalePrice)
{
throw new BusinessException(ErrorCodes.BadRequest, "originalPrice 不能小于 salePrice");
}
var validityType = PunchCardMapping.ParseValidityType(request.ValidityType);
var (normalizedValidityDays, normalizedValidFrom, normalizedValidTo) = PunchCardMapping.NormalizeValidity(
validityType,
request.ValidityDays,
request.ValidFrom,
request.ValidTo);
var scopeType = PunchCardMapping.ParseScopeType(request.ScopeType);
var (normalizedCategoryIds, normalizedTagIds, normalizedProductIds) = PunchCardMapping.NormalizeScopeIds(
scopeType,
request.ScopeCategoryIds,
request.ScopeTagIds,
request.ScopeProductIds);
var usageMode = PunchCardMapping.ParseUsageMode(request.UsageMode);
var normalizedUsageCapAmount = usageMode switch
{
PunchCardUsageMode.Free => null,
PunchCardUsageMode.Cap => PunchCardMapping.NormalizeOptionalAmount(request.UsageCapAmount, "usageCapAmount", false),
_ => null
};
if (usageMode == PunchCardUsageMode.Cap && !normalizedUsageCapAmount.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "usageCapAmount 不能为空");
}
var normalizedDailyLimit = PunchCardMapping.NormalizeOptionalLimit(request.DailyLimit, "dailyLimit", normalizedTotalTimes);
var normalizedPerOrderLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerOrderLimit, "perOrderLimit", normalizedTotalTimes);
var normalizedPerUserPurchaseLimit = PunchCardMapping.NormalizeOptionalLimit(request.PerUserPurchaseLimit, "perUserPurchaseLimit", 1000);
var expireStrategy = PunchCardMapping.ParseExpireStrategy(request.ExpireStrategy);
var normalizedDescription = PunchCardMapping.NormalizeOptionalDescription(request.Description);
var normalizedNotifyChannelsJson = PunchCardMapping.SerializeNotifyChannels(request.NotifyChannels);
var normalizedCategoryIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedCategoryIds);
var normalizedTagIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedTagIds);
var normalizedProductIdsJson = PunchCardMapping.SerializeSnowflakeIds(normalizedProductIds);
if (!request.TemplateId.HasValue)
{
var newEntity = PunchCardDtoFactory.CreateTemplateEntity(
request,
normalizedName,
normalizedCoverImageUrl,
normalizedSalePrice,
normalizedOriginalPrice,
normalizedTotalTimes,
validityType,
normalizedValidityDays,
normalizedValidFrom,
normalizedValidTo,
scopeType,
normalizedCategoryIdsJson,
normalizedTagIdsJson,
normalizedProductIdsJson,
usageMode,
normalizedUsageCapAmount,
normalizedDailyLimit,
normalizedPerOrderLimit,
normalizedPerUserPurchaseLimit,
expireStrategy,
normalizedDescription,
normalizedNotifyChannelsJson);
await repository.AddTemplateAsync(newEntity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return PunchCardDtoFactory.ToDetailDto(
newEntity,
PunchCardDtoFactory.EmptyAggregate(newEntity.Id));
}
var entity = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
entity.Name = normalizedName;
entity.CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
? null
: normalizedCoverImageUrl;
entity.SalePrice = normalizedSalePrice;
entity.OriginalPrice = normalizedOriginalPrice;
entity.TotalTimes = normalizedTotalTimes;
entity.ValidityType = validityType;
entity.ValidityDays = normalizedValidityDays;
entity.ValidFrom = normalizedValidFrom;
entity.ValidTo = normalizedValidTo;
entity.ScopeType = scopeType;
entity.ScopeCategoryIdsJson = normalizedCategoryIdsJson;
entity.ScopeTagIdsJson = normalizedTagIdsJson;
entity.ScopeProductIdsJson = normalizedProductIdsJson;
entity.UsageMode = usageMode;
entity.UsageCapAmount = normalizedUsageCapAmount;
entity.DailyLimit = normalizedDailyLimit;
entity.PerOrderLimit = normalizedPerOrderLimit;
entity.PerUserPurchaseLimit = normalizedPerUserPurchaseLimit;
entity.AllowTransfer = request.AllowTransfer;
entity.ExpireStrategy = expireStrategy;
entity.Description = normalizedDescription;
entity.NotifyChannelsJson = normalizedNotifyChannelsJson;
await repository.UpdateTemplateAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregateMap = await repository.GetTemplateAggregateByTemplateIdsAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregateMap.TryGetValue(entity.Id, out var value)
? value
: PunchCardDtoFactory.EmptyAggregate(entity.Id);
return PunchCardDtoFactory.ToDetailDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,160 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.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.PunchCard.Handlers;
/// <summary>
/// 写入次卡使用记录处理器。
/// </summary>
public sealed class WritePunchCardUsageRecordCommandHandler(
IPunchCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<WritePunchCardUsageRecordCommand, PunchCardUsageRecordDto>
{
/// <inheritdoc />
public async Task<PunchCardUsageRecordDto> Handle(
WritePunchCardUsageRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var usedAt = request.UsedAt.HasValue
? PunchCardMapping.NormalizeUtc(request.UsedAt.Value)
: DateTime.UtcNow;
var template = await repository.FindTemplateByIdAsync(
tenantId,
request.StoreId,
request.TemplateId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "次卡不存在");
if (template.Status != PunchCardStatus.Enabled)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已下架,无法使用");
}
var productName = PunchCardMapping.NormalizeProductName(request.ProductName);
var usedTimes = PunchCardMapping.NormalizeRequiredPositiveInt(request.UsedTimes, "usedTimes", template.TotalTimes);
var extraPayAmount = PunchCardMapping.NormalizeOptionalAmount(request.ExtraPayAmount, "extraPayAmount", true);
PunchCardInstance? instance = null;
if (request.InstanceId.HasValue && request.InstanceId.Value > 0)
{
instance = await repository.FindInstanceByIdAsync(
tenantId,
request.StoreId,
request.InstanceId.Value,
cancellationToken);
}
else if (!string.IsNullOrWhiteSpace(request.InstanceNo))
{
var normalizedInstanceNo = PunchCardMapping.NormalizeInstanceNo(request.InstanceNo);
instance = await repository.FindInstanceByNoAsync(
tenantId,
request.StoreId,
normalizedInstanceNo,
cancellationToken);
}
if (instance is not null && instance.PunchCardTemplateId != template.Id)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡实例与模板不匹配");
}
var isNewInstance = false;
if (instance is null)
{
var memberName = PunchCardMapping.NormalizeMemberName(request.MemberName);
var memberPhoneMasked = PunchCardMapping.NormalizeMemberPhoneMasked(request.MemberPhoneMasked);
var purchasedAt = usedAt;
instance = new PunchCardInstance
{
StoreId = request.StoreId,
PunchCardTemplateId = template.Id,
InstanceNo = PunchCardDtoFactory.GenerateInstanceNo(usedAt),
MemberName = memberName,
MemberPhoneMasked = memberPhoneMasked,
PurchasedAt = purchasedAt,
ExpiresAt = PunchCardMapping.ResolveInstanceExpireAt(template, purchasedAt),
TotalTimes = template.TotalTimes,
RemainingTimes = template.TotalTimes,
PaidAmount = template.SalePrice,
Status = PunchCardInstanceStatus.Active
};
isNewInstance = true;
}
if (PunchCardMapping.IsInstanceExpired(instance, usedAt))
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已过期");
}
if (instance.Status == PunchCardInstanceStatus.Refunded)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已退款");
}
if (instance.RemainingTimes <= 0 || instance.Status == PunchCardInstanceStatus.UsedUp)
{
throw new BusinessException(ErrorCodes.BadRequest, "次卡已用完");
}
if (template.PerOrderLimit.HasValue && usedTimes > template.PerOrderLimit.Value)
{
throw new BusinessException(ErrorCodes.BadRequest, "超出每单限用次数");
}
if (usedTimes > instance.RemainingTimes)
{
throw new BusinessException(ErrorCodes.BadRequest, "超出次卡剩余次数");
}
var remainingTimes = instance.RemainingTimes - usedTimes;
var statusAfterUse = PunchCardMapping.ResolveUsageRecordStatus(instance, remainingTimes, usedAt);
instance.RemainingTimes = remainingTimes;
instance.Status = statusAfterUse switch
{
PunchCardUsageRecordStatus.UsedUp => PunchCardInstanceStatus.UsedUp,
PunchCardUsageRecordStatus.Expired => PunchCardInstanceStatus.Expired,
_ => PunchCardInstanceStatus.Active
};
if (isNewInstance)
{
await repository.AddInstanceAsync(instance, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
else
{
await repository.UpdateInstanceAsync(instance, cancellationToken);
}
var record = new PunchCardUsageRecord
{
StoreId = request.StoreId,
PunchCardTemplateId = template.Id,
PunchCardInstanceId = instance.Id,
RecordNo = PunchCardDtoFactory.GenerateRecordNo(usedAt),
ProductName = productName,
UsedAt = usedAt,
UsedTimes = usedTimes,
RemainingTimesAfterUse = remainingTimes,
StatusAfterUse = statusAfterUse,
ExtraPayAmount = extraPayAmount
};
await repository.AddUsageRecordAsync(record, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
return PunchCardDtoFactory.ToUsageRecordDto(record, instance, template, usedAt);
}
}

View File

@@ -0,0 +1,210 @@
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
/// <summary>
/// 次卡 DTO 构造器。
/// </summary>
internal static class PunchCardDtoFactory
{
public static PunchCardTemplateAggregateSnapshot EmptyAggregate(long templateId)
{
return new PunchCardTemplateAggregateSnapshot
{
TemplateId = templateId,
SoldCount = 0,
ActiveCount = 0,
RevenueAmount = 0m
};
}
public static PunchCardListItemDto ToListItemDto(
PunchCardTemplate template,
PunchCardTemplateAggregateSnapshot aggregate)
{
return new PunchCardListItemDto
{
Id = template.Id,
Name = template.Name,
CoverImageUrl = template.CoverImageUrl,
SalePrice = template.SalePrice,
OriginalPrice = template.OriginalPrice,
TotalTimes = template.TotalTimes,
ValiditySummary = PunchCardMapping.BuildValiditySummary(template),
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
UsageCapAmount = template.UsageCapAmount,
DailyLimit = template.DailyLimit,
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
IsDimmed = template.Status == PunchCardStatus.Disabled,
SoldCount = aggregate.SoldCount,
ActiveCount = aggregate.ActiveCount,
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
};
}
public static PunchCardDetailDto ToDetailDto(
PunchCardTemplate template,
PunchCardTemplateAggregateSnapshot aggregate)
{
return new PunchCardDetailDto
{
Id = template.Id,
StoreId = template.StoreId,
Name = template.Name,
CoverImageUrl = template.CoverImageUrl,
SalePrice = template.SalePrice,
OriginalPrice = template.OriginalPrice,
TotalTimes = template.TotalTimes,
ValidityType = PunchCardMapping.ToValidityTypeText(template.ValidityType),
ValidityDays = template.ValidityDays,
ValidFrom = template.ValidFrom,
ValidTo = template.ValidTo,
Scope = new PunchCardScopeDto
{
ScopeType = PunchCardMapping.ToScopeTypeText(template.ScopeType),
CategoryIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeCategoryIdsJson),
TagIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeTagIdsJson),
ProductIds = PunchCardMapping.DeserializeSnowflakeIds(template.ScopeProductIdsJson)
},
UsageMode = PunchCardMapping.ToUsageModeText(template.UsageMode),
UsageCapAmount = template.UsageCapAmount,
DailyLimit = template.DailyLimit,
PerOrderLimit = template.PerOrderLimit,
PerUserPurchaseLimit = template.PerUserPurchaseLimit,
AllowTransfer = template.AllowTransfer,
ExpireStrategy = PunchCardMapping.ToExpireStrategyText(template.ExpireStrategy),
Description = template.Description,
NotifyChannels = PunchCardMapping.DeserializeNotifyChannels(template.NotifyChannelsJson),
Status = PunchCardMapping.ToTemplateStatusText(template.Status),
SoldCount = aggregate.SoldCount,
ActiveCount = aggregate.ActiveCount,
RevenueAmount = decimal.Round(aggregate.RevenueAmount, 2, MidpointRounding.AwayFromZero),
UpdatedAt = template.UpdatedAt ?? template.CreatedAt
};
}
public static PunchCardStatsDto ToStatsDto(PunchCardTemplateStatsSnapshot source)
{
return new PunchCardStatsDto
{
OnSaleCount = source.OnSaleCount,
TotalSoldCount = source.TotalSoldCount,
TotalRevenueAmount = decimal.Round(source.TotalRevenueAmount, 2, MidpointRounding.AwayFromZero),
ActiveInUseCount = source.ActiveInUseCount
};
}
public static PunchCardUsageStatsDto ToUsageStatsDto(PunchCardUsageStatsSnapshot source)
{
return new PunchCardUsageStatsDto
{
TodayUsedCount = source.TodayUsedCount,
MonthUsedCount = source.MonthUsedCount,
ExpiringSoonCount = source.ExpiringSoonCount
};
}
public static PunchCardUsageRecordDto ToUsageRecordDto(
PunchCardUsageRecord record,
PunchCardInstance? instance,
PunchCardTemplate? template,
DateTime nowUtc)
{
var resolvedTotalTimes = instance?.TotalTimes ?? template?.TotalTimes ?? 0;
var status = record.StatusAfterUse;
if (instance is not null)
{
status = PunchCardMapping.ResolveUsageRecordStatus(instance, record.RemainingTimesAfterUse, nowUtc);
}
return new PunchCardUsageRecordDto
{
Id = record.Id,
RecordNo = record.RecordNo,
PunchCardTemplateId = record.PunchCardTemplateId,
PunchCardName = template?.Name ?? string.Empty,
PunchCardInstanceId = record.PunchCardInstanceId,
MemberName = instance?.MemberName ?? string.Empty,
MemberPhoneMasked = instance?.MemberPhoneMasked ?? string.Empty,
ProductName = record.ProductName,
UsedAt = record.UsedAt,
UsedTimes = record.UsedTimes,
RemainingTimesAfterUse = record.RemainingTimesAfterUse,
TotalTimes = resolvedTotalTimes,
DisplayStatus = PunchCardMapping.ToUsageDisplayStatusText(status),
ExtraPayAmount = record.ExtraPayAmount
};
}
public static PunchCardTemplate CreateTemplateEntity(
SavePunchCardTemplateCommand request,
string normalizedName,
string normalizedCoverImageUrl,
decimal normalizedSalePrice,
decimal? normalizedOriginalPrice,
int normalizedTotalTimes,
PunchCardValidityType validityType,
int? normalizedValidityDays,
DateTime? normalizedValidFrom,
DateTime? normalizedValidTo,
PunchCardScopeType scopeType,
string normalizedCategoryIdsJson,
string normalizedTagIdsJson,
string normalizedProductIdsJson,
PunchCardUsageMode usageMode,
decimal? normalizedUsageCapAmount,
int? normalizedDailyLimit,
int? normalizedPerOrderLimit,
int? normalizedPerUserPurchaseLimit,
PunchCardExpireStrategy expireStrategy,
string? normalizedDescription,
string normalizedNotifyChannelsJson)
{
return new PunchCardTemplate
{
StoreId = request.StoreId,
Name = normalizedName,
CoverImageUrl = string.IsNullOrWhiteSpace(normalizedCoverImageUrl)
? null
: normalizedCoverImageUrl,
SalePrice = normalizedSalePrice,
OriginalPrice = normalizedOriginalPrice,
TotalTimes = normalizedTotalTimes,
ValidityType = validityType,
ValidityDays = normalizedValidityDays,
ValidFrom = normalizedValidFrom,
ValidTo = normalizedValidTo,
ScopeType = scopeType,
ScopeCategoryIdsJson = normalizedCategoryIdsJson,
ScopeTagIdsJson = normalizedTagIdsJson,
ScopeProductIdsJson = normalizedProductIdsJson,
UsageMode = usageMode,
UsageCapAmount = normalizedUsageCapAmount,
DailyLimit = normalizedDailyLimit,
PerOrderLimit = normalizedPerOrderLimit,
PerUserPurchaseLimit = normalizedPerUserPurchaseLimit,
AllowTransfer = request.AllowTransfer,
ExpireStrategy = expireStrategy,
Description = normalizedDescription,
NotifyChannelsJson = normalizedNotifyChannelsJson,
Status = PunchCardStatus.Enabled
};
}
public static string GenerateInstanceNo(DateTime nowUtc)
{
return $"PKI{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
public static string GenerateRecordNo(DateTime nowUtc)
{
return $"PK{nowUtc:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
}

View File

@@ -0,0 +1,546 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Coupons.PunchCard.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;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard;
/// <summary>
/// 次卡模块映射与标准化。
/// </summary>
internal static class PunchCardMapping
{
private static readonly HashSet<string> AllowedNotifyChannels =
[
"in_app",
"sms"
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static PunchCardStatus? ParseTemplateStatusFilter(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"" => null,
"enabled" => PunchCardStatus.Enabled,
"disabled" => PunchCardStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static PunchCardStatus ParseTemplateStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"enabled" => PunchCardStatus.Enabled,
"disabled" => PunchCardStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToTemplateStatusText(PunchCardStatus value)
{
return value switch
{
PunchCardStatus.Enabled => "enabled",
PunchCardStatus.Disabled => "disabled",
_ => "disabled"
};
}
public static PunchCardValidityType ParseValidityType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"days" => PunchCardValidityType.Days,
"range" => PunchCardValidityType.DateRange,
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
};
}
public static string ToValidityTypeText(PunchCardValidityType value)
{
return value switch
{
PunchCardValidityType.Days => "days",
PunchCardValidityType.DateRange => "range",
_ => "days"
};
}
public static PunchCardScopeType ParseScopeType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"all" => PunchCardScopeType.All,
"category" => PunchCardScopeType.Category,
"tag" => PunchCardScopeType.Tag,
"product" => PunchCardScopeType.Product,
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
};
}
public static string ToScopeTypeText(PunchCardScopeType value)
{
return value switch
{
PunchCardScopeType.All => "all",
PunchCardScopeType.Category => "category",
PunchCardScopeType.Tag => "tag",
PunchCardScopeType.Product => "product",
_ => "all"
};
}
public static PunchCardUsageMode ParseUsageMode(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"free" => PunchCardUsageMode.Free,
"cap" => PunchCardUsageMode.Cap,
_ => throw new BusinessException(ErrorCodes.BadRequest, "usageMode 参数不合法")
};
}
public static string ToUsageModeText(PunchCardUsageMode value)
{
return value switch
{
PunchCardUsageMode.Free => "free",
PunchCardUsageMode.Cap => "cap",
_ => "free"
};
}
public static PunchCardExpireStrategy ParseExpireStrategy(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"invalidate" => PunchCardExpireStrategy.Invalidate,
"refund" => PunchCardExpireStrategy.Refund,
_ => throw new BusinessException(ErrorCodes.BadRequest, "expireStrategy 参数不合法")
};
}
public static string ToExpireStrategyText(PunchCardExpireStrategy value)
{
return value switch
{
PunchCardExpireStrategy.Invalidate => "invalidate",
PunchCardExpireStrategy.Refund => "refund",
_ => "invalidate"
};
}
public static PunchCardUsageRecordFilterStatus? ParseUsageStatusFilter(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"" => null,
"normal" => PunchCardUsageRecordFilterStatus.Normal,
"used_up" => PunchCardUsageRecordFilterStatus.UsedUp,
"expired" => PunchCardUsageRecordFilterStatus.Expired,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToUsageDisplayStatusText(PunchCardUsageRecordStatus value)
{
return value switch
{
PunchCardUsageRecordStatus.Normal => "normal",
PunchCardUsageRecordStatus.AlmostUsedUp => "almost_used_up",
PunchCardUsageRecordStatus.UsedUp => "used_up",
PunchCardUsageRecordStatus.Expired => "expired",
_ => "normal"
};
}
public static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
public static string NormalizeName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "name 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "name 长度不能超过 64");
}
return normalized;
}
public static string NormalizeOptionalCoverUrl(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "coverImageUrl 长度不能超过 512");
}
return normalized;
}
public static string? NormalizeOptionalDescription(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 512)
{
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
}
return normalized;
}
public static string NormalizeInstanceNo(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 不能为空");
}
if (normalized.Length > 32)
{
throw new BusinessException(ErrorCodes.BadRequest, "punchCardInstanceNo 长度不能超过 32");
}
return normalized;
}
public static string NormalizeMemberName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "memberName 不能为空");
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "memberName 长度不能超过 64");
}
return normalized;
}
public static string NormalizeMemberPhoneMasked(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 不能为空");
}
if (normalized.Length > 32)
{
throw new BusinessException(ErrorCodes.BadRequest, "memberPhoneMasked 长度不能超过 32");
}
return normalized;
}
public static string NormalizeProductName(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new BusinessException(ErrorCodes.BadRequest, "productName 不能为空");
}
if (normalized.Length > 128)
{
throw new BusinessException(ErrorCodes.BadRequest, "productName 长度不能超过 128");
}
return normalized;
}
public static decimal NormalizeAmount(decimal value, string fieldName, bool allowZero = false)
{
if (value < 0 || (!allowZero && value <= 0))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static decimal? NormalizeOptionalAmount(decimal? value, string fieldName, bool allowZero = true)
{
if (!value.HasValue)
{
return null;
}
if (value.Value < 0 || (!allowZero && value.Value <= 0))
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
}
public static int NormalizeRequiredPositiveInt(int value, string fieldName, int max = 100_000)
{
if (value <= 0 || value > max)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return value;
}
public static int? NormalizeOptionalLimit(int? value, string fieldName, int max = 100_000)
{
if (!value.HasValue || value.Value <= 0)
{
return null;
}
if (value.Value > max)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
}
return value;
}
public static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeValidity(
PunchCardValidityType validityType,
int? validityDays,
DateTime? validFrom,
DateTime? validTo)
{
return validityType switch
{
PunchCardValidityType.Days =>
(
NormalizeRequiredPositiveInt(validityDays ?? 0, "validityDays", 3650),
null,
null
),
PunchCardValidityType.DateRange => NormalizeRange(validFrom, validTo),
_ => throw new BusinessException(ErrorCodes.BadRequest, "validityType 参数不合法")
};
}
public static (IReadOnlyList<long> CategoryIds, IReadOnlyList<long> TagIds, IReadOnlyList<long> ProductIds) NormalizeScopeIds(
PunchCardScopeType scopeType,
IReadOnlyCollection<long>? categoryIds,
IReadOnlyCollection<long>? tagIds,
IReadOnlyCollection<long>? productIds)
{
var normalizedCategoryIds = NormalizeSnowflakeIds(categoryIds, "scopeCategoryIds", false);
var normalizedTagIds = NormalizeSnowflakeIds(tagIds, "scopeTagIds", false);
var normalizedProductIds = NormalizeSnowflakeIds(productIds, "scopeProductIds", false);
return scopeType switch
{
PunchCardScopeType.All => ([], [], []),
PunchCardScopeType.Category =>
normalizedCategoryIds.Count == 0
? throw new BusinessException(ErrorCodes.BadRequest, "scopeCategoryIds 不能为空")
: (normalizedCategoryIds, [], []),
PunchCardScopeType.Tag =>
normalizedTagIds.Count == 0
? throw new BusinessException(ErrorCodes.BadRequest, "scopeTagIds 不能为空")
: ([], normalizedTagIds, []),
PunchCardScopeType.Product =>
normalizedProductIds.Count == 0
? throw new BusinessException(ErrorCodes.BadRequest, "scopeProductIds 不能为空")
: ([], [], normalizedProductIds),
_ => throw new BusinessException(ErrorCodes.BadRequest, "scopeType 参数不合法")
};
}
public static IReadOnlyList<string> NormalizeNotifyChannels(IEnumerable<string>? values)
{
var normalized = (values ?? [])
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.ToList();
if (normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 不能为空");
}
if (normalized.Any(item => !AllowedNotifyChannels.Contains(item)))
{
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 存在非法值");
}
return normalized;
}
public static IReadOnlyList<string> DeserializeNotifyChannels(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
return values
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
.Where(item => AllowedNotifyChannels.Contains(item))
.Distinct()
.ToList();
}
public static string SerializeNotifyChannels(IEnumerable<string>? values)
{
return JsonSerializer.Serialize(NormalizeNotifyChannels(values), JsonOptions);
}
public static IReadOnlyList<long> DeserializeSnowflakeIds(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
var values = JsonSerializer.Deserialize<List<long>>(payload, JsonOptions) ?? [];
return values
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
}
public static string SerializeSnowflakeIds(IEnumerable<long>? values)
{
return JsonSerializer.Serialize(NormalizeSnowflakeIds(values, "ids", false), JsonOptions);
}
public static string BuildValiditySummary(PunchCardTemplate template)
{
return template.ValidityType switch
{
PunchCardValidityType.Days => $"{template.ValidityDays ?? 0}天有效",
PunchCardValidityType.DateRange when template.ValidFrom.HasValue && template.ValidTo.HasValue =>
$"{template.ValidFrom.Value:yyyy-MM-dd} 至 {template.ValidTo.Value:yyyy-MM-dd}",
_ => "-"
};
}
public static DateTime ResolveInstanceExpireAt(PunchCardTemplate template, DateTime purchasedAtUtc)
{
var purchasedAt = NormalizeUtc(purchasedAtUtc);
return template.ValidityType switch
{
PunchCardValidityType.Days => purchasedAt.Date.AddDays(template.ValidityDays ?? 0).AddTicks(-1),
PunchCardValidityType.DateRange => template.ValidTo ?? purchasedAt.Date.AddTicks(-1),
_ => purchasedAt.Date.AddTicks(-1)
};
}
public static bool IsInstanceExpired(PunchCardInstance instance, DateTime nowUtc)
{
var utcNow = NormalizeUtc(nowUtc);
if (instance.Status == PunchCardInstanceStatus.Expired)
{
return true;
}
return instance.ExpiresAt.HasValue && instance.ExpiresAt.Value < utcNow;
}
public static PunchCardUsageRecordStatus ResolveUsageRecordStatus(
PunchCardInstance instance,
int remainingTimes,
DateTime usedAtUtc)
{
if (IsInstanceExpired(instance, usedAtUtc))
{
return PunchCardUsageRecordStatus.Expired;
}
if (remainingTimes <= 0)
{
return PunchCardUsageRecordStatus.UsedUp;
}
return remainingTimes <= 2
? PunchCardUsageRecordStatus.AlmostUsedUp
: PunchCardUsageRecordStatus.Normal;
}
private static IReadOnlyList<long> NormalizeSnowflakeIds(
IEnumerable<long>? values,
string fieldName,
bool required)
{
var normalized = (values ?? [])
.Where(id => id > 0)
.Distinct()
.OrderBy(id => id)
.ToList();
if (required && normalized.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
}
return normalized;
}
private static (int? ValidityDays, DateTime? ValidFrom, DateTime? ValidTo) NormalizeRange(
DateTime? validFrom,
DateTime? validTo)
{
if (!validFrom.HasValue || !validTo.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "validFrom / validTo 不能为空");
}
var normalizedFrom = NormalizeUtc(validFrom.Value).Date;
var normalizedTo = NormalizeUtc(validTo.Value).Date.AddDays(1).AddTicks(-1);
if (normalizedFrom > normalizedTo)
{
throw new BusinessException(ErrorCodes.BadRequest, "validFrom 不能晚于 validTo");
}
return (null, normalizedFrom, normalizedTo);
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 导出次卡使用记录 CSV。
/// </summary>
public sealed class ExportPunchCardUsageRecordCsvQuery : IRequest<PunchCardUsageRecordExportDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板筛选 ID可空
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 查询次卡模板详情。
/// </summary>
public sealed class GetPunchCardTemplateDetailQuery : IRequest<PunchCardDetailDto?>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long TemplateId { get; init; }
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 查询次卡模板列表。
/// </summary>
public sealed class GetPunchCardTemplateListQuery : IRequest<PunchCardListResultDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 名称关键字。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 状态筛选enabled/disabled
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 4;
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
namespace TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
/// <summary>
/// 查询次卡使用记录列表。
/// </summary>
public sealed class GetPunchCardUsageRecordListQuery : IRequest<PunchCardUsageRecordListResultDto>
{
/// <summary>
/// 操作门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 次卡模板筛选 ID可空
/// </summary>
public long? TemplateId { get; init; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; init; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}