feat: 落地滚动会员、券发放与邀请分成奖励
All checks were successful
Build and Deploy / deploy (push) Successful in 3m50s
All checks were successful
Build and Deploy / deploy (push) Successful in 3m50s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8d7df40bdf
commit
f972a28d79
@ -3,5 +3,5 @@
|
||||
OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。
|
||||
|
||||
- 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行)
|
||||
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录下单个表脚本(如 `c_parcel_arrival.sql`、`c_parcel_item_AttributeText.sql`、`c_parcel_item_ImageUrl.sql`)可直接在已有库执行增量建表;若仍为旧名 `C_*`,先执行 `migrate_rename_warehouse_C_to_c.sql`
|
||||
- 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过)
|
||||
- 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
-- 存量库:排货规则服务商表增加判断条件 MatchCondition
|
||||
-- 可重复执行;仅在列不存在时 ADD。
|
||||
-- 目标表:t_sales_product_allocation_provider
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_sales_product_allocation_provider'
|
||||
AND COLUMN_NAME = 'MatchCondition'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`',
|
||||
'SELECT ''MatchCondition already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
109
sql/migrate_coupon_grant_system.sql
Normal file
109
sql/migrate_coupon_grant_system.sql
Normal file
@ -0,0 +1,109 @@
|
||||
-- 优惠券发放体系:未解锁状态、到期提醒标记、新人/裂变券模板(可重复执行)
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_coupon_instance'
|
||||
AND COLUMN_NAME = 'ExpiryNoticeSent'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_coupon_instance`
|
||||
ADD COLUMN `ExpiryNoticeSent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''到期前3天已提醒'' AFTER `LockExpireTime`',
|
||||
'SELECT ''ExpiryNoticeSent already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 新人首单券 15 元,领取后 15 天
|
||||
INSERT INTO `t_coupon_template` (
|
||||
`TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
|
||||
`MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
|
||||
`ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
|
||||
`ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
|
||||
`IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
|
||||
)
|
||||
SELECT
|
||||
'NEWCOMER_FIRST', '新人首单券', 'fixed_off', 15.00, NULL, NULL,
|
||||
0, 0, 0, 1,
|
||||
2, NULL, NULL, 15,
|
||||
NULL, 'newcomer', 1, '注册自动下发,仅抵基础运费,退款不退回',
|
||||
0, 0, NOW(), 0, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_FIRST'
|
||||
);
|
||||
|
||||
-- 新人第二单券 15 元,满 150 基础运费,解锁后 30 天
|
||||
INSERT INTO `t_coupon_template` (
|
||||
`TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
|
||||
`MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
|
||||
`ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
|
||||
`ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
|
||||
`IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
|
||||
)
|
||||
SELECT
|
||||
'NEWCOMER_SECOND', '新人第二单券', 'fixed_off', 15.00, NULL, NULL,
|
||||
150, 0, 0, 1,
|
||||
3, NULL, NULL, 30,
|
||||
NULL, 'newcomer', 1, '首单派送成功后解锁,实付基础运费满 150',
|
||||
0, 0, NOW(), 0, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_SECOND'
|
||||
);
|
||||
|
||||
-- 新人备货券 20 元,满 200,注册后 60 天
|
||||
INSERT INTO `t_coupon_template` (
|
||||
`TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
|
||||
`MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
|
||||
`ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
|
||||
`ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
|
||||
`IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
|
||||
)
|
||||
SELECT
|
||||
'NEWCOMER_STOCK', '新人备货券', 'fixed_off', 20.00, NULL, NULL,
|
||||
200, 0, 0, 1,
|
||||
2, NULL, NULL, 60,
|
||||
NULL, 'newcomer', 1, '注册自动下发,实付基础运费满 200',
|
||||
0, 0, NOW(), 0, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'NEWCOMER_STOCK'
|
||||
);
|
||||
|
||||
-- 裂变:老客 20 元无门槛
|
||||
INSERT INTO `t_coupon_template` (
|
||||
`TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
|
||||
`MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
|
||||
`ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
|
||||
`ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
|
||||
`IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
|
||||
)
|
||||
SELECT
|
||||
'REFERRAL_INVITER', '邀请有礼券', 'fixed_off', 20.00, NULL, NULL,
|
||||
0, 0, 0, 1,
|
||||
2, NULL, NULL, 30,
|
||||
NULL, 'referral', 1, '被邀请人首单派送成功后发给邀请人',
|
||||
0, 0, NOW(), 0, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITER'
|
||||
);
|
||||
|
||||
-- 裂变:新客额外 5 元
|
||||
INSERT INTO `t_coupon_template` (
|
||||
`TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
|
||||
`MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
|
||||
`ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
|
||||
`ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
|
||||
`IsDeleted`, `CreateUserId`, `CreateTime`, `UpdateUserId`, `UpdateTime`
|
||||
)
|
||||
SELECT
|
||||
'REFERRAL_INVITEE', '邀请注册加赠券', 'fixed_off', 5.00, NULL, NULL,
|
||||
0, 0, 0, 1,
|
||||
2, NULL, NULL, 30,
|
||||
NULL, 'referral', 1, '被邀请人注册即发 5 元券',
|
||||
0, 0, NOW(), 0, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `t_coupon_template` x WHERE x.`IsDeleted` = 0 AND x.`TemplateCode` = 'REFERRAL_INVITEE'
|
||||
);
|
||||
@ -1,98 +0,0 @@
|
||||
-- 存量库:客户出口资质备案表(含 IsDefault / Remark)
|
||||
-- 可重复执行。
|
||||
-- 目标表:t_customer_export_qualification
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- 1) 表不存在则整表创建
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_customer_export_qualification'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'CREATE TABLE `t_customer_export_qualification` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
|
||||
`CustomerId` INT NOT NULL COMMENT ''归属客户 t_customer.Id'',
|
||||
`CompanyName` VARCHAR(200) NOT NULL COMMENT ''企业名称'',
|
||||
`SocialCreditCode` VARCHAR(64) NOT NULL COMMENT ''企业社会信用代码'',
|
||||
`DomesticConsignor` VARCHAR(200) NOT NULL COMMENT ''境内发货人'',
|
||||
`DomesticConsignorCustomsCode` VARCHAR(64) NULL COMMENT ''境内发货人海关代码'',
|
||||
`ProducerName` VARCHAR(200) NOT NULL COMMENT ''生产销售单位'',
|
||||
`ProducerCustomsCode` VARCHAR(64) NULL COMMENT ''生产销售单位海关代码'',
|
||||
`CustomsPort` VARCHAR(100) NULL COMMENT ''备案海关港口地'',
|
||||
`IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'',
|
||||
`Remark` VARCHAR(512) NULL COMMENT ''备注'',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
|
||||
`UpdateUserId` INT NULL COMMENT ''更新人'',
|
||||
`UpdateTime` DATETIME NULL COMMENT ''更新时间'',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `idx_ceq_customer` (`CustomerId`, `IsDeleted`, `Id`),
|
||||
KEY `idx_ceq_credit` (`SocialCreditCode`, `IsDeleted`),
|
||||
KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)
|
||||
) COMMENT=''客户出口资质备案''',
|
||||
'SELECT ''t_customer_export_qualification already exists'' AS info'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2) 已建表但缺 IsDefault 时补列
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_customer_export_qualification'
|
||||
AND COLUMN_NAME = 'IsDefault'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_customer_export_qualification`
|
||||
ADD COLUMN `IsDefault` TINYINT NOT NULL DEFAULT 0 COMMENT ''是否默认:0-否,1-是(同客户仅一条)'' AFTER `CustomsPort`',
|
||||
'SELECT ''IsDefault already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 3) 已建表但缺 Remark 时补列
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_customer_export_qualification'
|
||||
AND COLUMN_NAME = 'Remark'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_customer_export_qualification`
|
||||
ADD COLUMN `Remark` VARCHAR(512) NULL COMMENT ''备注'' AFTER `IsDefault`',
|
||||
'SELECT ''Remark already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 4) 默认索引
|
||||
SET @idx := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_customer_export_qualification'
|
||||
AND INDEX_NAME = 'idx_ceq_customer_default'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@idx = 0,
|
||||
'ALTER TABLE `t_customer_export_qualification` ADD KEY `idx_ceq_customer_default` (`CustomerId`, `IsDefault`)',
|
||||
'SELECT ''idx_ceq_customer_default already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -1,41 +0,0 @@
|
||||
-- 客户 OpenAPI 机机凭证(一客户一对 AppKey/AppSecret,独立于 itfx)
|
||||
-- 可重复执行。
|
||||
-- 目标表:t_customer_openapi_credential
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_customer_openapi_credential'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'CREATE TABLE `t_customer_openapi_credential` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
|
||||
`CustomerId` INT NOT NULL COMMENT ''归属主客户 t_customer.Id'',
|
||||
`AppKey` VARCHAR(64) NOT NULL COMMENT ''开放接口 AppKey'',
|
||||
`AppSecret` VARCHAR(128) NOT NULL COMMENT ''开放接口 AppSecret(明文,创建/重置时返回)'',
|
||||
`Enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:0-否,1-是'',
|
||||
`LastUsedAt` DATETIME NULL COMMENT ''最近鉴权成功时间'',
|
||||
`LastUsedIp` VARCHAR(64) NULL COMMENT ''最近鉴权成功 IP'',
|
||||
`Remark` VARCHAR(512) NULL COMMENT ''备注'',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
|
||||
`UpdateUserId` INT NULL COMMENT ''更新人'',
|
||||
`UpdateTime` DATETIME NULL COMMENT ''更新时间'',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_coc_customer` (`CustomerId`),
|
||||
UNIQUE KEY `uk_coc_app_key` (`AppKey`),
|
||||
KEY `idx_coc_enabled` (`Enabled`, `IsDeleted`)
|
||||
) COMMENT=''客户 OpenAPI 机机凭证''',
|
||||
'SELECT ''t_customer_openapi_credential already exists'' AS info'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -1,38 +0,0 @@
|
||||
-- 存量库:SKU / 订单申报增加「出口申报要素」DeclareElement
|
||||
-- 可重复执行;仅在列不存在时 ADD。
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- c_sku.DeclareElement
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 'c_sku'
|
||||
AND COLUMN_NAME = 'DeclareElement'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `c_sku` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ImHsCode`',
|
||||
'SELECT ''c_sku.DeclareElement already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- t_order_declaration.DeclareElement
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order_declaration'
|
||||
AND COLUMN_NAME = 'DeclareElement'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_order_declaration` ADD COLUMN `DeclareElement` VARCHAR(255) NULL COMMENT ''出口申报要素'' AFTER `ExHsCode`',
|
||||
'SELECT ''t_order_declaration.DeclareElement already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -1,45 +0,0 @@
|
||||
-- 存量库:为问题模板补充处理人字段(与 db.sql / IssueTemplate 实体对齐)
|
||||
-- 可重复执行;仅在列不存在时 ADD。
|
||||
-- 目标表:t_issue_template
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- HandlerType
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_issue_template'
|
||||
AND COLUMN_NAME = 'HandlerType'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerType` VARCHAR(16) NULL COMMENT ''处理人类型:customer=订单所属客户,user=指定用户'' AFTER `NotifyMethod`',
|
||||
'SELECT ''HandlerType already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- HandlerUserId
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_issue_template'
|
||||
AND COLUMN_NAME = 'HandlerUserId'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_issue_template` ADD COLUMN `HandlerUserId` INT NULL COMMENT ''指定处理人用户Id(HandlerType=user 时必填)'' AFTER `HandlerType`',
|
||||
'SELECT ''HandlerUserId already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 历史数据默认按「指定用户」语义;无处理人时可在页面再编辑
|
||||
UPDATE `t_issue_template`
|
||||
SET `HandlerType` = 'user'
|
||||
WHERE (`HandlerType` IS NULL OR `HandlerType` = '')
|
||||
AND `IsDeleted` = 0;
|
||||
216
sql/migrate_member_level_rolling_points.sql
Normal file
216
sql/migrate_member_level_rolling_points.sql
Normal file
@ -0,0 +1,216 @@
|
||||
-- 旧会员体系 → V0~V5(可重复执行)
|
||||
-- old: normal/silver/gold/diamond
|
||||
-- new: V0 普通 / V1 铜卡(新增) / V2 银卡 / V3 金卡 / V4 钻石 / V5 皇冠(新增)
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_member_level'
|
||||
AND COLUMN_NAME = 'CanDowngrade'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_member_level`
|
||||
ADD COLUMN `CanDowngrade` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''V4/V5 允许逐级降级'' AFTER `ExtraDiscountRate`',
|
||||
'SELECT ''CanDowngrade already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------- 把旧行升级成新编码(仅当目标 V* 还不存在)----------
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelCode` = 'V0',
|
||||
`LevelName` = '普通会员',
|
||||
`MinPoints` = 0,
|
||||
`ExtraDiscountRate` = 0.00,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 0,
|
||||
`BenefitDesc` = '新注册 / 小单试单卖家',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0
|
||||
AND LOWER(`LevelCode`) = 'normal'
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V0') t);
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelCode` = 'V2',
|
||||
`LevelName` = '银卡会员',
|
||||
`MinPoints` = 60000,
|
||||
`ExtraDiscountRate` = 0.02,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 2,
|
||||
`BenefitDesc` = '主力中小卖家,基础运费 98 折',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0
|
||||
AND LOWER(`LevelCode`) = 'silver'
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V2') t);
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelCode` = 'V3',
|
||||
`LevelName` = '金卡会员',
|
||||
`MinPoints` = 120000,
|
||||
`ExtraDiscountRate` = 0.03,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 3,
|
||||
`BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0
|
||||
AND LOWER(`LevelCode`) = 'gold'
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V3') t);
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelCode` = 'V4',
|
||||
`LevelName` = '钻石会员',
|
||||
`MinPoints` = 240000,
|
||||
`ExtraDiscountRate` = 0.04,
|
||||
`CanDowngrade` = 1,
|
||||
`Sort` = 4,
|
||||
`BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0
|
||||
AND LOWER(`LevelCode`) = 'diamond'
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT 1 FROM `t_member_level` x WHERE x.`IsDeleted` = 0 AND x.`LevelCode` = 'V4') t);
|
||||
|
||||
-- ---------- 缺档则插入(铜卡、皇冠,以及未被旧行带过来的档)----------
|
||||
INSERT INTO `t_member_level`
|
||||
(`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
|
||||
SELECT 'V0', '普通会员', 0, 0.00, 0, 0, '新注册 / 小单试单卖家', 2, 0, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V0' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_member_level`
|
||||
(`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
|
||||
SELECT 'V1', '铜卡会员', 24000, 0.01, 0, 1, '起步稳定发货卖家,基础运费 99 折', 2, 0, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V1' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_member_level`
|
||||
(`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
|
||||
SELECT 'V2', '银卡会员', 60000, 0.02, 0, 2, '主力中小卖家,基础运费 98 折', 2, 0, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V2' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_member_level`
|
||||
(`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
|
||||
SELECT 'V3', '金卡会员', 120000, 0.03, 0, 3, '优质中型卖家,基础运费 97 折;此级及以下只升不降', 2, 0, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V3' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_member_level`
|
||||
(`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
|
||||
SELECT 'V4', '钻石会员', 240000, 0.04, 1, 4, '中大卖家,基础运费 96 折;滚动积分不足可降到金卡', 2, 0, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V4' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_member_level`
|
||||
(`LevelCode`, `LevelName`, `MinPoints`, `ExtraDiscountRate`, `CanDowngrade`, `Sort`, `BenefitDesc`, `Status`, `IsDeleted`, `CreateTime`)
|
||||
SELECT 'V5', '皇冠会员', 600000, 0.05, 1, 5, '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石', 2, 0, NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_member_level` WHERE `LevelCode` = 'V5' AND `IsDeleted` = 0);
|
||||
|
||||
-- ---------- 旧编码与新档并存时:客户挂到新档,旧行停用 ----------
|
||||
UPDATE `t_customer` c
|
||||
INNER JOIN `t_member_level` old ON old.`Id` = c.`MemberLevelId` AND old.`IsDeleted` = 0
|
||||
INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`)
|
||||
WHEN 'normal' THEN 'V0'
|
||||
WHEN 'silver' THEN 'V2'
|
||||
WHEN 'gold' THEN 'V3'
|
||||
WHEN 'diamond' THEN 'V4'
|
||||
ELSE NULL
|
||||
END
|
||||
SET c.`MemberLevelId` = canon.`Id`, c.`UpdateTime` = NOW()
|
||||
WHERE LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond')
|
||||
AND old.`Id` <> canon.`Id`;
|
||||
|
||||
UPDATE `t_member_level` old
|
||||
INNER JOIN `t_member_level` canon ON canon.`IsDeleted` = 0 AND canon.`LevelCode` = CASE LOWER(old.`LevelCode`)
|
||||
WHEN 'normal' THEN 'V0'
|
||||
WHEN 'silver' THEN 'V2'
|
||||
WHEN 'gold' THEN 'V3'
|
||||
WHEN 'diamond' THEN 'V4'
|
||||
ELSE NULL
|
||||
END
|
||||
SET
|
||||
old.`IsDeleted` = 1,
|
||||
old.`Status` = 1,
|
||||
old.`UpdateTime` = NOW()
|
||||
WHERE old.`IsDeleted` = 0
|
||||
AND LOWER(old.`LevelCode`) IN ('normal', 'silver', 'gold', 'diamond')
|
||||
AND old.`Id` <> canon.`Id`;
|
||||
|
||||
-- ---------- 校正 V0~V5 门槛/折扣(已占用客户的行 Id 不变)----------
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelName` = '普通会员',
|
||||
`MinPoints` = 0,
|
||||
`ExtraDiscountRate` = 0.00,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 0,
|
||||
`BenefitDesc` = '新注册 / 小单试单卖家',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0 AND `LevelCode` = 'V0';
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelName` = '铜卡会员',
|
||||
`MinPoints` = 24000,
|
||||
`ExtraDiscountRate` = 0.01,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 1,
|
||||
`BenefitDesc` = '起步稳定发货卖家,基础运费 99 折',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0 AND `LevelCode` = 'V1';
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelName` = '银卡会员',
|
||||
`MinPoints` = 60000,
|
||||
`ExtraDiscountRate` = 0.02,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 2,
|
||||
`BenefitDesc` = '主力中小卖家,基础运费 98 折',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0 AND `LevelCode` = 'V2';
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelName` = '金卡会员',
|
||||
`MinPoints` = 120000,
|
||||
`ExtraDiscountRate` = 0.03,
|
||||
`CanDowngrade` = 0,
|
||||
`Sort` = 3,
|
||||
`BenefitDesc` = '优质中型卖家,基础运费 97 折;此级及以下只升不降',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0 AND `LevelCode` = 'V3';
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelName` = '钻石会员',
|
||||
`MinPoints` = 240000,
|
||||
`ExtraDiscountRate` = 0.04,
|
||||
`CanDowngrade` = 1,
|
||||
`Sort` = 4,
|
||||
`BenefitDesc` = '中大卖家,基础运费 96 折;滚动积分不足可降到金卡',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0 AND `LevelCode` = 'V4';
|
||||
|
||||
UPDATE `t_member_level`
|
||||
SET
|
||||
`LevelName` = '皇冠会员',
|
||||
`MinPoints` = 600000,
|
||||
`ExtraDiscountRate` = 0.05,
|
||||
`CanDowngrade` = 1,
|
||||
`Sort` = 5,
|
||||
`BenefitDesc` = '顶级头部卖家,基础运费 95 折封顶;滚动积分不足可降到钻石',
|
||||
`Status` = 2,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0 AND `LevelCode` = 'V5';
|
||||
@ -1,24 +0,0 @@
|
||||
-- 存量库:t_order 增加出口资质备案 Id(9610Summary 选用)
|
||||
-- 可重复执行。
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order'
|
||||
AND COLUMN_NAME = 'ExportQualificationId'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_order`
|
||||
ADD COLUMN `ExportQualificationId` INT NULL COMMENT ''出口资质备案 Id(t_customer_export_qualification.Id)'' AFTER `ExportCustomsMode`,
|
||||
ADD KEY `idx_order_export_qualification` (`ExportQualificationId`)',
|
||||
'SELECT ''t_order.ExportQualificationId already exists'' AS info'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -1,16 +0,0 @@
|
||||
-- 揽收方向 + 客户地址类型扩展(国内揽收)
|
||||
-- 正向 forward:客户揽收地址 → 仓库
|
||||
-- 逆向 reverse:仓库 → 客户揽收地址
|
||||
|
||||
ALTER TABLE `t_pickup_order`
|
||||
ADD COLUMN `Direction` VARCHAR(16) NOT NULL DEFAULT 'forward'
|
||||
COMMENT 'forward正向(客户→仓)/reverse逆向(仓→客户)'
|
||||
AFTER `SenderAddressId`;
|
||||
|
||||
ALTER TABLE `t_pickup_order`
|
||||
ADD KEY `idx_pickup_direction_status` (`Direction`, `Status`, `CreateTime`);
|
||||
|
||||
-- 地址类型注释更新(列已存在,仅说明)
|
||||
ALTER TABLE `t_customer_sender_address`
|
||||
MODIFY COLUMN `AddressType` VARCHAR(16) NOT NULL DEFAULT 'sender'
|
||||
COMMENT 'sender国内发件/return国内退件/pickup国内揽收/receiver海外收件';
|
||||
@ -1,11 +0,0 @@
|
||||
-- 揽收单:承运推送件重尺 + 系统卖价/买价费用快照
|
||||
ALTER TABLE `t_pickup_order`
|
||||
ADD COLUMN `VolumetricWeight` DECIMAL(18,3) NULL COMMENT '体积重' AFTER `ChargeableWeight`,
|
||||
ADD COLUMN `PackageSizesJson` TEXT NULL COMMENT '承运回传尺寸 JSON' AFTER `VolumetricWeight`,
|
||||
ADD COLUMN `CarrierFreightWeight` DECIMAL(18,3) NULL COMMENT '承运计费重(对照)' AFTER `PackageSizesJson`,
|
||||
ADD COLUMN `CarrierQuotedFreightAmount` DECIMAL(18,2) NULL COMMENT '承运报价运费(对照)' AFTER `CarrierFreightWeight`,
|
||||
ADD COLUMN `CarrierQuotedReceivableAmount` DECIMAL(18,2) NULL COMMENT '承运报文应收(对照)' AFTER `CarrierQuotedFreightAmount`,
|
||||
ADD COLUMN `CarrierQuoteJson` TEXT NULL COMMENT '承运报价原文' AFTER `CarrierQuotedReceivableAmount`,
|
||||
ADD COLUMN `ReceivableFeeJson` TEXT NULL COMMENT '系统卖价费用明细 JSON' AFTER `CarrierQuoteJson`,
|
||||
ADD COLUMN `PayableFeeJson` TEXT NULL COMMENT '系统买价费用明细 JSON' AFTER `ReceivableFeeJson`,
|
||||
ADD COLUMN `FeePushedAt` DATETIME NULL COMMENT '承运推送件重尺时间' AFTER `PayableFeeJson`;
|
||||
97
sql/migrate_referral_invite_reward.sql
Normal file
97
sql/migrate_referral_invite_reward.sql
Normal file
@ -0,0 +1,97 @@
|
||||
-- 邀请奖励:按实付基础运费百分比、T+N 工作日入运费余额、退款标记(可重复执行)
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- ---------- t_order.IsRefunded ----------
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order'
|
||||
AND COLUMN_NAME = 'IsRefunded'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_order`
|
||||
ADD COLUMN `IsRefunded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否已退款(分成计次排除)'' AFTER `IsPaid`',
|
||||
'SELECT ''t_order.IsRefunded already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------- t_referral_commission_rule 新字段 ----------
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_referral_commission_rule'
|
||||
AND COLUMN_NAME = 'MinInviteLevelCode'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_referral_commission_rule`
|
||||
ADD COLUMN `MinInviteLevelCode` VARCHAR(16) NOT NULL DEFAULT ''V1'' COMMENT ''邀请资格最低等级'' AFTER `MonthlyCap`,
|
||||
ADD COLUMN `MinPaidBaseFreight` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''计奖最低实付基础运费'' AFTER `MinInviteLevelCode`,
|
||||
ADD COLUMN `FirstOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0500 COMMENT ''首单奖励比例'' AFTER `MinPaidBaseFreight`,
|
||||
ADD COLUMN `FirstOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 50.00 COMMENT ''首单奖励封顶'' AFTER `FirstOrderRate`,
|
||||
ADD COLUMN `FollowOrderRate` DECIMAL(8,4) NOT NULL DEFAULT 0.0300 COMMENT ''后续单奖励比例'' AFTER `FirstOrderCap`,
|
||||
ADD COLUMN `FollowOrderCap` DECIMAL(18,2) NOT NULL DEFAULT 30.00 COMMENT ''后续单奖励封顶'' AFTER `FollowOrderRate`,
|
||||
ADD COLUMN `FollowWindowDays` INT NOT NULL DEFAULT 90 COMMENT ''后续单注册后天数窗口'' AFTER `FollowOrderCap`,
|
||||
ADD COLUMN `MaxRewardOrders` INT NOT NULL DEFAULT 6 COMMENT ''最多计奖订单数'' AFTER `FollowWindowDays`,
|
||||
ADD COLUMN `SettleBusinessDays` INT NOT NULL DEFAULT 3 COMMENT ''派送成功后结算工作日'' AFTER `MaxRewardOrders`',
|
||||
'SELECT ''t_referral_commission_rule reward columns already exist'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `t_referral_commission_rule`
|
||||
SET
|
||||
`CommissionMode` = 'percent_of_paid_base_freight',
|
||||
`SettleTriggerOrderStatus` = 'deliverySuccess',
|
||||
`SettleBusinessDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END,
|
||||
`FreezeDays` = CASE WHEN `SettleBusinessDays` > 0 THEN `SettleBusinessDays` ELSE 3 END,
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0;
|
||||
|
||||
-- ---------- t_referral_commission_detail ----------
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_referral_commission_detail'
|
||||
AND COLUMN_NAME = 'SettleDueTime'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_referral_commission_detail`
|
||||
ADD COLUMN `SettleDueTime` DATETIME NULL COMMENT ''计划结算时间'' AFTER `EligibleTime`',
|
||||
'SELECT ''SettleDueTime already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_referral_commission_detail'
|
||||
AND COLUMN_NAME = 'RewardOrderIndex'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_referral_commission_detail`
|
||||
ADD COLUMN `RewardOrderIndex` INT NOT NULL DEFAULT 0 COMMENT ''被邀请人第几单'' AFTER `SettleDueTime`',
|
||||
'SELECT ''RewardOrderIndex already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `t_coupon_template`
|
||||
SET
|
||||
`Remark` = '被邀请人注册即发 5 元券',
|
||||
`UpdateTime` = NOW()
|
||||
WHERE `IsDeleted` = 0
|
||||
AND `TemplateCode` = 'REFERRAL_INVITEE';
|
||||
@ -22,6 +22,24 @@ public class ReferralCommissionRuleOutput
|
||||
|
||||
public decimal? MonthlyCap { get; set; }
|
||||
|
||||
public string MinInviteLevelCode { get; set; } = "V1";
|
||||
|
||||
public decimal MinPaidBaseFreight { get; set; }
|
||||
|
||||
public decimal FirstOrderRate { get; set; }
|
||||
|
||||
public decimal FirstOrderCap { get; set; }
|
||||
|
||||
public decimal FollowOrderRate { get; set; }
|
||||
|
||||
public decimal FollowOrderCap { get; set; }
|
||||
|
||||
public int FollowWindowDays { get; set; }
|
||||
|
||||
public int MaxRewardOrders { get; set; }
|
||||
|
||||
public int SettleBusinessDays { get; set; }
|
||||
|
||||
public EnableStatus Status { get; set; }
|
||||
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
@ -50,6 +68,24 @@ public class ReferralCommissionRuleSaveInput
|
||||
|
||||
public decimal? MonthlyCap { get; set; }
|
||||
|
||||
public string MinInviteLevelCode { get; set; } = "V1";
|
||||
|
||||
public decimal MinPaidBaseFreight { get; set; } = 50M;
|
||||
|
||||
public decimal FirstOrderRate { get; set; } = 0.05M;
|
||||
|
||||
public decimal FirstOrderCap { get; set; } = 50M;
|
||||
|
||||
public decimal FollowOrderRate { get; set; } = 0.03M;
|
||||
|
||||
public decimal FollowOrderCap { get; set; } = 30M;
|
||||
|
||||
public int FollowWindowDays { get; set; } = 90;
|
||||
|
||||
public int MaxRewardOrders { get; set; } = 6;
|
||||
|
||||
public int SettleBusinessDays { get; set; } = 3;
|
||||
|
||||
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
|
||||
}
|
||||
|
||||
@ -92,6 +128,10 @@ public class CommissionDetailOutput
|
||||
|
||||
public decimal CommissionAmount { get; set; }
|
||||
|
||||
public int RewardOrderIndex { get; set; }
|
||||
|
||||
public DateTime? SettleDueTime { get; set; }
|
||||
|
||||
public CommissionStatus Status { get; set; }
|
||||
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
|
||||
@ -387,6 +387,10 @@ public class CouponInstanceOutput
|
||||
|
||||
public DateTime? LockExpireTime { get; set; }
|
||||
|
||||
public string? TemplateCode { get; set; }
|
||||
|
||||
public string? UnlockHint { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -415,6 +419,10 @@ public class CouponAvailableInput
|
||||
|
||||
public decimal? AmountAfterLevel { get; set; }
|
||||
|
||||
public decimal? BaseFreightAmount { get; set; }
|
||||
|
||||
public string? DestinationCountry { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ public class MarketingPriceBreakdown
|
||||
{
|
||||
public decimal ReceivableSubtotal { get; set; }
|
||||
|
||||
public decimal BaseFreightAmount { get; set; }
|
||||
|
||||
public decimal LevelDiscountAmount { get; set; }
|
||||
|
||||
public decimal AmountAfterLevel { get; set; }
|
||||
@ -12,6 +14,8 @@ public class MarketingPriceBreakdown
|
||||
|
||||
public decimal PayableAmount { get; set; }
|
||||
|
||||
public decimal PaidBaseFreightAmount { get; set; }
|
||||
|
||||
public int? MemberLevelId { get; set; }
|
||||
|
||||
public string? MemberLevelName { get; set; }
|
||||
|
||||
@ -17,6 +17,9 @@ public class MemberLevelOutput
|
||||
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
|
||||
/// <summary>是否允许降级。V4 钻石、V5 皇冠为 true,且只能逐级降。</summary>
|
||||
public bool CanDowngrade { get; set; }
|
||||
|
||||
public int Sort { get; set; }
|
||||
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
@ -20,6 +20,8 @@ public class MemberLevelSaveInput
|
||||
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
|
||||
public bool CanDowngrade { get; set; }
|
||||
|
||||
public int Sort { get; set; }
|
||||
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
@ -1,25 +1,27 @@
|
||||
namespace Oms.Application.Dtos.Marketing;
|
||||
|
||||
public class MemberOverviewOutput
|
||||
{
|
||||
public int? MemberLevelId { get; set; }
|
||||
|
||||
public string? MemberLevelName { get; set; }
|
||||
|
||||
public string? PriceTierKey { get; set; }
|
||||
|
||||
public int TotalPoints { get; set; }
|
||||
|
||||
public int AvailablePoints { get; set; }
|
||||
|
||||
public int? NextLevelId { get; set; }
|
||||
|
||||
public string? NextLevelName { get; set; }
|
||||
|
||||
public int? PointsToNextLevel { get; set; }
|
||||
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
|
||||
public string? BenefitDesc { get; set; }
|
||||
}
|
||||
namespace Oms.Application.Dtos.Marketing;
|
||||
|
||||
public class MemberOverviewOutput
|
||||
{
|
||||
public int? MemberLevelId { get; set; }
|
||||
|
||||
public string? MemberLevelName { get; set; }
|
||||
|
||||
public string? PriceTierKey { get; set; }
|
||||
|
||||
public int TotalPoints { get; set; }
|
||||
|
||||
public int RollingPoints { get; set; }
|
||||
|
||||
public int AvailablePoints { get; set; }
|
||||
|
||||
public int? NextLevelId { get; set; }
|
||||
|
||||
public string? NextLevelName { get; set; }
|
||||
|
||||
public int? PointsToNextLevel { get; set; }
|
||||
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
|
||||
public string? BenefitDesc { get; set; }
|
||||
}
|
||||
|
||||
@ -1,123 +1,137 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Marketing;
|
||||
|
||||
public class ReferralMyInviteOutput
|
||||
{
|
||||
public string InviteCode { get; set; } = string.Empty;
|
||||
|
||||
public byte Status { get; set; }
|
||||
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
|
||||
public int RefereeCount { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralRefereeListInput
|
||||
{
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralRefereeOutput
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string? CustomerNameMasked { get; set; }
|
||||
|
||||
public string? PhoneMasked { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
public bool HasPaidOrder { get; set; }
|
||||
|
||||
public bool HasDeliverySuccessOrder { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralValidateCodeOutput
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
|
||||
public string? InviterCustomerCode { get; set; }
|
||||
|
||||
public string? InviterNameMasked { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralTreeInput
|
||||
{
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralReferrerListInput
|
||||
{
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralReferrerOutput
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public int RefereeCount { get; set; }
|
||||
|
||||
public DateTime? LatestBindTime { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralTreeNodeOutput
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public int? ReferrerCustomerId { get; set; }
|
||||
|
||||
public string? ReferrerCustomerCode { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
public DateTime? ReferralBindTime { get; set; }
|
||||
|
||||
public string? ReferralChannel { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralRebindInput
|
||||
{
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
public int? NewReferrerCustomerId { get; set; }
|
||||
|
||||
public string? NewReferrerCustomerCode { get; set; }
|
||||
|
||||
public string? NewInviteCode { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(512, MinimumLength = 20)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Marketing;
|
||||
|
||||
public class ReferralMyInviteOutput
|
||||
{
|
||||
public string InviteCode { get; set; } = string.Empty;
|
||||
|
||||
public byte Status { get; set; }
|
||||
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
|
||||
public int RefereeCount { get; set; }
|
||||
|
||||
public bool CanInvite { get; set; }
|
||||
|
||||
public string MinInviteLevelCode { get; set; } = "V1";
|
||||
|
||||
public decimal PendingReward { get; set; }
|
||||
|
||||
public decimal SettledReward { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralRefereeListInput
|
||||
{
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralRefereeOutput
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string? CustomerNameMasked { get; set; }
|
||||
|
||||
public string? PhoneMasked { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
public bool HasPaidOrder { get; set; }
|
||||
|
||||
public bool HasDeliverySuccessOrder { get; set; }
|
||||
|
||||
public int RewardOrderCount { get; set; }
|
||||
|
||||
public decimal PendingReward { get; set; }
|
||||
|
||||
public decimal SettledReward { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralValidateCodeOutput
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
|
||||
public string? InviterCustomerCode { get; set; }
|
||||
|
||||
public string? InviterNameMasked { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralTreeInput
|
||||
{
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralReferrerListInput
|
||||
{
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralReferrerOutput
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public int RefereeCount { get; set; }
|
||||
|
||||
public DateTime? LatestBindTime { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralTreeNodeOutput
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public int? ReferrerCustomerId { get; set; }
|
||||
|
||||
public string? ReferrerCustomerCode { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
public DateTime? ReferralBindTime { get; set; }
|
||||
|
||||
public string? ReferralChannel { get; set; }
|
||||
}
|
||||
|
||||
public class ReferralRebindInput
|
||||
{
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
public int? NewReferrerCustomerId { get; set; }
|
||||
|
||||
public string? NewReferrerCustomerCode { get; set; }
|
||||
|
||||
public string? NewInviteCode { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(512, MinimumLength = 20)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
14
src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
Normal file
14
src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
public class OrderRefundInput
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// true=全额退款(可退券);false=部分退款(券不退回)。
|
||||
/// </summary>
|
||||
public bool IsFullRefund { get; set; } = true;
|
||||
}
|
||||
@ -242,7 +242,9 @@ public class CustomerService : ApplicationService
|
||||
entity,
|
||||
input.InviteCode,
|
||||
ReferralCodeRepository,
|
||||
CustomerRepository);
|
||||
CustomerRepository,
|
||||
MemberLevelRepository,
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
|
||||
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
||||
entity,
|
||||
input.AdvisorCode,
|
||||
@ -251,6 +253,10 @@ public class CustomerService : ApplicationService
|
||||
try
|
||||
{
|
||||
await CouponManager.GrantNewcomerCouponsAsync(entity.Id);
|
||||
if (entity.ReferrerCustomerId.HasValue)
|
||||
{
|
||||
await CouponManager.GrantInviteeCouponAsync(entity.Id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@ -246,6 +246,8 @@ public class MarketingCommissionAdminService : ApplicationService
|
||||
Status = entity.Status,
|
||||
StatusName = MarketingEnumNames.Of(entity.Status),
|
||||
EligibleTime = entity.EligibleTime,
|
||||
SettleDueTime = entity.SettleDueTime,
|
||||
RewardOrderIndex = entity.RewardOrderIndex,
|
||||
SettleTime = entity.SettleTime,
|
||||
CancelReason = entity.CancelReason,
|
||||
CreateTime = entity.CreateTime
|
||||
|
||||
@ -47,15 +47,26 @@ public class MarketingCommissionRuleService : ApplicationService
|
||||
|
||||
entity.RuleName = input.RuleName.Trim();
|
||||
entity.CommissionMode = string.IsNullOrWhiteSpace(input.CommissionMode)
|
||||
? "per_amount"
|
||||
? "percent_of_paid_base_freight"
|
||||
: input.CommissionMode.Trim();
|
||||
entity.BaseAmount = input.BaseAmount;
|
||||
entity.CommissionAmount = input.CommissionAmount;
|
||||
entity.FreezeDays = input.FreezeDays;
|
||||
entity.BaseAmount = input.MinPaidBaseFreight;
|
||||
entity.CommissionAmount = 0;
|
||||
entity.SettleTriggerOrderStatus = string.IsNullOrWhiteSpace(input.SettleTriggerOrderStatus)
|
||||
? "deliverySuccess"
|
||||
: input.SettleTriggerOrderStatus.Trim();
|
||||
entity.MonthlyCap = input.MonthlyCap;
|
||||
entity.MinInviteLevelCode = string.IsNullOrWhiteSpace(input.MinInviteLevelCode) ? "V1" : input.MinInviteLevelCode.Trim();
|
||||
entity.MinPaidBaseFreight = input.MinPaidBaseFreight;
|
||||
entity.FirstOrderRate = input.FirstOrderRate;
|
||||
entity.FirstOrderCap = input.FirstOrderCap;
|
||||
entity.FollowOrderRate = input.FollowOrderRate;
|
||||
entity.FollowOrderCap = input.FollowOrderCap;
|
||||
entity.FollowWindowDays = input.FollowWindowDays;
|
||||
entity.MaxRewardOrders = input.MaxRewardOrders;
|
||||
entity.SettleBusinessDays = input.SettleBusinessDays >= 0
|
||||
? input.SettleBusinessDays
|
||||
: Math.Max(input.FreezeDays, 0);
|
||||
entity.FreezeDays = entity.SettleBusinessDays;
|
||||
entity.Status = input.Status;
|
||||
|
||||
if (input.Id is > 0)
|
||||
@ -75,19 +86,20 @@ public class MarketingCommissionRuleService : ApplicationService
|
||||
throw new BusinessException("Rule name is required");
|
||||
}
|
||||
|
||||
if (input.BaseAmount <= 0)
|
||||
if (input.MinPaidBaseFreight < 0
|
||||
|| input.FirstOrderRate is < 0 or > 1
|
||||
|| input.FirstOrderCap < 0
|
||||
|| input.FollowOrderRate is < 0 or > 1
|
||||
|| input.FollowOrderCap < 0
|
||||
|| input.FollowWindowDays <= 0
|
||||
|| input.MaxRewardOrders <= 0)
|
||||
{
|
||||
throw new BusinessException("Base amount must be positive");
|
||||
throw new BusinessException("Invite reward parameters are invalid");
|
||||
}
|
||||
|
||||
if (input.CommissionAmount <= 0)
|
||||
if (input.SettleBusinessDays < 0 && input.FreezeDays < 0)
|
||||
{
|
||||
throw new BusinessException("Commission amount must be positive");
|
||||
}
|
||||
|
||||
if (input.FreezeDays < 0)
|
||||
{
|
||||
throw new BusinessException("Freeze days cannot be negative");
|
||||
throw new BusinessException("Settle business days cannot be negative");
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,6 +113,15 @@ public class MarketingCommissionRuleService : ApplicationService
|
||||
FreezeDays = rule.FreezeDays,
|
||||
SettleTriggerOrderStatus = rule.SettleTriggerOrderStatus,
|
||||
MonthlyCap = rule.MonthlyCap,
|
||||
MinInviteLevelCode = rule.MinInviteLevelCode,
|
||||
MinPaidBaseFreight = rule.MinPaidBaseFreight,
|
||||
FirstOrderRate = rule.FirstOrderRate,
|
||||
FirstOrderCap = rule.FirstOrderCap,
|
||||
FollowOrderRate = rule.FollowOrderRate,
|
||||
FollowOrderCap = rule.FollowOrderCap,
|
||||
FollowWindowDays = rule.FollowWindowDays,
|
||||
MaxRewardOrders = rule.MaxRewardOrders,
|
||||
SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays,
|
||||
Status = rule.Status,
|
||||
StatusName = MarketingEnumNames.Of(rule.Status),
|
||||
CreateTime = rule.CreateTime,
|
||||
|
||||
@ -224,6 +224,10 @@ public class MarketingCouponGrantService : ApplicationService
|
||||
ValidEndTime = entity.ValidEndTime,
|
||||
UseTime = entity.UseTime,
|
||||
OrderId = entity.OrderId,
|
||||
LockExpireTime = entity.LockExpireTime
|
||||
LockExpireTime = entity.LockExpireTime,
|
||||
TemplateCode = template?.TemplateCode,
|
||||
UnlockHint = entity.Status == CouponInstanceStatus.PendingUnlock
|
||||
? "完成首单派送即可解锁"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
@ -165,6 +165,12 @@ public class MarketingCouponTemplateService : ApplicationService
|
||||
throw new BusinessException("Template code already exists");
|
||||
}
|
||||
|
||||
if (input.ValidType == CouponValidType.DaysAfterUnlock &&
|
||||
(!input.ValidDaysAfterReceive.HasValue || input.ValidDaysAfterReceive.Value <= 0))
|
||||
{
|
||||
throw new BusinessException("Unlock validity days is required");
|
||||
}
|
||||
|
||||
ValidateCouponTypeFields(input);
|
||||
}
|
||||
|
||||
|
||||
@ -132,9 +132,9 @@ public class MarketingMemberLevelService : ApplicationService
|
||||
throw new BusinessException("等级名称不能为空");
|
||||
}
|
||||
|
||||
if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate >= 1)
|
||||
if (input.ExtraDiscountRate < 0 || input.ExtraDiscountRate > MarketingBillingCalculator.MaxFreightOffRate)
|
||||
{
|
||||
throw new BusinessException("额外折扣率须在 0~1 之间(不含 1)");
|
||||
throw new BusinessException("基础运费折扣率须在 0~0.05 之间(0.05 即 95 折封顶)");
|
||||
}
|
||||
|
||||
if (await MemberLevelRepository.ExistsLevelCodeAsync(levelCode, excludeId))
|
||||
@ -149,6 +149,7 @@ public class MarketingMemberLevelService : ApplicationService
|
||||
entity.LevelName = input.LevelName.Trim();
|
||||
entity.MinPoints = input.MinPoints;
|
||||
entity.ExtraDiscountRate = input.ExtraDiscountRate;
|
||||
entity.CanDowngrade = input.CanDowngrade;
|
||||
entity.Sort = input.Sort;
|
||||
entity.IconUrl = string.IsNullOrWhiteSpace(input.IconUrl) ? null : input.IconUrl.Trim();
|
||||
entity.BenefitDesc = string.IsNullOrWhiteSpace(input.BenefitDesc) ? null : input.BenefitDesc.Trim();
|
||||
@ -166,6 +167,7 @@ public class MarketingMemberLevelService : ApplicationService
|
||||
LevelName = entity.LevelName,
|
||||
MinPoints = entity.MinPoints,
|
||||
ExtraDiscountRate = entity.ExtraDiscountRate,
|
||||
CanDowngrade = entity.CanDowngrade,
|
||||
Sort = entity.Sort,
|
||||
IconUrl = entity.IconUrl,
|
||||
BenefitDesc = entity.BenefitDesc,
|
||||
|
||||
@ -116,6 +116,8 @@ public class MarketingPortalCommissionService : ApplicationService
|
||||
Status = entity.Status,
|
||||
StatusName = MarketingEnumNames.Of(entity.Status),
|
||||
EligibleTime = entity.EligibleTime,
|
||||
SettleDueTime = entity.SettleDueTime,
|
||||
RewardOrderIndex = entity.RewardOrderIndex,
|
||||
SettleTime = entity.SettleTime,
|
||||
CancelReason = entity.CancelReason,
|
||||
CreateTime = entity.CreateTime
|
||||
|
||||
@ -84,7 +84,8 @@ public class MarketingPortalCouponService : ApplicationService
|
||||
var mainCustomer = await MarketingPortalSessionHelper.ResolveMainCustomerAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser);
|
||||
return await CouponManager.BuildAvailableCouponsAsync(mainCustomer.Id, input.AmountAfterLevel);
|
||||
return await CouponManager.BuildAvailableCouponsAsync(
|
||||
mainCustomer.Id, input.BaseFreightAmount ?? input.AmountAfterLevel, input.DestinationCountry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -38,6 +38,7 @@ public class MarketingPortalMemberService : ApplicationService
|
||||
CustomerRepository,
|
||||
CurrentUser);
|
||||
var account = await PointsManager.GetOrCreateAccountAsync(mainCustomer.Id);
|
||||
var rollingPoints = await PointsManager.GetRollingPointsAsync(mainCustomer.Id);
|
||||
var levels = await MemberLevelRepository.GetAllEnabledOrderedAsync();
|
||||
|
||||
MemberLevel? currentLevel = null;
|
||||
@ -48,7 +49,7 @@ public class MarketingPortalMemberService : ApplicationService
|
||||
}
|
||||
|
||||
var nextLevel = levels
|
||||
.Where(x => x.MinPoints > account.TotalPoints)
|
||||
.Where(x => x.MinPoints > rollingPoints)
|
||||
.OrderBy(x => x.MinPoints)
|
||||
.ThenBy(x => x.Sort)
|
||||
.FirstOrDefault();
|
||||
@ -59,10 +60,11 @@ public class MarketingPortalMemberService : ApplicationService
|
||||
MemberLevelName = currentLevel?.LevelName,
|
||||
PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey),
|
||||
TotalPoints = account.TotalPoints,
|
||||
RollingPoints = rollingPoints,
|
||||
AvailablePoints = account.AvailablePoints,
|
||||
NextLevelId = nextLevel?.Id,
|
||||
NextLevelName = nextLevel?.LevelName,
|
||||
PointsToNextLevel = nextLevel == null ? null : nextLevel.MinPoints - account.TotalPoints,
|
||||
PointsToNextLevel = nextLevel == null ? null : Math.Max(0, nextLevel.MinPoints - rollingPoints),
|
||||
ExtraDiscountRate = currentLevel?.ExtraDiscountRate ?? 0M,
|
||||
BenefitDesc = currentLevel?.BenefitDesc
|
||||
};
|
||||
|
||||
@ -28,6 +28,15 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
private IOrderRepository OrderRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||
|
||||
private IMemberLevelRepository MemberLevelRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IMemberLevelRepository>();
|
||||
|
||||
private IReferralCommissionRuleRepository CommissionRuleRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>();
|
||||
|
||||
private IReferralCommissionDetailRepository CommissionDetailRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionDetailRepository>();
|
||||
|
||||
private MarketingReferralCodeProvisioner ReferralCodeProvisioner =>
|
||||
LazyServiceProvider.GetRequiredService<MarketingReferralCodeProvisioner>();
|
||||
|
||||
@ -48,13 +57,22 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
var referralCode = await ReferralCodeProvisioner.GetOrCreateAsync(mainCustomer.Id);
|
||||
var refereeCount = await CustomerRepository.CountAsync(
|
||||
x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null);
|
||||
var (canInvite, minLevel) = await ResolveCanInviteAsync(mainCustomer);
|
||||
var pendingReward = await CommissionDetailRepository.SumByReferrerAsync(
|
||||
mainCustomer.Id, CommissionStatus.Pending);
|
||||
var settledReward = await CommissionDetailRepository.SumByReferrerAsync(
|
||||
mainCustomer.Id, CommissionStatus.Settled);
|
||||
|
||||
return new ReferralMyInviteOutput
|
||||
{
|
||||
InviteCode = referralCode.InviteCode,
|
||||
Status = (byte)referralCode.Status,
|
||||
StatusName = MarketingEnumNames.Of(referralCode.Status),
|
||||
RefereeCount = refereeCount
|
||||
RefereeCount = refereeCount,
|
||||
CanInvite = canInvite,
|
||||
MinInviteLevelCode = minLevel,
|
||||
PendingReward = pendingReward,
|
||||
SettledReward = settledReward
|
||||
};
|
||||
}
|
||||
|
||||
@ -82,6 +100,14 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
var total = await CustomerRepository.CountAsync(
|
||||
x => x.ReferrerCustomerId == mainCustomer.Id && x.ParentCustomerId == null);
|
||||
|
||||
var refereeIds = entities.Select(x => x.Id).ToArray();
|
||||
var details = await CommissionDetailRepository.ListByReferrerAndRefereesAsync(
|
||||
mainCustomer.Id, refereeIds);
|
||||
var detailsByReferee = details
|
||||
.Where(x => x.Status != CommissionStatus.Cancelled)
|
||||
.GroupBy(x => x.RefereeCustomerId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var items = new List<ReferralRefereeOutput>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
@ -90,6 +116,8 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
var hasDeliverySuccess = await OrderRepository.AnyAsync(
|
||||
x => x.CustomerId == entity.Id
|
||||
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess);
|
||||
detailsByReferee.TryGetValue(entity.Id, out var refereeDetails);
|
||||
refereeDetails ??= [];
|
||||
|
||||
items.Add(new ReferralRefereeOutput
|
||||
{
|
||||
@ -99,7 +127,14 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
PhoneMasked = MarketingPortalSessionHelper.MaskPhone(entity.Phone),
|
||||
CreateTime = entity.CreateTime,
|
||||
HasPaidOrder = hasPaidOrder,
|
||||
HasDeliverySuccessOrder = hasDeliverySuccess
|
||||
HasDeliverySuccessOrder = hasDeliverySuccess,
|
||||
RewardOrderCount = refereeDetails.Count,
|
||||
PendingReward = refereeDetails
|
||||
.Where(x => x.Status == CommissionStatus.Pending)
|
||||
.Sum(x => x.CommissionAmount),
|
||||
SettledReward = refereeDetails
|
||||
.Where(x => x.Status == CommissionStatus.Settled)
|
||||
.Sum(x => x.CommissionAmount)
|
||||
});
|
||||
}
|
||||
|
||||
@ -143,6 +178,16 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
};
|
||||
}
|
||||
|
||||
var (canInvite, _) = await ResolveCanInviteAsync(inviter);
|
||||
if (!canInvite)
|
||||
{
|
||||
return new ReferralValidateCodeOutput
|
||||
{
|
||||
Valid = false,
|
||||
Message = "邀请人暂无邀请资格"
|
||||
};
|
||||
}
|
||||
|
||||
return new ReferralValidateCodeOutput
|
||||
{
|
||||
Valid = true,
|
||||
@ -151,4 +196,18 @@ public class MarketingPortalReferralService : ApplicationService
|
||||
Message = "邀请码有效"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(bool CanInvite, string MinLevel)> ResolveCanInviteAsync(
|
||||
Domain.CustomersAggregate.Entities.Customer customer)
|
||||
{
|
||||
var rule = await CommissionRuleRepository.GetActiveRuleAsync();
|
||||
var minLevel = string.IsNullOrWhiteSpace(rule?.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode;
|
||||
Domain.MarketingAggregate.Entities.MemberLevel? level = null;
|
||||
if (customer.MemberLevelId.HasValue)
|
||||
{
|
||||
level = await MemberLevelRepository.FirstOrDefaultAsync(x => x.Id == customer.MemberLevelId.Value);
|
||||
}
|
||||
|
||||
return (MarketingInviteRewardCalculator.CanInvite(level?.LevelCode, minLevel), minLevel);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2849,11 +2849,15 @@ public partial class OrderService
|
||||
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
|
||||
{
|
||||
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||||
var receivableSubtotal = fees
|
||||
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
|
||||
.Sum(x => x.Amount);
|
||||
var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
|
||||
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
|
||||
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
|
||||
var breakdown = await marketingHelper.BuildBreakdownAsync(order.CustomerId, receivableSubtotal, couponInstanceId);
|
||||
var breakdown = await marketingHelper.BuildBreakdownAsync(
|
||||
order.CustomerId,
|
||||
receivableSubtotal,
|
||||
baseFreightAmount,
|
||||
couponInstanceId,
|
||||
order.ConsigneeCountry);
|
||||
marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId);
|
||||
|
||||
if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue)
|
||||
@ -2883,14 +2887,15 @@ public partial class OrderService
|
||||
private async Task RefreshMarketingPayableAmountAsync(Order order)
|
||||
{
|
||||
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||||
var receivableSubtotal = fees
|
||||
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
|
||||
.Sum(x => x.Amount);
|
||||
var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
|
||||
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
|
||||
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
|
||||
var breakdown = await marketingHelper.BuildBreakdownAsync(
|
||||
order.CustomerId,
|
||||
receivableSubtotal,
|
||||
order.CouponInstanceId);
|
||||
baseFreightAmount,
|
||||
order.CouponInstanceId,
|
||||
order.ConsigneeCountry);
|
||||
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
|
||||
}
|
||||
|
||||
|
||||
@ -326,15 +326,18 @@ public partial class OrderService
|
||||
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
var originalTotal = item.TotalCost;
|
||||
var breakdown = await marketingHelper.BuildBreakdownAsync(
|
||||
input.CustomerId,
|
||||
item.TotalCost,
|
||||
input.CouponInstanceId);
|
||||
originalTotal,
|
||||
item.Freight,
|
||||
input.CouponInstanceId,
|
||||
input.DestinationCountry);
|
||||
item.PriceBreakdown = breakdown;
|
||||
item.TotalCost = breakdown.PayableAmount;
|
||||
item.TotalCostNum = breakdown.PayableAmount;
|
||||
firstBreakdown ??= breakdown;
|
||||
amountAfterLevel ??= breakdown.AmountAfterLevel;
|
||||
amountAfterLevel ??= item.Freight;
|
||||
}
|
||||
|
||||
result.PriceBreakdown = firstBreakdown;
|
||||
@ -342,7 +345,8 @@ public partial class OrderService
|
||||
{
|
||||
result.AvailableCoupons = await marketingHelper.BuildAvailableCouponsAsync(
|
||||
input.CustomerId,
|
||||
amountAfterLevel);
|
||||
amountAfterLevel,
|
||||
input.DestinationCountry);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -178,7 +178,11 @@ public partial class OrderService : ApplicationService
|
||||
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
|
||||
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
|
||||
await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, payAmount);
|
||||
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
|
||||
MarketingFeeHelper.SumReceivableBaseFreight(fees),
|
||||
order.LevelDiscountAmount,
|
||||
order.CouponDiscountAmount);
|
||||
await pointsManager.EarnOnOrderPaidAsync(mainCustomer.Id, order.Id, paidBaseFreight, order.ServiceNo);
|
||||
|
||||
order.PayTime = DateTime.Now;
|
||||
order.IsPaid = 1;
|
||||
@ -199,6 +203,47 @@ public partial class OrderService : ApplicationService
|
||||
|
||||
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
|
||||
await commissionManager.OnOrderDeliverySuccessAsync(order.Id);
|
||||
|
||||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
await couponManager.OnFirstDeliverySuccessAsync(order);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
|
||||
/// </summary>
|
||||
[HttpPost("/order/refund")]
|
||||
public async Task RefundAsync(OrderRefundInput input)
|
||||
{
|
||||
var order = await GetOrderOrThrowAsync(input.Id);
|
||||
if (order.IsPaid == 0)
|
||||
{
|
||||
throw new BusinessException("订单未支付,无需退款");
|
||||
}
|
||||
|
||||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund);
|
||||
|
||||
order.IsRefunded = 1;
|
||||
await UpdateOrderAsync(order);
|
||||
|
||||
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
|
||||
await commissionManager.ClawbackOnOrderRefundAsync(order);
|
||||
|
||||
if (input.IsFullRefund && order.CustomerId.HasValue)
|
||||
{
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
|
||||
if (customer != null)
|
||||
{
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
CustomerRepository, customer);
|
||||
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
|
||||
await pointsManager.ClawbackOnOrderAsync(
|
||||
mainCustomer.Id,
|
||||
order.Id,
|
||||
order.ServiceNo,
|
||||
"订单退款扣回积分");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -265,6 +310,23 @@ public partial class OrderService : ApplicationService
|
||||
}
|
||||
|
||||
order.OrderStatus = OrderStatusCodes.Deleted;
|
||||
if (order.IsPaid > 0 && order.CustomerId.HasValue)
|
||||
{
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
|
||||
if (customer != null)
|
||||
{
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
CustomerRepository,
|
||||
customer);
|
||||
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
|
||||
await pointsManager.ClawbackOnOrderAsync(
|
||||
mainCustomer.Id,
|
||||
order.Id,
|
||||
order.ServiceNo,
|
||||
"订单删除扣回积分");
|
||||
}
|
||||
}
|
||||
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
await OrderRepository.DeleteAsync(order);
|
||||
}
|
||||
|
||||
@ -999,9 +999,10 @@ internal static class ParcelBillingCalculator
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsFreightCostItem(string costItem) =>
|
||||
costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|
||||
|| costItem.Contains("运费", StringComparison.OrdinalIgnoreCase);
|
||||
public static bool IsFreightCostItem(string costItem) =>
|
||||
!string.IsNullOrWhiteSpace(costItem)
|
||||
&& (costItem.Equals("freight", StringComparison.OrdinalIgnoreCase)
|
||||
|| costItem.Contains("运费", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
internal static string ResolveFeeDisplayName(string costItem, string? priceName)
|
||||
{
|
||||
|
||||
@ -63,6 +63,58 @@ public class CustomerBalanceMutator : IScopedDependency
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task CreditReferralRewardAsync(
|
||||
Customer customer,
|
||||
decimal amount,
|
||||
int detailId,
|
||||
string? orderNo,
|
||||
string? remark,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
throw new BusinessException("奖励金额必须大于 0");
|
||||
}
|
||||
|
||||
return ApplyAsync(
|
||||
customer,
|
||||
amount,
|
||||
CustomerAccountFlowBizTypes.ReferralCommission,
|
||||
detailId,
|
||||
orderNo,
|
||||
remark,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task DebitReferralClawbackAsync(
|
||||
Customer customer,
|
||||
decimal amount,
|
||||
int detailId,
|
||||
string? orderNo,
|
||||
string? remark,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var debit = Math.Min(amount, customer.Balance);
|
||||
if (debit <= 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return ApplyAsync(
|
||||
customer,
|
||||
-debit,
|
||||
CustomerAccountFlowBizTypes.ReferralCommission,
|
||||
detailId,
|
||||
orderNo,
|
||||
remark,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task DebitPickupPayAsync(
|
||||
Customer customer,
|
||||
decimal amount,
|
||||
|
||||
@ -3,10 +3,12 @@ using Oms.Application.Dtos.Marketing;
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 营销叠加计费(§11)。
|
||||
/// 会员折扣仅作用于基础运费;与优惠券不叠加,取更低应付(同价保留会员折,避免占券)。
|
||||
/// </summary>
|
||||
public static class MarketingBillingCalculator
|
||||
{
|
||||
public const decimal MaxFreightOffRate = 0.05M;
|
||||
|
||||
public static MarketingPriceBreakdown Calculate(
|
||||
decimal receivableSubtotal,
|
||||
decimal extraDiscountRate,
|
||||
@ -15,35 +17,75 @@ public static class MarketingBillingCalculator
|
||||
string? priceTierKey,
|
||||
CouponDiscountInput? coupon)
|
||||
{
|
||||
var levelDiscount = Math.Round(receivableSubtotal * extraDiscountRate, 2, MidpointRounding.AwayFromZero);
|
||||
var amountAfterLevel = receivableSubtotal - levelDiscount;
|
||||
var couponDiscount = coupon == null
|
||||
? 0M
|
||||
: CalculateCouponDiscount(coupon, amountAfterLevel);
|
||||
var payable = Math.Max(0M, amountAfterLevel - couponDiscount);
|
||||
|
||||
return new MarketingPriceBreakdown
|
||||
{
|
||||
ReceivableSubtotal = receivableSubtotal,
|
||||
LevelDiscountAmount = levelDiscount,
|
||||
AmountAfterLevel = amountAfterLevel,
|
||||
CouponDiscountAmount = couponDiscount,
|
||||
PayableAmount = payable,
|
||||
MemberLevelId = memberLevelId,
|
||||
MemberLevelName = memberLevelName,
|
||||
PriceTierKey = priceTierKey,
|
||||
CouponInstanceId = coupon?.CouponInstanceId
|
||||
};
|
||||
return Calculate(
|
||||
receivableSubtotal,
|
||||
receivableSubtotal,
|
||||
extraDiscountRate,
|
||||
memberLevelId,
|
||||
memberLevelName,
|
||||
priceTierKey,
|
||||
coupon);
|
||||
}
|
||||
|
||||
public static decimal CalculateCouponDiscount(CouponDiscountInput coupon, decimal amountAfterLevel)
|
||||
public static MarketingPriceBreakdown Calculate(
|
||||
decimal receivableSubtotal,
|
||||
decimal baseFreightAmount,
|
||||
decimal extraDiscountRate,
|
||||
int? memberLevelId,
|
||||
string? memberLevelName,
|
||||
string? priceTierKey,
|
||||
CouponDiscountInput? coupon)
|
||||
{
|
||||
if (amountAfterLevel <= 0)
|
||||
receivableSubtotal = Math.Max(0M, receivableSubtotal);
|
||||
baseFreightAmount = Math.Max(0M, Math.Min(baseFreightAmount, receivableSubtotal));
|
||||
extraDiscountRate = Math.Clamp(extraDiscountRate, 0M, MaxFreightOffRate);
|
||||
|
||||
var levelDiscount = Math.Round(baseFreightAmount * extraDiscountRate, 2, MidpointRounding.AwayFromZero);
|
||||
var payableMember = Math.Max(0M, receivableSubtotal - levelDiscount);
|
||||
|
||||
var couponDiscount = coupon == null
|
||||
? 0M
|
||||
: CalculateCouponDiscount(coupon, baseFreightAmount);
|
||||
var payableCoupon = Math.Max(0M, receivableSubtotal - couponDiscount);
|
||||
|
||||
var useCoupon = coupon != null && couponDiscount > 0M && payableCoupon < payableMember;
|
||||
if (useCoupon)
|
||||
{
|
||||
return Build(
|
||||
receivableSubtotal,
|
||||
baseFreightAmount,
|
||||
0M,
|
||||
couponDiscount,
|
||||
payableCoupon,
|
||||
memberLevelId,
|
||||
memberLevelName,
|
||||
priceTierKey,
|
||||
coupon!.CouponInstanceId);
|
||||
}
|
||||
|
||||
return Build(
|
||||
receivableSubtotal,
|
||||
baseFreightAmount,
|
||||
levelDiscount,
|
||||
0M,
|
||||
payableMember,
|
||||
memberLevelId,
|
||||
memberLevelName,
|
||||
priceTierKey,
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅按基础运费判断门槛并抵扣,封顶不超过基础运费(不找零、不冲附加费)。
|
||||
/// </summary>
|
||||
public static decimal CalculateCouponDiscount(CouponDiscountInput coupon, decimal baseFreightAmount)
|
||||
{
|
||||
if (baseFreightAmount <= 0)
|
||||
{
|
||||
return 0M;
|
||||
}
|
||||
|
||||
if (amountAfterLevel < coupon.MinOrderAmount)
|
||||
if (baseFreightAmount < coupon.MinOrderAmount)
|
||||
{
|
||||
return 0M;
|
||||
}
|
||||
@ -52,12 +94,12 @@ public static class MarketingBillingCalculator
|
||||
{
|
||||
MarketingCouponTypeCodes.FixedOff => coupon.FaceValue ?? 0M,
|
||||
MarketingCouponTypeCodes.DirectOff => coupon.FaceValue ?? 0M,
|
||||
MarketingCouponTypeCodes.PercentOff => CalculatePercentOff(amountAfterLevel, coupon.DiscountRate, coupon.MaxDiscountAmount),
|
||||
MarketingCouponTypeCodes.PercentOff => CalculatePercentOff(baseFreightAmount, coupon.DiscountRate, coupon.MaxDiscountAmount),
|
||||
MarketingCouponTypeCodes.ShippingOff => coupon.FaceValue ?? 0M,
|
||||
_ => 0M
|
||||
};
|
||||
|
||||
return Math.Min(Math.Max(0M, discount), amountAfterLevel);
|
||||
return Math.Min(Math.Max(0M, discount), baseFreightAmount);
|
||||
}
|
||||
|
||||
private static decimal CalculatePercentOff(decimal baseAmount, decimal? discountRate, decimal? maxDiscount)
|
||||
@ -86,6 +128,36 @@ public static class MarketingBillingCalculator
|
||||
var segments = Math.Floor(basePayableAmount / baseAmount);
|
||||
return segments * commissionAmount;
|
||||
}
|
||||
|
||||
private static MarketingPriceBreakdown Build(
|
||||
decimal receivableSubtotal,
|
||||
decimal baseFreightAmount,
|
||||
decimal levelDiscount,
|
||||
decimal couponDiscount,
|
||||
decimal payable,
|
||||
int? memberLevelId,
|
||||
string? memberLevelName,
|
||||
string? priceTierKey,
|
||||
int? couponInstanceId)
|
||||
{
|
||||
return new MarketingPriceBreakdown
|
||||
{
|
||||
ReceivableSubtotal = receivableSubtotal,
|
||||
BaseFreightAmount = baseFreightAmount,
|
||||
LevelDiscountAmount = levelDiscount,
|
||||
AmountAfterLevel = Math.Max(0M, receivableSubtotal - levelDiscount),
|
||||
CouponDiscountAmount = couponDiscount,
|
||||
PayableAmount = payable,
|
||||
PaidBaseFreightAmount = MarketingFeeHelper.ResolvePaidBaseFreight(
|
||||
baseFreightAmount,
|
||||
levelDiscount,
|
||||
couponDiscount),
|
||||
MemberLevelId = memberLevelId,
|
||||
MemberLevelName = memberLevelName,
|
||||
PriceTierKey = priceTierKey,
|
||||
CouponInstanceId = couponInstanceId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class MarketingCouponTypeCodes
|
||||
@ -112,4 +184,6 @@ public class CouponDiscountInput
|
||||
public decimal? DiscountRate { get; set; }
|
||||
|
||||
public decimal? MaxDiscountAmount { get; set; }
|
||||
|
||||
public string? ApplyScopeJson { get; set; }
|
||||
}
|
||||
|
||||
@ -4,25 +4,34 @@ using Fake.Domain.Exceptions;
|
||||
using Fake.Security.Claims;
|
||||
using Fake.Users;
|
||||
using Oms.Application.Dtos.Marketing;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.MarketingAggregate.Consts;
|
||||
using Oms.Domain.MarketingAggregate.Entities;
|
||||
using Oms.Domain.MarketingAggregate.Enums;
|
||||
using Oms.Domain.MarketingAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.Domain.SystemAggregate.Entities;
|
||||
using Oms.Domain.SystemAggregate.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券发放、锁定、核销与释放。
|
||||
/// 优惠券发放、锁定、核销、解锁与释放。
|
||||
/// </summary>
|
||||
public class MarketingCouponManager : IScopedDependency
|
||||
{
|
||||
private const int LockMinutes = 30;
|
||||
private static readonly DateTime PendingValidEnd = new(2099, 12, 31, 23, 59, 59);
|
||||
|
||||
private readonly ICouponTemplateRepository _templateRepository;
|
||||
private readonly ICouponInstanceRepository _instanceRepository;
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly INoticeRepository _noticeRepository;
|
||||
private readonly IMarketingAuditLogRepository _auditLogRepository;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
@ -30,12 +39,16 @@ public class MarketingCouponManager : IScopedDependency
|
||||
ICouponTemplateRepository templateRepository,
|
||||
ICouponInstanceRepository instanceRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IOrderRepository orderRepository,
|
||||
INoticeRepository noticeRepository,
|
||||
IMarketingAuditLogRepository auditLogRepository,
|
||||
ICurrentUser currentUser)
|
||||
{
|
||||
_templateRepository = templateRepository;
|
||||
_instanceRepository = instanceRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_orderRepository = orderRepository;
|
||||
_noticeRepository = noticeRepository;
|
||||
_auditLogRepository = auditLogRepository;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
@ -60,13 +73,16 @@ public class MarketingCouponManager : IScopedDependency
|
||||
throw new BusinessException("该客户已达领取上限");
|
||||
}
|
||||
|
||||
var (validStart, validEnd) = ResolveValidRange(template);
|
||||
var pendingUnlock = RequiresUnlock(template);
|
||||
var (validStart, validEnd) = pendingUnlock
|
||||
? (DateTime.Now, PendingValidEnd)
|
||||
: ResolveValidRange(template);
|
||||
var instance = new CouponInstance
|
||||
{
|
||||
TemplateId = templateId,
|
||||
CustomerId = mainCustomerId,
|
||||
CouponCode = await GenerateCouponCodeAsync(cancellationToken),
|
||||
Status = CouponInstanceStatus.Available,
|
||||
Status = pendingUnlock ? CouponInstanceStatus.PendingUnlock : CouponInstanceStatus.Available,
|
||||
ReceiveTime = DateTime.Now,
|
||||
ValidStartTime = validStart,
|
||||
ValidEndTime = validEnd
|
||||
@ -82,7 +98,7 @@ public class MarketingCouponManager : IScopedDependency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册成功后发放所有「新人券」模板(GrantChannel=newcomer 且已发布),有多少发多少;单张失败不阻断。
|
||||
/// 注册成功后发放所有已发布新人券。第二单券发成未解锁。
|
||||
/// </summary>
|
||||
public async Task<int> GrantNewcomerCouponsAsync(int mainCustomerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@ -109,13 +125,65 @@ public class MarketingCouponManager : IScopedDependency
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
// 库存不足、已达上限等跳过
|
||||
}
|
||||
}
|
||||
|
||||
return granted;
|
||||
}
|
||||
|
||||
public async Task GrantInviteeCouponAsync(int mainCustomerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (mainCustomerId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var templates = await _templateRepository.GetListAsync(
|
||||
x => x.Status == CouponTemplateStatus.Active
|
||||
&& x.GrantChannel == MarketingGrantChannels.Referral
|
||||
&& x.TemplateCode == MarketingCouponTemplateCodes.ReferralInvitee);
|
||||
foreach (var template in templates)
|
||||
{
|
||||
try
|
||||
{
|
||||
await GrantAsync(template.Id, mainCustomerId, "referral_invitee_register", cancellationToken);
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnFirstDeliverySuccessAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (order.CustomerId is not > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionCustomer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value, cancellationToken);
|
||||
if (sessionCustomer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
_customerRepository, sessionCustomer, cancellationToken);
|
||||
var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken);
|
||||
var hadOtherDelivery = await _orderRepository.AnyAsync(
|
||||
x => x.Id != order.Id
|
||||
&& x.CustomerId != null
|
||||
&& familyIds.Contains(x.CustomerId.Value)
|
||||
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess,
|
||||
cancellationToken);
|
||||
if (hadOtherDelivery)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await UnlockPendingCouponsAsync(mainCustomer.Id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LockForOrderAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
|
||||
@ -137,6 +205,44 @@ public class MarketingCouponManager : IScopedDependency
|
||||
await _instanceRepository.UpdateAsync(instance, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!order.CouponInstanceId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var instance = await _instanceRepository.FirstOrDefaultAsync(
|
||||
x => x.Id == order.CouponInstanceId.Value, cancellationToken);
|
||||
if (instance == null || instance.Status != CouponInstanceStatus.Used)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken);
|
||||
var isFirstOrderCoupon = template != null
|
||||
&& string.Equals(template.TemplateCode, MarketingCouponTemplateCodes.NewcomerFirst, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isFirstOrderCoupon || !isFullRefund)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (instance.ValidEndTime < DateTime.Now)
|
||||
{
|
||||
instance.Status = CouponInstanceStatus.Expired;
|
||||
instance.LockExpireTime = null;
|
||||
await _instanceRepository.UpdateAsync(instance, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
instance.Status = CouponInstanceStatus.Available;
|
||||
instance.OrderId = null;
|
||||
instance.UseTime = null;
|
||||
instance.LockExpireTime = null;
|
||||
await _instanceRepository.UpdateAsync(instance, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
|
||||
@ -163,9 +269,53 @@ public class MarketingCouponManager : IScopedDependency
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExpireOverdueAsync(int batchSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var overdue = await _instanceRepository.GetExpiredAvailableAsync(DateTime.Now, batchSize, cancellationToken);
|
||||
foreach (var instance in overdue)
|
||||
{
|
||||
instance.Status = CouponInstanceStatus.Expired;
|
||||
instance.LockExpireTime = null;
|
||||
await _instanceRepository.UpdateAsync(instance, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyExpiringAsync(int batchSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var expiring = await _instanceRepository.GetExpiringAvailableAsync(
|
||||
now, now.AddDays(3), batchSize, cancellationToken);
|
||||
if (expiring.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var templateIds = expiring.Select(x => x.TemplateId).Distinct().ToArray();
|
||||
var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
|
||||
var templateMap = templates.ToDictionary(x => x.Id);
|
||||
|
||||
foreach (var instance in expiring)
|
||||
{
|
||||
templateMap.TryGetValue(instance.TemplateId, out var template);
|
||||
var name = template?.TemplateName ?? "优惠券";
|
||||
await _noticeRepository.InsertAsync(new Notice
|
||||
{
|
||||
NoticeType = "message",
|
||||
Title = "优惠券即将到期",
|
||||
Content = $"您的「{name}」将于 {instance.ValidEndTime:yyyy-MM-dd HH:mm} 过期,请尽快下单使用。",
|
||||
IsPopup = 0,
|
||||
ReceiverType = "customer",
|
||||
ReceiverIds = JsonSerializer.Serialize(new[] { instance.CustomerId })
|
||||
}, cancellationToken);
|
||||
instance.ExpiryNoticeSent = 1;
|
||||
await _instanceRepository.UpdateAsync(instance, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync(
|
||||
int? couponInstanceId,
|
||||
int mainCustomerId,
|
||||
string? destinationCountry = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!couponInstanceId.HasValue)
|
||||
@ -177,6 +327,10 @@ public class MarketingCouponManager : IScopedDependency
|
||||
EnsureUsable(instance);
|
||||
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
|
||||
?? throw new BusinessException("券模板不存在");
|
||||
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
|
||||
{
|
||||
throw new BusinessException("该优惠券不适用于当前线路");
|
||||
}
|
||||
|
||||
return new CouponDiscountInput
|
||||
{
|
||||
@ -185,7 +339,8 @@ public class MarketingCouponManager : IScopedDependency
|
||||
MinOrderAmount = template.MinOrderAmount,
|
||||
FaceValue = template.FaceValue,
|
||||
DiscountRate = template.DiscountRate,
|
||||
MaxDiscountAmount = template.MaxDiscountAmount
|
||||
MaxDiscountAmount = template.MaxDiscountAmount,
|
||||
ApplyScopeJson = template.ApplyScopeJson
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,7 +353,8 @@ public class MarketingCouponManager : IScopedDependency
|
||||
|
||||
public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync(
|
||||
int mainCustomerId,
|
||||
decimal? amountAfterLevel,
|
||||
decimal? baseFreightAmount,
|
||||
string? destinationCountry = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
|
||||
@ -220,12 +376,17 @@ public class MarketingCouponManager : IScopedDependency
|
||||
string? unavailableReason = null;
|
||||
decimal? estimatedDiscount = null;
|
||||
|
||||
if (amountAfterLevel.HasValue)
|
||||
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
|
||||
{
|
||||
if (amountAfterLevel.Value < template.MinOrderAmount)
|
||||
canUse = false;
|
||||
unavailableReason = "不适用于当前线路";
|
||||
}
|
||||
else if (baseFreightAmount.HasValue)
|
||||
{
|
||||
if (baseFreightAmount.Value < template.MinOrderAmount)
|
||||
{
|
||||
canUse = false;
|
||||
unavailableReason = $"未满 {template.MinOrderAmount:0.##} 元门槛";
|
||||
unavailableReason = $"实付基础运费未满 {template.MinOrderAmount:0.##} 元";
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -236,15 +397,16 @@ public class MarketingCouponManager : IScopedDependency
|
||||
MinOrderAmount = template.MinOrderAmount,
|
||||
FaceValue = template.FaceValue,
|
||||
DiscountRate = template.DiscountRate,
|
||||
MaxDiscountAmount = template.MaxDiscountAmount
|
||||
MaxDiscountAmount = template.MaxDiscountAmount,
|
||||
ApplyScopeJson = template.ApplyScopeJson
|
||||
};
|
||||
estimatedDiscount = MarketingBillingCalculator.CalculateCouponDiscount(
|
||||
discountInput,
|
||||
amountAfterLevel.Value);
|
||||
baseFreightAmount.Value);
|
||||
if (estimatedDiscount <= 0)
|
||||
{
|
||||
canUse = false;
|
||||
unavailableReason = "当前金额不可用";
|
||||
unavailableReason = "当前基础运费不可用";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -267,6 +429,35 @@ public class MarketingCouponManager : IScopedDependency
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task UnlockPendingCouponsAsync(int mainCustomerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = await _instanceRepository.GetPendingUnlockByCustomerAsync(mainCustomerId, cancellationToken);
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var templateIds = pending.Select(x => x.TemplateId).Distinct().ToArray();
|
||||
var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
|
||||
var templateMap = templates.ToDictionary(x => x.Id);
|
||||
var now = DateTime.Now;
|
||||
foreach (var instance in pending)
|
||||
{
|
||||
templateMap.TryGetValue(instance.TemplateId, out var template);
|
||||
var days = template?.ValidDaysAfterReceive ?? 30;
|
||||
instance.Status = CouponInstanceStatus.Available;
|
||||
instance.ValidStartTime = now;
|
||||
instance.ValidEndTime = now.AddDays(days);
|
||||
await _instanceRepository.UpdateAsync(instance, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int[]> ResolveFamilyCustomerIdsAsync(int mainCustomerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var children = await _customerRepository.GetListAsync(x => x.ParentCustomerId == mainCustomerId);
|
||||
return children.Select(x => x.Id).Append(mainCustomerId).ToArray();
|
||||
}
|
||||
|
||||
private async Task<CouponInstance> GetOwnedInstanceAsync(int id, int mainCustomerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
|
||||
@ -282,6 +473,11 @@ public class MarketingCouponManager : IScopedDependency
|
||||
private static void EnsureUsable(CouponInstance instance)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
if (instance.Status == CouponInstanceStatus.PendingUnlock)
|
||||
{
|
||||
throw new BusinessException("优惠券未解锁,完成首单派送后可用");
|
||||
}
|
||||
|
||||
if (instance.Status == CouponInstanceStatus.Used)
|
||||
{
|
||||
throw new BusinessException("优惠券已使用");
|
||||
@ -305,9 +501,24 @@ public class MarketingCouponManager : IScopedDependency
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresUnlock(CouponTemplate template)
|
||||
{
|
||||
if (template.ValidType == CouponValidType.DaysAfterUnlock)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
template.TemplateCode,
|
||||
MarketingCouponTemplateCodes.NewcomerSecond,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (DateTime Start, DateTime End) ResolveValidRange(CouponTemplate template)
|
||||
{
|
||||
if (template.ValidType == CouponValidType.DaysAfterReceive && template.ValidDaysAfterReceive.HasValue)
|
||||
if ((template.ValidType == CouponValidType.DaysAfterReceive
|
||||
|| template.ValidType == CouponValidType.DaysAfterUnlock)
|
||||
&& template.ValidDaysAfterReceive.HasValue)
|
||||
{
|
||||
var start = DateTime.Now;
|
||||
return (start, start.AddDays(template.ValidDaysAfterReceive.Value));
|
||||
|
||||
52
src/Oms.Application/Shared/MarketingCouponScope.cs
Normal file
52
src/Oms.Application/Shared/MarketingCouponScope.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
public class CouponApplyScope
|
||||
{
|
||||
[JsonPropertyName("countryCodes")]
|
||||
public List<string>? CountryCodes { get; set; }
|
||||
}
|
||||
|
||||
public static class MarketingCouponScope
|
||||
{
|
||||
public static bool Matches(string? applyScopeJson, string? destinationCountry)
|
||||
{
|
||||
var codes = ParseCountryCodes(applyScopeJson);
|
||||
if (codes.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(destinationCountry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dest = destinationCountry.Trim();
|
||||
return codes.Any(x => string.Equals(x, dest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> ParseCountryCodes(string? applyScopeJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(applyScopeJson))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scope = JsonSerializer.Deserialize<CouponApplyScope>(applyScopeJson);
|
||||
return (scope?.CountryCodes ?? [])
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,7 +113,9 @@ public static class MarketingCustomerHelper
|
||||
{
|
||||
var named = await memberLevelRepository.FirstOrDefaultAsync(
|
||||
x => x.Status == EnableStatus.Enabled
|
||||
&& (x.LevelName == DefaultMemberLevelName || x.LevelCode == DefaultMemberLevelName),
|
||||
&& (x.LevelCode == "V0"
|
||||
|| x.LevelCode == "normal"
|
||||
|| x.LevelName == DefaultMemberLevelName),
|
||||
cancellationToken);
|
||||
if (named != null)
|
||||
{
|
||||
|
||||
@ -23,6 +23,7 @@ public static class MarketingEnumNames
|
||||
|
||||
public static string Of(CouponInstanceStatus status) => status switch
|
||||
{
|
||||
CouponInstanceStatus.PendingUnlock => "未解锁",
|
||||
CouponInstanceStatus.Available => "可用",
|
||||
CouponInstanceStatus.Locked => "预占中",
|
||||
CouponInstanceStatus.Used => "已使用",
|
||||
@ -88,6 +89,9 @@ public static class MarketingEnumNames
|
||||
MarketingGrantChannels.SelfReceive => "客户自领",
|
||||
MarketingGrantChannels.Portal => "门户领取",
|
||||
MarketingGrantChannels.Manual => "手动发放",
|
||||
MarketingGrantChannels.Referral => "裂变券",
|
||||
MarketingGrantChannels.Route => "线路定向券",
|
||||
MarketingGrantChannels.Festival => "节日券",
|
||||
_ => grantChannel
|
||||
};
|
||||
}
|
||||
|
||||
33
src/Oms.Application/Shared/MarketingFeeHelper.cs
Normal file
33
src/Oms.Application/Shared/MarketingFeeHelper.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 会员积分/折扣用的基础运费口径:仅 freight / 名称含「运费」的应收。
|
||||
/// </summary>
|
||||
public static class MarketingFeeHelper
|
||||
{
|
||||
public static bool IsBaseFreightCostItem(string? costItem) =>
|
||||
!string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
|
||||
|
||||
public static decimal SumReceivable(IEnumerable<OrderFee> fees) =>
|
||||
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount);
|
||||
|
||||
public static decimal SumReceivableBaseFreight(IEnumerable<OrderFee> fees) =>
|
||||
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))
|
||||
.Sum(x => x.Amount);
|
||||
|
||||
/// <summary>
|
||||
/// 会员折与券互斥,扣减最多只落一笔;优惠券抵扣不计入积分。
|
||||
/// </summary>
|
||||
public static decimal ResolvePaidBaseFreight(
|
||||
decimal baseFreightAmount,
|
||||
decimal levelDiscountAmount,
|
||||
decimal couponDiscountAmount)
|
||||
{
|
||||
var cut = Math.Max(0M, levelDiscountAmount) + Math.Max(0M, couponDiscountAmount);
|
||||
return Math.Max(0M, baseFreightAmount - cut);
|
||||
}
|
||||
}
|
||||
141
src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs
Normal file
141
src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs
Normal file
@ -0,0 +1,141 @@
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
public class InviteRewardRuleSnapshot
|
||||
{
|
||||
public decimal MinPaidBaseFreight { get; set; } = 50M;
|
||||
|
||||
public decimal FirstOrderRate { get; set; } = 0.05M;
|
||||
|
||||
public decimal FirstOrderCap { get; set; } = 50M;
|
||||
|
||||
public decimal FollowOrderRate { get; set; } = 0.03M;
|
||||
|
||||
public decimal FollowOrderCap { get; set; } = 30M;
|
||||
|
||||
public int FollowWindowDays { get; set; } = 90;
|
||||
|
||||
public int MaxRewardOrders { get; set; } = 6;
|
||||
|
||||
public int SettleBusinessDays { get; set; } = 3;
|
||||
|
||||
public string MinInviteLevelCode { get; set; } = "V1";
|
||||
}
|
||||
|
||||
public class InviteRewardDecision
|
||||
{
|
||||
public bool Eligible { get; set; }
|
||||
|
||||
public int OrderIndex { get; set; }
|
||||
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
public decimal Cap { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public static class MarketingInviteRewardCalculator
|
||||
{
|
||||
public static InviteRewardDecision Resolve(
|
||||
decimal paidBaseFreight,
|
||||
int completedOrderIndex,
|
||||
DateTime refereeRegisterTime,
|
||||
DateTime completeTime,
|
||||
InviteRewardRuleSnapshot rule)
|
||||
{
|
||||
var decision = new InviteRewardDecision { OrderIndex = completedOrderIndex };
|
||||
if (completedOrderIndex <= 0 || paidBaseFreight < rule.MinPaidBaseFreight)
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (completedOrderIndex == 1)
|
||||
{
|
||||
decision.Eligible = true;
|
||||
decision.Rate = rule.FirstOrderRate;
|
||||
decision.Cap = rule.FirstOrderCap;
|
||||
decision.Amount = Cap(paidBaseFreight * rule.FirstOrderRate, rule.FirstOrderCap);
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (completedOrderIndex > rule.MaxRewardOrders)
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
|
||||
var windowEnd = refereeRegisterTime.Date.AddDays(rule.FollowWindowDays);
|
||||
if (completeTime.Date > windowEnd)
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
|
||||
decision.Eligible = true;
|
||||
decision.Rate = rule.FollowOrderRate;
|
||||
decision.Cap = rule.FollowOrderCap;
|
||||
decision.Amount = Cap(paidBaseFreight * rule.FollowOrderRate, rule.FollowOrderCap);
|
||||
return decision;
|
||||
}
|
||||
|
||||
public static DateTime AddBusinessDays(DateTime start, int days)
|
||||
{
|
||||
if (days <= 0)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
var cursor = start;
|
||||
var added = 0;
|
||||
while (added < days)
|
||||
{
|
||||
cursor = cursor.AddDays(1);
|
||||
if (cursor.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
added++;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public static int ParseLevelRank(string? levelCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(levelCode))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var code = levelCode.Trim();
|
||||
if (code.Length >= 2 && (code[0] == 'V' || code[0] == 'v') && int.TryParse(code[1..], out var n))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
|
||||
return code.ToLowerInvariant() switch
|
||||
{
|
||||
"normal" => 0,
|
||||
"silver" => 2,
|
||||
"gold" => 3,
|
||||
"diamond" => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public static bool CanInvite(string? memberLevelCode, string? minInviteLevelCode)
|
||||
{
|
||||
return ParseLevelRank(memberLevelCode) >= ParseLevelRank(
|
||||
string.IsNullOrWhiteSpace(minInviteLevelCode) ? "V1" : minInviteLevelCode);
|
||||
}
|
||||
|
||||
private static decimal Cap(decimal amount, decimal cap)
|
||||
{
|
||||
var value = Math.Round(Math.Max(0M, amount), 2, MidpointRounding.AwayFromZero);
|
||||
if (cap > 0)
|
||||
{
|
||||
value = Math.Min(value, cap);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -31,16 +31,50 @@ public class MarketingOrderHelper : IScopedDependency
|
||||
decimal receivableSubtotal,
|
||||
int? couponInstanceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await BuildBreakdownAsync(
|
||||
customerId,
|
||||
receivableSubtotal,
|
||||
receivableSubtotal,
|
||||
couponInstanceId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MarketingPriceBreakdown> BuildBreakdownAsync(
|
||||
int? customerId,
|
||||
decimal receivableSubtotal,
|
||||
decimal baseFreightAmount,
|
||||
int? couponInstanceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await BuildBreakdownAsync(
|
||||
customerId,
|
||||
receivableSubtotal,
|
||||
baseFreightAmount,
|
||||
couponInstanceId,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MarketingPriceBreakdown> BuildBreakdownAsync(
|
||||
int? customerId,
|
||||
decimal receivableSubtotal,
|
||||
decimal baseFreightAmount,
|
||||
int? couponInstanceId,
|
||||
string? destinationCountry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!customerId.HasValue || customerId.Value <= 0)
|
||||
{
|
||||
return MarketingBillingCalculator.Calculate(receivableSubtotal, 0M, null, null, null, null);
|
||||
return MarketingBillingCalculator.Calculate(
|
||||
receivableSubtotal, baseFreightAmount, 0M, null, null, null, null);
|
||||
}
|
||||
|
||||
var sessionCustomer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value, cancellationToken);
|
||||
if (sessionCustomer == null)
|
||||
{
|
||||
return MarketingBillingCalculator.Calculate(receivableSubtotal, 0M, null, null, null, null);
|
||||
return MarketingBillingCalculator.Calculate(
|
||||
receivableSubtotal, baseFreightAmount, 0M, null, null, null, null);
|
||||
}
|
||||
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(_customerRepository, sessionCustomer, cancellationToken);
|
||||
@ -52,10 +86,12 @@ public class MarketingOrderHelper : IScopedDependency
|
||||
}
|
||||
|
||||
var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey);
|
||||
var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(couponInstanceId, mainCustomerId, cancellationToken);
|
||||
var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(
|
||||
couponInstanceId, mainCustomerId, destinationCountry, cancellationToken);
|
||||
|
||||
return MarketingBillingCalculator.Calculate(
|
||||
receivableSubtotal,
|
||||
baseFreightAmount,
|
||||
level?.ExtraDiscountRate ?? 0M,
|
||||
level?.Id,
|
||||
level?.LevelName,
|
||||
@ -76,7 +112,8 @@ public class MarketingOrderHelper : IScopedDependency
|
||||
|
||||
public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync(
|
||||
int? customerId,
|
||||
decimal? amountAfterLevel,
|
||||
decimal? baseFreightAmount,
|
||||
string? destinationCountry = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!customerId.HasValue || customerId.Value <= 0)
|
||||
@ -94,6 +131,7 @@ public class MarketingOrderHelper : IScopedDependency
|
||||
_customerRepository,
|
||||
sessionCustomer,
|
||||
cancellationToken);
|
||||
return await _couponManager.BuildAvailableCouponsAsync(mainCustomer.Id, amountAfterLevel, cancellationToken);
|
||||
return await _couponManager.BuildAvailableCouponsAsync(
|
||||
mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,22 +8,22 @@ using Oms.Domain.MarketingAggregate.Consts;
|
||||
using Oms.Domain.MarketingAggregate.Entities;
|
||||
using Oms.Domain.MarketingAggregate.Enums;
|
||||
using Oms.Domain.MarketingAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 积分发放、调账与等级升级。
|
||||
/// 积分发放、调账与滚动 12 个月定级。
|
||||
/// </summary>
|
||||
public class MarketingPointsManager : IScopedDependency
|
||||
{
|
||||
public static readonly TimeSpan RollingWindow = TimeSpan.FromDays(365);
|
||||
|
||||
private readonly IMemberPointsRuleRepository _ruleRepository;
|
||||
private readonly ICustomerPointsAccountRepository _accountRepository;
|
||||
private readonly ICustomerPointsFlowRepository _flowRepository;
|
||||
private readonly IMemberLevelRepository _levelRepository;
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IMarketingAuditLogRepository _auditLogRepository;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
@ -33,7 +33,6 @@ public class MarketingPointsManager : IScopedDependency
|
||||
ICustomerPointsFlowRepository flowRepository,
|
||||
IMemberLevelRepository levelRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IOrderRepository orderRepository,
|
||||
IMarketingAuditLogRepository auditLogRepository,
|
||||
ICurrentUser currentUser)
|
||||
{
|
||||
@ -42,14 +41,18 @@ public class MarketingPointsManager : IScopedDependency
|
||||
_flowRepository = flowRepository;
|
||||
_levelRepository = levelRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_orderRepository = orderRepository;
|
||||
_auditLogRepository = auditLogRepository;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task EarnOnOrderPaidAsync(int mainCustomerId, int orderId, decimal payableAmount, CancellationToken cancellationToken = default)
|
||||
public async Task EarnOnOrderPaidAsync(
|
||||
int mainCustomerId,
|
||||
int orderId,
|
||||
decimal paidBaseFreight,
|
||||
string? bizNo,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (payableAmount <= 0)
|
||||
if (paidBaseFreight <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -60,40 +63,53 @@ public class MarketingPointsManager : IScopedDependency
|
||||
}
|
||||
|
||||
var rule = await _ruleRepository.GetActiveRuleAsync(cancellationToken);
|
||||
if (rule == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var points = (int)Math.Floor(payableAmount * rule.PointsPerYuan);
|
||||
var pointsPerYuan = rule?.PointsPerYuan > 0 ? rule.PointsPerYuan : 1M;
|
||||
var points = (int)Math.Floor(paidBaseFreight * pointsPerYuan);
|
||||
if (points <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isFirstOrder = !await _orderRepository.AnyAsync(
|
||||
x => x.CustomerId == mainCustomerId && x.IsPaid > 0 && x.Id != orderId,
|
||||
cancellationToken);
|
||||
if (isFirstOrder && rule.FirstOrderMultiplier > 1)
|
||||
{
|
||||
points = (int)Math.Floor(points * rule.FirstOrderMultiplier);
|
||||
}
|
||||
|
||||
if (rule.MaxPointsPerOrder > 0)
|
||||
{
|
||||
points = Math.Min(points, rule.MaxPointsPerOrder);
|
||||
}
|
||||
|
||||
await ApplyPointsChangeAsync(
|
||||
mainCustomerId,
|
||||
PointsChangeType.Earn,
|
||||
points,
|
||||
MarketingPointsBizTypes.OrderPay,
|
||||
orderId,
|
||||
null,
|
||||
"订单支付积分",
|
||||
bizNo,
|
||||
"实付基础运费积分",
|
||||
cancellationToken);
|
||||
await TryUpgradeLevelAsync(mainCustomerId, cancellationToken);
|
||||
await RecalcLevelAsync(mainCustomerId, allowDowngrade: false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ClawbackOnOrderAsync(
|
||||
int mainCustomerId,
|
||||
int orderId,
|
||||
string? bizNo,
|
||||
string remark,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (await _flowRepository.ExistsClawbackForOrderAsync(mainCustomerId, orderId, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var earned = await _flowRepository.GetEarnPointsForOrderAsync(mainCustomerId, orderId, cancellationToken);
|
||||
if (earned <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplyPointsChangeAsync(
|
||||
mainCustomerId,
|
||||
PointsChangeType.RefundClawback,
|
||||
-earned,
|
||||
MarketingPointsBizTypes.Refund,
|
||||
orderId,
|
||||
bizNo,
|
||||
remark,
|
||||
cancellationToken);
|
||||
await RecalcLevelAsync(mainCustomerId, allowDowngrade: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AdjustAsync(int mainCustomerId, int points, string remark, CancellationToken cancellationToken = default)
|
||||
@ -123,10 +139,7 @@ public class MarketingPointsManager : IScopedDependency
|
||||
Remark = remark.Trim()
|
||||
}, cancellationToken);
|
||||
|
||||
if (points > 0)
|
||||
{
|
||||
await TryUpgradeLevelAsync(mainCustomerId, cancellationToken);
|
||||
}
|
||||
await RecalcLevelAsync(mainCustomerId, allowDowngrade: points < 0, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CustomerPointsAccount> GetOrCreateAccountAsync(int mainCustomerId, CancellationToken cancellationToken = default)
|
||||
@ -148,6 +161,115 @@ public class MarketingPointsManager : IScopedDependency
|
||||
return account;
|
||||
}
|
||||
|
||||
public Task<int> GetRollingPointsAsync(int mainCustomerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var since = DateTime.Now.Subtract(RollingWindow);
|
||||
return _flowRepository.SumPointsSinceAsync(mainCustomerId, since, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RecalcAllLevelsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var accounts = await _accountRepository.GetListAsync();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
await RecalcLevelAsync(account.CustomerId, allowDowngrade: true, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecalcLevelAsync(
|
||||
int mainCustomerId,
|
||||
bool allowDowngrade,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rolling = await GetRollingPointsAsync(mainCustomerId, cancellationToken);
|
||||
var levels = (await _levelRepository.GetAllEnabledOrderedAsync(cancellationToken))
|
||||
.OrderBy(x => x.MinPoints)
|
||||
.ThenBy(x => x.Sort)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToList();
|
||||
if (levels.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetByPoints = levels
|
||||
.Where(x => x.MinPoints <= rolling)
|
||||
.OrderByDescending(x => x.MinPoints)
|
||||
.ThenByDescending(x => x.Sort)
|
||||
.FirstOrDefault() ?? levels[0];
|
||||
|
||||
var customer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == mainCustomerId, cancellationToken);
|
||||
if (customer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MemberLevel? current = null;
|
||||
if (customer.MemberLevelId.HasValue)
|
||||
{
|
||||
current = levels.FirstOrDefault(x => x.Id == customer.MemberLevelId.Value)
|
||||
?? await _levelRepository.FirstOrDefaultAsync(x => x.Id == customer.MemberLevelId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
var next = ResolveNextLevel(levels, current, targetByPoints, allowDowngrade);
|
||||
if (next == null || customer.MemberLevelId == next.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
customer.MemberLevelId = next.Id;
|
||||
await _customerRepository.UpdateAsync(customer, cancellationToken);
|
||||
}
|
||||
|
||||
internal static MemberLevel? ResolveNextLevel(
|
||||
IReadOnlyList<MemberLevel> orderedByMinPoints,
|
||||
MemberLevel? current,
|
||||
MemberLevel targetByPoints,
|
||||
bool allowDowngrade)
|
||||
{
|
||||
if (orderedByMinPoints.Count == 0)
|
||||
{
|
||||
return targetByPoints;
|
||||
}
|
||||
|
||||
if (current == null)
|
||||
{
|
||||
return targetByPoints;
|
||||
}
|
||||
|
||||
if (targetByPoints.MinPoints > current.MinPoints)
|
||||
{
|
||||
return targetByPoints;
|
||||
}
|
||||
|
||||
if (targetByPoints.MinPoints == current.MinPoints)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
if (!allowDowngrade || !current.CanDowngrade)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var index = -1;
|
||||
for (var i = 0; i < orderedByMinPoints.Count; i++)
|
||||
{
|
||||
if (orderedByMinPoints[i].Id == current.Id || orderedByMinPoints[i].LevelCode == current.LevelCode)
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index <= 0)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return orderedByMinPoints[index - 1];
|
||||
}
|
||||
|
||||
private async Task ApplyPointsChangeAsync(
|
||||
int mainCustomerId,
|
||||
PointsChangeType changeType,
|
||||
@ -164,21 +286,13 @@ public class MarketingPointsManager : IScopedDependency
|
||||
}
|
||||
|
||||
var account = await GetOrCreateAccountAsync(mainCustomerId, cancellationToken);
|
||||
if (changeType == PointsChangeType.Earn || changeType == PointsChangeType.Adjust)
|
||||
if (account.AvailablePoints + points < 0)
|
||||
{
|
||||
if (points < 0 && account.AvailablePoints + points < 0)
|
||||
{
|
||||
throw new BusinessException("可用积分不足");
|
||||
}
|
||||
|
||||
account.TotalPoints = Math.Max(0, account.TotalPoints + Math.Max(0, points));
|
||||
account.AvailablePoints += points;
|
||||
}
|
||||
else
|
||||
{
|
||||
account.AvailablePoints += points;
|
||||
throw new BusinessException("可用积分不足");
|
||||
}
|
||||
|
||||
account.AvailablePoints += points;
|
||||
account.TotalPoints = Math.Max(0, account.TotalPoints + points);
|
||||
await _accountRepository.UpdateAsync(account, cancellationToken);
|
||||
await _flowRepository.InsertAsync(new CustomerPointsFlow
|
||||
{
|
||||
@ -191,28 +305,4 @@ public class MarketingPointsManager : IScopedDependency
|
||||
Remark = remark
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TryUpgradeLevelAsync(int mainCustomerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetOrCreateAccountAsync(mainCustomerId, cancellationToken);
|
||||
var levels = await _levelRepository.GetAllEnabledOrderedAsync(cancellationToken);
|
||||
var target = levels
|
||||
.Where(x => x.MinPoints <= account.TotalPoints)
|
||||
.OrderByDescending(x => x.MinPoints)
|
||||
.ThenByDescending(x => x.Sort)
|
||||
.FirstOrDefault();
|
||||
if (target == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var customer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == mainCustomerId, cancellationToken);
|
||||
if (customer == null || customer.MemberLevelId == target.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
customer.MemberLevelId = target.Id;
|
||||
await _customerRepository.UpdateAsync(customer, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.MarketingAggregate.Entities;
|
||||
using Oms.Domain.MarketingAggregate.Enums;
|
||||
using Oms.Domain.MarketingAggregate.Repositories;
|
||||
|
||||
@ -14,6 +15,8 @@ public static class MarketingReferralBinder
|
||||
string? inviteCode,
|
||||
ICustomerReferralCodeRepository referralCodeRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IMemberLevelRepository? memberLevelRepository = null,
|
||||
IReferralCommissionRuleRepository? commissionRuleRepository = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (entity.ParentCustomerId.HasValue || entity.ReferrerCustomerId.HasValue)
|
||||
@ -45,6 +48,31 @@ public static class MarketingReferralBinder
|
||||
throw new BusinessException("邀请码对应客户无效");
|
||||
}
|
||||
|
||||
if (memberLevelRepository != null)
|
||||
{
|
||||
var minLevel = "V1";
|
||||
if (commissionRuleRepository != null)
|
||||
{
|
||||
var rule = await commissionRuleRepository.GetActiveRuleAsync(cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(rule?.MinInviteLevelCode))
|
||||
{
|
||||
minLevel = rule.MinInviteLevelCode;
|
||||
}
|
||||
}
|
||||
|
||||
MemberLevel? level = null;
|
||||
if (referrer.MemberLevelId.HasValue)
|
||||
{
|
||||
level = await memberLevelRepository.FirstOrDefaultAsync(
|
||||
x => x.Id == referrer.MemberLevelId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
if (!MarketingInviteRewardCalculator.CanInvite(level?.LevelCode, minLevel))
|
||||
{
|
||||
throw new BusinessException("邀请人暂无邀请资格");
|
||||
}
|
||||
}
|
||||
|
||||
if (IsSameIdentity(entity, referrer))
|
||||
{
|
||||
throw new BusinessException("不能使用自己的邀请码");
|
||||
|
||||
@ -13,28 +13,31 @@ using System.Text.Json;
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 引荐分成:派送成功触发、冻结结算。
|
||||
/// 引荐分成:派送成功按实付基础运费百分比计奖,T+N 工作日入运费余额。
|
||||
/// </summary>
|
||||
public class ReferralCommissionManager : IScopedDependency
|
||||
{
|
||||
private readonly IReferralCommissionRuleRepository _ruleRepository;
|
||||
private readonly IReferralCommissionDetailRepository _detailRepository;
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly IMemberLevelRepository _memberLevelRepository;
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly CommissionBalanceMutator _commissionBalanceMutator;
|
||||
private readonly CustomerBalanceMutator _balanceMutator;
|
||||
|
||||
public ReferralCommissionManager(
|
||||
IReferralCommissionRuleRepository ruleRepository,
|
||||
IReferralCommissionDetailRepository detailRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IMemberLevelRepository memberLevelRepository,
|
||||
IOrderRepository orderRepository,
|
||||
CommissionBalanceMutator commissionBalanceMutator)
|
||||
CustomerBalanceMutator balanceMutator)
|
||||
{
|
||||
_ruleRepository = ruleRepository;
|
||||
_detailRepository = detailRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_memberLevelRepository = memberLevelRepository;
|
||||
_orderRepository = orderRepository;
|
||||
_commissionBalanceMutator = commissionBalanceMutator;
|
||||
_balanceMutator = balanceMutator;
|
||||
}
|
||||
|
||||
public async Task OnOrderDeliverySuccessAsync(int orderId, CancellationToken cancellationToken = default)
|
||||
@ -46,8 +49,7 @@ public class ReferralCommissionManager : IScopedDependency
|
||||
throw new BusinessException("订单未达到派送成功状态");
|
||||
}
|
||||
|
||||
var payable = order.PayableAmount ?? 0M;
|
||||
if (payable <= 0 || !order.CustomerId.HasValue)
|
||||
if (order.IsRefunded > 0 || !order.CustomerId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -58,18 +60,15 @@ public class ReferralCommissionManager : IScopedDependency
|
||||
return;
|
||||
}
|
||||
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(_customerRepository, sessionCustomer, cancellationToken);
|
||||
if (!mainCustomer.ReferrerCustomerId.HasValue || mainCustomer.ReferrerCustomerId.Value <= 0)
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
_customerRepository, sessionCustomer, cancellationToken);
|
||||
if (!mainCustomer.ReferrerCustomerId.HasValue || mainCustomer.ReferrerCustomerId.Value <= 0
|
||||
|| mainCustomer.ReferrerCustomerId.Value == mainCustomer.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var referrerId = mainCustomer.ReferrerCustomerId.Value;
|
||||
if (referrerId == mainCustomer.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await _detailRepository.GetByOrderAndReferrerAsync(orderId, referrerId, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
@ -82,44 +81,69 @@ public class ReferralCommissionManager : IScopedDependency
|
||||
return;
|
||||
}
|
||||
|
||||
var commission = MarketingBillingCalculator.CalculateCommission(payable, rule.BaseAmount, rule.CommissionAmount);
|
||||
if (commission <= 0)
|
||||
var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == referrerId, cancellationToken);
|
||||
if (referrer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (rule.MonthlyCap.HasValue && rule.MonthlyCap.Value > 0)
|
||||
var referrerLevel = referrer.MemberLevelId.HasValue
|
||||
? await _memberLevelRepository.FirstOrDefaultAsync(x => x.Id == referrer.MemberLevelId.Value, cancellationToken)
|
||||
: null;
|
||||
if (!MarketingInviteRewardCalculator.CanInvite(referrerLevel?.LevelCode, rule.MinInviteLevelCode))
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var settled = await _detailRepository.SumSettledCommissionInMonthAsync(referrerId, now.Year, now.Month, cancellationToken);
|
||||
var remaining = rule.MonthlyCap.Value - settled;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
commission = Math.Min(commission, remaining);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = JsonSerializer.Serialize(new
|
||||
var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken);
|
||||
var completedOrders = await _orderRepository.GetListAsync(
|
||||
x => x.CustomerId != null
|
||||
&& familyIds.Contains(x.CustomerId.Value)
|
||||
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess
|
||||
&& x.IsRefunded == 0);
|
||||
var ordered = completedOrders
|
||||
.OrderBy(x => x.SignTime ?? x.PayTime ?? x.CreateTime)
|
||||
.ThenBy(x => x.Id)
|
||||
.ToList();
|
||||
var orderIndex = ordered.FindIndex(x => x.Id == order.Id) + 1;
|
||||
if (orderIndex <= 0)
|
||||
{
|
||||
rule.BaseAmount,
|
||||
rule.CommissionAmount,
|
||||
rule.FreezeDays,
|
||||
rule.MonthlyCap
|
||||
});
|
||||
orderIndex = ordered.Count + 1;
|
||||
}
|
||||
|
||||
var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||||
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
|
||||
MarketingFeeHelper.SumReceivableBaseFreight(fees),
|
||||
order.LevelDiscountAmount,
|
||||
order.CouponDiscountAmount);
|
||||
|
||||
var snapshot = ToRewardSnapshot(rule);
|
||||
var completeTime = order.SignTime ?? DateTime.Now;
|
||||
var decision = MarketingInviteRewardCalculator.Resolve(
|
||||
paidBaseFreight,
|
||||
orderIndex,
|
||||
mainCustomer.CreateTime,
|
||||
completeTime,
|
||||
snapshot);
|
||||
if (!decision.Eligible || decision.Amount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settleDue = MarketingInviteRewardCalculator.AddBusinessDays(completeTime, snapshot.SettleBusinessDays);
|
||||
await _detailRepository.InsertAsync(new ReferralCommissionDetail
|
||||
{
|
||||
ReferrerCustomerId = referrerId,
|
||||
RefereeCustomerId = mainCustomer.Id,
|
||||
OrderId = orderId,
|
||||
OrderId = order.Id,
|
||||
OrderNo = order.ServiceNo,
|
||||
BasePayableAmount = payable,
|
||||
CommissionAmount = commission,
|
||||
BasePayableAmount = paidBaseFreight,
|
||||
CommissionAmount = decision.Amount,
|
||||
Status = CommissionStatus.Pending,
|
||||
EligibleTime = order.SignTime ?? DateTime.Now,
|
||||
RuleSnapshotJson = snapshot
|
||||
EligibleTime = completeTime,
|
||||
SettleDueTime = settleDue,
|
||||
RewardOrderIndex = decision.OrderIndex,
|
||||
RuleSnapshotJson = JsonSerializer.Serialize(snapshot)
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
@ -128,13 +152,7 @@ public class ReferralCommissionManager : IScopedDependency
|
||||
var pendingList = await _detailRepository.GetPendingSettleAsync(DateTime.Now, batchSize, cancellationToken);
|
||||
foreach (var detail in pendingList)
|
||||
{
|
||||
if (detail.EligibleTime == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var freezeDays = ParseFreezeDays(detail.RuleSnapshotJson);
|
||||
if (detail.EligibleTime.Value.AddDays(freezeDays) > DateTime.Now)
|
||||
if (!IsDue(detail, DateTime.Now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@ -145,12 +163,12 @@ public class ReferralCommissionManager : IScopedDependency
|
||||
continue;
|
||||
}
|
||||
|
||||
await _commissionBalanceMutator.CreditCommissionAsync(
|
||||
await _balanceMutator.CreditReferralRewardAsync(
|
||||
referrer,
|
||||
detail.CommissionAmount,
|
||||
detail.Id,
|
||||
detail.OrderNo,
|
||||
$"引荐分成 {detail.OrderNo}",
|
||||
$"邀请奖励 {detail.OrderNo}",
|
||||
cancellationToken);
|
||||
|
||||
detail.Status = CommissionStatus.Settled;
|
||||
@ -159,26 +177,94 @@ public class ReferralCommissionManager : IScopedDependency
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseFreezeDays(string? snapshotJson)
|
||||
public async Task ClawbackOnOrderRefundAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotJson))
|
||||
var detail = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken);
|
||||
if (detail == null || detail.Status == CommissionStatus.Cancelled)
|
||||
{
|
||||
return 7;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
if (detail.Status == CommissionStatus.Pending)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(snapshotJson);
|
||||
if (doc.RootElement.TryGetProperty("FreezeDays", out var prop))
|
||||
detail.Status = CommissionStatus.Cancelled;
|
||||
detail.CancelReason = "订单退款扣回";
|
||||
await _detailRepository.UpdateAsync(detail, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail.Status == CommissionStatus.Settled)
|
||||
{
|
||||
var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == detail.ReferrerCustomerId, cancellationToken);
|
||||
if (referrer != null)
|
||||
{
|
||||
return prop.GetInt32();
|
||||
await _balanceMutator.DebitReferralClawbackAsync(
|
||||
referrer,
|
||||
detail.CommissionAmount,
|
||||
detail.Id,
|
||||
detail.OrderNo,
|
||||
$"邀请奖励退款扣回 {detail.OrderNo}",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
detail.Status = CommissionStatus.Cancelled;
|
||||
detail.CancelReason = "订单退款扣回";
|
||||
await _detailRepository.UpdateAsync(detail, cancellationToken);
|
||||
}
|
||||
catch
|
||||
}
|
||||
|
||||
private async Task<int[]> ResolveFamilyCustomerIdsAsync(int mainCustomerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var children = await _customerRepository.GetListAsync(x => x.ParentCustomerId == mainCustomerId);
|
||||
return children.Select(x => x.Id).Append(mainCustomerId).ToArray();
|
||||
}
|
||||
|
||||
private static bool IsDue(ReferralCommissionDetail detail, DateTime now)
|
||||
{
|
||||
if (detail.SettleDueTime.HasValue)
|
||||
{
|
||||
// ignore
|
||||
return detail.SettleDueTime.Value <= now;
|
||||
}
|
||||
|
||||
return 7;
|
||||
if (detail.EligibleTime.HasValue)
|
||||
{
|
||||
var freezeDays = 7;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(detail.RuleSnapshotJson))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(detail.RuleSnapshotJson);
|
||||
if (doc.RootElement.TryGetProperty("SettleBusinessDays", out var days))
|
||||
{
|
||||
freezeDays = days.GetInt32();
|
||||
}
|
||||
else if (doc.RootElement.TryGetProperty("FreezeDays", out var oldDays))
|
||||
{
|
||||
freezeDays = oldDays.GetInt32();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return MarketingInviteRewardCalculator.AddBusinessDays(detail.EligibleTime.Value, freezeDays) <= now;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static InviteRewardRuleSnapshot ToRewardSnapshot(ReferralCommissionRule rule) => new()
|
||||
{
|
||||
MinPaidBaseFreight = rule.MinPaidBaseFreight > 0 ? rule.MinPaidBaseFreight : 50M,
|
||||
FirstOrderRate = rule.FirstOrderRate > 0 ? rule.FirstOrderRate : 0.05M,
|
||||
FirstOrderCap = rule.FirstOrderCap > 0 ? rule.FirstOrderCap : 50M,
|
||||
FollowOrderRate = rule.FollowOrderRate > 0 ? rule.FollowOrderRate : 0.03M,
|
||||
FollowOrderCap = rule.FollowOrderCap > 0 ? rule.FollowOrderCap : 30M,
|
||||
FollowWindowDays = rule.FollowWindowDays > 0 ? rule.FollowWindowDays : 90,
|
||||
MaxRewardOrders = rule.MaxRewardOrders > 0 ? rule.MaxRewardOrders : 6,
|
||||
SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays,
|
||||
MinInviteLevelCode = string.IsNullOrWhiteSpace(rule.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ using Oms.Application.Shared;
|
||||
namespace Oms.Application.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// 营销定时任务:释放过期券锁、结算 Pending 分成。
|
||||
/// 营销定时任务:释放过期券锁、过期券、到期提醒、结算 Pending 分成。
|
||||
/// </summary>
|
||||
public class MarketingBackgroundWorker : BackgroundService
|
||||
{
|
||||
@ -57,10 +57,30 @@ public class MarketingBackgroundWorker : BackgroundService
|
||||
|
||||
var couponManager = scope.ServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
await couponManager.ReleaseExpiredLocksAsync(BatchSize, cancellationToken);
|
||||
await couponManager.ExpireOverdueAsync(BatchSize, cancellationToken);
|
||||
await couponManager.NotifyExpiringAsync(BatchSize, cancellationToken);
|
||||
|
||||
var commissionManager = scope.ServiceProvider.GetRequiredService<ReferralCommissionManager>();
|
||||
await commissionManager.SettlePendingAsync(BatchSize, cancellationToken);
|
||||
|
||||
await RecalcMemberLevelsIfDueAsync(scope.ServiceProvider, cancellationToken);
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private DateTime _lastLevelRecalcDate = DateTime.MinValue;
|
||||
|
||||
private async Task RecalcMemberLevelsIfDueAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var today = DateTime.Now.Date;
|
||||
if (DateTime.Now.Hour != 0 || _lastLevelRecalcDate >= today)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointsManager = services.GetRequiredService<MarketingPointsManager>();
|
||||
await pointsManager.RecalcAllLevelsAsync(cancellationToken);
|
||||
_lastLevelRecalcDate = today;
|
||||
_logger.LogInformation("会员等级已按滚动 12 个月积分重算");
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,25 @@ public static class MarketingGrantChannels
|
||||
public const string SelfReceive = "selfReceive";
|
||||
|
||||
public const string Portal = "portal";
|
||||
|
||||
public const string Referral = "referral";
|
||||
|
||||
public const string Route = "route";
|
||||
|
||||
public const string Festival = "festival";
|
||||
}
|
||||
|
||||
public static class MarketingCouponTemplateCodes
|
||||
{
|
||||
public const string NewcomerFirst = "NEWCOMER_FIRST";
|
||||
|
||||
public const string NewcomerSecond = "NEWCOMER_SECOND";
|
||||
|
||||
public const string NewcomerStock = "NEWCOMER_STOCK";
|
||||
|
||||
public const string ReferralInviter = "REFERRAL_INVITER";
|
||||
|
||||
public const string ReferralInvitee = "REFERRAL_INVITEE";
|
||||
}
|
||||
|
||||
public static class MarketingPointsBizTypes
|
||||
|
||||
@ -26,4 +26,6 @@ public class CouponInstance : OmsAuditedAggregateRoot
|
||||
public int? OrderId { get; set; }
|
||||
|
||||
public DateTime? LockExpireTime { get; set; }
|
||||
|
||||
public byte ExpiryNoticeSent { get; set; }
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ public class MemberLevel : OmsAuditedAggregateRoot
|
||||
|
||||
public decimal ExtraDiscountRate { get; set; }
|
||||
|
||||
public bool CanDowngrade { get; set; }
|
||||
|
||||
public int Sort { get; set; }
|
||||
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
@ -23,6 +23,10 @@ public class ReferralCommissionDetail : OmsAuditedAggregateRoot
|
||||
|
||||
public DateTime? EligibleTime { get; set; }
|
||||
|
||||
public DateTime? SettleDueTime { get; set; }
|
||||
|
||||
public int RewardOrderIndex { get; set; }
|
||||
|
||||
public DateTime? SettleTime { get; set; }
|
||||
|
||||
public string? RuleSnapshotJson { get; set; }
|
||||
|
||||
@ -21,5 +21,23 @@ public class ReferralCommissionRule : OmsAuditedAggregateRoot
|
||||
|
||||
public decimal? MonthlyCap { get; set; }
|
||||
|
||||
public string MinInviteLevelCode { get; set; } = "V1";
|
||||
|
||||
public decimal MinPaidBaseFreight { get; set; } = 50M;
|
||||
|
||||
public decimal FirstOrderRate { get; set; } = 0.05M;
|
||||
|
||||
public decimal FirstOrderCap { get; set; } = 50M;
|
||||
|
||||
public decimal FollowOrderRate { get; set; } = 0.03M;
|
||||
|
||||
public decimal FollowOrderCap { get; set; } = 30M;
|
||||
|
||||
public int FollowWindowDays { get; set; } = 90;
|
||||
|
||||
public int MaxRewardOrders { get; set; } = 6;
|
||||
|
||||
public int SettleBusinessDays { get; set; } = 3;
|
||||
|
||||
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ public enum CouponTemplateStatus : byte
|
||||
|
||||
public enum CouponInstanceStatus : byte
|
||||
{
|
||||
PendingUnlock = 5,
|
||||
Available = 1,
|
||||
Locked = 2,
|
||||
Used = 3,
|
||||
@ -53,5 +54,6 @@ public enum PointsChangeType : byte
|
||||
public enum CouponValidType : byte
|
||||
{
|
||||
FixedDate = 1,
|
||||
DaysAfterReceive = 2
|
||||
DaysAfterReceive = 2,
|
||||
DaysAfterUnlock = 3
|
||||
}
|
||||
|
||||
@ -26,6 +26,12 @@ public interface ICustomerPointsAccountRepository : IRepository<CustomerPointsAc
|
||||
public interface ICustomerPointsFlowRepository : IRepository<CustomerPointsFlow>
|
||||
{
|
||||
Task<bool> ExistsEarnForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsClawbackForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> GetEarnPointsForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> SumPointsSinceAsync(int customerId, DateTime since, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ICouponTemplateRepository : IRepository<CouponTemplate>
|
||||
@ -39,7 +45,13 @@ public interface ICouponInstanceRepository : IRepository<CouponInstance>
|
||||
|
||||
Task<List<CouponInstance>> GetAvailableByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<CouponInstance>> GetPendingUnlockByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<CouponInstance>> GetExpiredLocksAsync(DateTime now, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<CouponInstance>> GetExpiredAvailableAsync(DateTime now, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<CouponInstance>> GetExpiringAvailableAsync(DateTime from, DateTime to, int take, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IReferralCommissionRuleRepository : IRepository<ReferralCommissionRule>
|
||||
@ -51,9 +63,18 @@ public interface IReferralCommissionDetailRepository : IRepository<ReferralCommi
|
||||
{
|
||||
Task<ReferralCommissionDetail?> GetByOrderAndReferrerAsync(int orderId, int referrerCustomerId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReferralCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<decimal> SumByReferrerAsync(int referrerCustomerId, CommissionStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<decimal> SumSettledCommissionInMonthAsync(int referrerCustomerId, int year, int month, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<ReferralCommissionDetail>> GetPendingSettleAsync(DateTime now, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<ReferralCommissionDetail>> ListByReferrerAndRefereesAsync(
|
||||
int referrerCustomerId,
|
||||
int[] refereeIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ICommissionWithdrawRequestRepository : IRepository<CommissionWithdrawRequest>
|
||||
|
||||
@ -234,4 +234,6 @@ public class Order : OmsAuditedAggregateRoot
|
||||
/// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。
|
||||
/// </summary>
|
||||
public int? LabelTemplateId { get; set; }
|
||||
|
||||
public byte IsRefunded { get; set; }
|
||||
}
|
||||
|
||||
@ -49,6 +49,35 @@ public class CustomerPointsFlowRepository : OmsSqlSugarRepository<CustomerPoints
|
||||
{
|
||||
public Task<bool> ExistsEarnForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default)
|
||||
=> AnyAsync(x => x.CustomerId == customerId && x.BizId == orderId && x.BizType == "orderPay" && x.ChangeType == PointsChangeType.Earn, cancellationToken);
|
||||
|
||||
public Task<bool> ExistsClawbackForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default)
|
||||
=> AnyAsync(
|
||||
x => x.CustomerId == customerId
|
||||
&& x.BizId == orderId
|
||||
&& x.ChangeType == PointsChangeType.RefundClawback,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<int> GetEarnPointsForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var flow = await db.Queryable<CustomerPointsFlow>()
|
||||
.Where(x => x.CustomerId == customerId
|
||||
&& x.BizId == orderId
|
||||
&& x.BizType == "orderPay"
|
||||
&& x.ChangeType == PointsChangeType.Earn)
|
||||
.OrderByDescending(x => x.Id)
|
||||
.FirstAsync(cancellationToken);
|
||||
return flow?.Points ?? 0;
|
||||
}
|
||||
|
||||
public async Task<int> SumPointsSinceAsync(int customerId, DateTime since, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var sum = await db.Queryable<CustomerPointsFlow>()
|
||||
.Where(x => x.CustomerId == customerId && x.CreateTime >= since)
|
||||
.SumAsync(x => x.Points);
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
public class CouponTemplateRepository : OmsSqlSugarRepository<CouponTemplate>, ICouponTemplateRepository, IScopedDependency
|
||||
@ -84,6 +113,15 @@ public class CouponInstanceRepository : OmsSqlSugarRepository<CouponInstance>, I
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<CouponInstance>> GetPendingUnlockByCustomerAsync(int customerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<CouponInstance>()
|
||||
.Where(x => x.CustomerId == customerId && x.Status == CouponInstanceStatus.PendingUnlock)
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<CouponInstance>> GetExpiredLocksAsync(DateTime now, int take, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
@ -92,6 +130,33 @@ public class CouponInstanceRepository : OmsSqlSugarRepository<CouponInstance>, I
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<CouponInstance>> GetExpiredAvailableAsync(DateTime now, int take, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<CouponInstance>()
|
||||
.Where(x => (x.Status == CouponInstanceStatus.Available
|
||||
|| x.Status == CouponInstanceStatus.Locked)
|
||||
&& x.ValidEndTime < now)
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<CouponInstance>> GetExpiringAvailableAsync(
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<CouponInstance>()
|
||||
.Where(x => x.Status == CouponInstanceStatus.Available
|
||||
&& x.ExpiryNoticeSent == 0
|
||||
&& x.ValidEndTime >= from
|
||||
&& x.ValidEndTime <= to)
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class ReferralCommissionRuleRepository : OmsSqlSugarRepository<ReferralCommissionRule>, IReferralCommissionRuleRepository, IScopedDependency
|
||||
@ -111,6 +176,17 @@ public class ReferralCommissionDetailRepository : OmsSqlSugarRepository<Referral
|
||||
public Task<ReferralCommissionDetail?> GetByOrderAndReferrerAsync(int orderId, int referrerCustomerId, CancellationToken cancellationToken = default)
|
||||
=> FirstOrDefaultAsync(x => x.OrderId == orderId && x.ReferrerCustomerId == referrerCustomerId, cancellationToken);
|
||||
|
||||
public Task<ReferralCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
|
||||
=> FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
|
||||
|
||||
public async Task<decimal> SumByReferrerAsync(int referrerCustomerId, CommissionStatus status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<ReferralCommissionDetail>()
|
||||
.Where(x => x.ReferrerCustomerId == referrerCustomerId && x.Status == status)
|
||||
.SumAsync(x => x.CommissionAmount);
|
||||
}
|
||||
|
||||
public async Task<decimal> SumSettledCommissionInMonthAsync(int referrerCustomerId, int year, int month, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = new DateTime(year, month, 1);
|
||||
@ -129,7 +205,27 @@ public class ReferralCommissionDetailRepository : OmsSqlSugarRepository<Referral
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<ReferralCommissionDetail>()
|
||||
.Where(x => x.Status == CommissionStatus.Pending && x.EligibleTime != null)
|
||||
.Where(x => x.Status == CommissionStatus.Pending
|
||||
&& x.EligibleTime != null
|
||||
&& ((x.SettleDueTime != null && x.SettleDueTime <= now)
|
||||
|| (x.SettleDueTime == null)))
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<ReferralCommissionDetail>> ListByReferrerAndRefereesAsync(
|
||||
int referrerCustomerId,
|
||||
int[] refereeIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (refereeIds.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<ReferralCommissionDetail>()
|
||||
.Where(x => x.ReferrerCustomerId == referrerCustomerId && refereeIds.Contains(x.RefereeCustomerId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
76
src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs
Normal file
76
src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs
Normal file
@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using Oms.Application.Shared;
|
||||
|
||||
namespace Oms.Tests;
|
||||
|
||||
public class MarketingInviteRewardCalculatorTests
|
||||
{
|
||||
private static readonly InviteRewardRuleSnapshot Rule = new();
|
||||
|
||||
[Fact]
|
||||
public void FirstOrder_ShouldTakeFivePercentCapped()
|
||||
{
|
||||
var decision = MarketingInviteRewardCalculator.Resolve(
|
||||
1000M, 1, DateTime.Today.AddDays(-1), DateTime.Today, Rule);
|
||||
|
||||
decision.Eligible.Should().BeTrue();
|
||||
decision.Rate.Should().Be(0.05M);
|
||||
decision.Amount.Should().Be(50M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BelowMinPaidBaseFreight_ShouldSkip()
|
||||
{
|
||||
var decision = MarketingInviteRewardCalculator.Resolve(
|
||||
49.99M, 1, DateTime.Today, DateTime.Today, Rule);
|
||||
|
||||
decision.Eligible.Should().BeFalse();
|
||||
decision.Amount.Should().Be(0M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FollowOrder_ShouldTakeThreePercent()
|
||||
{
|
||||
var decision = MarketingInviteRewardCalculator.Resolve(
|
||||
200M, 2, DateTime.Today.AddDays(-10), DateTime.Today, Rule);
|
||||
|
||||
decision.Eligible.Should().BeTrue();
|
||||
decision.Rate.Should().Be(0.03M);
|
||||
decision.Amount.Should().Be(6M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FollowOrderOutsideWindow_ShouldSkip()
|
||||
{
|
||||
var decision = MarketingInviteRewardCalculator.Resolve(
|
||||
200M, 2, DateTime.Today.AddDays(-91), DateTime.Today, Rule);
|
||||
|
||||
decision.Eligible.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeventhOrder_ShouldSkip()
|
||||
{
|
||||
var decision = MarketingInviteRewardCalculator.Resolve(
|
||||
200M, 7, DateTime.Today, DateTime.Today, Rule);
|
||||
|
||||
decision.Eligible.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanInvite_V0ShouldFail_V1ShouldPass()
|
||||
{
|
||||
MarketingInviteRewardCalculator.CanInvite("V0", "V1").Should().BeFalse();
|
||||
MarketingInviteRewardCalculator.CanInvite("V1", "V1").Should().BeTrue();
|
||||
MarketingInviteRewardCalculator.CanInvite("normal", "V1").Should().BeFalse();
|
||||
MarketingInviteRewardCalculator.CanInvite("silver", "V1").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBusinessDays_ShouldSkipWeekend()
|
||||
{
|
||||
var friday = new DateTime(2026, 10, 2);
|
||||
var due = MarketingInviteRewardCalculator.AddBusinessDays(friday, 3);
|
||||
due.Should().Be(new DateTime(2026, 10, 7));
|
||||
}
|
||||
}
|
||||
200
src/Oms.Tests/MarketingMemberPointsTests.cs
Normal file
200
src/Oms.Tests/MarketingMemberPointsTests.cs
Normal file
@ -0,0 +1,200 @@
|
||||
using FluentAssertions;
|
||||
using Oms.Application.Dtos.Marketing;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.MarketingAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Tests;
|
||||
|
||||
public class MarketingMemberPointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void MemberDiscount_ShouldApplyOnlyToBaseFreight()
|
||||
{
|
||||
var result = MarketingBillingCalculator.Calculate(
|
||||
receivableSubtotal: 1200M,
|
||||
baseFreightAmount: 1000M,
|
||||
extraDiscountRate: 0.05M,
|
||||
memberLevelId: 5,
|
||||
memberLevelName: "皇冠会员",
|
||||
priceTierKey: null,
|
||||
coupon: null);
|
||||
|
||||
result.LevelDiscountAmount.Should().Be(50M);
|
||||
result.PayableAmount.Should().Be(1150M);
|
||||
result.PaidBaseFreightAmount.Should().Be(950M);
|
||||
result.CouponInstanceId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CouponAndMember_ShouldPickCheaperWithoutStacking()
|
||||
{
|
||||
var coupon = new CouponDiscountInput
|
||||
{
|
||||
CouponInstanceId = 9,
|
||||
CouponType = MarketingCouponTypeCodes.FixedOff,
|
||||
FaceValue = 80M,
|
||||
MinOrderAmount = 0M
|
||||
};
|
||||
|
||||
var result = MarketingBillingCalculator.Calculate(
|
||||
1200M,
|
||||
1000M,
|
||||
0.05M,
|
||||
5,
|
||||
"皇冠会员",
|
||||
null,
|
||||
coupon);
|
||||
|
||||
result.LevelDiscountAmount.Should().Be(0M);
|
||||
result.CouponDiscountAmount.Should().Be(80M);
|
||||
result.PayableAmount.Should().Be(1120M);
|
||||
result.CouponInstanceId.Should().Be(9);
|
||||
result.PaidBaseFreightAmount.Should().Be(920M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coupon_ShouldCapAtBaseFreightAndIgnoreSurcharge()
|
||||
{
|
||||
var coupon = new CouponDiscountInput
|
||||
{
|
||||
CouponInstanceId = 1,
|
||||
CouponType = MarketingCouponTypeCodes.FixedOff,
|
||||
FaceValue = 200M,
|
||||
MinOrderAmount = 0M
|
||||
};
|
||||
|
||||
var result = MarketingBillingCalculator.Calculate(
|
||||
150M, 100M, 0M, null, null, null, coupon);
|
||||
|
||||
result.CouponDiscountAmount.Should().Be(100M);
|
||||
result.PayableAmount.Should().Be(50M);
|
||||
result.PaidBaseFreightAmount.Should().Be(0M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CouponThreshold_ShouldUseBaseFreightNotOrderTotal()
|
||||
{
|
||||
var coupon = new CouponDiscountInput
|
||||
{
|
||||
CouponInstanceId = 1,
|
||||
CouponType = MarketingCouponTypeCodes.FixedOff,
|
||||
FaceValue = 15M,
|
||||
MinOrderAmount = 150M
|
||||
};
|
||||
|
||||
var result = MarketingBillingCalculator.Calculate(
|
||||
250M, 140M, 0M, null, null, null, coupon);
|
||||
|
||||
result.CouponDiscountAmount.Should().Be(0M);
|
||||
result.CouponInstanceId.Should().BeNull();
|
||||
result.PayableAmount.Should().Be(250M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CouponScope_ShouldMatchCountryCodes()
|
||||
{
|
||||
MarketingCouponScope.Matches("""{"countryCodes":["MY","SG"]}""", "MY").Should().BeTrue();
|
||||
MarketingCouponScope.Matches("""{"countryCodes":["MY","SG"]}""", "US").Should().BeFalse();
|
||||
MarketingCouponScope.Matches(null, "MY").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualOrWorseCoupon_ShouldKeepMemberDiscount()
|
||||
{
|
||||
var coupon = new CouponDiscountInput
|
||||
{
|
||||
CouponInstanceId = 9,
|
||||
CouponType = MarketingCouponTypeCodes.FixedOff,
|
||||
FaceValue = 50M
|
||||
};
|
||||
|
||||
var result = MarketingBillingCalculator.Calculate(
|
||||
1200M,
|
||||
1000M,
|
||||
0.05M,
|
||||
5,
|
||||
"皇冠会员",
|
||||
null,
|
||||
coupon);
|
||||
|
||||
result.LevelDiscountAmount.Should().Be(50M);
|
||||
result.CouponDiscountAmount.Should().Be(0M);
|
||||
result.CouponInstanceId.Should().BeNull();
|
||||
result.PayableAmount.Should().Be(1150M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscountRate_ShouldCapAtFivePercent()
|
||||
{
|
||||
var result = MarketingBillingCalculator.Calculate(
|
||||
1000M,
|
||||
1000M,
|
||||
0.2M,
|
||||
1,
|
||||
"测试",
|
||||
null,
|
||||
null);
|
||||
|
||||
result.LevelDiscountAmount.Should().Be(50M);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FuelSurcharge_ShouldNotCountAsBaseFreight()
|
||||
{
|
||||
var fees = new[]
|
||||
{
|
||||
new OrderFee { Type = OrderFeeTypeCodes.Receivable, CostItem = "freight", Amount = 100 },
|
||||
new OrderFee { Type = OrderFeeTypeCodes.Receivable, CostItem = "燃油附加费", Amount = 30 },
|
||||
new OrderFee { Type = OrderFeeTypeCodes.Receivable, CostItem = "偏远附加费", Amount = 20 }
|
||||
};
|
||||
|
||||
MarketingFeeHelper.SumReceivableBaseFreight(fees).Should().Be(100M);
|
||||
MarketingFeeHelper.IsBaseFreightCostItem("燃油附加费").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void V5_ShouldStepDownOneLevelOnly()
|
||||
{
|
||||
var levels = new List<MemberLevel>
|
||||
{
|
||||
new() { Id = 1, LevelCode = "V0", MinPoints = 0, CanDowngrade = false },
|
||||
new() { Id = 2, LevelCode = "V3", MinPoints = 120000, CanDowngrade = false },
|
||||
new() { Id = 3, LevelCode = "V4", MinPoints = 240000, CanDowngrade = true },
|
||||
new() { Id = 4, LevelCode = "V5", MinPoints = 600000, CanDowngrade = true }
|
||||
};
|
||||
var v5 = levels[3];
|
||||
var v0Target = levels[0];
|
||||
|
||||
var next = MarketingPointsManager.ResolveNextLevel(levels, v5, v0Target, allowDowngrade: true);
|
||||
next!.LevelCode.Should().Be("V4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void V3_ShouldNotDowngrade()
|
||||
{
|
||||
var levels = new List<MemberLevel>
|
||||
{
|
||||
new() { Id = 1, LevelCode = "V0", MinPoints = 0, CanDowngrade = false },
|
||||
new() { Id = 2, LevelCode = "V3", MinPoints = 120000, CanDowngrade = false }
|
||||
};
|
||||
|
||||
var next = MarketingPointsManager.ResolveNextLevel(levels, levels[1], levels[0], allowDowngrade: true);
|
||||
next!.LevelCode.Should().Be("V3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upgrade_CanJumpToMatchingLevel()
|
||||
{
|
||||
var levels = new List<MemberLevel>
|
||||
{
|
||||
new() { Id = 1, LevelCode = "V0", MinPoints = 0, CanDowngrade = false },
|
||||
new() { Id = 2, LevelCode = "V4", MinPoints = 240000, CanDowngrade = true },
|
||||
new() { Id = 3, LevelCode = "V5", MinPoints = 600000, CanDowngrade = true }
|
||||
};
|
||||
|
||||
var next = MarketingPointsManager.ResolveNextLevel(levels, levels[0], levels[2], allowDowngrade: false);
|
||||
next!.LevelCode.Should().Be("V5");
|
||||
}
|
||||
}
|
||||
@ -159,6 +159,8 @@ public class WeChatCustomerService(
|
||||
input.InviteCode,
|
||||
referralCodeRepository,
|
||||
customerRepository,
|
||||
memberLevelRepository,
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>(),
|
||||
cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
|
||||
{
|
||||
@ -174,6 +176,10 @@ public class WeChatCustomerService(
|
||||
try
|
||||
{
|
||||
await couponManager.GrantNewcomerCouponsAsync(entity.Id, cancellationToken);
|
||||
if (entity.ReferrerCustomerId.HasValue)
|
||||
{
|
||||
await couponManager.GrantInviteeCouponAsync(entity.Id, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception grantEx)
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user