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