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>
|
/// </summary>
|
||||||
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
|
public DbSet<MemberPointLedger> MemberPointLedgers => Set<MemberPointLedger>();
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>();
|
public DbSet<MemberStoredCardPlan> MemberStoredCardPlans => Set<MemberStoredCardPlan>();
|
||||||
@@ -576,6 +588,9 @@ public sealed class TakeoutAppDbContext(
|
|||||||
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
|
ConfigureMemberProfileTag(modelBuilder.Entity<MemberProfileTag>());
|
||||||
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
|
ConfigureMemberDaySetting(modelBuilder.Entity<MemberDaySetting>());
|
||||||
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
|
||||||
|
ConfigureMemberPointMallRule(modelBuilder.Entity<MemberPointMallRule>());
|
||||||
|
ConfigureMemberPointMallProduct(modelBuilder.Entity<MemberPointMallProduct>());
|
||||||
|
ConfigureMemberPointMallRecord(modelBuilder.Entity<MemberPointMallRecord>());
|
||||||
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
|
ConfigureMemberStoredCardPlan(modelBuilder.Entity<MemberStoredCardPlan>());
|
||||||
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
|
ConfigureMemberStoredCardRechargeRecord(modelBuilder.Entity<MemberStoredCardRechargeRecord>());
|
||||||
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
|
ConfigureChatSession(modelBuilder.Entity<ChatSession>());
|
||||||
@@ -1856,6 +1871,80 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt });
|
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)
|
private static void ConfigureMemberStoredCardPlan(EntityTypeBuilder<MemberStoredCardPlan> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("member_stored_card_plans");
|
builder.ToTable("member_stored_card_plans");
|
||||||
@@ -2102,3 +2191,4 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
|
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