diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberPointsMallContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberPointsMallContracts.cs new file mode 100644 index 0000000..8d8f0f3 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Member/MemberPointsMallContracts.cs @@ -0,0 +1,808 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Member; + +/// +/// 积分商城规则详情查询请求。 +/// +public sealed class PointMallRuleDetailRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; +} + +/// +/// 保存积分商城规则请求。 +/// +public sealed class SavePointMallRuleRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 是否启用消费获取。 + /// + public bool IsConsumeRewardEnabled { get; set; } + + /// + /// 每消费多少元触发一次积分计算。 + /// + public int ConsumeAmountPerStep { get; set; } + + /// + /// 每步获得积分。 + /// + public int ConsumeRewardPointsPerStep { get; set; } + + /// + /// 是否启用评价奖励。 + /// + public bool IsReviewRewardEnabled { get; set; } + + /// + /// 评价奖励积分。 + /// + public int ReviewRewardPoints { get; set; } + + /// + /// 是否启用注册奖励。 + /// + public bool IsRegisterRewardEnabled { get; set; } + + /// + /// 注册奖励积分。 + /// + public int RegisterRewardPoints { get; set; } + + /// + /// 是否启用签到奖励。 + /// + public bool IsSigninRewardEnabled { get; set; } + + /// + /// 签到奖励积分。 + /// + public int SigninRewardPoints { get; set; } + + /// + /// 有效期模式(permanent/yearly_clear)。 + /// + public string ExpiryMode { get; set; } = "yearly_clear"; +} + +/// +/// 积分商城商品列表查询请求。 +/// +public sealed class PointMallProductListRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 状态(enabled/disabled,可空)。 + /// + public string? Status { get; set; } + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } +} + +/// +/// 积分商城商品详情查询请求。 +/// +public sealed class PointMallProductDetailRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 积分商城商品 ID。 + /// + public string PointMallProductId { get; set; } = string.Empty; +} + +/// +/// 保存积分商城商品请求。 +/// +public sealed class SavePointMallProductRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 积分商城商品 ID(编辑时传)。 + /// + public string? PointMallProductId { get; set; } + + /// + /// 展示名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 展示图片。 + /// + public string? ImageUrl { get; set; } + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string RedeemType { get; set; } = "product"; + + /// + /// 关联商品 ID。 + /// + public string? ProductId { get; set; } + + /// + /// 关联优惠券模板 ID。 + /// + public string? CouponTemplateId { get; set; } + + /// + /// 实物名称。 + /// + public string? PhysicalName { get; set; } + + /// + /// 领取方式(store_pickup/delivery)。 + /// + public string? PickupMethod { get; set; } + + /// + /// 商品描述。 + /// + public string? Description { get; set; } + + /// + /// 兑换方式(points/mixed)。 + /// + public string ExchangeType { get; set; } = "points"; + + /// + /// 所需积分。 + /// + public int RequiredPoints { get; set; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; set; } + + /// + /// 库存总量。 + /// + public int StockTotal { get; set; } + + /// + /// 每人限兑次数。 + /// + public int? PerMemberLimit { get; set; } + + /// + /// 通知渠道(in_app/sms)。 + /// + public List NotifyChannels { get; set; } = []; + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; +} + +/// +/// 修改积分商城商品状态请求。 +/// +public sealed class ChangePointMallProductStatusRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 积分商城商品 ID。 + /// + public string PointMallProductId { get; set; } = string.Empty; + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "disabled"; +} + +/// +/// 删除积分商城商品请求。 +/// +public sealed class DeletePointMallProductRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 积分商城商品 ID。 + /// + public string PointMallProductId { get; set; } = string.Empty; +} + +/// +/// 积分商城兑换记录分页查询请求。 +/// +public sealed class PointMallRecordListRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string? RedeemType { get; set; } + + /// + /// 状态(pending_pickup/issued/completed/canceled)。 + /// + public string? Status { get; set; } + + /// + /// 开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 10; +} + +/// +/// 积分商城兑换记录详情请求。 +/// +public sealed class PointMallRecordDetailRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 兑换记录 ID。 + /// + public string RecordId { get; set; } = string.Empty; +} + +/// +/// 导出积分商城兑换记录请求。 +/// +public sealed class ExportPointMallRecordRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string? RedeemType { get; set; } + + /// + /// 状态(pending_pickup/issued/completed/canceled)。 + /// + public string? Status { get; set; } + + /// + /// 开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } +} + +/// +/// 写入积分商城兑换记录请求。 +/// +public sealed class WritePointMallRecordRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 积分商城商品 ID。 + /// + public string PointMallProductId { get; set; } = string.Empty; + + /// + /// 会员 ID。 + /// + public string MemberId { get; set; } = string.Empty; + + /// + /// 兑换时间(可空,默认当前时间)。 + /// + public DateTime? RedeemedAt { get; set; } +} + +/// +/// 核销积分商城兑换记录请求。 +/// +public sealed class VerifyPointMallRecordRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 兑换记录 ID。 + /// + public string RecordId { get; set; } = string.Empty; + + /// + /// 核销方式(scan/manual)。 + /// + public string VerifyMethod { get; set; } = "manual"; + + /// + /// 核销备注。 + /// + public string? VerifyRemark { get; set; } +} + +/// +/// 积分商城规则响应。 +/// +public sealed class PointMallRuleResponse +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 是否启用消费获取。 + /// + public bool IsConsumeRewardEnabled { get; set; } + + /// + /// 每消费多少元触发一次积分计算。 + /// + public int ConsumeAmountPerStep { get; set; } + + /// + /// 每步获得积分。 + /// + public int ConsumeRewardPointsPerStep { get; set; } + + /// + /// 是否启用评价奖励。 + /// + public bool IsReviewRewardEnabled { get; set; } + + /// + /// 评价奖励积分。 + /// + public int ReviewRewardPoints { get; set; } + + /// + /// 是否启用注册奖励。 + /// + public bool IsRegisterRewardEnabled { get; set; } + + /// + /// 注册奖励积分。 + /// + public int RegisterRewardPoints { get; set; } + + /// + /// 是否启用签到奖励。 + /// + public bool IsSigninRewardEnabled { get; set; } + + /// + /// 签到奖励积分。 + /// + public int SigninRewardPoints { get; set; } + + /// + /// 有效期模式(permanent/yearly_clear)。 + /// + public string ExpiryMode { get; set; } = "yearly_clear"; +} + +/// +/// 积分商城规则统计响应。 +/// +public sealed class PointMallRuleStatsResponse +{ + /// + /// 累计发放积分。 + /// + public int TotalIssuedPoints { get; set; } + + /// + /// 已兑换积分。 + /// + public int RedeemedPoints { get; set; } + + /// + /// 积分用户。 + /// + public int PointMembers { get; set; } + + /// + /// 兑换率(0-100)。 + /// + public decimal RedeemRate { get; set; } +} + +/// +/// 积分商城规则详情响应。 +/// +public sealed class PointMallRuleDetailResultResponse +{ + /// + /// 规则。 + /// + public PointMallRuleResponse Rule { get; set; } = new(); + + /// + /// 统计。 + /// + public PointMallRuleStatsResponse Stats { get; set; } = new(); +} + +/// +/// 积分商城商品响应。 +/// +public sealed class PointMallProductResponse +{ + /// + /// 积分商城商品 ID。 + /// + public string PointMallProductId { get; set; } = string.Empty; + + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 展示名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 展示图片。 + /// + public string? ImageUrl { get; set; } + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string RedeemType { get; set; } = "product"; + + /// + /// 兑换类型文案。 + /// + public string RedeemTypeText { get; set; } = "商品"; + + /// + /// 关联商品 ID。 + /// + public string? ProductId { get; set; } + + /// + /// 关联优惠券模板 ID。 + /// + public string? CouponTemplateId { get; set; } + + /// + /// 实物名称。 + /// + public string? PhysicalName { get; set; } + + /// + /// 领取方式(store_pickup/delivery)。 + /// + public string? PickupMethod { get; set; } + + /// + /// 商品描述。 + /// + public string? Description { get; set; } + + /// + /// 兑换方式(points/mixed)。 + /// + public string ExchangeType { get; set; } = "points"; + + /// + /// 所需积分。 + /// + public int RequiredPoints { get; set; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; set; } + + /// + /// 初始库存。 + /// + public int StockTotal { get; set; } + + /// + /// 剩余库存。 + /// + public int StockAvailable { get; set; } + + /// + /// 已兑换数量。 + /// + public int RedeemedCount { get; set; } + + /// + /// 每人限兑次数。 + /// + public int? PerMemberLimit { get; set; } + + /// + /// 通知渠道。 + /// + public List NotifyChannels { get; set; } = []; + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; + + /// + /// 状态文案。 + /// + public string StatusText { get; set; } = "上架"; + + /// + /// 更新时间。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 积分商城商品列表响应。 +/// +public sealed class PointMallProductListResultResponse +{ + /// + /// 列表。 + /// + public List Items { get; set; } = []; +} + +/// +/// 积分商城兑换记录响应。 +/// +public class PointMallRecordResponse +{ + /// + /// 兑换记录 ID。 + /// + public string RecordId { get; set; } = string.Empty; + + /// + /// 兑换单号。 + /// + public string RecordNo { get; set; } = string.Empty; + + /// + /// 积分商城商品 ID。 + /// + public string PointMallProductId { get; set; } = string.Empty; + + /// + /// 商品名称。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string RedeemType { get; set; } = "product"; + + /// + /// 兑换类型文案。 + /// + public string RedeemTypeText { get; set; } = "商品"; + + /// + /// 兑换方式(points/mixed)。 + /// + public string ExchangeType { get; set; } = "points"; + + /// + /// 会员 ID。 + /// + public string MemberId { get; set; } = string.Empty; + + /// + /// 会员名称。 + /// + public string MemberName { get; set; } = string.Empty; + + /// + /// 会员手机号(脱敏)。 + /// + public string MemberMobileMasked { get; set; } = string.Empty; + + /// + /// 消耗积分。 + /// + public int UsedPoints { get; set; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; set; } + + /// + /// 状态(pending_pickup/issued/completed/canceled)。 + /// + public string Status { get; set; } = "issued"; + + /// + /// 状态文案。 + /// + public string StatusText { get; set; } = "已发放"; + + /// + /// 兑换时间。 + /// + public string RedeemedAt { get; set; } = string.Empty; + + /// + /// 发放时间。 + /// + public string? IssuedAt { get; set; } + + /// + /// 核销时间。 + /// + public string? VerifiedAt { get; set; } +} + +/// +/// 积分商城兑换记录详情响应。 +/// +public sealed class PointMallRecordDetailResponse : PointMallRecordResponse +{ + /// + /// 核销方式(scan/manual)。 + /// + public string? VerifyMethod { get; set; } + + /// + /// 核销方式文案。 + /// + public string? VerifyMethodText { get; set; } + + /// + /// 核销备注。 + /// + public string? VerifyRemark { get; set; } + + /// + /// 核销人 ID。 + /// + public string? VerifiedBy { get; set; } +} + +/// +/// 积分商城兑换记录统计响应。 +/// +public sealed class PointMallRecordStatsResponse +{ + /// + /// 今日兑换。 + /// + public int TodayRedeemCount { get; set; } + + /// + /// 待领取实物。 + /// + public int PendingPhysicalCount { get; set; } + + /// + /// 本月消耗积分。 + /// + public int CurrentMonthUsedPoints { get; set; } +} + +/// +/// 积分商城兑换记录分页响应。 +/// +public sealed class PointMallRecordListResultResponse +{ + /// + /// 列表。 + /// + public List Items { get; set; } = []; + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 总条数。 + /// + public int TotalCount { get; set; } + + /// + /// 统计。 + /// + public PointMallRecordStatsResponse Stats { get; set; } = new(); +} + +/// +/// 积分商城兑换记录导出响应。 +/// +public sealed class PointMallRecordExportResponse +{ + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Base64 文件内容。 + /// + public string FileContentBase64 { get; set; } = string.Empty; + + /// + /// 导出总数。 + /// + public int TotalCount { get; set; } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberPointsMallController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberPointsMallController.cs new file mode 100644 index 0000000..1647f89 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MemberPointsMallController.cs @@ -0,0 +1,526 @@ +using System.Globalization; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.TenantApi.Contracts.Member; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 会员中心积分商城管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/member/points-mall")] +public sealed class MemberPointsMallController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) + : BaseApiController +{ + private const string ViewPermission = "tenant:member:points-mall:view"; + private const string ManagePermission = "tenant:member:points-mall:manage"; + + /// + /// 获取积分规则详情。 + /// + [HttpGet("rule/detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> RuleDetail( + [FromQuery] PointMallRuleDetailRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new GetPointMallRuleDetailQuery + { + StoreId = storeId + }, cancellationToken); + + return ApiResponse.Ok(new PointMallRuleDetailResultResponse + { + Rule = MapRule(result.Rule), + Stats = new PointMallRuleStatsResponse + { + TotalIssuedPoints = result.Stats.TotalIssuedPoints, + RedeemedPoints = result.Stats.RedeemedPoints, + PointMembers = result.Stats.PointMembers, + RedeemRate = result.Stats.RedeemRate + } + }); + } + + /// + /// 保存积分规则。 + /// + [HttpPost("rule/save")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SaveRule( + [FromBody] SavePointMallRuleRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new SavePointMallRuleCommand + { + StoreId = storeId, + IsConsumeRewardEnabled = request.IsConsumeRewardEnabled, + ConsumeAmountPerStep = request.ConsumeAmountPerStep, + ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep, + IsReviewRewardEnabled = request.IsReviewRewardEnabled, + ReviewRewardPoints = request.ReviewRewardPoints, + IsRegisterRewardEnabled = request.IsRegisterRewardEnabled, + RegisterRewardPoints = request.RegisterRewardPoints, + IsSigninRewardEnabled = request.IsSigninRewardEnabled, + SigninRewardPoints = request.SigninRewardPoints, + ExpiryMode = request.ExpiryMode + }, cancellationToken); + + return ApiResponse.Ok(MapRule(result)); + } + + /// + /// 查询兑换商品列表。 + /// + [HttpGet("product/list")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ProductList( + [FromQuery] PointMallProductListRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new GetPointMallProductListQuery + { + StoreId = storeId, + Status = request.Status, + Keyword = request.Keyword + }, cancellationToken); + + return ApiResponse.Ok(new PointMallProductListResultResponse + { + Items = result.Items.Select(MapProduct).ToList() + }); + } + + /// + /// 查询兑换商品详情。 + /// + [HttpGet("product/detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ProductDetail( + [FromQuery] PointMallProductDetailRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new GetPointMallProductDetailQuery + { + StoreId = storeId, + PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)) + }, cancellationToken); + + return ApiResponse.Ok(MapProduct(result)); + } + + /// + /// 保存兑换商品。 + /// + [HttpPost("product/save")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SaveProduct( + [FromBody] SavePointMallProductRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new SavePointMallProductCommand + { + StoreId = storeId, + PointMallProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.PointMallProductId), + Name = request.Name, + ImageUrl = request.ImageUrl, + RedeemType = request.RedeemType, + ProductId = StoreApiHelpers.ParseSnowflakeOrNull(request.ProductId), + CouponTemplateId = StoreApiHelpers.ParseSnowflakeOrNull(request.CouponTemplateId), + PhysicalName = request.PhysicalName, + PickupMethod = request.PickupMethod, + Description = request.Description, + ExchangeType = request.ExchangeType, + RequiredPoints = request.RequiredPoints, + CashAmount = request.CashAmount, + StockTotal = request.StockTotal, + PerMemberLimit = request.PerMemberLimit, + NotifyChannels = request.NotifyChannels, + Status = request.Status + }, cancellationToken); + + return ApiResponse.Ok(MapProduct(result)); + } + + /// + /// 修改兑换商品状态。 + /// + [HttpPost("product/status")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ChangeProductStatus( + [FromBody] ChangePointMallProductStatusRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new ChangePointMallProductStatusCommand + { + StoreId = storeId, + PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)), + Status = request.Status + }, cancellationToken); + + return ApiResponse.Ok(MapProduct(result)); + } + + /// + /// 删除兑换商品。 + /// + [HttpPost("product/delete")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteProduct( + [FromBody] DeletePointMallProductRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + await mediator.Send(new DeletePointMallProductCommand + { + StoreId = storeId, + PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)) + }, cancellationToken); + + return ApiResponse.Ok(null); + } + + /// + /// 查询兑换记录分页。 + /// + [HttpGet("record/list")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> RecordList( + [FromQuery] PointMallRecordListRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new GetPointMallRecordListQuery + { + StoreId = storeId, + RedeemType = request.RedeemType, + Status = request.Status, + StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)), + EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)), + Keyword = request.Keyword, + Page = request.Page, + PageSize = request.PageSize + }, cancellationToken); + + return ApiResponse.Ok(new PointMallRecordListResultResponse + { + Items = result.Items.Select(MapRecord).ToList(), + Page = result.Page, + PageSize = result.PageSize, + TotalCount = result.TotalCount, + Stats = new PointMallRecordStatsResponse + { + TodayRedeemCount = result.Stats.TodayRedeemCount, + PendingPhysicalCount = result.Stats.PendingPhysicalCount, + CurrentMonthUsedPoints = result.Stats.CurrentMonthUsedPoints + } + }); + } + + /// + /// 查询兑换记录详情。 + /// + [HttpGet("record/detail")] + [PermissionAuthorize(ViewPermission, ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> RecordDetail( + [FromQuery] PointMallRecordDetailRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new GetPointMallRecordDetailQuery + { + StoreId = storeId, + RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)) + }, cancellationToken); + + return ApiResponse.Ok(MapRecordDetail(result)); + } + + /// + /// 导出兑换记录 CSV。 + /// + [HttpGet("record/export")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ExportRecord( + [FromQuery] ExportPointMallRecordRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new ExportPointMallRecordCsvQuery + { + StoreId = storeId, + RedeemType = request.RedeemType, + Status = request.Status, + StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)), + EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)), + Keyword = request.Keyword + }, cancellationToken); + + return ApiResponse.Ok(new PointMallRecordExportResponse + { + FileName = result.FileName, + FileContentBase64 = result.FileContentBase64, + TotalCount = result.TotalCount + }); + } + + /// + /// 写入兑换记录。 + /// + [HttpPost("record/write")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> WriteRecord( + [FromBody] WritePointMallRecordRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new WritePointMallRecordCommand + { + StoreId = storeId, + PointMallProductId = StoreApiHelpers.ParseRequiredSnowflake(request.PointMallProductId, nameof(request.PointMallProductId)), + MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)), + RedeemedAt = request.RedeemedAt + }, cancellationToken); + + return ApiResponse.Ok(MapRecord(result)); + } + + /// + /// 核销兑换记录。 + /// + [HttpPost("record/verify")] + [PermissionAuthorize(ManagePermission)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> VerifyRecord( + [FromBody] VerifyPointMallRecordRequest request, + CancellationToken cancellationToken) + { + var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(storeId, cancellationToken); + + var result = await mediator.Send(new VerifyPointMallRecordCommand + { + StoreId = storeId, + RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)), + VerifyMethod = request.VerifyMethod, + VerifyRemark = request.VerifyRemark + }, cancellationToken); + + return ApiResponse.Ok(MapRecordDetail(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 PointMallRuleResponse MapRule(MemberPointMallRuleDto source) + { + return new PointMallRuleResponse + { + StoreId = source.StoreId.ToString(), + IsConsumeRewardEnabled = source.IsConsumeRewardEnabled, + ConsumeAmountPerStep = source.ConsumeAmountPerStep, + ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep, + IsReviewRewardEnabled = source.IsReviewRewardEnabled, + ReviewRewardPoints = source.ReviewRewardPoints, + IsRegisterRewardEnabled = source.IsRegisterRewardEnabled, + RegisterRewardPoints = source.RegisterRewardPoints, + IsSigninRewardEnabled = source.IsSigninRewardEnabled, + SigninRewardPoints = source.SigninRewardPoints, + ExpiryMode = source.ExpiryMode + }; + } + + private static PointMallProductResponse MapProduct(MemberPointMallProductDto source) + { + return new PointMallProductResponse + { + PointMallProductId = source.PointMallProductId.ToString(), + StoreId = source.StoreId.ToString(), + Name = source.Name, + ImageUrl = source.ImageUrl, + RedeemType = source.RedeemType, + RedeemTypeText = ResolveRedeemTypeText(source.RedeemType), + ProductId = source.ProductId?.ToString(), + CouponTemplateId = source.CouponTemplateId?.ToString(), + PhysicalName = source.PhysicalName, + PickupMethod = source.PickupMethod, + Description = source.Description, + ExchangeType = source.ExchangeType, + RequiredPoints = source.RequiredPoints, + CashAmount = source.CashAmount, + StockTotal = source.StockTotal, + StockAvailable = source.StockAvailable, + RedeemedCount = source.RedeemedCount, + PerMemberLimit = source.PerMemberLimit, + NotifyChannels = source.NotifyChannels.ToList(), + Status = source.Status, + StatusText = ResolveProductStatusText(source.Status), + UpdatedAt = source.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + }; + } + + private static PointMallRecordResponse MapRecord(MemberPointMallRecordDto source) + { + return new PointMallRecordResponse + { + RecordId = source.RecordId.ToString(), + RecordNo = source.RecordNo, + PointMallProductId = source.PointMallProductId.ToString(), + ProductName = source.ProductName, + RedeemType = source.RedeemType, + RedeemTypeText = ResolveRedeemTypeText(source.RedeemType), + ExchangeType = source.ExchangeType, + MemberId = source.MemberId.ToString(), + MemberName = source.MemberName, + MemberMobileMasked = source.MemberMobileMasked, + UsedPoints = source.UsedPoints, + CashAmount = source.CashAmount, + Status = source.Status, + StatusText = ResolveRecordStatusText(source.Status), + RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + }; + } + + private static PointMallRecordDetailResponse MapRecordDetail(MemberPointMallRecordDetailDto source) + { + var response = new PointMallRecordDetailResponse + { + RecordId = source.RecordId.ToString(), + RecordNo = source.RecordNo, + PointMallProductId = source.PointMallProductId.ToString(), + ProductName = source.ProductName, + RedeemType = source.RedeemType, + RedeemTypeText = ResolveRedeemTypeText(source.RedeemType), + ExchangeType = source.ExchangeType, + MemberId = source.MemberId.ToString(), + MemberName = source.MemberName, + MemberMobileMasked = source.MemberMobileMasked, + UsedPoints = source.UsedPoints, + CashAmount = source.CashAmount, + Status = source.Status, + StatusText = ResolveRecordStatusText(source.Status), + RedeemedAt = source.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + VerifiedAt = source.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + VerifyMethod = source.VerifyMethod, + VerifyMethodText = ResolveVerifyMethodText(source.VerifyMethod), + VerifyRemark = source.VerifyRemark, + VerifiedBy = source.VerifiedBy?.ToString() + }; + + return response; + } + + private static string ResolveRedeemTypeText(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "product" => "商品", + "coupon" => "优惠券", + "physical" => "实物", + _ => "未知" + }; + } + + private static string ResolveProductStatusText(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "enabled" => "上架", + "disabled" => "下架", + _ => "未知" + }; + } + + private static string ResolveRecordStatusText(string value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "pending_pickup" => "待领取", + "issued" => "已发放", + "completed" => "已完成", + "canceled" => "已取消", + _ => "未知" + }; + } + + private static string? ResolveVerifyMethodText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim().ToLowerInvariant() switch + { + "scan" => "扫码核销", + "manual" => "手动核销", + _ => "未知" + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/ChangePointMallProductStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/ChangePointMallProductStatusCommand.cs new file mode 100644 index 0000000..5e13bde --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/ChangePointMallProductStatusCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands; + +/// +/// 修改积分商城商品状态命令。 +/// +public sealed class ChangePointMallProductStatusCommand : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 积分商城商品标识。 + /// + public long PointMallProductId { get; init; } + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "disabled"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/DeletePointMallProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/DeletePointMallProductCommand.cs new file mode 100644 index 0000000..cc5b184 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/DeletePointMallProductCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands; + +/// +/// 删除积分商城商品命令。 +/// +public sealed class DeletePointMallProductCommand : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 积分商城商品标识。 + /// + public long PointMallProductId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/SavePointMallProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/SavePointMallProductCommand.cs new file mode 100644 index 0000000..eb1b54f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/SavePointMallProductCommand.cs @@ -0,0 +1,95 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands; + +/// +/// 保存积分商城兑换商品命令。 +/// +public sealed class SavePointMallProductCommand : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 积分商城商品标识(编辑时传)。 + /// + public long? PointMallProductId { get; init; } + + /// + /// 展示名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 展示图片。 + /// + public string? ImageUrl { get; init; } + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string RedeemType { get; init; } = "product"; + + /// + /// 关联商品 ID。 + /// + public long? ProductId { get; init; } + + /// + /// 关联优惠券模板 ID。 + /// + public long? CouponTemplateId { get; init; } + + /// + /// 实物名称。 + /// + public string? PhysicalName { get; init; } + + /// + /// 领取方式(store_pickup/delivery)。 + /// + public string? PickupMethod { get; init; } + + /// + /// 商品描述。 + /// + public string? Description { get; init; } + + /// + /// 兑换方式(points/mixed)。 + /// + public string ExchangeType { get; init; } = "points"; + + /// + /// 所需积分。 + /// + public int RequiredPoints { get; init; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; init; } + + /// + /// 库存总量。 + /// + public int StockTotal { get; init; } + + /// + /// 每人限兑次数(null 表示不限)。 + /// + public int? PerMemberLimit { get; init; } + + /// + /// 到账通知渠道。 + /// + public IReadOnlyCollection NotifyChannels { get; init; } = []; + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; init; } = "enabled"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/SavePointMallRuleCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/SavePointMallRuleCommand.cs new file mode 100644 index 0000000..f36cfd1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/SavePointMallRuleCommand.cs @@ -0,0 +1,65 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands; + +/// +/// 保存积分商城规则命令。 +/// +public sealed class SavePointMallRuleCommand : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 是否启用消费获取。 + /// + public bool IsConsumeRewardEnabled { get; init; } + + /// + /// 每消费多少元触发一次积分计算。 + /// + public int ConsumeAmountPerStep { get; init; } + + /// + /// 每步获得积分。 + /// + public int ConsumeRewardPointsPerStep { get; init; } + + /// + /// 是否启用评价奖励。 + /// + public bool IsReviewRewardEnabled { get; init; } + + /// + /// 评价奖励积分。 + /// + public int ReviewRewardPoints { get; init; } + + /// + /// 是否启用注册奖励。 + /// + public bool IsRegisterRewardEnabled { get; init; } + + /// + /// 注册奖励积分。 + /// + public int RegisterRewardPoints { get; init; } + + /// + /// 是否启用签到奖励。 + /// + public bool IsSigninRewardEnabled { get; init; } + + /// + /// 签到奖励积分。 + /// + public int SigninRewardPoints { get; init; } + + /// + /// 有效期模式(permanent/yearly_clear)。 + /// + public string ExpiryMode { get; init; } = "yearly_clear"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/VerifyPointMallRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/VerifyPointMallRecordCommand.cs new file mode 100644 index 0000000..0dea3f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/VerifyPointMallRecordCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands; + +/// +/// 核销积分商城兑换记录命令。 +/// +public sealed class VerifyPointMallRecordCommand : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 兑换记录标识。 + /// + public long RecordId { get; init; } + + /// + /// 核销方式(scan/manual)。 + /// + public string VerifyMethod { get; init; } = "manual"; + + /// + /// 核销备注。 + /// + public string? VerifyRemark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/WritePointMallRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/WritePointMallRecordCommand.cs new file mode 100644 index 0000000..4be610e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Commands/WritePointMallRecordCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands; + +/// +/// 写入积分商城兑换记录命令。 +/// +public sealed class WritePointMallRecordCommand : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 积分商城商品标识。 + /// + public long PointMallProductId { get; init; } + + /// + /// 会员标识。 + /// + public long MemberId { get; init; } + + /// + /// 兑换时间(可空,默认当前 UTC)。 + /// + public DateTime? RedeemedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallProductDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallProductDto.cs new file mode 100644 index 0000000..8ca9b67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallProductDto.cs @@ -0,0 +1,107 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换商品数据。 +/// +public sealed class MemberPointMallProductDto +{ + /// + /// 积分商城商品标识。 + /// + public long PointMallProductId { get; set; } + + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 展示名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 展示图片。 + /// + public string? ImageUrl { get; set; } + + /// + /// 兑换类型编码(product/coupon/physical)。 + /// + public string RedeemType { get; set; } = "product"; + + /// + /// 关联商品 ID。 + /// + public long? ProductId { get; set; } + + /// + /// 关联优惠券模板 ID。 + /// + public long? CouponTemplateId { get; set; } + + /// + /// 实物名称。 + /// + public string? PhysicalName { get; set; } + + /// + /// 领取方式编码(store_pickup/delivery)。 + /// + public string? PickupMethod { get; set; } + + /// + /// 商品描述。 + /// + public string? Description { get; set; } + + /// + /// 兑换方式编码(points/mixed)。 + /// + public string ExchangeType { get; set; } = "points"; + + /// + /// 所需积分。 + /// + public int RequiredPoints { get; set; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; set; } + + /// + /// 初始库存。 + /// + public int StockTotal { get; set; } + + /// + /// 剩余库存。 + /// + public int StockAvailable { get; set; } + + /// + /// 已兑换数量。 + /// + public int RedeemedCount { get; set; } + + /// + /// 每人限兑次数。 + /// + public int? PerMemberLimit { get; set; } + + /// + /// 通知渠道列表(in_app/sms)。 + /// + public IReadOnlyList NotifyChannels { get; set; } = []; + + /// + /// 状态(enabled/disabled)。 + /// + public string Status { get; set; } = "enabled"; + + /// + /// 更新时间(UTC)。 + /// + public DateTime UpdatedAt { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallProductListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallProductListResultDto.cs new file mode 100644 index 0000000..2432ff3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallProductListResultDto.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换商品列表结果。 +/// +public sealed class MemberPointMallProductListResultDto +{ + /// + /// 列表项。 + /// + public IReadOnlyList Items { get; set; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordDetailDto.cs new file mode 100644 index 0000000..c2d95da --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordDetailDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换记录详情数据。 +/// +public sealed class MemberPointMallRecordDetailDto : MemberPointMallRecordDto +{ + /// + /// 核销方式(scan/manual)。 + /// + public string? VerifyMethod { get; set; } + + /// + /// 核销备注。 + /// + public string? VerifyRemark { get; set; } + + /// + /// 核销人标识。 + /// + public long? VerifiedBy { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordDto.cs new file mode 100644 index 0000000..953d27d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordDto.cs @@ -0,0 +1,82 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换记录数据。 +/// +public class MemberPointMallRecordDto +{ + /// + /// 兑换记录标识。 + /// + public long RecordId { get; set; } + + /// + /// 兑换单号。 + /// + public string RecordNo { get; set; } = string.Empty; + + /// + /// 积分商城商品标识。 + /// + public long PointMallProductId { get; set; } + + /// + /// 商品名称。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string RedeemType { get; set; } = "product"; + + /// + /// 兑换方式(points/mixed)。 + /// + public string ExchangeType { get; set; } = "points"; + + /// + /// 会员标识。 + /// + public long MemberId { get; set; } + + /// + /// 会员名称。 + /// + public string MemberName { get; set; } = string.Empty; + + /// + /// 会员手机号(脱敏)。 + /// + public string MemberMobileMasked { get; set; } = string.Empty; + + /// + /// 消耗积分。 + /// + public int UsedPoints { get; set; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; set; } + + /// + /// 记录状态(pending_pickup/issued/completed/canceled)。 + /// + public string Status { get; set; } = "issued"; + + /// + /// 兑换时间(UTC)。 + /// + public DateTime RedeemedAt { get; set; } + + /// + /// 发放时间(UTC)。 + /// + public DateTime? IssuedAt { get; set; } + + /// + /// 核销时间(UTC)。 + /// + public DateTime? VerifiedAt { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordExportDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordExportDto.cs new file mode 100644 index 0000000..f94d9ee --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordExportDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换记录导出结果。 +/// +public sealed class MemberPointMallRecordExportDto +{ + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// Base64 文件内容。 + /// + public string FileContentBase64 { get; set; } = string.Empty; + + /// + /// 导出总条数。 + /// + public int TotalCount { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordListResultDto.cs new file mode 100644 index 0000000..f071fc7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordListResultDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换记录列表结果。 +/// +public sealed class MemberPointMallRecordListResultDto +{ + /// + /// 列表项。 + /// + public IReadOnlyList Items { get; set; } = []; + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 总条数。 + /// + public int TotalCount { get; set; } + + /// + /// 页面统计。 + /// + public MemberPointMallRecordStatsDto Stats { get; set; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordStatsDto.cs new file mode 100644 index 0000000..8d108fa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRecordStatsDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分商城兑换记录页统计。 +/// +public sealed class MemberPointMallRecordStatsDto +{ + /// + /// 今日兑换。 + /// + public int TodayRedeemCount { get; set; } + + /// + /// 待领取实物。 + /// + public int PendingPhysicalCount { get; set; } + + /// + /// 本月消耗积分。 + /// + public int CurrentMonthUsedPoints { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleDetailResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleDetailResultDto.cs new file mode 100644 index 0000000..dd32cbe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleDetailResultDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分规则页详情结果。 +/// +public sealed class MemberPointMallRuleDetailResultDto +{ + /// + /// 规则配置。 + /// + public MemberPointMallRuleDto Rule { get; set; } = new(); + + /// + /// 统计数据。 + /// + public MemberPointMallRuleStatsDto Stats { get; set; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleDto.cs new file mode 100644 index 0000000..26313e2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleDto.cs @@ -0,0 +1,62 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分规则数据。 +/// +public sealed class MemberPointMallRuleDto +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 是否启用消费获取。 + /// + public bool IsConsumeRewardEnabled { get; set; } + + /// + /// 每消费多少元触发一次积分计算。 + /// + public int ConsumeAmountPerStep { get; set; } + + /// + /// 每步获得积分。 + /// + public int ConsumeRewardPointsPerStep { get; set; } + + /// + /// 是否启用评价奖励。 + /// + public bool IsReviewRewardEnabled { get; set; } + + /// + /// 评价奖励积分。 + /// + public int ReviewRewardPoints { get; set; } + + /// + /// 是否启用注册奖励。 + /// + public bool IsRegisterRewardEnabled { get; set; } + + /// + /// 注册奖励积分。 + /// + public int RegisterRewardPoints { get; set; } + + /// + /// 是否启用签到奖励。 + /// + public bool IsSigninRewardEnabled { get; set; } + + /// + /// 签到奖励积分。 + /// + public int SigninRewardPoints { get; set; } + + /// + /// 有效期模式(permanent/yearly_clear)。 + /// + public string ExpiryMode { get; set; } = "yearly_clear"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleStatsDto.cs new file mode 100644 index 0000000..4b204c0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Dto/MemberPointMallRuleStatsDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +/// +/// 积分规则页统计数据。 +/// +public sealed class MemberPointMallRuleStatsDto +{ + /// + /// 累计发放积分。 + /// + public int TotalIssuedPoints { get; set; } + + /// + /// 已兑换积分。 + /// + public int RedeemedPoints { get; set; } + + /// + /// 积分用户。 + /// + public int PointMembers { get; set; } + + /// + /// 兑换率(0-100)。 + /// + public decimal RedeemRate { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/ChangePointMallProductStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/ChangePointMallProductStatusCommandHandler.cs new file mode 100644 index 0000000..62a1865 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/ChangePointMallProductStatusCommandHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 修改积分商城商品状态处理器。 +/// +public sealed class ChangePointMallProductStatusCommandHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + ChangePointMallProductStatusCommand request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var status = MemberPointMallMapping.ParseProductStatus(request.Status); + + var product = await repository.FindProductByIdAsync( + tenantId, + request.StoreId, + request.PointMallProductId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在"); + + product.Status = status; + await repository.UpdateProductAsync(product, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + + var aggregates = await repository.GetProductAggregatesAsync( + tenantId, + request.StoreId, + [product.Id], + cancellationToken); + + var aggregate = aggregates.TryGetValue(product.Id, out var value) + ? value + : MemberPointMallDtoFactory.EmptyProductAggregate(product.Id); + + return MemberPointMallDtoFactory.ToProductDto(product, aggregate); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/DeletePointMallProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/DeletePointMallProductCommandHandler.cs new file mode 100644 index 0000000..f8eba13 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/DeletePointMallProductCommandHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 删除积分商城商品处理器。 +/// +public sealed class DeletePointMallProductCommandHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(DeletePointMallProductCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + + var product = await repository.FindProductByIdAsync( + tenantId, + request.StoreId, + request.PointMallProductId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在"); + + var hasRecords = await repository.HasRecordsByProductIdAsync( + tenantId, + request.StoreId, + request.PointMallProductId, + cancellationToken); + + if (hasRecords) + { + throw new BusinessException(ErrorCodes.BadRequest, "存在兑换记录的商品不允许删除"); + } + + await repository.DeleteProductAsync(product, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/ExportPointMallRecordCsvQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/ExportPointMallRecordCsvQueryHandler.cs new file mode 100644 index 0000000..8623c1f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/ExportPointMallRecordCsvQueryHandler.cs @@ -0,0 +1,83 @@ +using System.Text; +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 导出积分商城兑换记录处理器。 +/// +public sealed class ExportPointMallRecordCsvQueryHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + ExportPointMallRecordCsvQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var redeemType = MemberPointMallMapping.TryParseRedeemType(request.RedeemType); + var status = MemberPointMallMapping.TryParseRecordStatus(request.Status); + var keyword = MemberPointMallMapping.NormalizeKeyword(request.Keyword); + var (startUtc, endUtc) = MemberPointMallMapping.NormalizeDateRange( + request.StartDateUtc, + request.EndDateUtc); + + var records = await repository.ListRecordsForExportAsync( + tenantId, + request.StoreId, + redeemType, + status, + startUtc, + endUtc, + keyword, + cancellationToken); + + var csv = BuildCsv(records); + var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}"); + + return new MemberPointMallRecordExportDto + { + FileName = $"积分商城兑换记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv", + FileContentBase64 = Convert.ToBase64String(bytes), + TotalCount = records.Count + }; + } + + private static string BuildCsv(IReadOnlyCollection records) + { + var lines = new List + { + "兑换单号,会员,手机号,兑换商品,类型,消耗积分,现金部分,兑换时间,状态,核销时间" + }; + + foreach (var item in records) + { + lines.Add(string.Join(",", + Escape(item.RecordNo), + Escape(item.MemberName), + Escape(item.MemberMobileMasked), + Escape(item.ProductName), + Escape(MemberPointMallMapping.ToRedeemTypeDisplayText(item.RedeemType)), + item.UsedPoints.ToString(), + item.CashAmount.ToString("0.00"), + Escape(item.RedeemedAt.ToString("yyyy-MM-dd HH:mm:ss")), + Escape(MemberPointMallMapping.ToRecordStatusDisplayText(item.Status)), + Escape(item.VerifiedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? string.Empty))); + } + + return string.Join('\n', lines); + } + + private static string Escape(string value) + { + var normalized = value.Replace("\"", "\"\""); + return $"\"{normalized}\""; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallProductDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallProductDetailQueryHandler.cs new file mode 100644 index 0000000..0ed97d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallProductDetailQueryHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 查询积分商城商品详情处理器。 +/// +public sealed class GetPointMallProductDetailQueryHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + GetPointMallProductDetailQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + + var product = await repository.GetProductByIdAsync( + tenantId, + request.StoreId, + request.PointMallProductId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在"); + + var aggregates = await repository.GetProductAggregatesAsync( + tenantId, + request.StoreId, + [product.Id], + cancellationToken); + + var aggregate = aggregates.TryGetValue(product.Id, out var value) + ? value + : MemberPointMallDtoFactory.EmptyProductAggregate(product.Id); + + return MemberPointMallDtoFactory.ToProductDto(product, aggregate); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallProductListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallProductListQueryHandler.cs new file mode 100644 index 0000000..ce2ea1f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallProductListQueryHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 查询积分商城商品列表处理器。 +/// +public sealed class GetPointMallProductListQueryHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + GetPointMallProductListQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var status = MemberPointMallMapping.TryParseProductStatus(request.Status); + var keyword = MemberPointMallMapping.NormalizeKeyword(request.Keyword); + + var items = await repository.SearchProductsAsync( + tenantId, + request.StoreId, + status, + keyword, + cancellationToken); + + var productIds = items.Select(item => item.Id).ToList(); + var aggregates = await repository.GetProductAggregatesAsync( + tenantId, + request.StoreId, + productIds, + cancellationToken); + + var rows = items + .Select(item => + { + var aggregate = aggregates.TryGetValue(item.Id, out var value) + ? value + : MemberPointMallDtoFactory.EmptyProductAggregate(item.Id); + return MemberPointMallDtoFactory.ToProductDto(item, aggregate); + }) + .ToList(); + + return new MemberPointMallProductListResultDto + { + Items = rows + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRecordDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRecordDetailQueryHandler.cs new file mode 100644 index 0000000..adc86b8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRecordDetailQueryHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 查询积分商城兑换记录详情处理器。 +/// +public sealed class GetPointMallRecordDetailQueryHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + GetPointMallRecordDetailQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + + var record = await repository.GetRecordByIdAsync( + tenantId, + request.StoreId, + request.RecordId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换记录不存在"); + + return MemberPointMallDtoFactory.ToRecordDetailDto(record); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRecordListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRecordListQueryHandler.cs new file mode 100644 index 0000000..637536d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRecordListQueryHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 查询积分商城兑换记录分页处理器。 +/// +public sealed class GetPointMallRecordListQueryHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + GetPointMallRecordListQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var redeemType = MemberPointMallMapping.TryParseRedeemType(request.RedeemType); + var status = MemberPointMallMapping.TryParseRecordStatus(request.Status); + var keyword = MemberPointMallMapping.NormalizeKeyword(request.Keyword); + var page = Math.Max(1, request.Page); + var pageSize = Math.Clamp(request.PageSize, 1, 200); + var (startUtc, endUtc) = MemberPointMallMapping.NormalizeDateRange( + request.StartDateUtc, + request.EndDateUtc); + + var (items, totalCount) = await repository.SearchRecordsAsync( + tenantId, + request.StoreId, + redeemType, + status, + startUtc, + endUtc, + keyword, + page, + pageSize, + cancellationToken); + + var stats = await repository.GetRecordStatsAsync( + tenantId, + request.StoreId, + DateTime.UtcNow, + cancellationToken); + + return new MemberPointMallRecordListResultDto + { + Items = items.Select(MemberPointMallDtoFactory.ToRecordDto).ToList(), + Page = page, + PageSize = pageSize, + TotalCount = totalCount, + Stats = MemberPointMallDtoFactory.ToRecordStatsDto(stats) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRuleDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRuleDetailQueryHandler.cs new file mode 100644 index 0000000..fe24b88 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/GetPointMallRuleDetailQueryHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Application.App.Members.PointsMall.Queries; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 查询积分商城规则详情处理器。 +/// +public sealed class GetPointMallRuleDetailQueryHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + GetPointMallRuleDetailQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + + var rule = await repository.GetRuleByStoreAsync( + tenantId, + request.StoreId, + cancellationToken) ?? new MemberPointMallRule + { + StoreId = request.StoreId, + IsConsumeRewardEnabled = true, + ConsumeAmountPerStep = 1, + ConsumeRewardPointsPerStep = 1, + IsReviewRewardEnabled = true, + ReviewRewardPoints = 10, + IsRegisterRewardEnabled = true, + RegisterRewardPoints = 100, + IsSigninRewardEnabled = false, + SigninRewardPoints = 5 + }; + + var stats = await repository.GetRuleStatsAsync( + tenantId, + request.StoreId, + cancellationToken); + + return new MemberPointMallRuleDetailResultDto + { + Rule = MemberPointMallDtoFactory.ToRuleDto(rule), + Stats = MemberPointMallDtoFactory.ToRuleStatsDto(stats) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/SavePointMallProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/SavePointMallProductCommandHandler.cs new file mode 100644 index 0000000..16612a1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/SavePointMallProductCommandHandler.cs @@ -0,0 +1,147 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 保存积分商城商品处理器。 +/// +public sealed class SavePointMallProductCommandHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + SavePointMallProductCommand request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var redeemType = MemberPointMallMapping.ParseRedeemType(request.RedeemType); + var exchangeType = MemberPointMallMapping.ParseExchangeType(request.ExchangeType); + var status = MemberPointMallMapping.ParseProductStatus(request.Status); + var name = MemberPointMallMapping.NormalizeName(request.Name); + var imageUrl = MemberPointMallMapping.NormalizeImageUrl(request.ImageUrl); + var description = MemberPointMallMapping.NormalizeDescription(request.Description); + var requiredPoints = MemberPointMallMapping.NormalizeRequiredPoints(request.RequiredPoints); + var cashAmount = MemberPointMallMapping.NormalizeCashAmount(request.CashAmount, exchangeType); + var stockTotal = MemberPointMallMapping.NormalizeStockTotal(request.StockTotal); + var perMemberLimit = MemberPointMallMapping.NormalizePerMemberLimit(request.PerMemberLimit); + var notifyChannels = MemberPointMallMapping.ParseNotifyChannels(request.NotifyChannels); + + var productId = (long?)null; + var couponTemplateId = (long?)null; + var physicalName = (string?)null; + MemberPointMallPickupMethod? pickupMethod = null; + + switch (redeemType) + { + case MemberPointMallRedeemType.Product: + { + productId = request.ProductId.HasValue && request.ProductId.Value > 0 + ? request.ProductId.Value + : throw new BusinessException(ErrorCodes.BadRequest, "兑换商品类型必须选择关联商品"); + break; + } + case MemberPointMallRedeemType.Coupon: + { + couponTemplateId = request.CouponTemplateId.HasValue && request.CouponTemplateId.Value > 0 + ? request.CouponTemplateId.Value + : throw new BusinessException(ErrorCodes.BadRequest, "兑换优惠券类型必须选择关联优惠券"); + break; + } + case MemberPointMallRedeemType.Physical: + { + physicalName = MemberPointMallMapping.NormalizePhysicalName(request.PhysicalName); + pickupMethod = MemberPointMallMapping.ParsePickupMethod(request.PickupMethod); + break; + } + default: + { + throw new BusinessException(ErrorCodes.BadRequest, "redeemType 参数不合法"); + } + } + + MemberPointMallProduct entity; + if (request.PointMallProductId.HasValue && request.PointMallProductId.Value > 0) + { + entity = await repository.FindProductByIdAsync( + tenantId, + request.StoreId, + request.PointMallProductId.Value, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在"); + + var redeemedCount = Math.Max(0, entity.StockTotal - entity.StockAvailable); + if (stockTotal < redeemedCount) + { + throw new BusinessException(ErrorCodes.BadRequest, "库存总量不能小于已兑换数量"); + } + + entity.Name = name; + entity.ImageUrl = imageUrl; + entity.RedeemType = redeemType; + entity.ProductId = productId; + entity.CouponTemplateId = couponTemplateId; + entity.PhysicalName = physicalName; + entity.PickupMethod = pickupMethod; + entity.Description = description; + entity.ExchangeType = exchangeType; + entity.RequiredPoints = requiredPoints; + entity.CashAmount = cashAmount; + entity.StockTotal = stockTotal; + entity.StockAvailable = stockTotal - redeemedCount; + entity.PerMemberLimit = perMemberLimit; + entity.NotifyChannelsJson = MemberPointMallMapping.SerializeNotifyChannels(notifyChannels); + entity.Status = status; + + await repository.UpdateProductAsync(entity, cancellationToken); + } + else + { + entity = new MemberPointMallProduct + { + StoreId = request.StoreId, + Name = name, + ImageUrl = imageUrl, + RedeemType = redeemType, + ProductId = productId, + CouponTemplateId = couponTemplateId, + PhysicalName = physicalName, + PickupMethod = pickupMethod, + Description = description, + ExchangeType = exchangeType, + RequiredPoints = requiredPoints, + CashAmount = cashAmount, + StockTotal = stockTotal, + StockAvailable = stockTotal, + PerMemberLimit = perMemberLimit, + NotifyChannelsJson = MemberPointMallMapping.SerializeNotifyChannels(notifyChannels), + Status = status + }; + + await repository.AddProductAsync(entity, cancellationToken); + } + + await repository.SaveChangesAsync(cancellationToken); + + var aggregates = await repository.GetProductAggregatesAsync( + tenantId, + request.StoreId, + [entity.Id], + cancellationToken); + + var aggregate = aggregates.TryGetValue(entity.Id, out var value) + ? value + : MemberPointMallDtoFactory.EmptyProductAggregate(entity.Id); + + return MemberPointMallDtoFactory.ToProductDto(entity, aggregate); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/SavePointMallRuleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/SavePointMallRuleCommandHandler.cs new file mode 100644 index 0000000..74956be --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/SavePointMallRuleCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 保存积分商城规则处理器。 +/// +public sealed class SavePointMallRuleCommandHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + SavePointMallRuleCommand request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var expiryMode = MemberPointMallMapping.ParseExpiryMode(request.ExpiryMode); + var consumeAmountPerStep = request.IsConsumeRewardEnabled + ? MemberPointMallMapping.NormalizePositiveInt(request.ConsumeAmountPerStep, "consumeAmountPerStep") + : Math.Max(1, request.ConsumeAmountPerStep); + var consumeRewardPointsPerStep = request.IsConsumeRewardEnabled + ? MemberPointMallMapping.NormalizePositiveInt(request.ConsumeRewardPointsPerStep, "consumeRewardPointsPerStep") + : Math.Max(0, request.ConsumeRewardPointsPerStep); + var reviewRewardPoints = request.IsReviewRewardEnabled + ? MemberPointMallMapping.NormalizePositiveInt(request.ReviewRewardPoints, "reviewRewardPoints") + : Math.Max(0, request.ReviewRewardPoints); + var registerRewardPoints = request.IsRegisterRewardEnabled + ? MemberPointMallMapping.NormalizePositiveInt(request.RegisterRewardPoints, "registerRewardPoints") + : Math.Max(0, request.RegisterRewardPoints); + var signinRewardPoints = request.IsSigninRewardEnabled + ? MemberPointMallMapping.NormalizePositiveInt(request.SigninRewardPoints, "signinRewardPoints") + : Math.Max(0, request.SigninRewardPoints); + + var existing = await repository.GetRuleByStoreAsync( + tenantId, + request.StoreId, + cancellationToken); + + if (existing is null) + { + var created = MemberPointMallDtoFactory.CreateRuleEntity(request, expiryMode); + created.ConsumeAmountPerStep = consumeAmountPerStep; + created.ConsumeRewardPointsPerStep = consumeRewardPointsPerStep; + created.ReviewRewardPoints = reviewRewardPoints; + created.RegisterRewardPoints = registerRewardPoints; + created.SigninRewardPoints = signinRewardPoints; + await repository.AddRuleAsync(created, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + + return MemberPointMallDtoFactory.ToRuleDto(created); + } + + existing.IsConsumeRewardEnabled = request.IsConsumeRewardEnabled; + existing.ConsumeAmountPerStep = consumeAmountPerStep; + existing.ConsumeRewardPointsPerStep = consumeRewardPointsPerStep; + existing.IsReviewRewardEnabled = request.IsReviewRewardEnabled; + existing.ReviewRewardPoints = reviewRewardPoints; + existing.IsRegisterRewardEnabled = request.IsRegisterRewardEnabled; + existing.RegisterRewardPoints = registerRewardPoints; + existing.IsSigninRewardEnabled = request.IsSigninRewardEnabled; + existing.SigninRewardPoints = signinRewardPoints; + existing.ExpiryMode = expiryMode; + + await repository.UpdateRuleAsync(existing, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + + return MemberPointMallDtoFactory.ToRuleDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/VerifyPointMallRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/VerifyPointMallRecordCommandHandler.cs new file mode 100644 index 0000000..0d6a433 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/VerifyPointMallRecordCommandHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 核销积分商城兑换记录处理器。 +/// +public sealed class VerifyPointMallRecordCommandHandler( + IPointMallRepository repository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle( + VerifyPointMallRecordCommand request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var verifyMethod = MemberPointMallMapping.ParseVerifyMethod(request.VerifyMethod); + var verifyRemark = MemberPointMallMapping.NormalizeRemark(request.VerifyRemark, "verifyRemark"); + + var record = await repository.FindRecordByIdAsync( + tenantId, + request.StoreId, + request.RecordId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换记录不存在"); + + if (record.Status != MemberPointMallRecordStatus.PendingPickup) + { + throw new BusinessException(ErrorCodes.BadRequest, "当前状态不可核销"); + } + + var nowUtc = DateTime.UtcNow; + record.Status = MemberPointMallRecordStatus.Completed; + record.IssuedAt ??= nowUtc; + record.VerifiedAt = nowUtc; + record.VerifyMethod = verifyMethod; + record.VerifyRemark = verifyRemark; + record.VerifiedBy = currentUserAccessor.IsAuthenticated && currentUserAccessor.UserId > 0 + ? currentUserAccessor.UserId + : null; + + await repository.UpdateRecordAsync(record, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + + return MemberPointMallDtoFactory.ToRecordDetailDto(record); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/WritePointMallRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/WritePointMallRecordCommandHandler.cs new file mode 100644 index 0000000..e70d5e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Handlers/WritePointMallRecordCommandHandler.cs @@ -0,0 +1,118 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Handlers; + +/// +/// 写入积分商城兑换记录处理器。 +/// +public sealed class WritePointMallRecordCommandHandler( + IPointMallRepository repository, + IMemberRepository memberRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle( + WritePointMallRecordCommand request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var redeemedAt = request.RedeemedAt.HasValue + ? MemberPointMallMapping.NormalizeUtc(request.RedeemedAt.Value) + : DateTime.UtcNow; + + var product = await repository.FindProductByIdAsync( + tenantId, + request.StoreId, + request.PointMallProductId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "兑换商品不存在"); + + if (product.Status != MemberPointMallProductStatus.Enabled) + { + throw new BusinessException(ErrorCodes.BadRequest, "兑换商品未上架"); + } + + if (product.StockAvailable <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "兑换商品库存不足"); + } + + var member = await memberRepository.FindProfileByIdAsync( + tenantId, + request.MemberId, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "会员不存在"); + + var usedPoints = MemberPointMallMapping.NormalizeRequiredPoints(product.RequiredPoints); + if (member.PointsBalance < usedPoints) + { + throw new BusinessException(ErrorCodes.BadRequest, "会员积分不足"); + } + + if (product.PerMemberLimit.HasValue && product.PerMemberLimit.Value > 0) + { + var redeemedCount = await repository.CountMemberRedeemsByProductAsync( + tenantId, + request.StoreId, + product.Id, + member.Id, + cancellationToken); + + if (redeemedCount >= product.PerMemberLimit.Value) + { + throw new BusinessException(ErrorCodes.BadRequest, "已达到每人限兑次数"); + } + } + + member.PointsBalance -= usedPoints; + await memberRepository.UpdateProfileAsync(member, cancellationToken); + + product.StockAvailable -= 1; + await repository.UpdateProductAsync(product, cancellationToken); + + var initialStatus = MemberPointMallMapping.ResolveRecordInitialStatus(product.RedeemType); + var record = new MemberPointMallRecord + { + StoreId = request.StoreId, + RecordNo = MemberPointMallMapping.BuildRecordNo(redeemedAt), + PointMallProductId = product.Id, + MemberId = member.Id, + MemberName = MemberPointMallMapping.ResolveMemberName(member), + MemberMobileMasked = MemberPointMallMapping.ResolveMemberMobileMasked(member), + ProductName = product.Name, + RedeemType = product.RedeemType, + ExchangeType = product.ExchangeType, + UsedPoints = usedPoints, + CashAmount = product.CashAmount, + Status = initialStatus, + RedeemedAt = redeemedAt, + IssuedAt = MemberPointMallMapping.ResolveRecordInitialIssuedAt(product.RedeemType, redeemedAt) + }; + + await repository.AddRecordAsync(record, cancellationToken); + + var ledger = new MemberPointLedger + { + MemberId = member.Id, + ChangeAmount = -usedPoints, + BalanceAfterChange = member.PointsBalance, + Reason = PointChangeReason.Redeem, + SourceId = product.Id, + OccurredAt = redeemedAt + }; + + await repository.AddPointLedgerAsync(ledger, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + + return MemberPointMallDtoFactory.ToRecordDto(record); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/MemberPointMallDtoFactory.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/MemberPointMallDtoFactory.cs new file mode 100644 index 0000000..62358a8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/MemberPointMallDtoFactory.cs @@ -0,0 +1,203 @@ +using TakeoutSaaS.Application.App.Members.PointsMall.Commands; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Domain.Membership.Repositories; + +namespace TakeoutSaaS.Application.App.Members.PointsMall; + +/// +/// 积分商城 DTO 构造器。 +/// +internal static class MemberPointMallDtoFactory +{ + public static MemberPointMallProductAggregateSnapshot EmptyProductAggregate(long pointMallProductId) + { + return new MemberPointMallProductAggregateSnapshot + { + PointMallProductId = pointMallProductId, + RedeemedCount = 0 + }; + } + + public static MemberPointMallRuleDto ToRuleDto(MemberPointMallRule source) + { + return new MemberPointMallRuleDto + { + StoreId = source.StoreId, + IsConsumeRewardEnabled = source.IsConsumeRewardEnabled, + ConsumeAmountPerStep = source.ConsumeAmountPerStep, + ConsumeRewardPointsPerStep = source.ConsumeRewardPointsPerStep, + IsReviewRewardEnabled = source.IsReviewRewardEnabled, + ReviewRewardPoints = source.ReviewRewardPoints, + IsRegisterRewardEnabled = source.IsRegisterRewardEnabled, + RegisterRewardPoints = source.RegisterRewardPoints, + IsSigninRewardEnabled = source.IsSigninRewardEnabled, + SigninRewardPoints = source.SigninRewardPoints, + ExpiryMode = MemberPointMallMapping.ToExpiryModeText(source.ExpiryMode) + }; + } + + public static MemberPointMallRuleStatsDto ToRuleStatsDto(MemberPointMallRuleStatsSnapshot source) + { + return new MemberPointMallRuleStatsDto + { + TotalIssuedPoints = source.TotalIssuedPoints, + RedeemedPoints = source.RedeemedPoints, + PointMembers = source.PointMembers, + RedeemRate = decimal.Round(source.RedeemRate, 1, MidpointRounding.AwayFromZero) + }; + } + + public static MemberPointMallProductDto ToProductDto( + MemberPointMallProduct source, + MemberPointMallProductAggregateSnapshot aggregate) + { + var notifyChannels = MemberPointMallMapping.DeserializeNotifyChannels(source.NotifyChannelsJson) + .Select(MemberPointMallMapping.ToNotifyChannelText) + .ToList(); + + return new MemberPointMallProductDto + { + PointMallProductId = source.Id, + StoreId = source.StoreId, + Name = source.Name, + ImageUrl = source.ImageUrl, + RedeemType = MemberPointMallMapping.ToRedeemTypeText(source.RedeemType), + ProductId = source.ProductId, + CouponTemplateId = source.CouponTemplateId, + PhysicalName = source.PhysicalName, + PickupMethod = source.PickupMethod.HasValue + ? MemberPointMallMapping.ToPickupMethodText(source.PickupMethod.Value) + : null, + Description = source.Description, + ExchangeType = MemberPointMallMapping.ToExchangeTypeText(source.ExchangeType), + RequiredPoints = source.RequiredPoints, + CashAmount = decimal.Round(source.CashAmount, 2, MidpointRounding.AwayFromZero), + StockTotal = source.StockTotal, + StockAvailable = source.StockAvailable, + RedeemedCount = aggregate.RedeemedCount, + PerMemberLimit = source.PerMemberLimit, + NotifyChannels = notifyChannels, + Status = MemberPointMallMapping.ToProductStatusText(source.Status), + UpdatedAt = source.UpdatedAt ?? source.CreatedAt + }; + } + + public static MemberPointMallRecordDto ToRecordDto(MemberPointMallRecord source) + { + return new MemberPointMallRecordDto + { + RecordId = source.Id, + RecordNo = source.RecordNo, + PointMallProductId = source.PointMallProductId, + ProductName = source.ProductName, + RedeemType = MemberPointMallMapping.ToRedeemTypeText(source.RedeemType), + ExchangeType = MemberPointMallMapping.ToExchangeTypeText(source.ExchangeType), + MemberId = source.MemberId, + MemberName = source.MemberName, + MemberMobileMasked = source.MemberMobileMasked, + UsedPoints = source.UsedPoints, + CashAmount = decimal.Round(source.CashAmount, 2, MidpointRounding.AwayFromZero), + Status = MemberPointMallMapping.ToRecordStatusText(source.Status), + RedeemedAt = source.RedeemedAt, + IssuedAt = source.IssuedAt, + VerifiedAt = source.VerifiedAt + }; + } + + public static MemberPointMallRecordDetailDto ToRecordDetailDto(MemberPointMallRecord source) + { + return new MemberPointMallRecordDetailDto + { + RecordId = source.Id, + RecordNo = source.RecordNo, + PointMallProductId = source.PointMallProductId, + ProductName = source.ProductName, + RedeemType = MemberPointMallMapping.ToRedeemTypeText(source.RedeemType), + ExchangeType = MemberPointMallMapping.ToExchangeTypeText(source.ExchangeType), + MemberId = source.MemberId, + MemberName = source.MemberName, + MemberMobileMasked = source.MemberMobileMasked, + UsedPoints = source.UsedPoints, + CashAmount = decimal.Round(source.CashAmount, 2, MidpointRounding.AwayFromZero), + Status = MemberPointMallMapping.ToRecordStatusText(source.Status), + RedeemedAt = source.RedeemedAt, + IssuedAt = source.IssuedAt, + VerifiedAt = source.VerifiedAt, + VerifyMethod = source.VerifyMethod.HasValue + ? MemberPointMallMapping.ToVerifyMethodText(source.VerifyMethod.Value) + : null, + VerifyRemark = source.VerifyRemark, + VerifiedBy = source.VerifiedBy + }; + } + + public static MemberPointMallRecordStatsDto ToRecordStatsDto(MemberPointMallRecordStatsSnapshot source) + { + return new MemberPointMallRecordStatsDto + { + TodayRedeemCount = source.TodayRedeemCount, + PendingPhysicalCount = source.PendingPhysicalCount, + CurrentMonthUsedPoints = source.CurrentMonthUsedPoints + }; + } + + public static MemberPointMallRule CreateRuleEntity( + SavePointMallRuleCommand request, + MemberPointMallExpiryMode expiryMode) + { + return new MemberPointMallRule + { + StoreId = request.StoreId, + IsConsumeRewardEnabled = request.IsConsumeRewardEnabled, + ConsumeAmountPerStep = request.ConsumeAmountPerStep, + ConsumeRewardPointsPerStep = request.ConsumeRewardPointsPerStep, + IsReviewRewardEnabled = request.IsReviewRewardEnabled, + ReviewRewardPoints = request.ReviewRewardPoints, + IsRegisterRewardEnabled = request.IsRegisterRewardEnabled, + RegisterRewardPoints = request.RegisterRewardPoints, + IsSigninRewardEnabled = request.IsSigninRewardEnabled, + SigninRewardPoints = request.SigninRewardPoints, + ExpiryMode = expiryMode + }; + } + + public static MemberPointMallProduct CreateProductEntity( + SavePointMallProductCommand request, + MemberPointMallRedeemType redeemType, + MemberPointMallExchangeType exchangeType, + MemberPointMallProductStatus status, + string name, + string? imageUrl, + string? physicalName, + MemberPointMallPickupMethod? pickupMethod, + string? description, + int requiredPoints, + decimal cashAmount, + int stockTotal, + int? perMemberLimit, + IReadOnlyCollection notifyChannels) + { + return new MemberPointMallProduct + { + StoreId = request.StoreId, + Name = name, + ImageUrl = imageUrl, + RedeemType = redeemType, + ProductId = request.ProductId, + CouponTemplateId = request.CouponTemplateId, + PhysicalName = physicalName, + PickupMethod = pickupMethod, + Description = description, + ExchangeType = exchangeType, + RequiredPoints = requiredPoints, + CashAmount = cashAmount, + StockTotal = stockTotal, + StockAvailable = stockTotal, + PerMemberLimit = perMemberLimit, + NotifyChannelsJson = MemberPointMallMapping.SerializeNotifyChannels(notifyChannels), + Status = status + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/MemberPointMallMapping.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/MemberPointMallMapping.cs new file mode 100644 index 0000000..e2462d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/MemberPointMallMapping.cs @@ -0,0 +1,583 @@ +using System.Globalization; +using System.Text.Json; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Members.PointsMall; + +/// +/// 积分商城模块映射与标准化工具。 +/// +internal static class MemberPointMallMapping +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public static MemberPointMallExpiryMode ParseExpiryMode(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "permanent" => MemberPointMallExpiryMode.Permanent, + "yearly_clear" => MemberPointMallExpiryMode.YearlyClear, + _ => throw new BusinessException(ErrorCodes.BadRequest, "expiryMode 参数不合法") + }; + } + + public static string ToExpiryModeText(MemberPointMallExpiryMode value) + { + return value switch + { + MemberPointMallExpiryMode.Permanent => "permanent", + MemberPointMallExpiryMode.YearlyClear => "yearly_clear", + _ => "yearly_clear" + }; + } + + public static MemberPointMallRedeemType ParseRedeemType(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "product" => MemberPointMallRedeemType.Product, + "coupon" => MemberPointMallRedeemType.Coupon, + "physical" => MemberPointMallRedeemType.Physical, + _ => throw new BusinessException(ErrorCodes.BadRequest, "redeemType 参数不合法") + }; + } + + public static MemberPointMallRedeemType? TryParseRedeemType(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return ParseRedeemType(value); + } + + public static string ToRedeemTypeText(MemberPointMallRedeemType value) + { + return value switch + { + MemberPointMallRedeemType.Product => "product", + MemberPointMallRedeemType.Coupon => "coupon", + MemberPointMallRedeemType.Physical => "physical", + _ => "product" + }; + } + + public static string ToRedeemTypeDisplayText(MemberPointMallRedeemType value) + { + return value switch + { + MemberPointMallRedeemType.Product => "商品", + MemberPointMallRedeemType.Coupon => "优惠券", + MemberPointMallRedeemType.Physical => "实物", + _ => "未知" + }; + } + + public static MemberPointMallExchangeType ParseExchangeType(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "points" => MemberPointMallExchangeType.PointsOnly, + "mixed" => MemberPointMallExchangeType.PointsAndCash, + _ => throw new BusinessException(ErrorCodes.BadRequest, "exchangeType 参数不合法") + }; + } + + public static string ToExchangeTypeText(MemberPointMallExchangeType value) + { + return value switch + { + MemberPointMallExchangeType.PointsOnly => "points", + MemberPointMallExchangeType.PointsAndCash => "mixed", + _ => "points" + }; + } + + public static MemberPointMallProductStatus ParseProductStatus(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "enabled" => MemberPointMallProductStatus.Enabled, + "disabled" => MemberPointMallProductStatus.Disabled, + _ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法") + }; + } + + public static MemberPointMallProductStatus? TryParseProductStatus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return ParseProductStatus(value); + } + + public static string ToProductStatusText(MemberPointMallProductStatus value) + { + return value switch + { + MemberPointMallProductStatus.Enabled => "enabled", + MemberPointMallProductStatus.Disabled => "disabled", + _ => "disabled" + }; + } + + public static MemberPointMallPickupMethod ParsePickupMethod(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "store_pickup" => MemberPointMallPickupMethod.StorePickup, + "delivery" => MemberPointMallPickupMethod.Delivery, + _ => throw new BusinessException(ErrorCodes.BadRequest, "pickupMethod 参数不合法") + }; + } + + public static string ToPickupMethodText(MemberPointMallPickupMethod value) + { + return value switch + { + MemberPointMallPickupMethod.StorePickup => "store_pickup", + MemberPointMallPickupMethod.Delivery => "delivery", + _ => "store_pickup" + }; + } + + public static MemberPointMallVerifyMethod ParseVerifyMethod(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "scan" => MemberPointMallVerifyMethod.Scan, + "manual" => MemberPointMallVerifyMethod.Manual, + _ => throw new BusinessException(ErrorCodes.BadRequest, "verifyMethod 参数不合法") + }; + } + + public static string ToVerifyMethodText(MemberPointMallVerifyMethod value) + { + return value switch + { + MemberPointMallVerifyMethod.Scan => "scan", + MemberPointMallVerifyMethod.Manual => "manual", + _ => "manual" + }; + } + + public static string ToVerifyMethodDisplayText(MemberPointMallVerifyMethod value) + { + return value switch + { + MemberPointMallVerifyMethod.Scan => "扫码核销", + MemberPointMallVerifyMethod.Manual => "手动核销", + _ => "未知" + }; + } + + public static MemberPointMallRecordStatus ParseRecordStatus(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "pending_pickup" => MemberPointMallRecordStatus.PendingPickup, + "issued" => MemberPointMallRecordStatus.Issued, + "completed" => MemberPointMallRecordStatus.Completed, + "canceled" => MemberPointMallRecordStatus.Canceled, + _ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法") + }; + } + + public static MemberPointMallRecordStatus? TryParseRecordStatus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return ParseRecordStatus(value); + } + + public static string ToRecordStatusText(MemberPointMallRecordStatus value) + { + return value switch + { + MemberPointMallRecordStatus.PendingPickup => "pending_pickup", + MemberPointMallRecordStatus.Issued => "issued", + MemberPointMallRecordStatus.Completed => "completed", + MemberPointMallRecordStatus.Canceled => "canceled", + _ => "issued" + }; + } + + public static string ToRecordStatusDisplayText(MemberPointMallRecordStatus value) + { + return value switch + { + MemberPointMallRecordStatus.PendingPickup => "待领取", + MemberPointMallRecordStatus.Issued => "已发放", + MemberPointMallRecordStatus.Completed => "已完成", + MemberPointMallRecordStatus.Canceled => "已取消", + _ => "未知" + }; + } + + public static MemberPointMallNotifyChannel ParseNotifyChannel(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "in_app" => MemberPointMallNotifyChannel.InApp, + "sms" => MemberPointMallNotifyChannel.Sms, + _ => throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 参数不合法") + }; + } + + public static string ToNotifyChannelText(MemberPointMallNotifyChannel value) + { + return value switch + { + MemberPointMallNotifyChannel.InApp => "in_app", + MemberPointMallNotifyChannel.Sms => "sms", + _ => "in_app" + }; + } + + public static IReadOnlyList ParseNotifyChannels( + IReadOnlyCollection? values) + { + var parsed = (values ?? Array.Empty()) + .Select(ParseNotifyChannel) + .Distinct() + .ToList(); + + if (parsed.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 至少选择一项"); + } + + return parsed; + } + + public static IReadOnlyList DeserializeNotifyChannels(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + try + { + var source = JsonSerializer.Deserialize>(value, JsonOptions) ?? []; + var channels = source + .Select(item => + { + try + { + return (MemberPointMallNotifyChannel?)ParseNotifyChannel(item); + } + catch + { + return null; + } + }) + .Where(item => item.HasValue) + .Select(item => item!.Value) + .Distinct() + .ToList(); + + return channels; + } + catch + { + return []; + } + } + + public static string SerializeNotifyChannels(IReadOnlyCollection values) + { + var payload = (values ?? Array.Empty()) + .Distinct() + .OrderBy(item => item) + .Select(ToNotifyChannelText) + .ToList(); + + return JsonSerializer.Serialize(payload, JsonOptions); + } + + public static string NormalizeName(string? value, string fieldName = "name") + { + var normalized = (value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空"); + } + + if (normalized.Length > 64) + { + throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 64"); + } + + return normalized; + } + + public static string? NormalizePhysicalName(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new BusinessException(ErrorCodes.BadRequest, "physicalName 不能为空"); + } + + if (normalized.Length > 64) + { + throw new BusinessException(ErrorCodes.BadRequest, "physicalName 长度不能超过 64"); + } + + return normalized; + } + + public static string? NormalizeImageUrl(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + if (normalized.Length == 0) + { + return null; + } + + if (normalized.Length > 512) + { + throw new BusinessException(ErrorCodes.BadRequest, "imageUrl 长度不能超过 512"); + } + + return normalized; + } + + public static string? NormalizeDescription(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? NormalizeKeyword(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + if (normalized.Length == 0) + { + return null; + } + + if (normalized.Length > 64) + { + throw new BusinessException(ErrorCodes.BadRequest, "keyword 长度不能超过 64"); + } + + return normalized; + } + + public static string? NormalizeRemark(string? value, string fieldName = "remark") + { + var normalized = (value ?? string.Empty).Trim(); + if (normalized.Length == 0) + { + return null; + } + + if (normalized.Length > 256) + { + throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 256"); + } + + return normalized; + } + + public static int NormalizePositiveInt(int value, string fieldName) + { + if (value <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 必须大于 0"); + } + + return value; + } + + public static int NormalizeRequiredPoints(int value) + { + if (value <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "requiredPoints 必须大于 0"); + } + + if (value > 1_000_000) + { + throw new BusinessException(ErrorCodes.BadRequest, "requiredPoints 不能超过 1000000"); + } + + return value; + } + + public static int NormalizeStockTotal(int value) + { + if (value < 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "stockTotal 不能小于 0"); + } + + if (value > 10_000_000) + { + throw new BusinessException(ErrorCodes.BadRequest, "stockTotal 不能超过 10000000"); + } + + return value; + } + + public static int? NormalizePerMemberLimit(int? value) + { + if (!value.HasValue || value.Value <= 0) + { + return null; + } + + if (value.Value > 9999) + { + throw new BusinessException(ErrorCodes.BadRequest, "perMemberLimit 不能超过 9999"); + } + + return value.Value; + } + + public static decimal NormalizeCashAmount(decimal value, MemberPointMallExchangeType exchangeType) + { + var normalized = decimal.Round(value, 2, MidpointRounding.AwayFromZero); + if (exchangeType == MemberPointMallExchangeType.PointsOnly) + { + return 0m; + } + + if (normalized <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "cashAmount 必须大于 0"); + } + + return normalized; + } + + public static (DateTime? StartUtc, DateTime? EndUtc) NormalizeDateRange(DateTime? startUtc, DateTime? endUtc) + { + DateTime? normalizedStart = null; + DateTime? normalizedEnd = null; + + if (startUtc.HasValue) + { + var utc = NormalizeUtc(startUtc.Value); + normalizedStart = new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc); + } + + if (endUtc.HasValue) + { + var utc = NormalizeUtc(endUtc.Value); + normalizedEnd = new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc) + .AddDays(1) + .AddTicks(-1); + } + + if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart.Value > normalizedEnd.Value) + { + throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期"); + } + + return (normalizedStart, normalizedEnd); + } + + 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 ResolveMemberName(MemberProfile member) + { + var nickname = (member.Nickname ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(nickname)) + { + return nickname.Length <= 64 ? nickname : nickname[..64]; + } + + var mobile = NormalizePhone(member.Mobile); + return mobile.Length >= 4 ? $"会员{mobile[^4..]}" : "会员"; + } + + public static string ResolveMemberMobileMasked(MemberProfile member) + { + return MaskPhone(NormalizePhone(member.Mobile)); + } + + public static string BuildRecordNo(DateTime nowUtc) + { + var utcNow = NormalizeUtc(nowUtc); + return $"PT{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}"; + } + + public static MemberPointMallRecordStatus ResolveRecordInitialStatus(MemberPointMallRedeemType redeemType) + { + return redeemType == MemberPointMallRedeemType.Physical + ? MemberPointMallRecordStatus.PendingPickup + : MemberPointMallRecordStatus.Issued; + } + + public static DateTime? ResolveRecordInitialIssuedAt(MemberPointMallRedeemType redeemType, DateTime redeemedAt) + { + return redeemType == MemberPointMallRedeemType.Physical ? null : redeemedAt; + } + + private static string NormalizePhone(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var chars = value.Where(char.IsDigit).ToArray(); + return chars.Length == 0 ? string.Empty : new string(chars); + } + + private static string MaskPhone(string normalizedPhone) + { + if (normalizedPhone.Length >= 11) + { + return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}"; + } + + if (normalizedPhone.Length >= 7) + { + return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}"; + } + + return normalizedPhone; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/ExportPointMallRecordCsvQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/ExportPointMallRecordCsvQuery.cs new file mode 100644 index 0000000..c745f7c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/ExportPointMallRecordCsvQuery.cs @@ -0,0 +1,40 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries; + +/// +/// 导出积分商城兑换记录 CSV。 +/// +public sealed class ExportPointMallRecordCsvQuery : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string? RedeemType { get; init; } + + /// + /// 状态(pending_pickup/issued/completed/canceled)。 + /// + public string? Status { get; init; } + + /// + /// 开始日期(UTC,可空)。 + /// + public DateTime? StartDateUtc { get; init; } + + /// + /// 结束日期(UTC,可空)。 + /// + public DateTime? EndDateUtc { get; init; } + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallProductDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallProductDetailQuery.cs new file mode 100644 index 0000000..b507213 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallProductDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries; + +/// +/// 查询积分商城商品详情。 +/// +public sealed class GetPointMallProductDetailQuery : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 积分商城商品标识。 + /// + public long PointMallProductId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallProductListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallProductListQuery.cs new file mode 100644 index 0000000..f5c1307 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallProductListQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries; + +/// +/// 查询积分商城商品列表。 +/// +public sealed class GetPointMallProductListQuery : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 状态筛选(enabled/disabled)。 + /// + public string? Status { get; init; } + + /// + /// 关键字(名称)。 + /// + public string? Keyword { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRecordDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRecordDetailQuery.cs new file mode 100644 index 0000000..43c7509 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRecordDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries; + +/// +/// 查询积分商城兑换记录详情。 +/// +public sealed class GetPointMallRecordDetailQuery : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 兑换记录标识。 + /// + public long RecordId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRecordListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRecordListQuery.cs new file mode 100644 index 0000000..f0bc2f4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRecordListQuery.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries; + +/// +/// 查询积分商城兑换记录分页。 +/// +public sealed class GetPointMallRecordListQuery : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } + + /// + /// 兑换类型(product/coupon/physical)。 + /// + public string? RedeemType { get; init; } + + /// + /// 状态(pending_pickup/issued/completed/canceled)。 + /// + public string? Status { get; init; } + + /// + /// 开始日期(UTC,可空)。 + /// + public DateTime? StartDateUtc { get; init; } + + /// + /// 结束日期(UTC,可空)。 + /// + public DateTime? EndDateUtc { get; init; } + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRuleDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRuleDetailQuery.cs new file mode 100644 index 0000000..73dfe89 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Members/PointsMall/Queries/GetPointMallRuleDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Members.PointsMall.Dto; + +namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries; + +/// +/// 查询积分商城规则详情。 +/// +public sealed class GetPointMallRuleDetailQuery : IRequest +{ + /// + /// 门店标识。 + /// + public long StoreId { get; init; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallProduct.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallProduct.cs new file mode 100644 index 0000000..96c730b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallProduct.cs @@ -0,0 +1,95 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员积分商城兑换商品。 +/// +public sealed class MemberPointMallProduct : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 展示名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 展示图片地址。 + /// + public string? ImageUrl { get; set; } + + /// + /// 兑换类型。 + /// + public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product; + + /// + /// 关联商品 ID(兑换商品时必填)。 + /// + public long? ProductId { get; set; } + + /// + /// 关联优惠券模板 ID(兑换优惠券时必填)。 + /// + public long? CouponTemplateId { get; set; } + + /// + /// 实物名称(兑换实物时必填)。 + /// + public string? PhysicalName { get; set; } + + /// + /// 实物领取方式。 + /// + public MemberPointMallPickupMethod? PickupMethod { get; set; } + + /// + /// 商品描述。 + /// + public string? Description { get; set; } + + /// + /// 兑换方式(纯积分/积分+现金)。 + /// + public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly; + + /// + /// 所需积分。 + /// + public int RequiredPoints { get; set; } + + /// + /// 现金部分(积分+现金时使用)。 + /// + public decimal CashAmount { get; set; } + + /// + /// 初始库存数量。 + /// + public int StockTotal { get; set; } + + /// + /// 剩余库存数量。 + /// + public int StockAvailable { get; set; } + + /// + /// 每人限兑次数(null 表示不限)。 + /// + public int? PerMemberLimit { get; set; } + + /// + /// 到账通知渠道(JSON 数组)。 + /// + public string NotifyChannelsJson { get; set; } = "[]"; + + /// + /// 上下架状态。 + /// + public MemberPointMallProductStatus Status { get; set; } = MemberPointMallProductStatus.Enabled; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallRecord.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallRecord.cs new file mode 100644 index 0000000..5e13ead --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallRecord.cs @@ -0,0 +1,100 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员积分商城兑换记录。 +/// +public sealed class MemberPointMallRecord : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 兑换记录单号。 + /// + public string RecordNo { get; set; } = string.Empty; + + /// + /// 关联积分商品 ID。 + /// + public long PointMallProductId { get; set; } + + /// + /// 会员标识。 + /// + public long MemberId { get; set; } + + /// + /// 会员名称快照。 + /// + public string MemberName { get; set; } = string.Empty; + + /// + /// 会员手机号快照(脱敏)。 + /// + public string MemberMobileMasked { get; set; } = string.Empty; + + /// + /// 商品名称快照。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// 兑换类型快照。 + /// + public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product; + + /// + /// 兑换方式快照。 + /// + public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly; + + /// + /// 消耗积分。 + /// + public int UsedPoints { get; set; } + + /// + /// 现金部分。 + /// + public decimal CashAmount { get; set; } + + /// + /// 记录状态。 + /// + public MemberPointMallRecordStatus Status { get; set; } = MemberPointMallRecordStatus.Issued; + + /// + /// 兑换时间(UTC)。 + /// + public DateTime RedeemedAt { get; set; } + + /// + /// 发放时间(UTC)。 + /// + public DateTime? IssuedAt { get; set; } + + /// + /// 核销时间(UTC)。 + /// + public DateTime? VerifiedAt { get; set; } + + /// + /// 核销方式。 + /// + public MemberPointMallVerifyMethod? VerifyMethod { get; set; } + + /// + /// 核销备注。 + /// + public string? VerifyRemark { get; set; } + + /// + /// 核销人用户标识。 + /// + public long? VerifiedBy { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallRule.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallRule.cs new file mode 100644 index 0000000..e40c0ae --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointMallRule.cs @@ -0,0 +1,65 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员积分商城规则配置。 +/// +public sealed class MemberPointMallRule : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 是否启用消费获取。 + /// + public bool IsConsumeRewardEnabled { get; set; } = true; + + /// + /// 每消费多少元触发一次积分计算。 + /// + public int ConsumeAmountPerStep { get; set; } = 1; + + /// + /// 每步获得积分。 + /// + public int ConsumeRewardPointsPerStep { get; set; } = 1; + + /// + /// 是否启用评价奖励。 + /// + public bool IsReviewRewardEnabled { get; set; } = true; + + /// + /// 评价奖励积分。 + /// + public int ReviewRewardPoints { get; set; } = 10; + + /// + /// 是否启用注册奖励。 + /// + public bool IsRegisterRewardEnabled { get; set; } = true; + + /// + /// 注册奖励积分。 + /// + public int RegisterRewardPoints { get; set; } = 100; + + /// + /// 是否启用签到奖励。 + /// + public bool IsSigninRewardEnabled { get; set; } + + /// + /// 签到奖励积分。 + /// + public int SigninRewardPoints { get; set; } = 5; + + /// + /// 积分有效期模式。 + /// + public MemberPointMallExpiryMode ExpiryMode { get; set; } = MemberPointMallExpiryMode.YearlyClear; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallExchangeType.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallExchangeType.cs new file mode 100644 index 0000000..08753cb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallExchangeType.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 兑换方式。 +/// +public enum MemberPointMallExchangeType +{ + /// + /// 纯积分。 + /// + PointsOnly = 0, + + /// + /// 积分 + 现金。 + /// + PointsAndCash = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallExpiryMode.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallExpiryMode.cs new file mode 100644 index 0000000..0c443ee --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallExpiryMode.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 积分有效期模式。 +/// +public enum MemberPointMallExpiryMode +{ + /// + /// 永久有效。 + /// + Permanent = 0, + + /// + /// 按年清零(每年 12 月 31 日)。 + /// + YearlyClear = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallNotifyChannel.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallNotifyChannel.cs new file mode 100644 index 0000000..b00be11 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallNotifyChannel.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 到账通知渠道。 +/// +public enum MemberPointMallNotifyChannel +{ + /// + /// 站内消息。 + /// + InApp = 0, + + /// + /// 短信通知。 + /// + Sms = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallPickupMethod.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallPickupMethod.cs new file mode 100644 index 0000000..f517d51 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallPickupMethod.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 实物领取方式。 +/// +public enum MemberPointMallPickupMethod +{ + /// + /// 到店自提。 + /// + StorePickup = 0, + + /// + /// 快递配送。 + /// + Delivery = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallProductStatus.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallProductStatus.cs new file mode 100644 index 0000000..b2275fe --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallProductStatus.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 积分商城商品状态。 +/// +public enum MemberPointMallProductStatus +{ + /// + /// 下架。 + /// + Disabled = 0, + + /// + /// 上架。 + /// + Enabled = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallRecordStatus.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallRecordStatus.cs new file mode 100644 index 0000000..cee55a5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallRecordStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 兑换记录状态。 +/// +public enum MemberPointMallRecordStatus +{ + /// + /// 待领取。 + /// + PendingPickup = 0, + + /// + /// 已发放。 + /// + Issued = 1, + + /// + /// 已完成。 + /// + Completed = 2, + + /// + /// 已取消。 + /// + Canceled = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallRedeemType.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallRedeemType.cs new file mode 100644 index 0000000..ba174f8 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallRedeemType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 积分兑换类型。 +/// +public enum MemberPointMallRedeemType +{ + /// + /// 兑换商品。 + /// + Product = 0, + + /// + /// 兑换优惠券。 + /// + Coupon = 1, + + /// + /// 兑换实物。 + /// + Physical = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallVerifyMethod.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallVerifyMethod.cs new file mode 100644 index 0000000..956736b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberPointMallVerifyMethod.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 核销方式。 +/// +public enum MemberPointMallVerifyMethod +{ + /// + /// 扫码核销。 + /// + Scan = 0, + + /// + /// 手动核销。 + /// + Manual = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IPointMallRepository.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IPointMallRepository.cs new file mode 100644 index 0000000..97660aa --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Repositories/IPointMallRepository.cs @@ -0,0 +1,245 @@ +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; + +namespace TakeoutSaaS.Domain.Membership.Repositories; + +/// +/// 会员积分商城仓储契约。 +/// +public interface IPointMallRepository +{ + /// + /// 查询门店积分规则。 + /// + Task GetRuleByStoreAsync( + long tenantId, + long storeId, + CancellationToken cancellationToken = default); + + /// + /// 新增积分规则。 + /// + Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default); + + /// + /// 更新积分规则。 + /// + Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default); + + /// + /// 查询兑换商品列表。 + /// + Task> SearchProductsAsync( + long tenantId, + long storeId, + MemberPointMallProductStatus? status, + string? keyword, + CancellationToken cancellationToken = default); + + /// + /// 按标识查询兑换商品(追踪)。 + /// + Task FindProductByIdAsync( + long tenantId, + long storeId, + long productId, + CancellationToken cancellationToken = default); + + /// + /// 按标识查询兑换商品(只读)。 + /// + Task GetProductByIdAsync( + long tenantId, + long storeId, + long productId, + CancellationToken cancellationToken = default); + + /// + /// 新增兑换商品。 + /// + Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default); + + /// + /// 更新兑换商品。 + /// + Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default); + + /// + /// 删除兑换商品。 + /// + Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default); + + /// + /// 查询商品是否已有兑换记录。 + /// + Task HasRecordsByProductIdAsync( + long tenantId, + long storeId, + long pointMallProductId, + CancellationToken cancellationToken = default); + + /// + /// 统计会员在某商品上的有效兑换次数(排除已取消)。 + /// + Task CountMemberRedeemsByProductAsync( + long tenantId, + long storeId, + long pointMallProductId, + long memberId, + CancellationToken cancellationToken = default); + + /// + /// 查询兑换记录分页。 + /// + Task<(IReadOnlyList Items, int TotalCount)> SearchRecordsAsync( + long tenantId, + long storeId, + MemberPointMallRedeemType? redeemType, + MemberPointMallRecordStatus? status, + DateTime? startUtc, + DateTime? endUtc, + string? keyword, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// 查询兑换记录详情。 + /// + Task GetRecordByIdAsync( + long tenantId, + long storeId, + long recordId, + CancellationToken cancellationToken = default); + + /// + /// 查询兑换记录(追踪)。 + /// + Task FindRecordByIdAsync( + long tenantId, + long storeId, + long recordId, + CancellationToken cancellationToken = default); + + /// + /// 查询兑换记录导出数据。 + /// + Task> ListRecordsForExportAsync( + long tenantId, + long storeId, + MemberPointMallRedeemType? redeemType, + MemberPointMallRecordStatus? status, + DateTime? startUtc, + DateTime? endUtc, + string? keyword, + CancellationToken cancellationToken = default); + + /// + /// 新增兑换记录。 + /// + Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default); + + /// + /// 更新兑换记录。 + /// + Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default); + + /// + /// 新增积分流水。 + /// + Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default); + + /// + /// 查询规则页统计。 + /// + Task GetRuleStatsAsync( + long tenantId, + long storeId, + CancellationToken cancellationToken = default); + + /// + /// 查询记录页统计。 + /// + Task GetRecordStatsAsync( + long tenantId, + long storeId, + DateTime nowUtc, + CancellationToken cancellationToken = default); + + /// + /// 查询商品聚合统计快照。 + /// + Task> GetProductAggregatesAsync( + long tenantId, + long storeId, + IReadOnlyCollection pointMallProductIds, + CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} + +/// +/// 积分商城规则页统计快照。 +/// +public sealed record MemberPointMallRuleStatsSnapshot +{ + /// + /// 累计发放积分。 + /// + public int TotalIssuedPoints { get; init; } + + /// + /// 已兑换积分。 + /// + public int RedeemedPoints { get; init; } + + /// + /// 积分用户数。 + /// + public int PointMembers { get; init; } + + /// + /// 兑换率(0-100)。 + /// + public decimal RedeemRate { get; init; } +} + +/// +/// 积分商城记录页统计快照。 +/// +public sealed record MemberPointMallRecordStatsSnapshot +{ + /// + /// 今日兑换数量。 + /// + public int TodayRedeemCount { get; init; } + + /// + /// 待领取实物数量。 + /// + public int PendingPhysicalCount { get; init; } + + /// + /// 本月消耗积分。 + /// + public int CurrentMonthUsedPoints { get; init; } +} + +/// +/// 积分商城商品聚合快照。 +/// +public sealed record MemberPointMallProductAggregateSnapshot +{ + /// + /// 商品标识。 + /// + public required long PointMallProductId { get; init; } + + /// + /// 已兑换数量。 + /// + public int RedeemedCount { get; init; } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index be6c2a3..a02a0c2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -402,6 +402,18 @@ public sealed class TakeoutAppDbContext( /// public DbSet MemberPointLedgers => Set(); /// + /// 积分商城规则。 + /// + public DbSet MemberPointMallRules => Set(); + /// + /// 积分商城兑换商品。 + /// + public DbSet MemberPointMallProducts => Set(); + /// + /// 积分商城兑换记录。 + /// + public DbSet MemberPointMallRecords => Set(); + /// /// 会员储值方案。 /// public DbSet MemberStoredCardPlans => Set(); @@ -588,6 +600,9 @@ public sealed class TakeoutAppDbContext( ConfigureMemberProfileTag(modelBuilder.Entity()); ConfigureMemberDaySetting(modelBuilder.Entity()); ConfigureMemberPointLedger(modelBuilder.Entity()); + ConfigureMemberPointMallRule(modelBuilder.Entity()); + ConfigureMemberPointMallProduct(modelBuilder.Entity()); + ConfigureMemberPointMallRecord(modelBuilder.Entity()); ConfigureMemberStoredCardPlan(modelBuilder.Entity()); ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity()); ConfigureMemberReachMessage(modelBuilder.Entity()); @@ -1871,6 +1886,80 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt }); } + private static void ConfigureMemberPointMallRule(EntityTypeBuilder builder) + { + builder.ToTable("member_point_mall_rules"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.IsConsumeRewardEnabled).IsRequired(); + builder.Property(x => x.ConsumeAmountPerStep).IsRequired(); + builder.Property(x => x.ConsumeRewardPointsPerStep).IsRequired(); + builder.Property(x => x.IsReviewRewardEnabled).IsRequired(); + builder.Property(x => x.ReviewRewardPoints).IsRequired(); + builder.Property(x => x.IsRegisterRewardEnabled).IsRequired(); + builder.Property(x => x.RegisterRewardPoints).IsRequired(); + builder.Property(x => x.IsSigninRewardEnabled).IsRequired(); + builder.Property(x => x.SigninRewardPoints).IsRequired(); + builder.Property(x => x.ExpiryMode).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); + } + + private static void ConfigureMemberPointMallProduct(EntityTypeBuilder builder) + { + builder.ToTable("member_point_mall_products"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.ImageUrl).HasMaxLength(512); + builder.Property(x => x.RedeemType).HasConversion(); + builder.Property(x => x.ProductId); + builder.Property(x => x.CouponTemplateId); + builder.Property(x => x.PhysicalName).HasMaxLength(64); + builder.Property(x => x.PickupMethod).HasConversion(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.ExchangeType).HasConversion(); + builder.Property(x => x.RequiredPoints).IsRequired(); + builder.Property(x => x.CashAmount).HasPrecision(18, 2); + builder.Property(x => x.StockTotal).IsRequired(); + builder.Property(x => x.StockAvailable).IsRequired(); + builder.Property(x => x.PerMemberLimit); + builder.Property(x => x.NotifyChannelsJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductId }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.CouponTemplateId }); + } + + private static void ConfigureMemberPointMallRecord(EntityTypeBuilder builder) + { + builder.ToTable("member_point_mall_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired(); + builder.Property(x => x.PointMallProductId).IsRequired(); + builder.Property(x => x.MemberId).IsRequired(); + builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired(); + builder.Property(x => x.MemberMobileMasked).HasMaxLength(32).IsRequired(); + builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired(); + builder.Property(x => x.RedeemType).HasConversion(); + builder.Property(x => x.ExchangeType).HasConversion(); + builder.Property(x => x.UsedPoints).IsRequired(); + builder.Property(x => x.CashAmount).HasPrecision(18, 2); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.RedeemedAt).IsRequired(); + builder.Property(x => x.IssuedAt); + builder.Property(x => x.VerifiedAt); + builder.Property(x => x.VerifyMethod).HasConversion(); + builder.Property(x => x.VerifyRemark).HasMaxLength(256); + builder.Property(x => x.VerifiedBy); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PointMallProductId, x.RedeemedAt }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.MemberId, x.RedeemedAt }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status, x.RedeemedAt }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RedeemedAt }); + } + private static void ConfigureMemberStoredCardPlan(EntityTypeBuilder builder) { builder.ToTable("member_stored_card_plans"); @@ -2173,3 +2262,4 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt }); } } + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPointMallRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPointMallRepository.cs new file mode 100644 index 0000000..efc0f58 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPointMallRepository.cs @@ -0,0 +1,479 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Domain.Membership.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 会员积分商城 EF Core 仓储实现。 +/// +public sealed class EfPointMallRepository(TakeoutAppDbContext context) : IPointMallRepository +{ + /// + public Task GetRuleByStoreAsync( + long tenantId, + long storeId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallRules + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.StoreId == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default) + { + return context.MemberPointMallRules.AddAsync(entity, cancellationToken).AsTask(); + } + + /// + public Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default) + { + context.MemberPointMallRules.Update(entity); + return Task.CompletedTask; + } + + /// + public async Task> SearchProductsAsync( + long tenantId, + long storeId, + MemberPointMallProductStatus? status, + string? keyword, + CancellationToken cancellationToken = default) + { + var query = context.MemberPointMallProducts + .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 like = $"%{normalizedKeyword}%"; + query = query.Where(item => + EF.Functions.ILike(item.Name, like) || + (item.PhysicalName != null && EF.Functions.ILike(item.PhysicalName, like))); + } + + return await query + .OrderByDescending(item => item.Status) + .ThenByDescending(item => item.UpdatedAt ?? item.CreatedAt) + .ThenByDescending(item => item.Id) + .ToListAsync(cancellationToken); + } + + /// + public Task FindProductByIdAsync( + long tenantId, + long storeId, + long productId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallProducts + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.Id == productId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetProductByIdAsync( + long tenantId, + long storeId, + long productId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallProducts + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.Id == productId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default) + { + return context.MemberPointMallProducts.AddAsync(entity, cancellationToken).AsTask(); + } + + /// + public Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default) + { + context.MemberPointMallProducts.Update(entity); + return Task.CompletedTask; + } + + /// + public Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default) + { + context.MemberPointMallProducts.Remove(entity); + return Task.CompletedTask; + } + + /// + public Task HasRecordsByProductIdAsync( + long tenantId, + long storeId, + long pointMallProductId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallRecords + .AsNoTracking() + .AnyAsync(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.PointMallProductId == pointMallProductId, + cancellationToken); + } + + /// + public Task CountMemberRedeemsByProductAsync( + long tenantId, + long storeId, + long pointMallProductId, + long memberId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallRecords + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.PointMallProductId == pointMallProductId && + item.MemberId == memberId && + item.Status != MemberPointMallRecordStatus.Canceled) + .CountAsync(cancellationToken); + } + + /// + public async Task<(IReadOnlyList Items, int TotalCount)> SearchRecordsAsync( + long tenantId, + long storeId, + MemberPointMallRedeemType? redeemType, + MemberPointMallRecordStatus? status, + DateTime? startUtc, + DateTime? endUtc, + string? keyword, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var normalizedPage = Math.Max(1, page); + var normalizedPageSize = Math.Clamp(pageSize, 1, 500); + + var query = BuildRecordQuery( + tenantId, + storeId, + redeemType, + status, + startUtc, + endUtc, + keyword); + + var totalCount = await query.CountAsync(cancellationToken); + if (totalCount == 0) + { + return ([], 0); + } + + var items = await query + .OrderByDescending(item => item.RedeemedAt) + .ThenByDescending(item => item.Id) + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + } + + /// + public Task GetRecordByIdAsync( + long tenantId, + long storeId, + long recordId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallRecords + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.Id == recordId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindRecordByIdAsync( + long tenantId, + long storeId, + long recordId, + CancellationToken cancellationToken = default) + { + return context.MemberPointMallRecords + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.Id == recordId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListRecordsForExportAsync( + long tenantId, + long storeId, + MemberPointMallRedeemType? redeemType, + MemberPointMallRecordStatus? status, + DateTime? startUtc, + DateTime? endUtc, + string? keyword, + CancellationToken cancellationToken = default) + { + return await BuildRecordQuery( + tenantId, + storeId, + redeemType, + status, + startUtc, + endUtc, + keyword) + .OrderByDescending(item => item.RedeemedAt) + .ThenByDescending(item => item.Id) + .Take(20_000) + .ToListAsync(cancellationToken); + } + + /// + public Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default) + { + return context.MemberPointMallRecords.AddAsync(entity, cancellationToken).AsTask(); + } + + /// + public Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default) + { + context.MemberPointMallRecords.Update(entity); + return Task.CompletedTask; + } + + /// + public Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default) + { + return context.MemberPointLedgers.AddAsync(entity, cancellationToken).AsTask(); + } + + /// + public async Task GetRuleStatsAsync( + long tenantId, + long storeId, + CancellationToken cancellationToken = default) + { + var redeemedPoints = await context.MemberPointMallRecords + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.StoreId == storeId) + .Select(item => (int?)item.UsedPoints) + .SumAsync(cancellationToken) ?? 0; + + var memberIds = await context.MemberPointMallRecords + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.StoreId == storeId) + .Select(item => item.MemberId) + .Distinct() + .ToListAsync(cancellationToken); + + var pointMembers = memberIds.Count; + if (pointMembers == 0) + { + return new MemberPointMallRuleStatsSnapshot + { + TotalIssuedPoints = 0, + RedeemedPoints = 0, + PointMembers = 0, + RedeemRate = 0m + }; + } + + var currentPoints = await context.MemberProfiles + .AsNoTracking() + .Where(item => item.TenantId == tenantId && memberIds.Contains(item.Id)) + .Select(item => (int?)item.PointsBalance) + .SumAsync(cancellationToken) ?? 0; + + var totalIssuedPoints = Math.Max(redeemedPoints + Math.Max(0, currentPoints), 0); + var redeemRate = totalIssuedPoints <= 0 + ? 0m + : decimal.Round((decimal)redeemedPoints * 100m / totalIssuedPoints, 1, MidpointRounding.AwayFromZero); + + return new MemberPointMallRuleStatsSnapshot + { + TotalIssuedPoints = totalIssuedPoints, + RedeemedPoints = redeemedPoints, + PointMembers = pointMembers, + RedeemRate = redeemRate + }; + } + + /// + public async Task GetRecordStatsAsync( + long tenantId, + long storeId, + DateTime nowUtc, + CancellationToken cancellationToken = default) + { + var normalizedNow = NormalizeUtc(nowUtc); + var dayStart = new DateTime( + normalizedNow.Year, + normalizedNow.Month, + normalizedNow.Day, + 0, + 0, + 0, + DateTimeKind.Utc); + var monthStart = new DateTime( + normalizedNow.Year, + normalizedNow.Month, + 1, + 0, + 0, + 0, + DateTimeKind.Utc); + + var todayRedeemCount = await context.MemberPointMallRecords + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.RedeemedAt >= dayStart && + item.RedeemedAt <= normalizedNow) + .CountAsync(cancellationToken); + + var pendingPhysicalCount = await context.MemberPointMallRecords + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.RedeemType == MemberPointMallRedeemType.Physical && + item.Status == MemberPointMallRecordStatus.PendingPickup) + .CountAsync(cancellationToken); + + var currentMonthUsedPoints = await context.MemberPointMallRecords + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + item.RedeemedAt >= monthStart && + item.RedeemedAt <= normalizedNow) + .Select(item => (int?)item.UsedPoints) + .SumAsync(cancellationToken) ?? 0; + + return new MemberPointMallRecordStatsSnapshot + { + TodayRedeemCount = todayRedeemCount, + PendingPhysicalCount = pendingPhysicalCount, + CurrentMonthUsedPoints = currentMonthUsedPoints + }; + } + + /// + public async Task> GetProductAggregatesAsync( + long tenantId, + long storeId, + IReadOnlyCollection pointMallProductIds, + CancellationToken cancellationToken = default) + { + if (pointMallProductIds.Count == 0) + { + return []; + } + + var aggregates = await context.MemberPointMallRecords + .AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.StoreId == storeId && + pointMallProductIds.Contains(item.PointMallProductId)) + .GroupBy(item => item.PointMallProductId) + .Select(group => new MemberPointMallProductAggregateSnapshot + { + PointMallProductId = group.Key, + RedeemedCount = group.Count() + }) + .ToListAsync(cancellationToken); + + return aggregates.ToDictionary(item => item.PointMallProductId, item => item); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + private IQueryable BuildRecordQuery( + long tenantId, + long storeId, + MemberPointMallRedeemType? redeemType, + MemberPointMallRecordStatus? status, + DateTime? startUtc, + DateTime? endUtc, + string? keyword) + { + var query = context.MemberPointMallRecords + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.StoreId == storeId); + + if (redeemType.HasValue) + { + query = query.Where(item => item.RedeemType == redeemType.Value); + } + + if (status.HasValue) + { + query = query.Where(item => item.Status == status.Value); + } + + if (startUtc.HasValue) + { + var normalizedStart = NormalizeUtc(startUtc.Value); + query = query.Where(item => item.RedeemedAt >= normalizedStart); + } + + if (endUtc.HasValue) + { + var normalizedEnd = NormalizeUtc(endUtc.Value); + query = query.Where(item => item.RedeemedAt <= normalizedEnd); + } + + var normalizedKeyword = (keyword ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(normalizedKeyword)) + { + var like = $"%{normalizedKeyword}%"; + query = query.Where(item => + EF.Functions.ILike(item.RecordNo, like) || + EF.Functions.ILike(item.MemberName, like) || + EF.Functions.ILike(item.MemberMobileMasked, like) || + EF.Functions.ILike(item.ProductName, like)); + } + + return query; + } + + private static DateTime NormalizeUtc(DateTime value) + { + return value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304153000_AddMemberPointsMallModule.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304153000_AddMemberPointsMallModule.cs new file mode 100644 index 0000000..0f2ae97 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304153000_AddMemberPointsMallModule.cs @@ -0,0 +1,187 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class AddMemberPointsMallModule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "member_point_mall_products", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "展示名称。"), + ImageUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "展示图片地址。"), + RedeemType = table.Column(type: "integer", nullable: false, comment: "兑换类型。"), + ProductId = table.Column(type: "bigint", nullable: true, comment: "关联商品 ID(兑换商品时必填)。"), + CouponTemplateId = table.Column(type: "bigint", nullable: true, comment: "关联优惠券模板 ID(兑换优惠券时必填)。"), + PhysicalName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "实物名称(兑换实物时必填)。"), + PickupMethod = table.Column(type: "integer", nullable: true, comment: "实物领取方式。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "商品描述。"), + ExchangeType = table.Column(type: "integer", nullable: false, comment: "兑换方式(纯积分/积分+现金)。"), + RequiredPoints = table.Column(type: "integer", nullable: false, comment: "所需积分。"), + CashAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现金部分(积分+现金时使用)。"), + StockTotal = table.Column(type: "integer", nullable: false, comment: "初始库存数量。"), + StockAvailable = table.Column(type: "integer", nullable: false, comment: "剩余库存数量。"), + PerMemberLimit = table.Column(type: "integer", nullable: true, comment: "每人限兑次数(null 表示不限)。"), + NotifyChannelsJson = table.Column(type: "text", nullable: false, comment: "到账通知渠道(JSON 数组)。"), + Status = table.Column(type: "integer", nullable: false, comment: "上下架状态。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_point_mall_products", x => x.Id); + }, + comment: "会员积分商城兑换商品。"); + + migrationBuilder.CreateTable( + name: "member_point_mall_records", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + RecordNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "兑换记录单号。"), + PointMallProductId = table.Column(type: "bigint", nullable: false, comment: "关联积分商品 ID。"), + MemberId = table.Column(type: "bigint", nullable: false, comment: "会员标识。"), + MemberName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称快照。"), + MemberMobileMasked = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号快照(脱敏)。"), + ProductName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "商品名称快照。"), + RedeemType = table.Column(type: "integer", nullable: false, comment: "兑换类型快照。"), + ExchangeType = table.Column(type: "integer", nullable: false, comment: "兑换方式快照。"), + UsedPoints = table.Column(type: "integer", nullable: false, comment: "消耗积分。"), + CashAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现金部分。"), + Status = table.Column(type: "integer", nullable: false, comment: "记录状态。"), + RedeemedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "兑换时间(UTC)。"), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "发放时间(UTC)。"), + VerifiedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "核销时间(UTC)。"), + VerifyMethod = table.Column(type: "integer", nullable: true, comment: "核销方式。"), + VerifyRemark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "核销备注。"), + VerifiedBy = table.Column(type: "bigint", nullable: true, comment: "核销人用户标识。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_point_mall_records", x => x.Id); + }, + comment: "会员积分商城兑换记录。"); + + migrationBuilder.CreateTable( + name: "member_point_mall_rules", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + IsConsumeRewardEnabled = table.Column(type: "boolean", nullable: false, comment: "是否启用消费获取。"), + ConsumeAmountPerStep = table.Column(type: "integer", nullable: false, comment: "每消费多少元触发一次积分计算。"), + ConsumeRewardPointsPerStep = table.Column(type: "integer", nullable: false, comment: "每步获得积分。"), + IsReviewRewardEnabled = table.Column(type: "boolean", nullable: false, comment: "是否启用评价奖励。"), + ReviewRewardPoints = table.Column(type: "integer", nullable: false, comment: "评价奖励积分。"), + IsRegisterRewardEnabled = table.Column(type: "boolean", nullable: false, comment: "是否启用注册奖励。"), + RegisterRewardPoints = table.Column(type: "integer", nullable: false, comment: "注册奖励积分。"), + IsSigninRewardEnabled = table.Column(type: "boolean", nullable: false, comment: "是否启用签到奖励。"), + SigninRewardPoints = table.Column(type: "integer", nullable: false, comment: "签到奖励积分。"), + ExpiryMode = table.Column(type: "integer", nullable: false, comment: "积分有效期模式。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_point_mall_rules", x => x.Id); + }, + comment: "会员积分商城规则配置。"); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_products_TenantId_StoreId_CouponTemplateId", + table: "member_point_mall_products", + columns: new[] { "TenantId", "StoreId", "CouponTemplateId" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_products_TenantId_StoreId_Name", + table: "member_point_mall_products", + columns: new[] { "TenantId", "StoreId", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_products_TenantId_StoreId_ProductId", + table: "member_point_mall_products", + columns: new[] { "TenantId", "StoreId", "ProductId" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_products_TenantId_StoreId_Status", + table: "member_point_mall_products", + columns: new[] { "TenantId", "StoreId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_records_TenantId_StoreId_MemberId_RedeemedAt", + table: "member_point_mall_records", + columns: new[] { "TenantId", "StoreId", "MemberId", "RedeemedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_records_TenantId_StoreId_PointMallProductId_RedeemedAt", + table: "member_point_mall_records", + columns: new[] { "TenantId", "StoreId", "PointMallProductId", "RedeemedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_records_TenantId_StoreId_RecordNo", + table: "member_point_mall_records", + columns: new[] { "TenantId", "StoreId", "RecordNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_records_TenantId_StoreId_RedeemedAt", + table: "member_point_mall_records", + columns: new[] { "TenantId", "StoreId", "RedeemedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_records_TenantId_StoreId_Status_RedeemedAt", + table: "member_point_mall_records", + columns: new[] { "TenantId", "StoreId", "Status", "RedeemedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_mall_rules_TenantId_StoreId", + table: "member_point_mall_rules", + columns: new[] { "TenantId", "StoreId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "member_point_mall_products"); + + migrationBuilder.DropTable( + name: "member_point_mall_records"); + + migrationBuilder.DropTable( + name: "member_point_mall_rules"); + } + } +}