Merge pull request #3 from msumshk/feature/member-points-mall-1to1
feat(member): implement points mall backend module
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Member;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城规则请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallRuleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled,可空)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品详情查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? PointMallProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存总量。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道(in_app/sms)。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改积分商城商品状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangePointMallProductStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除积分商城商品请求。
|
||||
/// </summary>
|
||||
public sealed class DeletePointMallProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页查询请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情请求。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class ExportPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class WritePointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(可空,默认当前时间)。
|
||||
/// </summary>
|
||||
public DateTime? RedeemedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销积分商城兑换记录请求。
|
||||
/// </summary>
|
||||
public sealed class VerifyPointMallRecordRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string VerifyMethod { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 累计发放积分。
|
||||
/// </summary>
|
||||
public int TotalIssuedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换积分。
|
||||
/// </summary>
|
||||
public int RedeemedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分用户。
|
||||
/// </summary>
|
||||
public int PointMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换率(0-100)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRuleDetailResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则。
|
||||
/// </summary>
|
||||
public PointMallRuleResponse Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PointMallRuleStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public string? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public string? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始库存。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余库存。
|
||||
/// </summary>
|
||||
public int StockAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换数量。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道。
|
||||
/// </summary>
|
||||
public List<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "上架";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品列表响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallProductListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallProductResponse> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录响应。
|
||||
/// </summary>
|
||||
public class PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换记录 ID。
|
||||
/// </summary>
|
||||
public string RecordId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品 ID。
|
||||
/// </summary>
|
||||
public string PointMallProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型文案。
|
||||
/// </summary>
|
||||
public string RedeemTypeText { get; set; } = "商品";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 会员 ID。
|
||||
/// </summary>
|
||||
public string MemberId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消耗积分。
|
||||
/// </summary>
|
||||
public int UsedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "issued";
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = "已发放";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间。
|
||||
/// </summary>
|
||||
public string RedeemedAt { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发放时间。
|
||||
/// </summary>
|
||||
public string? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销时间。
|
||||
/// </summary>
|
||||
public string? VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordDetailResponse : PointMallRecordResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式文案。
|
||||
/// </summary>
|
||||
public string? VerifyMethodText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人 ID。
|
||||
/// </summary>
|
||||
public string? VerifiedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录统计响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录分页响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<PointMallRecordResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public PointMallRecordStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录导出响应。
|
||||
/// </summary>
|
||||
public sealed class PointMallRecordExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 会员中心积分商城管理。
|
||||
/// </summary>
|
||||
[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";
|
||||
|
||||
/// <summary>
|
||||
/// 获取积分规则详情。
|
||||
/// </summary>
|
||||
[HttpGet("rule/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleDetailResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleDetailResultResponse>> 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<PointMallRuleDetailResultResponse>.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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分规则。
|
||||
/// </summary>
|
||||
[HttpPost("rule/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRuleResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRuleResponse>> 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<PointMallRuleResponse>.Ok(MapRule(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品列表。
|
||||
/// </summary>
|
||||
[HttpGet("product/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductListResultResponse>> 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<PointMallProductListResultResponse>.Ok(new PointMallProductListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapProduct).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品详情。
|
||||
/// </summary>
|
||||
[HttpGet("product/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> 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<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/save")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> 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<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改兑换商品状态。
|
||||
/// </summary>
|
||||
[HttpPost("product/status")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallProductResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallProductResponse>> 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<PointMallProductResponse>.Ok(MapProduct(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除兑换商品。
|
||||
/// </summary>
|
||||
[HttpPost("product/delete")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> 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<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录分页。
|
||||
/// </summary>
|
||||
[HttpGet("record/list")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordListResultResponse>> 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<PointMallRecordListResultResponse>.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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录详情。
|
||||
/// </summary>
|
||||
[HttpGet("record/detail")]
|
||||
[PermissionAuthorize(ViewPermission, ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> 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<PointMallRecordDetailResponse>.Ok(MapRecordDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出兑换记录 CSV。
|
||||
/// </summary>
|
||||
[HttpGet("record/export")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordExportResponse>> 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<PointMallRecordExportResponse>.Ok(new PointMallRecordExportResponse
|
||||
{
|
||||
FileName = result.FileName,
|
||||
FileContentBase64 = result.FileContentBase64,
|
||||
TotalCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/write")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordResponse>> 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<PointMallRecordResponse>.Ok(MapRecord(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销兑换记录。
|
||||
/// </summary>
|
||||
[HttpPost("record/verify")]
|
||||
[PermissionAuthorize(ManagePermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PointMallRecordDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PointMallRecordDetailResponse>> 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<PointMallRecordDetailResponse>.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" => "手动核销",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 修改积分商城商品状态命令。
|
||||
/// </summary>
|
||||
public sealed class ChangePointMallProductStatusCommand : IRequest<MemberPointMallProductDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品标识。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "disabled";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除积分商城商品命令。
|
||||
/// </summary>
|
||||
public sealed class DeletePointMallProductCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品标识。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城兑换商品命令。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallProductCommand : IRequest<MemberPointMallProductDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品标识(编辑时传)。
|
||||
/// </summary>
|
||||
public long? PointMallProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; init; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public long? ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public long? CouponTemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; init; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存总量。
|
||||
/// </summary>
|
||||
public int StockTotal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数(null 表示不限)。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账通知渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> NotifyChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "enabled";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城规则命令。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallRuleCommand : IRequest<MemberPointMallRuleDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; init; } = "yearly_clear";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 核销积分商城兑换记录命令。
|
||||
/// </summary>
|
||||
public sealed class VerifyPointMallRecordCommand : IRequest<MemberPointMallRecordDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录标识。
|
||||
/// </summary>
|
||||
public long RecordId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string VerifyMethod { get; init; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入积分商城兑换记录命令。
|
||||
/// </summary>
|
||||
public sealed class WritePointMallRecordCommand : IRequest<MemberPointMallRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品标识。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(可空,默认当前 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? RedeemedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换商品数据。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallProductDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 积分商城商品标识。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型编码(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID。
|
||||
/// </summary>
|
||||
public long? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID。
|
||||
/// </summary>
|
||||
public long? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 领取方式编码(store_pickup/delivery)。
|
||||
/// </summary>
|
||||
public string? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式编码(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始库存。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余库存。
|
||||
/// </summary>
|
||||
public int StockAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换数量。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知渠道列表(in_app/sms)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NotifyChannels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "enabled";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换商品列表结果。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallProductListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MemberPointMallProductDto> Items { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录详情数据。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRecordDetailDto : MemberPointMallRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 核销方式(scan/manual)。
|
||||
/// </summary>
|
||||
public string? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人标识。
|
||||
/// </summary>
|
||||
public long? VerifiedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录数据。
|
||||
/// </summary>
|
||||
public class MemberPointMallRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换记录标识。
|
||||
/// </summary>
|
||||
public long RecordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品标识。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string RedeemType { get; set; } = "product";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(points/mixed)。
|
||||
/// </summary>
|
||||
public string ExchangeType { get; set; } = "points";
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 消耗积分。
|
||||
/// </summary>
|
||||
public int UsedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "issued";
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime RedeemedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录导出结果。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRecordExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录列表结果。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRecordListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MemberPointMallRecordDto> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页面统计。
|
||||
/// </summary>
|
||||
public MemberPointMallRecordStatsDto Stats { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录页统计。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRecordStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分规则页详情结果。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRuleDetailResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则配置。
|
||||
/// </summary>
|
||||
public MemberPointMallRuleDto Rule { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public MemberPointMallRuleStatsDto Stats { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分规则数据。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期模式(permanent/yearly_clear)。
|
||||
/// </summary>
|
||||
public string ExpiryMode { get; set; } = "yearly_clear";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 积分规则页统计数据。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRuleStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 累计发放积分。
|
||||
/// </summary>
|
||||
public int TotalIssuedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换积分。
|
||||
/// </summary>
|
||||
public int RedeemedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分用户。
|
||||
/// </summary>
|
||||
public int PointMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换率(0-100)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 修改积分商城商品状态处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangePointMallProductStatusCommandHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ChangePointMallProductStatusCommand, MemberPointMallProductDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallProductDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 删除积分商城商品处理器。
|
||||
/// </summary>
|
||||
public sealed class DeletePointMallProductCommandHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeletePointMallProductCommand>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 导出积分商城兑换记录处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportPointMallRecordCsvQueryHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportPointMallRecordCsvQuery, MemberPointMallRecordExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRecordExportDto> 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<MemberPointMallRecord> records)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"兑换单号,会员,手机号,兑换商品,类型,消耗积分,现金部分,兑换时间,状态,核销时间"
|
||||
};
|
||||
|
||||
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}\"";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城商品详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallProductDetailQueryHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPointMallProductDetailQuery, MemberPointMallProductDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallProductDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城商品列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallProductListQueryHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPointMallProductListQuery, MemberPointMallProductListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallProductListResultDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城兑换记录详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallRecordDetailQueryHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPointMallRecordDetailQuery, MemberPointMallRecordDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRecordDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城兑换记录分页处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallRecordListQueryHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPointMallRecordListQuery, MemberPointMallRecordListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRecordListResultDto> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城规则详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallRuleDetailQueryHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetPointMallRuleDetailQuery, MemberPointMallRuleDetailResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRuleDetailResultDto> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城商品处理器。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallProductCommandHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SavePointMallProductCommand, MemberPointMallProductDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallProductDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 保存积分商城规则处理器。
|
||||
/// </summary>
|
||||
public sealed class SavePointMallRuleCommandHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SavePointMallRuleCommand, MemberPointMallRuleDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRuleDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 核销积分商城兑换记录处理器。
|
||||
/// </summary>
|
||||
public sealed class VerifyPointMallRecordCommandHandler(
|
||||
IPointMallRepository repository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<VerifyPointMallRecordCommand, MemberPointMallRecordDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRecordDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 写入积分商城兑换记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WritePointMallRecordCommandHandler(
|
||||
IPointMallRepository repository,
|
||||
IMemberRepository memberRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<WritePointMallRecordCommand, MemberPointMallRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRecordDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城 DTO 构造器。
|
||||
/// </summary>
|
||||
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<MemberPointMallNotifyChannel> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城模块映射与标准化工具。
|
||||
/// </summary>
|
||||
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<MemberPointMallNotifyChannel> ParseNotifyChannels(
|
||||
IReadOnlyCollection<string>? values)
|
||||
{
|
||||
var parsed = (values ?? Array.Empty<string>())
|
||||
.Select(ParseNotifyChannel)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "notifyChannels 至少选择一项");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<MemberPointMallNotifyChannel> DeserializeNotifyChannels(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var source = JsonSerializer.Deserialize<List<string>>(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<MemberPointMallNotifyChannel> values)
|
||||
{
|
||||
var payload = (values ?? Array.Empty<MemberPointMallNotifyChannel>())
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出积分商城兑换记录 CSV。
|
||||
/// </summary>
|
||||
public sealed class ExportPointMallRecordCsvQuery : IRequest<MemberPointMallRecordExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(UTC,可空)。
|
||||
/// </summary>
|
||||
public DateTime? StartDateUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(UTC,可空)。
|
||||
/// </summary>
|
||||
public DateTime? EndDateUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城商品详情。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallProductDetailQuery : IRequest<MemberPointMallProductDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品标识。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城商品列表。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallProductListQuery : IRequest<MemberPointMallProductListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(enabled/disabled)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城兑换记录详情。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallRecordDetailQuery : IRequest<MemberPointMallRecordDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录标识。
|
||||
/// </summary>
|
||||
public long RecordId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城兑换记录分页。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallRecordListQuery : IRequest<MemberPointMallRecordListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型(product/coupon/physical)。
|
||||
/// </summary>
|
||||
public string? RedeemType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_pickup/issued/completed/canceled)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期(UTC,可空)。
|
||||
/// </summary>
|
||||
public DateTime? StartDateUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(UTC,可空)。
|
||||
/// </summary>
|
||||
public DateTime? EndDateUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Members.PointsMall.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Members.PointsMall.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询积分商城规则详情。
|
||||
/// </summary>
|
||||
public sealed class GetPointMallRuleDetailQuery : IRequest<MemberPointMallRuleDetailResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城兑换商品。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallProduct : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示图片地址。
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型。
|
||||
/// </summary>
|
||||
public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 关联商品 ID(兑换商品时必填)。
|
||||
/// </summary>
|
||||
public long? ProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联优惠券模板 ID(兑换优惠券时必填)。
|
||||
/// </summary>
|
||||
public long? CouponTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物名称(兑换实物时必填)。
|
||||
/// </summary>
|
||||
public string? PhysicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实物领取方式。
|
||||
/// </summary>
|
||||
public MemberPointMallPickupMethod? PickupMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式(纯积分/积分+现金)。
|
||||
/// </summary>
|
||||
public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly;
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
public int RequiredPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分(积分+现金时使用)。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始库存数量。
|
||||
/// </summary>
|
||||
public int StockTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余库存数量。
|
||||
/// </summary>
|
||||
public int StockAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每人限兑次数(null 表示不限)。
|
||||
/// </summary>
|
||||
public int? PerMemberLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 到账通知渠道(JSON 数组)。
|
||||
/// </summary>
|
||||
public string NotifyChannelsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 上下架状态。
|
||||
/// </summary>
|
||||
public MemberPointMallProductStatus Status { get; set; } = MemberPointMallProductStatus.Enabled;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城兑换记录。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRecord : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录单号。
|
||||
/// </summary>
|
||||
public string RecordNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联积分商品 ID。
|
||||
/// </summary>
|
||||
public long PointMallProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员标识。
|
||||
/// </summary>
|
||||
public long MemberId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会员名称快照。
|
||||
/// </summary>
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 会员手机号快照(脱敏)。
|
||||
/// </summary>
|
||||
public string MemberMobileMasked { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称快照。
|
||||
/// </summary>
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换类型快照。
|
||||
/// </summary>
|
||||
public MemberPointMallRedeemType RedeemType { get; set; } = MemberPointMallRedeemType.Product;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式快照。
|
||||
/// </summary>
|
||||
public MemberPointMallExchangeType ExchangeType { get; set; } = MemberPointMallExchangeType.PointsOnly;
|
||||
|
||||
/// <summary>
|
||||
/// 消耗积分。
|
||||
/// </summary>
|
||||
public int UsedPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 现金部分。
|
||||
/// </summary>
|
||||
public decimal CashAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录状态。
|
||||
/// </summary>
|
||||
public MemberPointMallRecordStatus Status { get; set; } = MemberPointMallRecordStatus.Issued;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime RedeemedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发放时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式。
|
||||
/// </summary>
|
||||
public MemberPointMallVerifyMethod? VerifyMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销备注。
|
||||
/// </summary>
|
||||
public string? VerifyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 核销人用户标识。
|
||||
/// </summary>
|
||||
public long? VerifiedBy { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城规则配置。
|
||||
/// </summary>
|
||||
public sealed class MemberPointMallRule : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店标识。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用消费获取。
|
||||
/// </summary>
|
||||
public bool IsConsumeRewardEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 每消费多少元触发一次积分计算。
|
||||
/// </summary>
|
||||
public int ConsumeAmountPerStep { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每步获得积分。
|
||||
/// </summary>
|
||||
public int ConsumeRewardPointsPerStep { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用评价奖励。
|
||||
/// </summary>
|
||||
public bool IsReviewRewardEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 评价奖励积分。
|
||||
/// </summary>
|
||||
public int ReviewRewardPoints { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用注册奖励。
|
||||
/// </summary>
|
||||
public bool IsRegisterRewardEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 注册奖励积分。
|
||||
/// </summary>
|
||||
public int RegisterRewardPoints { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用签到奖励。
|
||||
/// </summary>
|
||||
public bool IsSigninRewardEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签到奖励积分。
|
||||
/// </summary>
|
||||
public int SigninRewardPoints { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 积分有效期模式。
|
||||
/// </summary>
|
||||
public MemberPointMallExpiryMode ExpiryMode { get; set; } = MemberPointMallExpiryMode.YearlyClear;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换方式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallExchangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// 纯积分。
|
||||
/// </summary>
|
||||
PointsOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 积分 + 现金。
|
||||
/// </summary>
|
||||
PointsAndCash = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 积分有效期模式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallExpiryMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 永久有效。
|
||||
/// </summary>
|
||||
Permanent = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 按年清零(每年 12 月 31 日)。
|
||||
/// </summary>
|
||||
YearlyClear = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 到账通知渠道。
|
||||
/// </summary>
|
||||
public enum MemberPointMallNotifyChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 站内消息。
|
||||
/// </summary>
|
||||
InApp = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 短信通知。
|
||||
/// </summary>
|
||||
Sms = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 实物领取方式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallPickupMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// 到店自提。
|
||||
/// </summary>
|
||||
StorePickup = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 快递配送。
|
||||
/// </summary>
|
||||
Delivery = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品状态。
|
||||
/// </summary>
|
||||
public enum MemberPointMallProductStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 下架。
|
||||
/// </summary>
|
||||
Disabled = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 上架。
|
||||
/// </summary>
|
||||
Enabled = 1
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 兑换记录状态。
|
||||
/// </summary>
|
||||
public enum MemberPointMallRecordStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 待领取。
|
||||
/// </summary>
|
||||
PendingPickup = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 已发放。
|
||||
/// </summary>
|
||||
Issued = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 已完成。
|
||||
/// </summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已取消。
|
||||
/// </summary>
|
||||
Canceled = 3
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 积分兑换类型。
|
||||
/// </summary>
|
||||
public enum MemberPointMallRedeemType
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换商品。
|
||||
/// </summary>
|
||||
Product = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 兑换优惠券。
|
||||
/// </summary>
|
||||
Coupon = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 兑换实物。
|
||||
/// </summary>
|
||||
Physical = 2
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 核销方式。
|
||||
/// </summary>
|
||||
public enum MemberPointMallVerifyMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// 扫码核销。
|
||||
/// </summary>
|
||||
Scan = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 手动核销。
|
||||
/// </summary>
|
||||
Manual = 1
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Membership.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Membership.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城仓储契约。
|
||||
/// </summary>
|
||||
public interface IPointMallRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询门店积分规则。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRule?> GetRuleByStoreAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增积分规则。
|
||||
/// </summary>
|
||||
Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新积分规则。
|
||||
/// </summary>
|
||||
Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换商品列表。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MemberPointMallProduct>> SearchProductsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
MemberPointMallProductStatus? status,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询兑换商品(追踪)。
|
||||
/// </summary>
|
||||
Task<MemberPointMallProduct?> FindProductByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识查询兑换商品(只读)。
|
||||
/// </summary>
|
||||
Task<MemberPointMallProduct?> GetProductByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增兑换商品。
|
||||
/// </summary>
|
||||
Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新兑换商品。
|
||||
/// </summary>
|
||||
Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除兑换商品。
|
||||
/// </summary>
|
||||
Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询商品是否已有兑换记录。
|
||||
/// </summary>
|
||||
Task<bool> HasRecordsByProductIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long pointMallProductId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 统计会员在某商品上的有效兑换次数(排除已取消)。
|
||||
/// </summary>
|
||||
Task<int> CountMemberRedeemsByProductAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long pointMallProductId,
|
||||
long memberId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录分页。
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<MemberPointMallRecord> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录详情。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRecord?> GetRecordByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long recordId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录(追踪)。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRecord?> FindRecordByIdAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long recordId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询兑换记录导出数据。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MemberPointMallRecord>> ListRecordsForExportAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
MemberPointMallRedeemType? redeemType,
|
||||
MemberPointMallRecordStatus? status,
|
||||
DateTime? startUtc,
|
||||
DateTime? endUtc,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增兑换记录。
|
||||
/// </summary>
|
||||
Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新兑换记录。
|
||||
/// </summary>
|
||||
Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增积分流水。
|
||||
/// </summary>
|
||||
Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询规则页统计。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRuleStatsSnapshot> GetRuleStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询记录页统计。
|
||||
/// </summary>
|
||||
Task<MemberPointMallRecordStatsSnapshot> GetRecordStatsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询商品聚合统计快照。
|
||||
/// </summary>
|
||||
Task<Dictionary<long, MemberPointMallProductAggregateSnapshot>> GetProductAggregatesAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> pointMallProductIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化变更。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城规则页统计快照。
|
||||
/// </summary>
|
||||
public sealed record MemberPointMallRuleStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 累计发放积分。
|
||||
/// </summary>
|
||||
public int TotalIssuedPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换积分。
|
||||
/// </summary>
|
||||
public int RedeemedPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 积分用户数。
|
||||
/// </summary>
|
||||
public int PointMembers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换率(0-100)。
|
||||
/// </summary>
|
||||
public decimal RedeemRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城记录页统计快照。
|
||||
/// </summary>
|
||||
public sealed record MemberPointMallRecordStatsSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 今日兑换数量。
|
||||
/// </summary>
|
||||
public int TodayRedeemCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 待领取实物数量。
|
||||
/// </summary>
|
||||
public int PendingPhysicalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月消耗积分。
|
||||
/// </summary>
|
||||
public int CurrentMonthUsedPoints { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积分商城商品聚合快照。
|
||||
/// </summary>
|
||||
public sealed record MemberPointMallProductAggregateSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品标识。
|
||||
/// </summary>
|
||||
public required long PointMallProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已兑换数量。
|
||||
/// </summary>
|
||||
public int RedeemedCount { get; init; }
|
||||
}
|
||||
@@ -402,6 +402,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
|
||||
/// <summary>
|
||||
/// 积分商城规则。
|
||||
/// </summary>
|
||||
public DbSet<MemberPointMallRule> MemberPointMallRules => Set<MemberPointMallRule>();
|
||||
/// <summary>
|
||||
/// 积分商城兑换商品。
|
||||
/// </summary>
|
||||
public DbSet<MemberPointMallProduct> MemberPointMallProducts => Set<MemberPointMallProduct>();
|
||||
/// <summary>
|
||||
/// 积分商城兑换记录。
|
||||
/// </summary>
|
||||
public DbSet<MemberPointMallRecord> MemberPointMallRecords => Set<MemberPointMallRecord>();
|
||||
/// <summary>
|
||||
/// 会员储值方案。
|
||||
/// </summary>
|
||||
public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>();
|
||||
@@ -588,6 +600,9 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
|
||||
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
|
||||
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
||||
ConfigureMemberPointMallRule(modelBuilder.Entity<MemberPointMallRule>());
|
||||
ConfigureMemberPointMallProduct(modelBuilder.Entity<MemberPointMallProduct>());
|
||||
ConfigureMemberPointMallRecord(modelBuilder.Entity<MemberPointMallRecord>());
|
||||
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
|
||||
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
|
||||
ConfigureMemberReachMessage(modelBuilder.Entity<MemberReachMessage>());
|
||||
@@ -1871,6 +1886,80 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMemberPointMallRule(EntityTypeBuilder<MemberPointMallRule> 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<int>();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMemberPointMallProduct(EntityTypeBuilder<MemberPointMallProduct> 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<int>();
|
||||
builder.Property(x => x.ProductId);
|
||||
builder.Property(x => x.CouponTemplateId);
|
||||
builder.Property(x => x.PhysicalName).HasMaxLength(64);
|
||||
builder.Property(x => x.PickupMethod).HasConversion<int?>();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.ExchangeType).HasConversion<int>();
|
||||
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<int>();
|
||||
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<MemberPointMallRecord> 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<int>();
|
||||
builder.Property(x => x.ExchangeType).HasConversion<int>();
|
||||
builder.Property(x => x.UsedPoints).IsRequired();
|
||||
builder.Property(x => x.CashAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.RedeemedAt).IsRequired();
|
||||
builder.Property(x => x.IssuedAt);
|
||||
builder.Property(x => x.VerifiedAt);
|
||||
builder.Property(x => x.VerifyMethod).HasConversion<int?>();
|
||||
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<MemberStoredCardPlan> 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分商城 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfPointMallRepository(TakeoutAppDbContext context) : IPointMallRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<MemberPointMallRule?> GetRuleByStoreAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberPointMallRules
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberPointMallRules.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateRuleAsync(MemberPointMallRule entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberPointMallRules.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberPointMallProduct>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberPointMallProduct?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberPointMallProduct?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberPointMallProducts.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberPointMallProducts.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteProductAsync(MemberPointMallProduct entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberPointMallProducts.Remove(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<MemberPointMallRecord> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberPointMallRecord?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MemberPointMallRecord?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MemberPointMallRecord>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberPointMallRecords.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateRecordAsync(MemberPointMallRecord entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MemberPointMallRecords.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPointLedgerAsync(MemberPointLedger entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MemberPointLedgers.AddAsync(entity, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRuleStatsSnapshot> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MemberPointMallRecordStatsSnapshot> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<long, MemberPointMallProductAggregateSnapshot>> GetProductAggregatesAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private IQueryable<MemberPointMallRecord> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMemberPointsMallModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_point_mall_products",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
|
||||
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "展示名称。"),
|
||||
ImageUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "展示图片地址。"),
|
||||
RedeemType = table.Column<int>(type: "integer", nullable: false, comment: "兑换类型。"),
|
||||
ProductId = table.Column<long>(type: "bigint", nullable: true, comment: "关联商品 ID(兑换商品时必填)。"),
|
||||
CouponTemplateId = table.Column<long>(type: "bigint", nullable: true, comment: "关联优惠券模板 ID(兑换优惠券时必填)。"),
|
||||
PhysicalName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "实物名称(兑换实物时必填)。"),
|
||||
PickupMethod = table.Column<int>(type: "integer", nullable: true, comment: "实物领取方式。"),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "商品描述。"),
|
||||
ExchangeType = table.Column<int>(type: "integer", nullable: false, comment: "兑换方式(纯积分/积分+现金)。"),
|
||||
RequiredPoints = table.Column<int>(type: "integer", nullable: false, comment: "所需积分。"),
|
||||
CashAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现金部分(积分+现金时使用)。"),
|
||||
StockTotal = table.Column<int>(type: "integer", nullable: false, comment: "初始库存数量。"),
|
||||
StockAvailable = table.Column<int>(type: "integer", nullable: false, comment: "剩余库存数量。"),
|
||||
PerMemberLimit = table.Column<int>(type: "integer", nullable: true, comment: "每人限兑次数(null 表示不限)。"),
|
||||
NotifyChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "到账通知渠道(JSON 数组)。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "上下架状态。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_point_mall_products", x => x.Id);
|
||||
},
|
||||
comment: "会员积分商城兑换商品。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_point_mall_records",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
|
||||
RecordNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "兑换记录单号。"),
|
||||
PointMallProductId = table.Column<long>(type: "bigint", nullable: false, comment: "关联积分商品 ID。"),
|
||||
MemberId = table.Column<long>(type: "bigint", nullable: false, comment: "会员标识。"),
|
||||
MemberName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称快照。"),
|
||||
MemberMobileMasked = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号快照(脱敏)。"),
|
||||
ProductName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "商品名称快照。"),
|
||||
RedeemType = table.Column<int>(type: "integer", nullable: false, comment: "兑换类型快照。"),
|
||||
ExchangeType = table.Column<int>(type: "integer", nullable: false, comment: "兑换方式快照。"),
|
||||
UsedPoints = table.Column<int>(type: "integer", nullable: false, comment: "消耗积分。"),
|
||||
CashAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现金部分。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "记录状态。"),
|
||||
RedeemedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "兑换时间(UTC)。"),
|
||||
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "发放时间(UTC)。"),
|
||||
VerifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "核销时间(UTC)。"),
|
||||
VerifyMethod = table.Column<int>(type: "integer", nullable: true, comment: "核销方式。"),
|
||||
VerifyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "核销备注。"),
|
||||
VerifiedBy = table.Column<long>(type: "bigint", nullable: true, comment: "核销人用户标识。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_member_point_mall_records", x => x.Id);
|
||||
},
|
||||
comment: "会员积分商城兑换记录。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "member_point_mall_rules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店标识。"),
|
||||
IsConsumeRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用消费获取。"),
|
||||
ConsumeAmountPerStep = table.Column<int>(type: "integer", nullable: false, comment: "每消费多少元触发一次积分计算。"),
|
||||
ConsumeRewardPointsPerStep = table.Column<int>(type: "integer", nullable: false, comment: "每步获得积分。"),
|
||||
IsReviewRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用评价奖励。"),
|
||||
ReviewRewardPoints = table.Column<int>(type: "integer", nullable: false, comment: "评价奖励积分。"),
|
||||
IsRegisterRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用注册奖励。"),
|
||||
RegisterRewardPoints = table.Column<int>(type: "integer", nullable: false, comment: "注册奖励积分。"),
|
||||
IsSigninRewardEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用签到奖励。"),
|
||||
SigninRewardPoints = table.Column<int>(type: "integer", nullable: false, comment: "签到奖励积分。"),
|
||||
ExpiryMode = table.Column<int>(type: "integer", nullable: false, comment: "积分有效期模式。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user