diff --git a/sql/README.md b/sql/README.md
index ddb4def..9d84b35 100644
--- a/sql/README.md
+++ b/sql/README.md
@@ -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` 末尾对应段落
diff --git a/sql/migrate_allocation_match_condition.sql b/sql/migrate_allocation_match_condition.sql
deleted file mode 100644
index 274f2e0..0000000
--- a/sql/migrate_allocation_match_condition.sql
+++ /dev/null
@@ -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;
diff --git a/sql/migrate_coupon_grant_system.sql b/sql/migrate_coupon_grant_system.sql
new file mode 100644
index 0000000..f5b5419
--- /dev/null
+++ b/sql/migrate_coupon_grant_system.sql
@@ -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'
+);
diff --git a/sql/migrate_customer_export_qualification.sql b/sql/migrate_customer_export_qualification.sql
deleted file mode 100644
index efd88fe..0000000
--- a/sql/migrate_customer_export_qualification.sql
+++ /dev/null
@@ -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;
diff --git a/sql/migrate_customer_openapi_credential.sql b/sql/migrate_customer_openapi_credential.sql
deleted file mode 100644
index da9e138..0000000
--- a/sql/migrate_customer_openapi_credential.sql
+++ /dev/null
@@ -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;
diff --git a/sql/migrate_declare_element.sql b/sql/migrate_declare_element.sql
deleted file mode 100644
index 62f59fd..0000000
--- a/sql/migrate_declare_element.sql
+++ /dev/null
@@ -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;
diff --git a/sql/migrate_issue_template_handler.sql b/sql/migrate_issue_template_handler.sql
deleted file mode 100644
index 409cdb7..0000000
--- a/sql/migrate_issue_template_handler.sql
+++ /dev/null
@@ -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;
diff --git a/sql/migrate_member_level_rolling_points.sql b/sql/migrate_member_level_rolling_points.sql
new file mode 100644
index 0000000..ee8c2cf
--- /dev/null
+++ b/sql/migrate_member_level_rolling_points.sql
@@ -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';
diff --git a/sql/migrate_order_export_qualification_id.sql b/sql/migrate_order_export_qualification_id.sql
deleted file mode 100644
index 7cb5d9b..0000000
--- a/sql/migrate_order_export_qualification_id.sql
+++ /dev/null
@@ -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;
diff --git a/sql/migrate_pickup_direction_and_address_type.sql b/sql/migrate_pickup_direction_and_address_type.sql
deleted file mode 100644
index 8ebfac4..0000000
--- a/sql/migrate_pickup_direction_and_address_type.sql
+++ /dev/null
@@ -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海外收件';
diff --git a/sql/migrate_pickup_fee_push.sql b/sql/migrate_pickup_fee_push.sql
deleted file mode 100644
index 539f2a0..0000000
--- a/sql/migrate_pickup_fee_push.sql
+++ /dev/null
@@ -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`;
diff --git a/sql/migrate_referral_invite_reward.sql b/sql/migrate_referral_invite_reward.sql
new file mode 100644
index 0000000..3d23f2c
--- /dev/null
+++ b/sql/migrate_referral_invite_reward.sql
@@ -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';
diff --git a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
index e60808d..4fb56f0 100644
--- a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
@@ -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;
diff --git a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
index 42d15c3..826c143 100644
--- a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
@@ -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; }
+
}
diff --git a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
index 4ee00a8..ba4651b 100644
--- a/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
+++ b/src/Oms.Application/Dtos/Marketing/MarketingPriceBreakdown.cs
@@ -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; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
index 773eed9..84ba9ff 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberLevelOutput.cs
@@ -17,6 +17,9 @@ public class MemberLevelOutput
public decimal ExtraDiscountRate { get; set; }
+ /// 是否允许降级。V4 钻石、V5 皇冠为 true,且只能逐级降。
+ public bool CanDowngrade { get; set; }
+
public int Sort { get; set; }
public string? IconUrl { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
index f2f35bd..6aa2cc7 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberLevelSaveInput.cs
@@ -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; }
diff --git a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
index 3c8a01e..b2b1cf3 100644
--- a/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
+++ b/src/Oms.Application/Dtos/Marketing/MemberOverviewOutput.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
index 7c3a326..f8cc66e 100644
--- a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
@@ -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;
-}
-
\ No newline at end of file
+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;
+}
+
diff --git a/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
new file mode 100644
index 0000000..ac425f8
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderRefundInput.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+public class OrderRefundInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// true=全额退款(可退券);false=部分退款(券不退回)。
+ ///
+ public bool IsFullRefund { get; set; } = true;
+}
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 8e6bed7..dd14bec 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -242,7 +242,9 @@ public class CustomerService : ApplicationService
entity,
input.InviteCode,
ReferralCodeRepository,
- CustomerRepository);
+ CustomerRepository,
+ MemberLevelRepository,
+ LazyServiceProvider.GetRequiredService());
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
{
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
index 75f867e..fe8ffc3 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
@@ -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
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
index dc79370..6f43d77 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionRuleService.cs
@@ -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,
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
index 9899c3d..073e645 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
@@ -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
};
}
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
index d45d478..5230798 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponTemplateService.cs
@@ -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);
}
diff --git a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
index ee89690..455dd90 100644
--- a/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingMemberLevelService.cs
@@ -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,
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
index 54e1901..3c3e011 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalCommissionService.cs
@@ -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
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
index 93f0c15..cdd8ab5 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalCouponService.cs
@@ -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);
}
///
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
index 4c382be..15726fa 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalMemberService.cs
@@ -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
};
diff --git a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
index 16cb88d..2fe0eca 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPortalReferralService.cs
@@ -28,6 +28,15 @@ public class MarketingPortalReferralService : ApplicationService
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService();
+ private IMemberLevelRepository MemberLevelRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IReferralCommissionRuleRepository CommissionRuleRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IReferralCommissionDetailRepository CommissionDetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
private MarketingReferralCodeProvisioner ReferralCodeProvisioner =>
LazyServiceProvider.GetRequiredService();
@@ -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();
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);
+ }
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index f3c00a6..3423204 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -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();
- 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();
var breakdown = await marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
- order.CouponInstanceId);
+ baseFreightAmount,
+ order.CouponInstanceId,
+ order.ConsigneeCountry);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index 7220889..4000bae 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -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);
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 4d7ab7f..9d33c6c 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -178,7 +178,11 @@ public partial class OrderService : ApplicationService
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService();
- 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();
await commissionManager.OnOrderDeliverySuccessAsync(order.Id);
+
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ await couponManager.OnFirstDeliverySuccessAsync(order);
+ }
+
+ ///
+ /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
+ ///
+ [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();
+ await couponManager.RestoreOnOrderRefundAsync(order, input.IsFullRefund);
+
+ order.IsRefunded = 1;
+ await UpdateOrderAsync(order);
+
+ var commissionManager = LazyServiceProvider.GetRequiredService();
+ 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();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单退款扣回积分");
+ }
+ }
}
///
@@ -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();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单删除扣回积分");
+ }
+ }
+
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 3b781c8..c408031 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -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)
{
diff --git a/src/Oms.Application/Shared/CustomerBalanceMutator.cs b/src/Oms.Application/Shared/CustomerBalanceMutator.cs
index a2ded58..4a904ea 100644
--- a/src/Oms.Application/Shared/CustomerBalanceMutator.cs
+++ b/src/Oms.Application/Shared/CustomerBalanceMutator.cs
@@ -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,
diff --git a/src/Oms.Application/Shared/MarketingBillingCalculator.cs b/src/Oms.Application/Shared/MarketingBillingCalculator.cs
index b2b21b7..8887465 100644
--- a/src/Oms.Application/Shared/MarketingBillingCalculator.cs
+++ b/src/Oms.Application/Shared/MarketingBillingCalculator.cs
@@ -3,10 +3,12 @@ using Oms.Application.Dtos.Marketing;
namespace Oms.Application.Shared;
///
-/// 营销叠加计费(§11)。
+/// 会员折扣仅作用于基础运费;与优惠券不叠加,取更低应付(同价保留会员折,避免占券)。
///
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);
+ }
+
+ ///
+ /// 仅按基础运费判断门槛并抵扣,封顶不超过基础运费(不找零、不冲附加费)。
+ ///
+ 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; }
}
diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs
index 58ab925..3abf583 100644
--- a/src/Oms.Application/Shared/MarketingCouponManager.cs
+++ b/src/Oms.Application/Shared/MarketingCouponManager.cs
@@ -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;
///
-/// 优惠券发放、锁定、核销与释放。
+/// 优惠券发放、锁定、核销、解锁与释放。
///
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
}
///
- /// 注册成功后发放所有「新人券」模板(GrantChannel=newcomer 且已发布),有多少发多少;单张失败不阻断。
+ /// 注册成功后发放所有已发布新人券。第二单券发成未解锁。
///
public async Task 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 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> 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 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 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));
diff --git a/src/Oms.Application/Shared/MarketingCouponScope.cs b/src/Oms.Application/Shared/MarketingCouponScope.cs
new file mode 100644
index 0000000..b26e4fe
--- /dev/null
+++ b/src/Oms.Application/Shared/MarketingCouponScope.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Oms.Application.Shared;
+
+public class CouponApplyScope
+{
+ [JsonPropertyName("countryCodes")]
+ public List? 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 ParseCountryCodes(string? applyScopeJson)
+ {
+ if (string.IsNullOrWhiteSpace(applyScopeJson))
+ {
+ return [];
+ }
+
+ try
+ {
+ var scope = JsonSerializer.Deserialize(applyScopeJson);
+ return (scope?.CountryCodes ?? [])
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+}
diff --git a/src/Oms.Application/Shared/MarketingCustomerHelper.cs b/src/Oms.Application/Shared/MarketingCustomerHelper.cs
index 830a737..b856fbd 100644
--- a/src/Oms.Application/Shared/MarketingCustomerHelper.cs
+++ b/src/Oms.Application/Shared/MarketingCustomerHelper.cs
@@ -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)
{
diff --git a/src/Oms.Application/Shared/MarketingEnumNames.cs b/src/Oms.Application/Shared/MarketingEnumNames.cs
index 70b884a..dbe8f72 100644
--- a/src/Oms.Application/Shared/MarketingEnumNames.cs
+++ b/src/Oms.Application/Shared/MarketingEnumNames.cs
@@ -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
};
}
diff --git a/src/Oms.Application/Shared/MarketingFeeHelper.cs b/src/Oms.Application/Shared/MarketingFeeHelper.cs
new file mode 100644
index 0000000..52a8bf1
--- /dev/null
+++ b/src/Oms.Application/Shared/MarketingFeeHelper.cs
@@ -0,0 +1,33 @@
+using Oms.Application.Services.Orders;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Shared;
+
+///
+/// 会员积分/折扣用的基础运费口径:仅 freight / 名称含「运费」的应收。
+///
+public static class MarketingFeeHelper
+{
+ public static bool IsBaseFreightCostItem(string? costItem) =>
+ !string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
+
+ public static decimal SumReceivable(IEnumerable fees) =>
+ fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount);
+
+ public static decimal SumReceivableBaseFreight(IEnumerable fees) =>
+ fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))
+ .Sum(x => x.Amount);
+
+ ///
+ /// 会员折与券互斥,扣减最多只落一笔;优惠券抵扣不计入积分。
+ ///
+ 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);
+ }
+}
diff --git a/src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs b/src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs
new file mode 100644
index 0000000..fe04cb0
--- /dev/null
+++ b/src/Oms.Application/Shared/MarketingInviteRewardCalculator.cs
@@ -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;
+ }
+}
diff --git a/src/Oms.Application/Shared/MarketingOrderHelper.cs b/src/Oms.Application/Shared/MarketingOrderHelper.cs
index ed46a75..f06a035 100644
--- a/src/Oms.Application/Shared/MarketingOrderHelper.cs
+++ b/src/Oms.Application/Shared/MarketingOrderHelper.cs
@@ -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 BuildBreakdownAsync(
+ int? customerId,
+ decimal receivableSubtotal,
+ decimal baseFreightAmount,
+ int? couponInstanceId,
+ CancellationToken cancellationToken = default)
+ {
+ return await BuildBreakdownAsync(
+ customerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ couponInstanceId,
+ null,
+ cancellationToken);
+ }
+
+ public async Task 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> 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);
}
}
diff --git a/src/Oms.Application/Shared/MarketingPointsManager.cs b/src/Oms.Application/Shared/MarketingPointsManager.cs
index 8be36fc..7f835c9 100644
--- a/src/Oms.Application/Shared/MarketingPointsManager.cs
+++ b/src/Oms.Application/Shared/MarketingPointsManager.cs
@@ -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;
///
-/// 积分发放、调账与等级升级。
+/// 积分发放、调账与滚动 12 个月定级。
///
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 GetOrCreateAccountAsync(int mainCustomerId, CancellationToken cancellationToken = default)
@@ -148,6 +161,115 @@ public class MarketingPointsManager : IScopedDependency
return account;
}
+ public Task 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 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);
- }
}
diff --git a/src/Oms.Application/Shared/MarketingReferralBinder.cs b/src/Oms.Application/Shared/MarketingReferralBinder.cs
index fa9795a..801643e 100644
--- a/src/Oms.Application/Shared/MarketingReferralBinder.cs
+++ b/src/Oms.Application/Shared/MarketingReferralBinder.cs
@@ -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("不能使用自己的邀请码");
diff --git a/src/Oms.Application/Shared/ReferralCommissionManager.cs b/src/Oms.Application/Shared/ReferralCommissionManager.cs
index 9a7106e..15f70d1 100644
--- a/src/Oms.Application/Shared/ReferralCommissionManager.cs
+++ b/src/Oms.Application/Shared/ReferralCommissionManager.cs
@@ -13,28 +13,31 @@ using System.Text.Json;
namespace Oms.Application.Shared;
///
-/// 引荐分成:派送成功触发、冻结结算。
+/// 引荐分成:派送成功按实付基础运费百分比计奖,T+N 工作日入运费余额。
///
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 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
+ };
}
diff --git a/src/Oms.Application/Workers/MarketingBackgroundWorker.cs b/src/Oms.Application/Workers/MarketingBackgroundWorker.cs
index 54b466c..a1c2455 100644
--- a/src/Oms.Application/Workers/MarketingBackgroundWorker.cs
+++ b/src/Oms.Application/Workers/MarketingBackgroundWorker.cs
@@ -7,7 +7,7 @@ using Oms.Application.Shared;
namespace Oms.Application.Workers;
///
-/// 营销定时任务:释放过期券锁、结算 Pending 分成。
+/// 营销定时任务:释放过期券锁、过期券、到期提醒、结算 Pending 分成。
///
public class MarketingBackgroundWorker : BackgroundService
{
@@ -57,10 +57,30 @@ public class MarketingBackgroundWorker : BackgroundService
var couponManager = scope.ServiceProvider.GetRequiredService();
await couponManager.ReleaseExpiredLocksAsync(BatchSize, cancellationToken);
+ await couponManager.ExpireOverdueAsync(BatchSize, cancellationToken);
+ await couponManager.NotifyExpiringAsync(BatchSize, cancellationToken);
var commissionManager = scope.ServiceProvider.GetRequiredService();
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();
+ await pointsManager.RecalcAllLevelsAsync(cancellationToken);
+ _lastLevelRecalcDate = today;
+ _logger.LogInformation("会员等级已按滚动 12 个月积分重算");
+ }
}
diff --git a/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs b/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs
index db354ea..360169d 100644
--- a/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs
+++ b/src/Oms.Domain/MarketingAggregate/Consts/MarketingConsts.cs
@@ -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
diff --git a/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs b/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs
index 60d1f4b..80738b5 100644
--- a/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs
+++ b/src/Oms.Domain/MarketingAggregate/Entities/CouponInstance.cs
@@ -26,4 +26,6 @@ public class CouponInstance : OmsAuditedAggregateRoot
public int? OrderId { get; set; }
public DateTime? LockExpireTime { get; set; }
+
+ public byte ExpiryNoticeSent { get; set; }
}
diff --git a/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs b/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs
index 21b51f3..54a2e7a 100644
--- a/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs
+++ b/src/Oms.Domain/MarketingAggregate/Entities/MemberLevel.cs
@@ -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; }
diff --git a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs
index 275778a..5c01878 100644
--- a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs
+++ b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionDetail.cs
@@ -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; }
diff --git a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs
index d657fcb..fc848a1 100644
--- a/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs
+++ b/src/Oms.Domain/MarketingAggregate/Entities/ReferralCommissionRule.cs
@@ -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;
}
diff --git a/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs b/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs
index 52c2222..ce53b22 100644
--- a/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs
+++ b/src/Oms.Domain/MarketingAggregate/Enums/MarketingEnums.cs
@@ -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
}
diff --git a/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs b/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs
index bd75f2c..d0da7b6 100644
--- a/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs
+++ b/src/Oms.Domain/MarketingAggregate/Repositories/IMarketingRepositories.cs
@@ -26,6 +26,12 @@ public interface ICustomerPointsAccountRepository : IRepository
{
Task ExistsEarnForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default);
+
+ Task ExistsClawbackForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default);
+
+ Task GetEarnPointsForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default);
+
+ Task SumPointsSinceAsync(int customerId, DateTime since, CancellationToken cancellationToken = default);
}
public interface ICouponTemplateRepository : IRepository
@@ -39,7 +45,13 @@ public interface ICouponInstanceRepository : IRepository
Task> GetAvailableByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
+ Task> GetPendingUnlockByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
+
Task> GetExpiredLocksAsync(DateTime now, int take, CancellationToken cancellationToken = default);
+
+ Task> GetExpiredAvailableAsync(DateTime now, int take, CancellationToken cancellationToken = default);
+
+ Task> GetExpiringAvailableAsync(DateTime from, DateTime to, int take, CancellationToken cancellationToken = default);
}
public interface IReferralCommissionRuleRepository : IRepository
@@ -51,9 +63,18 @@ public interface IReferralCommissionDetailRepository : IRepository GetByOrderAndReferrerAsync(int orderId, int referrerCustomerId, CancellationToken cancellationToken = default);
+ Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
+
+ Task SumByReferrerAsync(int referrerCustomerId, CommissionStatus status, CancellationToken cancellationToken = default);
+
Task SumSettledCommissionInMonthAsync(int referrerCustomerId, int year, int month, CancellationToken cancellationToken = default);
Task> GetPendingSettleAsync(DateTime now, int take, CancellationToken cancellationToken = default);
+
+ Task> ListByReferrerAndRefereesAsync(
+ int referrerCustomerId,
+ int[] refereeIds,
+ CancellationToken cancellationToken = default);
}
public interface ICommissionWithdrawRequestRepository : IRepository
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 611dd00..3aea651 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -234,4 +234,6 @@ public class Order : OmsAuditedAggregateRoot
/// 出单时实际使用的标签模板 Id(面单文件见 t_order_file.systemLabel)。
///
public int? LabelTemplateId { get; set; }
+
+ public byte IsRefunded { get; set; }
}
diff --git a/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs b/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs
index 075e769..f42c74d 100644
--- a/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs
+++ b/src/Oms.SqlSugarCore/Marketing/MarketingRepositories.cs
@@ -49,6 +49,35 @@ public class CustomerPointsFlowRepository : OmsSqlSugarRepository 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 ExistsClawbackForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default)
+ => AnyAsync(
+ x => x.CustomerId == customerId
+ && x.BizId == orderId
+ && x.ChangeType == PointsChangeType.RefundClawback,
+ cancellationToken);
+
+ public async Task GetEarnPointsForOrderAsync(int customerId, int orderId, CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var flow = await db.Queryable()
+ .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 SumPointsSinceAsync(int customerId, DateTime since, CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var sum = await db.Queryable()
+ .Where(x => x.CustomerId == customerId && x.CreateTime >= since)
+ .SumAsync(x => x.Points);
+ return sum;
+ }
}
public class CouponTemplateRepository : OmsSqlSugarRepository, ICouponTemplateRepository, IScopedDependency
@@ -84,6 +113,15 @@ public class CouponInstanceRepository : OmsSqlSugarRepository, I
.ToListAsync(cancellationToken);
}
+ public async Task> GetPendingUnlockByCustomerAsync(int customerId, CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Queryable()
+ .Where(x => x.CustomerId == customerId && x.Status == CouponInstanceStatus.PendingUnlock)
+ .OrderBy(x => x.Id)
+ .ToListAsync(cancellationToken);
+ }
+
public async Task> GetExpiredLocksAsync(DateTime now, int take, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
@@ -92,6 +130,33 @@ public class CouponInstanceRepository : OmsSqlSugarRepository, I
.Take(take)
.ToListAsync(cancellationToken);
}
+
+ public async Task> GetExpiredAvailableAsync(DateTime now, int take, CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Queryable()
+ .Where(x => (x.Status == CouponInstanceStatus.Available
+ || x.Status == CouponInstanceStatus.Locked)
+ && x.ValidEndTime < now)
+ .Take(take)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetExpiringAvailableAsync(
+ DateTime from,
+ DateTime to,
+ int take,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Queryable()
+ .Where(x => x.Status == CouponInstanceStatus.Available
+ && x.ExpiryNoticeSent == 0
+ && x.ValidEndTime >= from
+ && x.ValidEndTime <= to)
+ .Take(take)
+ .ToListAsync(cancellationToken);
+ }
}
public class ReferralCommissionRuleRepository : OmsSqlSugarRepository, IReferralCommissionRuleRepository, IScopedDependency
@@ -111,6 +176,17 @@ public class ReferralCommissionDetailRepository : OmsSqlSugarRepository GetByOrderAndReferrerAsync(int orderId, int referrerCustomerId, CancellationToken cancellationToken = default)
=> FirstOrDefaultAsync(x => x.OrderId == orderId && x.ReferrerCustomerId == referrerCustomerId, cancellationToken);
+ public Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
+
+ public async Task SumByReferrerAsync(int referrerCustomerId, CommissionStatus status, CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Queryable()
+ .Where(x => x.ReferrerCustomerId == referrerCustomerId && x.Status == status)
+ .SumAsync(x => x.CommissionAmount);
+ }
+
public async Task 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()
- .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> ListByReferrerAndRefereesAsync(
+ int referrerCustomerId,
+ int[] refereeIds,
+ CancellationToken cancellationToken = default)
+ {
+ if (refereeIds.Length == 0)
+ {
+ return [];
+ }
+
+ var db = await GetDbContextAsync(cancellationToken);
+ return await db.Queryable()
+ .Where(x => x.ReferrerCustomerId == referrerCustomerId && refereeIds.Contains(x.RefereeCustomerId))
.ToListAsync(cancellationToken);
}
}
diff --git a/src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs b/src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs
new file mode 100644
index 0000000..e4cc024
--- /dev/null
+++ b/src/Oms.Tests/MarketingInviteRewardCalculatorTests.cs
@@ -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));
+ }
+}
diff --git a/src/Oms.Tests/MarketingMemberPointsTests.cs b/src/Oms.Tests/MarketingMemberPointsTests.cs
new file mode 100644
index 0000000..bcced27
--- /dev/null
+++ b/src/Oms.Tests/MarketingMemberPointsTests.cs
@@ -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
+ {
+ 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
+ {
+ 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
+ {
+ 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");
+ }
+}
diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
index 36b5ce9..f76a3ed 100644
--- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
+++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
@@ -159,6 +159,8 @@ public class WeChatCustomerService(
input.InviteCode,
referralCodeRepository,
customerRepository,
+ memberLevelRepository,
+ LazyServiceProvider.GetRequiredService(),
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)
{