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,809 @@
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
/// <summary>
/// 次卡列表查询请求。
/// </summary>
public sealed class PunchCardListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 名称关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 状态筛选enabled/disabled
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 4;
}
/// <summary>
/// 次卡详情请求。
/// </summary>
public sealed class PunchCardDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
}
/// <summary>
/// 保存次卡请求。
/// </summary>
public sealed class SavePunchCardRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID编辑时传
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图地址。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; set; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; set; }
/// <summary>
/// 固定开始日期yyyy-MM-dd
/// </summary>
public string? ValidFrom { get; set; }
/// <summary>
/// 固定结束日期yyyy-MM-dd
/// </summary>
public string? ValidTo { get; set; }
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; set; } = "all";
/// <summary>
/// 指定分类 ID。
/// </summary>
public List<string> ScopeCategoryIds { get; set; } = [];
/// <summary>
/// 指定标签 ID。
/// </summary>
public List<string> ScopeTagIds { get; set; } = [];
/// <summary>
/// 指定商品 ID。
/// </summary>
public List<string> ScopeProductIds { get; set; } = [];
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; set; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 每单限用次数。
/// </summary>
public int? PerOrderLimit { get; set; }
/// <summary>
/// 每人限购。
/// </summary>
public int? PerUserPurchaseLimit { get; set; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; set; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; set; } = "invalidate";
/// <summary>
/// 次卡描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 通知渠道in_app/sms
/// </summary>
public List<string> NotifyChannels { get; set; } = [];
}
/// <summary>
/// 次卡状态修改请求。
/// </summary>
public sealed class ChangePunchCardStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "disabled";
}
/// <summary>
/// 次卡删除请求。
/// </summary>
public sealed class DeletePunchCardRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
}
/// <summary>
/// 次卡使用记录查询请求。
/// </summary>
public sealed class PunchCardUsageRecordListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string? PunchCardId { get; set; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 次卡使用记录导出请求。
/// </summary>
public sealed class ExportPunchCardUsageRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string? PunchCardId { get; set; }
/// <summary>
/// 状态筛选normal/used_up/expired
/// </summary>
public string? Status { get; set; }
/// <summary>
/// 关键字(会员/商品)。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 写入次卡使用记录请求。
/// </summary>
public sealed class WritePunchCardUsageRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
/// <summary>
/// 次卡实例 ID可空
/// </summary>
public string? PunchCardInstanceId { get; set; }
/// <summary>
/// 次卡实例编号(可空)。
/// </summary>
public string? PunchCardInstanceNo { get; set; }
/// <summary>
/// 会员名称。
/// </summary>
public string? MemberName { get; set; }
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string? MemberPhoneMasked { get; set; }
/// <summary>
/// 兑换商品。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 使用时间。
/// </summary>
public DateTime? UsedAt { get; set; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; set; } = 1;
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; set; }
}
/// <summary>
/// 次卡模板统计。
/// </summary>
public sealed class PunchCardStatsResponse
{
/// <summary>
/// 在售次卡数量。
/// </summary>
public int OnSaleCount { get; set; }
/// <summary>
/// 累计售出数量。
/// </summary>
public int TotalSoldCount { get; set; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal TotalRevenueAmount { get; set; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveInUseCount { get; set; }
}
/// <summary>
/// 次卡列表项。
/// </summary>
public sealed class PunchCardListItemResponse
{
/// <summary>
/// 次卡 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期展示。
/// </summary>
public string ValiditySummary { get; set; } = string.Empty;
/// <summary>
/// 适用范围类型。
/// </summary>
public string ScopeType { get; set; } = "all";
/// <summary>
/// 使用模式。
/// </summary>
public string UsageMode { get; set; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 是否弱化展示。
/// </summary>
public bool IsDimmed { get; set; }
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; set; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; set; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 次卡列表结果。
/// </summary>
public sealed class PunchCardListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<PunchCardListItemResponse> Items { get; set; } = [];
/// <summary>
/// 当前页。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public PunchCardStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 次卡范围。
/// </summary>
public sealed class PunchCardScopeResponse
{
/// <summary>
/// 范围类型all/category/tag/product
/// </summary>
public string ScopeType { get; set; } = "all";
/// <summary>
/// 分类 ID。
/// </summary>
public List<string> CategoryIds { get; set; } = [];
/// <summary>
/// 标签 ID。
/// </summary>
public List<string> TagIds { get; set; } = [];
/// <summary>
/// 商品 ID。
/// </summary>
public List<string> ProductIds { get; set; } = [];
}
/// <summary>
/// 次卡详情。
/// </summary>
public sealed class PunchCardDetailResponse
{
/// <summary>
/// 次卡 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期类型days/range
/// </summary>
public string ValidityType { get; set; } = "days";
/// <summary>
/// 固定天数。
/// </summary>
public int? ValidityDays { get; set; }
/// <summary>
/// 固定开始日期yyyy-MM-dd
/// </summary>
public string? ValidFrom { get; set; }
/// <summary>
/// 固定结束日期yyyy-MM-dd
/// </summary>
public string? ValidTo { get; set; }
/// <summary>
/// 适用范围。
/// </summary>
public PunchCardScopeResponse Scope { get; set; } = new();
/// <summary>
/// 使用模式free/cap
/// </summary>
public string UsageMode { get; set; } = "free";
/// <summary>
/// 单次上限金额。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 每单限用。
/// </summary>
public int? PerOrderLimit { get; set; }
/// <summary>
/// 每人限购。
/// </summary>
public int? PerUserPurchaseLimit { get; set; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; set; }
/// <summary>
/// 过期策略invalidate/refund
/// </summary>
public string ExpireStrategy { get; set; } = "invalidate";
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 通知渠道。
/// </summary>
public List<string> NotifyChannels { get; set; } = [];
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 已售数量。
/// </summary>
public int SoldCount { get; set; }
/// <summary>
/// 使用中数量。
/// </summary>
public int ActiveCount { get; set; }
/// <summary>
/// 累计收入。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 更新时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UpdatedAt { get; set; } = string.Empty;
}
/// <summary>
/// 次卡下拉选项。
/// </summary>
public sealed class PunchCardTemplateOptionResponse
{
/// <summary>
/// 次卡 ID。
/// </summary>
public string TemplateId { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 使用记录统计。
/// </summary>
public sealed class PunchCardUsageStatsResponse
{
/// <summary>
/// 今日使用次数。
/// </summary>
public int TodayUsedCount { get; set; }
/// <summary>
/// 本月使用次数。
/// </summary>
public int MonthUsedCount { get; set; }
/// <summary>
/// 7 天内即将过期数量。
/// </summary>
public int ExpiringSoonCount { get; set; }
}
/// <summary>
/// 次卡使用记录项。
/// </summary>
public sealed class PunchCardUsageRecordResponse
{
/// <summary>
/// 使用记录 ID。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 使用单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 次卡模板 ID。
/// </summary>
public string PunchCardId { get; set; } = string.Empty;
/// <summary>
/// 次卡名称。
/// </summary>
public string PunchCardName { get; set; } = string.Empty;
/// <summary>
/// 次卡实例 ID。
/// </summary>
public string PunchCardInstanceId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberPhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 兑换商品。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 使用时间yyyy-MM-dd HH:mm:ss
/// </summary>
public string UsedAt { get; set; } = string.Empty;
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; set; }
/// <summary>
/// 剩余次数。
/// </summary>
public int RemainingTimesAfterUse { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 状态normal/almost_used_up/used_up/expired
/// </summary>
public string DisplayStatus { get; set; } = "normal";
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; set; }
}
/// <summary>
/// 使用记录分页结果。
/// </summary>
public sealed class PunchCardUsageRecordListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<PunchCardUsageRecordResponse> Items { get; set; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// 统计。
/// </summary>
public PunchCardUsageStatsResponse Stats { get; set; } = new();
/// <summary>
/// 次卡筛选项。
/// </summary>
public List<PunchCardTemplateOptionResponse> TemplateOptions { get; set; } = [];
}
/// <summary>
/// 使用记录导出回执。
/// </summary>
public sealed class PunchCardUsageRecordExportResponse
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Base64 文件内容。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 导出总条数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,402 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Commands;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Dto;
using TakeoutSaaS.Application.App.Coupons.PunchCard.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Marketing;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 营销中心次卡管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/marketing/punch-card")]
public sealed class MarketingPunchCardController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:marketing:punch-card:view";
private const string ManagePermission = "tenant:marketing:punch-card:manage";
/// <summary>
/// 获取次卡列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardListResultResponse>> List(
[FromQuery] PunchCardListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPunchCardTemplateListQuery
{
StoreId = storeId,
Keyword = request.Keyword,
Status = request.Status,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<PunchCardListResultResponse>.Ok(new PunchCardListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
Stats = MapTemplateStats(result.Stats)
});
}
/// <summary>
/// 获取次卡详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardDetailResponse>> Detail(
[FromQuery] PunchCardDetailRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPunchCardTemplateDetailQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
}, cancellationToken);
if (result is null)
{
return ApiResponse<PunchCardDetailResponse>.Error(ErrorCodes.NotFound, "次卡不存在");
}
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存次卡。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardDetailResponse>> Save(
[FromBody] SavePunchCardRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SavePunchCardTemplateCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
Name = request.Name,
CoverImageUrl = request.CoverImageUrl,
SalePrice = request.SalePrice,
OriginalPrice = request.OriginalPrice,
TotalTimes = request.TotalTimes,
ValidityType = request.ValidityType,
ValidityDays = request.ValidityDays,
ValidFrom = ParseDateOrNull(request.ValidFrom, nameof(request.ValidFrom)),
ValidTo = ParseDateOrNull(request.ValidTo, nameof(request.ValidTo)),
ScopeType = request.ScopeType,
ScopeCategoryIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeCategoryIds),
ScopeTagIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeTagIds),
ScopeProductIds = StoreApiHelpers.ParseSnowflakeList(request.ScopeProductIds),
UsageMode = request.UsageMode,
UsageCapAmount = request.UsageCapAmount,
DailyLimit = request.DailyLimit,
PerOrderLimit = request.PerOrderLimit,
PerUserPurchaseLimit = request.PerUserPurchaseLimit,
AllowTransfer = request.AllowTransfer,
ExpireStrategy = request.ExpireStrategy,
Description = request.Description,
NotifyChannels = request.NotifyChannels
}, cancellationToken);
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 修改次卡状态。
/// </summary>
[HttpPost("status")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardDetailResponse>> ChangeStatus(
[FromBody] ChangePunchCardStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ChangePunchCardTemplateStatusCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<PunchCardDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 删除次卡。
/// </summary>
[HttpPost("delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeletePunchCardRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new DeletePunchCardTemplateCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取次卡使用记录。
/// </summary>
[HttpGet("usage-record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardUsageRecordListResultResponse>> UsageRecordList(
[FromQuery] PunchCardUsageRecordListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetPunchCardUsageRecordListQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
Status = request.Status,
Keyword = request.Keyword,
Page = request.Page,
PageSize = request.PageSize
}, cancellationToken);
return ApiResponse<PunchCardUsageRecordListResultResponse>.Ok(new PunchCardUsageRecordListResultResponse
{
Items = result.Items.Select(MapUsageRecord).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
Stats = new PunchCardUsageStatsResponse
{
TodayUsedCount = result.Stats.TodayUsedCount,
MonthUsedCount = result.Stats.MonthUsedCount,
ExpiringSoonCount = result.Stats.ExpiringSoonCount
},
TemplateOptions = result.TemplateOptions.Select(item => new PunchCardTemplateOptionResponse
{
TemplateId = item.TemplateId.ToString(),
Name = item.Name
}).ToList()
});
}
/// <summary>
/// 导出次卡使用记录。
/// </summary>
[HttpGet("usage-record/export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardUsageRecordExportResponse>> ExportUsageRecord(
[FromQuery] ExportPunchCardUsageRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ExportPunchCardUsageRecordCsvQuery
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardId),
Status = request.Status,
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<PunchCardUsageRecordExportResponse>.Ok(new PunchCardUsageRecordExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 写入次卡使用记录。
/// </summary>
[HttpPost("usage-record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<PunchCardUsageRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PunchCardUsageRecordResponse>> WriteUsageRecord(
[FromBody] WritePunchCardUsageRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new WritePunchCardUsageRecordCommand
{
StoreId = storeId,
TemplateId = StoreApiHelpers.ParseRequiredSnowflake(request.PunchCardId, nameof(request.PunchCardId)),
InstanceId = StoreApiHelpers.ParseSnowflakeOrNull(request.PunchCardInstanceId),
InstanceNo = request.PunchCardInstanceNo,
MemberName = request.MemberName,
MemberPhoneMasked = request.MemberPhoneMasked,
ProductName = request.ProductName,
UsedAt = request.UsedAt,
UsedTimes = request.UsedTimes,
ExtraPayAmount = request.ExtraPayAmount
}, cancellationToken);
return ApiResponse<PunchCardUsageRecordResponse>.Ok(MapUsageRecord(result));
}
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
}
private static DateTime? ParseDateOrNull(string? value, string fieldName)
{
return string.IsNullOrWhiteSpace(value)
? null
: StoreApiHelpers.ParseDateOnly(value, fieldName);
}
private static PunchCardListItemResponse MapListItem(PunchCardListItemDto source)
{
return new PunchCardListItemResponse
{
Id = source.Id.ToString(),
Name = source.Name,
CoverImageUrl = source.CoverImageUrl,
SalePrice = source.SalePrice,
OriginalPrice = source.OriginalPrice,
TotalTimes = source.TotalTimes,
ValiditySummary = source.ValiditySummary,
ScopeType = source.ScopeType,
UsageMode = source.UsageMode,
UsageCapAmount = source.UsageCapAmount,
DailyLimit = source.DailyLimit,
Status = source.Status,
IsDimmed = source.IsDimmed,
SoldCount = source.SoldCount,
ActiveCount = source.ActiveCount,
RevenueAmount = source.RevenueAmount,
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static PunchCardStatsResponse MapTemplateStats(PunchCardStatsDto source)
{
return new PunchCardStatsResponse
{
OnSaleCount = source.OnSaleCount,
TotalSoldCount = source.TotalSoldCount,
TotalRevenueAmount = source.TotalRevenueAmount,
ActiveInUseCount = source.ActiveInUseCount
};
}
private static PunchCardDetailResponse MapDetail(PunchCardDetailDto source)
{
return new PunchCardDetailResponse
{
Id = source.Id.ToString(),
StoreId = source.StoreId.ToString(),
Name = source.Name,
CoverImageUrl = source.CoverImageUrl,
SalePrice = source.SalePrice,
OriginalPrice = source.OriginalPrice,
TotalTimes = source.TotalTimes,
ValidityType = source.ValidityType,
ValidityDays = source.ValidityDays,
ValidFrom = ToDateOnly(source.ValidFrom),
ValidTo = ToDateOnly(source.ValidTo),
Scope = new PunchCardScopeResponse
{
ScopeType = source.Scope.ScopeType,
CategoryIds = source.Scope.CategoryIds.Select(item => item.ToString()).ToList(),
TagIds = source.Scope.TagIds.Select(item => item.ToString()).ToList(),
ProductIds = source.Scope.ProductIds.Select(item => item.ToString()).ToList()
},
UsageMode = source.UsageMode,
UsageCapAmount = source.UsageCapAmount,
DailyLimit = source.DailyLimit,
PerOrderLimit = source.PerOrderLimit,
PerUserPurchaseLimit = source.PerUserPurchaseLimit,
AllowTransfer = source.AllowTransfer,
ExpireStrategy = source.ExpireStrategy,
Description = source.Description,
NotifyChannels = source.NotifyChannels.ToList(),
Status = source.Status,
SoldCount = source.SoldCount,
ActiveCount = source.ActiveCount,
RevenueAmount = source.RevenueAmount,
UpdatedAt = ToDateTime(source.UpdatedAt)
};
}
private static PunchCardUsageRecordResponse MapUsageRecord(PunchCardUsageRecordDto source)
{
return new PunchCardUsageRecordResponse
{
Id = source.Id.ToString(),
RecordNo = source.RecordNo,
PunchCardId = source.PunchCardTemplateId.ToString(),
PunchCardName = source.PunchCardName,
PunchCardInstanceId = source.PunchCardInstanceId.ToString(),
MemberName = source.MemberName,
MemberPhoneMasked = source.MemberPhoneMasked,
ProductName = source.ProductName,
UsedAt = ToDateTime(source.UsedAt),
UsedTimes = source.UsedTimes,
RemainingTimesAfterUse = source.RemainingTimesAfterUse,
TotalTimes = source.TotalTimes,
DisplayStatus = source.DisplayStatus,
ExtraPayAmount = source.ExtraPayAmount
};
}
private static string? ToDateOnly(DateTime? value)
{
return value.HasValue
? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: null;
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

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;
}

View File

@@ -0,0 +1,65 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 次卡实例(顾客购买后生成)。
/// </summary>
public sealed class PunchCardInstance : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long PunchCardTemplateId { get; set; }
/// <summary>
/// 实例编号(业务唯一)。
/// </summary>
public string InstanceNo { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; set; } = string.Empty;
/// <summary>
/// 会员手机号(脱敏)。
/// </summary>
public string MemberPhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 购买时间UTC
/// </summary>
public DateTime PurchasedAt { get; set; }
/// <summary>
/// 过期时间UTC可空
/// </summary>
public DateTime? ExpiresAt { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 剩余次数。
/// </summary>
public int RemainingTimes { get; set; }
/// <summary>
/// 实付金额。
/// </summary>
public decimal PaidAmount { get; set; }
/// <summary>
/// 实例状态。
/// </summary>
public PunchCardInstanceStatus Status { get; set; } = PunchCardInstanceStatus.Active;
}

View File

@@ -0,0 +1,130 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 次卡模板配置。
/// </summary>
public sealed class PunchCardTemplate : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 次卡名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 封面图片地址。
/// </summary>
public string? CoverImageUrl { get; set; }
/// <summary>
/// 售价。
/// </summary>
public decimal SalePrice { get; set; }
/// <summary>
/// 原价。
/// </summary>
public decimal? OriginalPrice { get; set; }
/// <summary>
/// 总次数。
/// </summary>
public int TotalTimes { get; set; }
/// <summary>
/// 有效期类型。
/// </summary>
public PunchCardValidityType ValidityType { get; set; } = PunchCardValidityType.Days;
/// <summary>
/// 固定天数ValidityType=Days 时有效)。
/// </summary>
public int? ValidityDays { get; set; }
/// <summary>
/// 固定开始日期UTCValidityType=DateRange 时有效)。
/// </summary>
public DateTime? ValidFrom { get; set; }
/// <summary>
/// 固定结束日期UTCValidityType=DateRange 时有效)。
/// </summary>
public DateTime? ValidTo { get; set; }
/// <summary>
/// 适用范围类型。
/// </summary>
public PunchCardScopeType ScopeType { get; set; } = PunchCardScopeType.All;
/// <summary>
/// 指定分类 ID JSON。
/// </summary>
public string ScopeCategoryIdsJson { get; set; } = "[]";
/// <summary>
/// 指定标签 ID JSON。
/// </summary>
public string ScopeTagIdsJson { get; set; } = "[]";
/// <summary>
/// 指定商品 ID JSON。
/// </summary>
public string ScopeProductIdsJson { get; set; } = "[]";
/// <summary>
/// 使用模式。
/// </summary>
public PunchCardUsageMode UsageMode { get; set; } = PunchCardUsageMode.Free;
/// <summary>
/// 金额上限UsageMode=Cap 时有效)。
/// </summary>
public decimal? UsageCapAmount { get; set; }
/// <summary>
/// 每日限用次数。
/// </summary>
public int? DailyLimit { get; set; }
/// <summary>
/// 每单限用次数。
/// </summary>
public int? PerOrderLimit { get; set; }
/// <summary>
/// 每人限购张数。
/// </summary>
public int? PerUserPurchaseLimit { get; set; }
/// <summary>
/// 是否允许转赠。
/// </summary>
public bool AllowTransfer { get; set; }
/// <summary>
/// 过期策略。
/// </summary>
public PunchCardExpireStrategy ExpireStrategy { get; set; } = PunchCardExpireStrategy.Invalidate;
/// <summary>
/// 次卡描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 购买通知渠道 JSON。
/// </summary>
public string NotifyChannelsJson { get; set; } = "[]";
/// <summary>
/// 次卡状态。
/// </summary>
public PunchCardStatus Status { get; set; } = PunchCardStatus.Enabled;
}

View File

@@ -0,0 +1,60 @@
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Coupons.Entities;
/// <summary>
/// 次卡使用记录。
/// </summary>
public sealed class PunchCardUsageRecord : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 次卡模板 ID。
/// </summary>
public long PunchCardTemplateId { get; set; }
/// <summary>
/// 次卡实例 ID。
/// </summary>
public long PunchCardInstanceId { get; set; }
/// <summary>
/// 使用单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <summary>
/// 兑换商品名称。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 使用时间UTC
/// </summary>
public DateTime UsedAt { get; set; }
/// <summary>
/// 本次使用次数。
/// </summary>
public int UsedTimes { get; set; } = 1;
/// <summary>
/// 使用后剩余次数。
/// </summary>
public int RemainingTimesAfterUse { get; set; }
/// <summary>
/// 本次记录状态。
/// </summary>
public PunchCardUsageRecordStatus StatusAfterUse { get; set; } = PunchCardUsageRecordStatus.Normal;
/// <summary>
/// 超额补差金额。
/// </summary>
public decimal? ExtraPayAmount { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡过期策略。
/// </summary>
public enum PunchCardExpireStrategy
{
/// <summary>
/// 剩余次数作废。
/// </summary>
Invalidate = 0,
/// <summary>
/// 可申请退款。
/// </summary>
Refund = 1
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡实例状态。
/// </summary>
public enum PunchCardInstanceStatus
{
/// <summary>
/// 使用中。
/// </summary>
Active = 0,
/// <summary>
/// 已用完。
/// </summary>
UsedUp = 1,
/// <summary>
/// 已过期。
/// </summary>
Expired = 2,
/// <summary>
/// 已退款。
/// </summary>
Refunded = 3
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡适用范围类型。
/// </summary>
public enum PunchCardScopeType
{
/// <summary>
/// 全部商品。
/// </summary>
All = 0,
/// <summary>
/// 指定分类。
/// </summary>
Category = 1,
/// <summary>
/// 指定标签。
/// </summary>
Tag = 2,
/// <summary>
/// 指定商品。
/// </summary>
Product = 3
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡状态。
/// </summary>
public enum PunchCardStatus
{
/// <summary>
/// 已下架。
/// </summary>
Disabled = 0,
/// <summary>
/// 已上架。
/// </summary>
Enabled = 1
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡使用模式。
/// </summary>
public enum PunchCardUsageMode
{
/// <summary>
/// 完全免费。
/// </summary>
Free = 0,
/// <summary>
/// 金额上限。
/// </summary>
Cap = 1
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡使用记录状态。
/// </summary>
public enum PunchCardUsageRecordStatus
{
/// <summary>
/// 正常使用。
/// </summary>
Normal = 0,
/// <summary>
/// 即将用完。
/// </summary>
AlmostUsedUp = 1,
/// <summary>
/// 已用完。
/// </summary>
UsedUp = 2,
/// <summary>
/// 已过期。
/// </summary>
Expired = 3
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Coupons.Enums;
/// <summary>
/// 次卡有效期类型。
/// </summary>
public enum PunchCardValidityType
{
/// <summary>
/// 购买后固定天数。
/// </summary>
Days = 0,
/// <summary>
/// 固定日期区间。
/// </summary>
DateRange = 1
}

View File

@@ -0,0 +1,247 @@
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
namespace TakeoutSaaS.Domain.Coupons.Repositories;
/// <summary>
/// 次卡管理仓储契约。
/// </summary>
public interface IPunchCardRepository
{
/// <summary>
/// 查询次卡模板分页。
/// </summary>
Task<(IReadOnlyList<PunchCardTemplate> Items, int TotalCount)> SearchTemplatesAsync(
long tenantId,
long storeId,
string? keyword,
PunchCardStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 读取指定次卡模板。
/// </summary>
Task<PunchCardTemplate?> FindTemplateByIdAsync(
long tenantId,
long storeId,
long templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识批量读取次卡模板。
/// </summary>
Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 按模板批量统计售卖与在用信息。
/// </summary>
Task<Dictionary<long, PunchCardTemplateAggregateSnapshot>> GetTemplateAggregateByTemplateIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询页面统计。
/// </summary>
Task<PunchCardTemplateStatsSnapshot> GetTemplateStatsAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增次卡模板。
/// </summary>
Task AddTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新次卡模板。
/// </summary>
Task UpdateTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default);
/// <summary>
/// 删除次卡模板。
/// </summary>
Task DeleteTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询次卡实例。
/// </summary>
Task<PunchCardInstance?> FindInstanceByNoAsync(
long tenantId,
long storeId,
string instanceNo,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询次卡实例。
/// </summary>
Task<PunchCardInstance?> FindInstanceByIdAsync(
long tenantId,
long storeId,
long instanceId,
CancellationToken cancellationToken = default);
/// <summary>
/// 按标识批量读取次卡实例。
/// </summary>
Task<IReadOnlyList<PunchCardInstance>> GetInstancesByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> instanceIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增次卡实例。
/// </summary>
Task AddInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default);
/// <summary>
/// 更新次卡实例。
/// </summary>
Task UpdateInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default);
/// <summary>
/// 查询使用记录分页。
/// </summary>
Task<(IReadOnlyList<PunchCardUsageRecord> Items, int TotalCount)> SearchUsageRecordsAsync(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询导出使用记录(不分页)。
/// </summary>
Task<IReadOnlyList<PunchCardUsageRecord>> ListUsageRecordsForExportAsync(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询使用记录统计。
/// </summary>
Task<PunchCardUsageStatsSnapshot> GetUsageStatsAsync(
long tenantId,
long storeId,
long? templateId,
DateTime nowUtc,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增使用记录。
/// </summary>
Task AddUsageRecordAsync(PunchCardUsageRecord entity, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// 次卡模板聚合快照。
/// </summary>
public sealed record PunchCardTemplateAggregateSnapshot
{
/// <summary>
/// 次卡模板标识。
/// </summary>
public required long TemplateId { 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 sealed record PunchCardTemplateStatsSnapshot
{
/// <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; }
}
/// <summary>
/// 使用记录筛选状态。
/// </summary>
public enum PunchCardUsageRecordFilterStatus
{
/// <summary>
/// 正常(包含即将用完)。
/// </summary>
Normal = 0,
/// <summary>
/// 已用完。
/// </summary>
UsedUp = 1,
/// <summary>
/// 已过期。
/// </summary>
Expired = 2
}
/// <summary>
/// 使用记录统计快照。
/// </summary>
public sealed record PunchCardUsageStatsSnapshot
{
/// <summary>
/// 今日使用次数。
/// </summary>
public int TodayUsedCount { get; init; }
/// <summary>
/// 本月使用次数。
/// </summary>
public int MonthUsedCount { get; init; }
/// <summary>
/// 7 天内即将过期数量。
/// </summary>
public int ExpiringSoonCount { get; init; }
}

View File

@@ -48,6 +48,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ICouponRepository, EfCouponRepository>();
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -370,6 +370,18 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<NewCustomerGrowthRecord> NewCustomerGrowthRecords => Set<NewCustomerGrowthRecord>();
/// <summary>
/// 次卡模板。
/// </summary>
public DbSet<PunchCardTemplate> PunchCardTemplates => Set<PunchCardTemplate>();
/// <summary>
/// 次卡实例。
/// </summary>
public DbSet<PunchCardInstance> PunchCardInstances => Set<PunchCardInstance>();
/// <summary>
/// 次卡使用记录。
/// </summary>
public DbSet<PunchCardUsageRecord> PunchCardUsageRecords => Set<PunchCardUsageRecord>();
/// <summary>
/// 会员档案。
/// </summary>
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();
@@ -540,6 +552,9 @@ public sealed class TakeoutAppDbContext(
ConfigureNewCustomerCouponRule(modelBuilder.Entity<NewCustomerCouponRule>());
ConfigureNewCustomerInviteRecord(modelBuilder.Entity<NewCustomerInviteRecord>());
ConfigureNewCustomerGrowthRecord(modelBuilder.Entity<NewCustomerGrowthRecord>());
ConfigurePunchCardTemplate(modelBuilder.Entity<PunchCardTemplate>());
ConfigurePunchCardInstance(modelBuilder.Entity<PunchCardInstance>());
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
@@ -1692,6 +1707,77 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RegisteredAt });
}
private static void ConfigurePunchCardTemplate(EntityTypeBuilder<PunchCardTemplate> builder)
{
builder.ToTable("punch_card_templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.CoverImageUrl).HasMaxLength(512);
builder.Property(x => x.SalePrice).HasPrecision(18, 2);
builder.Property(x => x.OriginalPrice).HasPrecision(18, 2);
builder.Property(x => x.TotalTimes).IsRequired();
builder.Property(x => x.ValidityType).HasConversion<int>();
builder.Property(x => x.ValidityDays);
builder.Property(x => x.ValidFrom);
builder.Property(x => x.ValidTo);
builder.Property(x => x.ScopeType).HasConversion<int>();
builder.Property(x => x.ScopeCategoryIdsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.ScopeTagIdsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.ScopeProductIdsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.UsageMode).HasConversion<int>();
builder.Property(x => x.UsageCapAmount).HasPrecision(18, 2);
builder.Property(x => x.DailyLimit);
builder.Property(x => x.PerOrderLimit);
builder.Property(x => x.PerUserPurchaseLimit);
builder.Property(x => x.AllowTransfer).IsRequired();
builder.Property(x => x.ExpireStrategy).HasConversion<int>();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.NotifyChannelsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status });
}
private static void ConfigurePunchCardInstance(EntityTypeBuilder<PunchCardInstance> builder)
{
builder.ToTable("punch_card_instances");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.PunchCardTemplateId).IsRequired();
builder.Property(x => x.InstanceNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired();
builder.Property(x => x.MemberPhoneMasked).HasMaxLength(32).IsRequired();
builder.Property(x => x.PurchasedAt).IsRequired();
builder.Property(x => x.ExpiresAt);
builder.Property(x => x.TotalTimes).IsRequired();
builder.Property(x => x.RemainingTimes).IsRequired();
builder.Property(x => x.PaidAmount).HasPrecision(18, 2);
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.InstanceNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardTemplateId });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status, x.ExpiresAt });
}
private static void ConfigurePunchCardUsageRecord(EntityTypeBuilder<PunchCardUsageRecord> builder)
{
builder.ToTable("punch_card_usage_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.PunchCardTemplateId).IsRequired();
builder.Property(x => x.PunchCardInstanceId).IsRequired();
builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired();
builder.Property(x => x.UsedAt).IsRequired();
builder.Property(x => x.UsedTimes).IsRequired();
builder.Property(x => x.RemainingTimesAfterUse).IsRequired();
builder.Property(x => x.StatusAfterUse).HasConversion<int>();
builder.Property(x => x.ExtraPayAmount).HasPrecision(18, 2);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardTemplateId, x.UsedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardInstanceId, x.UsedAt });
}
private static void ConfigureMemberProfile(EntityTypeBuilder<MemberProfile> builder)
{
builder.ToTable("member_profiles");

View File

@@ -0,0 +1,469 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 次卡仓储 EF Core 实现。
/// </summary>
public sealed class EfPunchCardRepository(TakeoutAppDbContext context) : IPunchCardRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<PunchCardTemplate> Items, int TotalCount)> SearchTemplatesAsync(
long tenantId,
long storeId,
string? keyword,
PunchCardStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var query = context.PunchCardTemplates
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var keywordLike = $"%{normalizedKeyword}%";
query = query.Where(item => EF.Functions.ILike(item.Name, keywordLike));
}
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.UpdatedAt ?? item.CreatedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public Task<PunchCardTemplate?> FindTemplateByIdAsync(
long tenantId,
long storeId,
long templateId,
CancellationToken cancellationToken = default)
{
return context.PunchCardTemplates
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == templateId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default)
{
if (templateIds.Count == 0)
{
return [];
}
return await context.PunchCardTemplates
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
templateIds.Contains(item.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Dictionary<long, PunchCardTemplateAggregateSnapshot>> GetTemplateAggregateByTemplateIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default)
{
if (templateIds.Count == 0)
{
return [];
}
var nowUtc = DateTime.UtcNow;
var aggregates = await context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
templateIds.Contains(item.PunchCardTemplateId))
.GroupBy(item => item.PunchCardTemplateId)
.Select(group => new PunchCardTemplateAggregateSnapshot
{
TemplateId = group.Key,
SoldCount = group.Count(),
ActiveCount = group.Count(item =>
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= nowUtc)),
RevenueAmount = group.Sum(item => item.PaidAmount)
})
.ToListAsync(cancellationToken);
return aggregates.ToDictionary(item => item.TemplateId, item => item);
}
/// <inheritdoc />
public async Task<PunchCardTemplateStatsSnapshot> GetTemplateStatsAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
var onSaleCount = await context.PunchCardTemplates
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Status == PunchCardStatus.Enabled)
.CountAsync(cancellationToken);
var summary = await context.PunchCardInstances
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.GroupBy(_ => 1)
.Select(group => new
{
TotalSoldCount = group.Count(),
TotalRevenueAmount = group.Sum(item => item.PaidAmount)
})
.FirstOrDefaultAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
var activeInUseCount = await context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= nowUtc))
.CountAsync(cancellationToken);
return new PunchCardTemplateStatsSnapshot
{
OnSaleCount = onSaleCount,
TotalSoldCount = summary?.TotalSoldCount ?? 0,
TotalRevenueAmount = summary?.TotalRevenueAmount ?? 0m,
ActiveInUseCount = activeInUseCount
};
}
/// <inheritdoc />
public Task AddTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
{
return context.PunchCardTemplates.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
{
context.PunchCardTemplates.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
{
context.PunchCardTemplates.Remove(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PunchCardInstance?> FindInstanceByNoAsync(
long tenantId,
long storeId,
string instanceNo,
CancellationToken cancellationToken = default)
{
return context.PunchCardInstances
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.InstanceNo == instanceNo)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<PunchCardInstance?> FindInstanceByIdAsync(
long tenantId,
long storeId,
long instanceId,
CancellationToken cancellationToken = default)
{
return context.PunchCardInstances
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == instanceId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardInstance>> GetInstancesByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> instanceIds,
CancellationToken cancellationToken = default)
{
if (instanceIds.Count == 0)
{
return [];
}
return await context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
instanceIds.Contains(item.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default)
{
return context.PunchCardInstances.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default)
{
context.PunchCardInstances.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<(IReadOnlyList<PunchCardUsageRecord> Items, int TotalCount)> SearchUsageRecordsAsync(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
var query = BuildUsageRecordQuery(
tenantId,
storeId,
templateId,
keyword,
status,
DateTime.UtcNow);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.UsedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardUsageRecord>> ListUsageRecordsForExportAsync(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
CancellationToken cancellationToken = default)
{
return await BuildUsageRecordQuery(
tenantId,
storeId,
templateId,
keyword,
status,
DateTime.UtcNow)
.OrderByDescending(item => item.UsedAt)
.ThenByDescending(item => item.Id)
.Take(20_000)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<PunchCardUsageStatsSnapshot> GetUsageStatsAsync(
long tenantId,
long storeId,
long? templateId,
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(nowUtc);
var todayStart = utcNow.Date;
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var soonEnd = todayStart.AddDays(7);
var usageQuery = context.PunchCardUsageRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (templateId.HasValue)
{
usageQuery = usageQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var todayUsedCount = await usageQuery
.Where(item => item.UsedAt >= todayStart && item.UsedAt < todayStart.AddDays(1))
.CountAsync(cancellationToken);
var monthUsedCount = await usageQuery
.Where(item => item.UsedAt >= monthStart && item.UsedAt < monthStart.AddMonths(1))
.CountAsync(cancellationToken);
var instanceQuery = context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
item.ExpiresAt.HasValue &&
item.ExpiresAt.Value >= todayStart &&
item.ExpiresAt.Value < soonEnd);
if (templateId.HasValue)
{
instanceQuery = instanceQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var expiringSoonCount = await instanceQuery.CountAsync(cancellationToken);
return new PunchCardUsageStatsSnapshot
{
TodayUsedCount = todayUsedCount,
MonthUsedCount = monthUsedCount,
ExpiringSoonCount = expiringSoonCount
};
}
/// <inheritdoc />
public Task AddUsageRecordAsync(PunchCardUsageRecord entity, CancellationToken cancellationToken = default)
{
return context.PunchCardUsageRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private IQueryable<PunchCardUsageRecord> BuildUsageRecordQuery(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
DateTime nowUtc)
{
var query = context.PunchCardUsageRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (templateId.HasValue)
{
query = query.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var instanceQuery = context.PunchCardInstances
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (templateId.HasValue)
{
instanceQuery = instanceQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var utcNow = NormalizeUtc(nowUtc);
if (status.HasValue)
{
instanceQuery = status.Value switch
{
PunchCardUsageRecordFilterStatus.Normal => instanceQuery.Where(item =>
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= utcNow)),
PunchCardUsageRecordFilterStatus.UsedUp => instanceQuery.Where(item =>
item.Status == PunchCardInstanceStatus.UsedUp ||
item.RemainingTimes <= 0),
PunchCardUsageRecordFilterStatus.Expired => instanceQuery.Where(item =>
(item.Status == PunchCardInstanceStatus.Expired ||
(item.ExpiresAt.HasValue && item.ExpiresAt.Value < utcNow)) &&
item.Status != PunchCardInstanceStatus.Refunded),
_ => instanceQuery
};
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = $"%{keyword.Trim()}%";
var matchedInstanceIds = context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
(EF.Functions.ILike(item.MemberName, normalizedKeyword) ||
EF.Functions.ILike(item.MemberPhoneMasked, normalizedKeyword) ||
EF.Functions.ILike(item.InstanceNo, normalizedKeyword)))
.Select(item => item.Id);
query = query.Where(item =>
EF.Functions.ILike(item.RecordNo, normalizedKeyword) ||
EF.Functions.ILike(item.ProductName, normalizedKeyword) ||
matchedInstanceIds.Contains(item.PunchCardInstanceId));
}
if (status.HasValue)
{
var filteredInstanceIds = instanceQuery.Select(item => item.Id);
query = query.Where(item => filteredInstanceIds.Contains(item.PunchCardInstanceId));
}
return query;
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
}

View File

@@ -0,0 +1,177 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPunchCardModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "punch_card_instances",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
PunchCardTemplateId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡模板 ID。"),
InstanceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "实例编号(业务唯一)。"),
MemberName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称。"),
MemberPhoneMasked = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号(脱敏)。"),
PurchasedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "购买时间UTC。"),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "过期时间UTC可空。"),
TotalTimes = table.Column<int>(type: "integer", nullable: false, comment: "总次数。"),
RemainingTimes = table.Column<int>(type: "integer", nullable: false, comment: "剩余次数。"),
PaidAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "实付金额。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "实例状态。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_punch_card_instances", x => x.Id);
},
comment: "次卡实例(顾客购买后生成)。");
migrationBuilder.CreateTable(
name: "punch_card_templates",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "次卡名称。"),
CoverImageUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "封面图片地址。"),
SalePrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "售价。"),
OriginalPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "原价。"),
TotalTimes = table.Column<int>(type: "integer", nullable: false, comment: "总次数。"),
ValidityType = table.Column<int>(type: "integer", nullable: false, comment: "有效期类型。"),
ValidityDays = table.Column<int>(type: "integer", nullable: true, comment: "固定天数ValidityType=Days 时有效)。"),
ValidFrom = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定开始日期UTCValidityType=DateRange 时有效)。"),
ValidTo = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定结束日期UTCValidityType=DateRange 时有效)。"),
ScopeType = table.Column<int>(type: "integer", nullable: false, comment: "适用范围类型。"),
ScopeCategoryIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定分类 ID JSON。"),
ScopeTagIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定标签 ID JSON。"),
ScopeProductIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定商品 ID JSON。"),
UsageMode = table.Column<int>(type: "integer", nullable: false, comment: "使用模式。"),
UsageCapAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "金额上限UsageMode=Cap 时有效)。"),
DailyLimit = table.Column<int>(type: "integer", nullable: true, comment: "每日限用次数。"),
PerOrderLimit = table.Column<int>(type: "integer", nullable: true, comment: "每单限用次数。"),
PerUserPurchaseLimit = table.Column<int>(type: "integer", nullable: true, comment: "每人限购张数。"),
AllowTransfer = table.Column<bool>(type: "boolean", nullable: false, comment: "是否允许转赠。"),
ExpireStrategy = table.Column<int>(type: "integer", nullable: false, comment: "过期策略。"),
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "次卡描述。"),
NotifyChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "购买通知渠道 JSON。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "次卡状态。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_punch_card_templates", x => x.Id);
},
comment: "次卡模板配置。");
migrationBuilder.CreateTable(
name: "punch_card_usage_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
PunchCardTemplateId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡模板 ID。"),
PunchCardInstanceId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡实例 ID。"),
RecordNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "使用单号。"),
ProductName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "兑换商品名称。"),
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "使用时间UTC。"),
UsedTimes = table.Column<int>(type: "integer", nullable: false, comment: "本次使用次数。"),
RemainingTimesAfterUse = table.Column<int>(type: "integer", nullable: false, comment: "使用后剩余次数。"),
StatusAfterUse = table.Column<int>(type: "integer", nullable: false, comment: "本次记录状态。"),
ExtraPayAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "超额补差金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_punch_card_usage_records", x => x.Id);
},
comment: "次卡使用记录。");
migrationBuilder.CreateIndex(
name: "IX_punch_card_instances_TenantId_StoreId_InstanceNo",
table: "punch_card_instances",
columns: new[] { "TenantId", "StoreId", "InstanceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_punch_card_instances_TenantId_StoreId_PunchCardTemplateId",
table: "punch_card_instances",
columns: new[] { "TenantId", "StoreId", "PunchCardTemplateId" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_instances_TenantId_StoreId_Status_ExpiresAt",
table: "punch_card_instances",
columns: new[] { "TenantId", "StoreId", "Status", "ExpiresAt" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_templates_TenantId_StoreId_Name",
table: "punch_card_templates",
columns: new[] { "TenantId", "StoreId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_punch_card_templates_TenantId_StoreId_Status",
table: "punch_card_templates",
columns: new[] { "TenantId", "StoreId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_usage_records_TenantId_StoreId_PunchCardInstance~",
table: "punch_card_usage_records",
columns: new[] { "TenantId", "StoreId", "PunchCardInstanceId", "UsedAt" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_usage_records_TenantId_StoreId_PunchCardTemplate~",
table: "punch_card_usage_records",
columns: new[] { "TenantId", "StoreId", "PunchCardTemplateId", "UsedAt" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_usage_records_TenantId_StoreId_RecordNo",
table: "punch_card_usage_records",
columns: new[] { "TenantId", "StoreId", "RecordNo" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "punch_card_instances");
migrationBuilder.DropTable(
name: "punch_card_templates");
migrationBuilder.DropTable(
name: "punch_card_usage_records");
}
}
}

View File

@@ -1012,6 +1012,363 @@ namespace TakeoutSaaS.Infrastructure.Migrations
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasComment("过期时间UTC可空。");
b.Property<string>("InstanceNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("实例编号(业务唯一)。");
b.Property<string>("MemberName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("会员名称。");
b.Property<string>("MemberPhoneMasked")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("会员手机号(脱敏)。");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("实付金额。");
b.Property<long>("PunchCardTemplateId")
.HasColumnType("bigint")
.HasComment("次卡模板 ID。");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("timestamp with time zone")
.HasComment("购买时间UTC。");
b.Property<int>("RemainingTimes")
.HasColumnType("integer")
.HasComment("剩余次数。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("实例状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<int>("TotalTimes")
.HasColumnType("integer")
.HasComment("总次数。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "InstanceNo")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "PunchCardTemplateId");
b.HasIndex("TenantId", "StoreId", "Status", "ExpiresAt");
b.ToTable("punch_card_instances", null, t =>
{
t.HasComment("次卡实例(顾客购买后生成)。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardTemplate", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("AllowTransfer")
.HasColumnType("boolean")
.HasComment("是否允许转赠。");
b.Property<string>("CoverImageUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("封面图片地址。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasComment("每日限用次数。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("次卡描述。");
b.Property<int>("ExpireStrategy")
.HasColumnType("integer")
.HasComment("过期策略。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("次卡名称。");
b.Property<string>("NotifyChannelsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("购买通知渠道 JSON。");
b.Property<decimal?>("OriginalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("原价。");
b.Property<int?>("PerOrderLimit")
.HasColumnType("integer")
.HasComment("每单限用次数。");
b.Property<int?>("PerUserPurchaseLimit")
.HasColumnType("integer")
.HasComment("每人限购张数。");
b.Property<decimal>("SalePrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("售价。");
b.Property<string>("ScopeCategoryIdsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("指定分类 ID JSON。");
b.Property<string>("ScopeProductIdsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("指定商品 ID JSON。");
b.Property<string>("ScopeTagIdsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("指定标签 ID JSON。");
b.Property<int>("ScopeType")
.HasColumnType("integer")
.HasComment("适用范围类型。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("次卡状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<int>("TotalTimes")
.HasColumnType("integer")
.HasComment("总次数。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<decimal?>("UsageCapAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("金额上限UsageMode=Cap 时有效)。");
b.Property<int>("UsageMode")
.HasColumnType("integer")
.HasComment("使用模式。");
b.Property<DateTime?>("ValidFrom")
.HasColumnType("timestamp with time zone")
.HasComment("固定开始日期UTCValidityType=DateRange 时有效)。");
b.Property<DateTime?>("ValidTo")
.HasColumnType("timestamp with time zone")
.HasComment("固定结束日期UTCValidityType=DateRange 时有效)。");
b.Property<int?>("ValidityDays")
.HasColumnType("integer")
.HasComment("固定天数ValidityType=Days 时有效)。");
b.Property<int>("ValidityType")
.HasColumnType("integer")
.HasComment("有效期类型。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "Name")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "Status");
b.ToTable("punch_card_templates", null, t =>
{
t.HasComment("次卡模板配置。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardUsageRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal?>("ExtraPayAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("超额补差金额。");
b.Property<string>("ProductName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("兑换商品名称。");
b.Property<long>("PunchCardInstanceId")
.HasColumnType("bigint")
.HasComment("次卡实例 ID。");
b.Property<long>("PunchCardTemplateId")
.HasColumnType("bigint")
.HasComment("次卡模板 ID。");
b.Property<string>("RecordNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("使用单号。");
b.Property<int>("RemainingTimesAfterUse")
.HasColumnType("integer")
.HasComment("使用后剩余次数。");
b.Property<int>("StatusAfterUse")
.HasColumnType("integer")
.HasComment("本次记录状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime>("UsedAt")
.HasColumnType("timestamp with time zone")
.HasComment("使用时间UTC。");
b.Property<int>("UsedTimes")
.HasColumnType("integer")
.HasComment("本次使用次数。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "RecordNo")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "PunchCardInstanceId", "UsedAt");
b.HasIndex("TenantId", "StoreId", "PunchCardTemplateId", "UsedAt");
b.ToTable("punch_card_usage_records", null, t =>
{
t.HasComment("次卡使用记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b =>
{
b.Property<long>("Id")