feat: 落地滚动会员、券发放与邀请分成奖励
All checks were successful
Build and Deploy / deploy (push) Successful in 3m50s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-06 17:40:24 +08:00
parent 8d7df40bdf
commit f972a28d79
59 changed files with 2323 additions and 652 deletions

View File

@ -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` 末尾对应段落

View File

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

View 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'
);

View File

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

View File

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

View File

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

View File

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

View 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';

View File

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

View File

@ -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海外收件';

View File

@ -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`;

View 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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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
{

View File

@ -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

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
{

View File

@ -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,

View File

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

View File

@ -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));

View 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 [];
}
}
}

View File

@ -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)
{

View File

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

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

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

View File

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

View File

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

View File

@ -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("不能使用自己的邀请码");

View File

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

View File

@ -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 个月积分重算");
}
}

View File

@ -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

View File

@ -26,4 +26,6 @@ public class CouponInstance : OmsAuditedAggregateRoot
public int? OrderId { get; set; }
public DateTime? LockExpireTime { get; set; }
public byte ExpiryNoticeSent { get; set; }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -234,4 +234,6 @@ public class Order : OmsAuditedAggregateRoot
/// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。
/// </summary>
public int? LabelTemplateId { get; set; }
public byte IsRefunded { get; set; }
}

View File

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

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

View 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");
}
}

View File

@ -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)
{