diff --git a/sql/seed_marketing_customer9.sql b/sql/seed_marketing_customer9.sql
new file mode 100644
index 0000000..b7056e9
--- /dev/null
+++ b/sql/seed_marketing_customer9.sql
@@ -0,0 +1,621 @@
+-- 营销模块种子(CustomerId=9 引荐人主客户,CustomerId=8 被引荐下级)
+-- 可重复执行:已存在则跳过 / 幂等更新
+--
+-- 用法:
+-- 1) mysql -u root -p oms < sql/migrate_marketing_module.sql
+-- 2) mysql -u root -p oms < sql/seed_marketing_customer9.sql
+--
+-- 覆盖页面:
+-- 管理端:会员等级 / 积分规则 / 积分流水 / 优惠券模板 / 发券实例 /
+-- 分成规则 / 分成明细 / 提现审核 / 引荐关系
+-- 门户端:邀请好友 / 我的等级 / 我的优惠券 / 推荐收益
+--
+-- 约定:此处「子」= 引荐下级(ReferrerCustomerId),非 ParentCustomerId 子账号。
+-- 8、9 均须为主客户(ParentCustomerId IS NULL),营销权益才挂得上。
+
+SET NAMES utf8mb4;
+
+SET @referrerId := 9;
+SET @refereeId := 8;
+
+-- ---------------------------------------------------------------------------
+-- 0. 前置校验
+-- ---------------------------------------------------------------------------
+SET @seed_err := (
+ SELECT
+ CASE
+ WHEN NOT EXISTS (SELECT 1 FROM `t_customer` WHERE `Id` = @referrerId AND `IsDeleted` = 0)
+ THEN CONCAT('缺少客户 Id=', @referrerId)
+ WHEN NOT EXISTS (SELECT 1 FROM `t_customer` WHERE `Id` = @refereeId AND `IsDeleted` = 0)
+ THEN CONCAT('缺少客户 Id=', @refereeId)
+ WHEN EXISTS (
+ SELECT 1 FROM `t_customer`
+ WHERE `Id` = @referrerId AND `ParentCustomerId` IS NOT NULL AND `IsDeleted` = 0
+ )
+ THEN CONCAT('客户 Id=', @referrerId, ' 不是主客户(存在 ParentCustomerId)')
+ WHEN EXISTS (
+ SELECT 1 FROM `t_customer`
+ WHERE `Id` = @refereeId AND `ParentCustomerId` IS NOT NULL AND `IsDeleted` = 0
+ )
+ THEN CONCAT('客户 Id=', @refereeId, ' 不是主客户(存在 ParentCustomerId);请先改为主客户再跑种子')
+ ELSE NULL
+ END
+);
+
+SELECT IFNULL(@seed_err, 'OK') AS seed_precheck;
+
+-- 校验失败则中止,避免写脏数据
+SET @__abort := IF(@seed_err IS NULL, 'SELECT 1', CONCAT('SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''', REPLACE(@seed_err, '''', ''), ''''));
+PREPARE abort_stmt FROM @__abort;
+EXECUTE abort_stmt;
+DEALLOCATE PREPARE abort_stmt;
+
+-- ---------------------------------------------------------------------------
+-- 1. 会员等级(migrate 已种子;此处确保存在并取 Id)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_member_level`
+ (`LevelCode`, `LevelName`, `MinPoints`, `PriceTierKey`, `ExtraDiscountRate`, `Sort`, `BenefitDesc`, `Status`, `CreateUserId`)
+VALUES
+ ('normal', '普通会员', 0, '普通客户', 0.0000, 10, '标准运价', 2, 0),
+ ('silver', '银卡会员', 5000, 'VIP1', 0.0000, 20, 'VIP1 价区', 2, 0),
+ ('gold', '金卡会员', 20000, 'VIP2', 0.0100, 30, 'VIP2 价区 + 额外 1% 折', 2, 0),
+ ('diamond', '钻石会员', 100000, 'VIP3', 0.0200, 40, 'VIP3 价区 + 额外 2% 折', 2, 0)
+ON DUPLICATE KEY UPDATE
+ `LevelName` = VALUES(`LevelName`),
+ `MinPoints` = VALUES(`MinPoints`),
+ `PriceTierKey` = VALUES(`PriceTierKey`),
+ `ExtraDiscountRate` = VALUES(`ExtraDiscountRate`),
+ `Sort` = VALUES(`Sort`),
+ `BenefitDesc` = VALUES(`BenefitDesc`),
+ `Status` = 2,
+ `IsDeleted` = 0;
+
+SET @levelNormal := (SELECT `Id` FROM `t_member_level` WHERE `LevelCode` = 'normal' AND `IsDeleted` = 0 LIMIT 1);
+SET @levelSilver := (SELECT `Id` FROM `t_member_level` WHERE `LevelCode` = 'silver' AND `IsDeleted` = 0 LIMIT 1);
+SET @levelGold := (SELECT `Id` FROM `t_member_level` WHERE `LevelCode` = 'gold' AND `IsDeleted` = 0 LIMIT 1);
+
+-- ---------------------------------------------------------------------------
+-- 2. 积分规则 / 分成规则(全局,缺则补)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_member_points_rule`
+ (`RuleName`, `PointsPerYuan`, `FirstOrderMultiplier`, `MaxPointsPerOrder`, `Status`, `CreateUserId`)
+SELECT '默认积分规则', 1.0000, 1.50, 10000, 2, 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `t_member_points_rule` WHERE `IsDeleted` = 0 LIMIT 1);
+
+UPDATE `t_member_points_rule`
+SET `RuleName` = '默认积分规则',
+ `PointsPerYuan` = 1.0000,
+ `FirstOrderMultiplier` = 1.50,
+ `MaxPointsPerOrder` = 10000,
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `Id` = (
+ SELECT `Id` FROM (
+ SELECT `Id` FROM `t_member_points_rule` WHERE `IsDeleted` = 0 ORDER BY `Id` LIMIT 1
+ ) AS t
+);
+
+INSERT INTO `t_referral_commission_rule`
+ (`RuleName`, `CommissionMode`, `BaseAmount`, `CommissionAmount`, `FreezeDays`, `SettleTriggerOrderStatus`, `MonthlyCap`, `Status`, `CreateUserId`)
+SELECT '默认分成', 'per_amount', 1000.00, 2.00, 7, 'deliverySuccess', NULL, 2, 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `t_referral_commission_rule` WHERE `IsDeleted` = 0 LIMIT 1);
+
+UPDATE `t_referral_commission_rule`
+SET `RuleName` = '默认分成',
+ `CommissionMode` = 'per_amount',
+ `BaseAmount` = 1000.00,
+ `CommissionAmount` = 2.00,
+ `FreezeDays` = 7,
+ `SettleTriggerOrderStatus` = 'deliverySuccess',
+ `Status` = 2,
+ `UpdateTime` = NOW()
+WHERE `Id` = (
+ SELECT `Id` FROM (
+ SELECT `Id` FROM `t_referral_commission_rule` WHERE `IsDeleted` = 0 ORDER BY `Id` LIMIT 1
+ ) AS t
+);
+
+-- ---------------------------------------------------------------------------
+-- 3. 客户:引荐关系 + 等级 + 佣金余额(9 引荐 8)
+-- ---------------------------------------------------------------------------
+UPDATE `t_customer`
+SET
+ `ReferrerCustomerId` = NULL,
+ `ReferralBindTime` = NULL,
+ `ReferralChannel` = NULL,
+ `MemberLevelId` = @levelGold,
+ `CommissionBalance` = 128.60,
+ `UpdateTime` = NOW()
+WHERE `Id` = @referrerId AND `IsDeleted` = 0 AND `ParentCustomerId` IS NULL;
+
+UPDATE `t_customer`
+SET
+ `ReferrerCustomerId` = @referrerId,
+ `ReferralBindTime` = DATE_SUB(NOW(), INTERVAL 45 DAY),
+ `ReferralChannel` = 'link',
+ `MemberLevelId` = @levelSilver,
+ `CommissionBalance` = 0.00,
+ `UpdateTime` = NOW()
+WHERE `Id` = @refereeId AND `IsDeleted` = 0 AND `ParentCustomerId` IS NULL;
+
+-- ---------------------------------------------------------------------------
+-- 4. 引荐码(邀请好友页)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_customer_referral_code`
+ (`CustomerId`, `InviteCode`, `Status`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @referrerId, 'SEED9INV', 1, 0, UUID(), 0, NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_referral_code`
+ WHERE `CustomerId` = @referrerId AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_customer_referral_code`
+ (`CustomerId`, `InviteCode`, `Status`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @refereeId, 'SEED8INV', 1, 0, UUID(), 0, NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_referral_code`
+ WHERE `CustomerId` = @refereeId AND `IsDeleted` = 0
+);
+
+UPDATE `t_customer_referral_code`
+SET `InviteCode` = 'SEED9INV', `Status` = 1, `IsDeleted` = 0, `UpdateTime` = NOW()
+WHERE `CustomerId` = @referrerId AND `IsDeleted` = 0;
+
+UPDATE `t_customer_referral_code`
+SET `InviteCode` = 'SEED8INV', `Status` = 1, `IsDeleted` = 0, `UpdateTime` = NOW()
+WHERE `CustomerId` = @refereeId AND `IsDeleted` = 0;
+
+-- ---------------------------------------------------------------------------
+-- 5. 积分账户 + 流水(积分流水 / 我的等级)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_customer_points_account`
+ (`CustomerId`, `TotalPoints`, `AvailablePoints`, `Version`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @referrerId, 26800, 24500, 0, 0, UUID(), 0, NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_account` WHERE `CustomerId` = @referrerId AND `IsDeleted` = 0
+);
+
+UPDATE `t_customer_points_account`
+SET `TotalPoints` = 26800, `AvailablePoints` = 24500, `UpdateTime` = NOW()
+WHERE `CustomerId` = @referrerId AND `IsDeleted` = 0;
+
+INSERT INTO `t_customer_points_account`
+ (`CustomerId`, `TotalPoints`, `AvailablePoints`, `Version`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @refereeId, 6200, 5800, 0, 0, UUID(), 0, NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_account` WHERE `CustomerId` = @refereeId AND `IsDeleted` = 0
+);
+
+UPDATE `t_customer_points_account`
+SET `TotalPoints` = 6200, `AvailablePoints` = 5800, `UpdateTime` = NOW()
+WHERE `CustomerId` = @refereeId AND `IsDeleted` = 0;
+
+-- 9:赚取 / 消耗 / 调账
+INSERT INTO `t_customer_points_flow`
+ (`CustomerId`, `ChangeType`, `Points`, `BizType`, `BizId`, `BizNo`, `Remark`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @referrerId, 1, 1200, 'orderPay', 9009001, 'SEED-MKT-ORD-9001', '种子:支付赚取积分', 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 20 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_flow`
+ WHERE `CustomerId` = @referrerId AND `BizNo` = 'SEED-MKT-ORD-9001' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_customer_points_flow`
+ (`CustomerId`, `ChangeType`, `Points`, `BizType`, `BizId`, `BizNo`, `Remark`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @referrerId, 1, 800, 'orderPay', 9009002, 'SEED-MKT-ORD-9002', '种子:支付赚取积分', 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 12 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_flow`
+ WHERE `CustomerId` = @referrerId AND `BizNo` = 'SEED-MKT-ORD-9002' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_customer_points_flow`
+ (`CustomerId`, `ChangeType`, `Points`, `BizType`, `BizId`, `BizNo`, `Remark`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @referrerId, 2, -300, 'orderPay', 9009003, 'SEED-MKT-ORD-9003', '种子:下单抵扣积分', 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 8 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_flow`
+ WHERE `CustomerId` = @referrerId AND `BizNo` = 'SEED-MKT-ORD-9003' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_customer_points_flow`
+ (`CustomerId`, `ChangeType`, `Points`, `BizType`, `BizId`, `BizNo`, `Remark`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @referrerId, 3, 500, 'adminAdjust', NULL, 'SEED-MKT-ADJ-9001', '种子:人工调账加分(演示用不少于十字原因)', 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 3 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_flow`
+ WHERE `CustomerId` = @referrerId AND `BizNo` = 'SEED-MKT-ADJ-9001' AND `IsDeleted` = 0
+);
+
+-- 8:赚取 / 退款追回
+INSERT INTO `t_customer_points_flow`
+ (`CustomerId`, `ChangeType`, `Points`, `BizType`, `BizId`, `BizNo`, `Remark`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @refereeId, 1, 1500, 'orderPay', 9008001, 'SEED-MKT-ORD-8001', '种子:被引荐人支付赚取', 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 30 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_flow`
+ WHERE `CustomerId` = @refereeId AND `BizNo` = 'SEED-MKT-ORD-8001' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_customer_points_flow`
+ (`CustomerId`, `ChangeType`, `Points`, `BizType`, `BizId`, `BizNo`, `Remark`, `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`)
+SELECT @refereeId, 4, -200, 'refund', 9008001, 'SEED-MKT-RF-8001', '种子:退款积分追回', 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 25 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_customer_points_flow`
+ WHERE `CustomerId` = @refereeId AND `BizNo` = 'SEED-MKT-RF-8001' AND `IsDeleted` = 0
+);
+
+-- ---------------------------------------------------------------------------
+-- 6. 优惠券模板(模板页)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'SEED-FIXED-20', '种子满减券20元', 'fixed_off', 20.00, NULL, NULL,
+ 100.00, 1000, 0, 2,
+ 1, DATE_SUB(NOW(), INTERVAL 7 DAY), DATE_ADD(NOW(), INTERVAL 180 DAY), NULL,
+ NULL, 'manual', 1, '种子:满100减20',
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-FIXED-20' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'SEED-PCT-95', '种子95折券', 'percent_off', NULL, 0.9500, 50.00,
+ 50.00, 500, 0, 1,
+ 2, NULL, NULL, 30,
+ NULL, 'referral', 1, '种子:领取后30天有效,封顶50',
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-PCT-95' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'SEED-SHIP-10', '种子运费抵扣10元', 'shipping_off', 10.00, NULL, NULL,
+ 0.00, 200, 0, 3,
+ 1, DATE_SUB(NOW(), INTERVAL 3 DAY), DATE_ADD(NOW(), INTERVAL 90 DAY), NULL,
+ NULL, 'activity', 1, '种子:运费抵扣',
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-SHIP-10' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_coupon_template` (
+ `TemplateCode`, `TemplateName`, `CouponType`, `FaceValue`, `DiscountRate`, `MaxDiscountAmount`,
+ `MinOrderAmount`, `TotalQuantity`, `IssuedQuantity`, `PerCustomerLimit`,
+ `ValidType`, `ValidStartTime`, `ValidEndTime`, `ValidDaysAfterReceive`,
+ `ApplyScopeJson`, `GrantChannel`, `Status`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'SEED-DRAFT-OFF', '种子草稿立减券', 'direct_off', 5.00, NULL, NULL,
+ 0.00, 0, 0, 1,
+ 1, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), NULL,
+ NULL, 'manual', 0, '种子:草稿态模板',
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-DRAFT-OFF' AND `IsDeleted` = 0
+);
+
+SET @tplFixed := (SELECT `Id` FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-FIXED-20' AND `IsDeleted` = 0 LIMIT 1);
+SET @tplPct := (SELECT `Id` FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-PCT-95' AND `IsDeleted` = 0 LIMIT 1);
+SET @tplShip := (SELECT `Id` FROM `t_coupon_template` WHERE `TemplateCode` = 'SEED-SHIP-10' AND `IsDeleted` = 0 LIMIT 1);
+
+-- ---------------------------------------------------------------------------
+-- 7. 优惠券实例(发券列表 / 我的优惠券)
+-- ---------------------------------------------------------------------------
+-- 9:可用 / 已用 / 过期
+INSERT INTO `t_coupon_instance` (
+ `TemplateId`, `CustomerId`, `CouponCode`, `Status`,
+ `ReceiveTime`, `ValidStartTime`, `ValidEndTime`, `UseTime`, `OrderId`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @tplFixed, @referrerId, 'SEED9-FIXED-AVAIL-01', 1,
+ DATE_SUB(NOW(), INTERVAL 5 DAY), DATE_SUB(NOW(), INTERVAL 7 DAY), DATE_ADD(NOW(), INTERVAL 180 DAY), NULL, NULL,
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE @tplFixed IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM `t_coupon_instance` WHERE `CouponCode` = 'SEED9-FIXED-AVAIL-01' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_coupon_instance` (
+ `TemplateId`, `CustomerId`, `CouponCode`, `Status`,
+ `ReceiveTime`, `ValidStartTime`, `ValidEndTime`, `UseTime`, `OrderId`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @tplPct, @referrerId, 'SEED9-PCT-USED-01', 3,
+ DATE_SUB(NOW(), INTERVAL 40 DAY), DATE_SUB(NOW(), INTERVAL 40 DAY), DATE_SUB(NOW(), INTERVAL 10 DAY),
+ DATE_SUB(NOW(), INTERVAL 15 DAY), 9009002,
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE @tplPct IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM `t_coupon_instance` WHERE `CouponCode` = 'SEED9-PCT-USED-01' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_coupon_instance` (
+ `TemplateId`, `CustomerId`, `CouponCode`, `Status`,
+ `ReceiveTime`, `ValidStartTime`, `ValidEndTime`, `UseTime`, `OrderId`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @tplShip, @referrerId, 'SEED9-SHIP-EXP-01', 4,
+ DATE_SUB(NOW(), INTERVAL 60 DAY), DATE_SUB(NOW(), INTERVAL 60 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY), NULL, NULL,
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE @tplShip IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM `t_coupon_instance` WHERE `CouponCode` = 'SEED9-SHIP-EXP-01' AND `IsDeleted` = 0
+);
+
+-- 8:可用 / 锁定
+INSERT INTO `t_coupon_instance` (
+ `TemplateId`, `CustomerId`, `CouponCode`, `Status`,
+ `ReceiveTime`, `ValidStartTime`, `ValidEndTime`, `UseTime`, `OrderId`, `LockExpireTime`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @tplFixed, @refereeId, 'SEED8-FIXED-AVAIL-01', 1,
+ DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 7 DAY), DATE_ADD(NOW(), INTERVAL 180 DAY), NULL, NULL, NULL,
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE @tplFixed IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM `t_coupon_instance` WHERE `CouponCode` = 'SEED8-FIXED-AVAIL-01' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_coupon_instance` (
+ `TemplateId`, `CustomerId`, `CouponCode`, `Status`,
+ `ReceiveTime`, `ValidStartTime`, `ValidEndTime`, `UseTime`, `OrderId`, `LockExpireTime`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @tplShip, @refereeId, 'SEED8-SHIP-LOCK-01', 2,
+ DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 3 DAY), DATE_ADD(NOW(), INTERVAL 90 DAY),
+ NULL, 9008002, DATE_ADD(NOW(), INTERVAL 30 MINUTE),
+ 0, UUID(), 0, NOW()
+FROM DUAL WHERE @tplShip IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM `t_coupon_instance` WHERE `CouponCode` = 'SEED8-SHIP-LOCK-01' AND `IsDeleted` = 0
+);
+
+-- 回写模板已发数量
+UPDATE `t_coupon_template` t
+SET t.`IssuedQuantity` = (
+ SELECT COUNT(*) FROM `t_coupon_instance` i
+ WHERE i.`TemplateId` = t.`Id` AND i.`IsDeleted` = 0
+)
+WHERE t.`TemplateCode` IN ('SEED-FIXED-20', 'SEED-PCT-95', 'SEED-SHIP-10', 'SEED-DRAFT-OFF')
+ AND t.`IsDeleted` = 0;
+
+-- ---------------------------------------------------------------------------
+-- 8. 分成明细(分成明细 / 推荐收益):引荐人=9,被引荐人=8
+-- ---------------------------------------------------------------------------
+SET @ruleSnap := JSON_OBJECT(
+ 'RuleName', '默认分成',
+ 'CommissionMode', 'per_amount',
+ 'BaseAmount', 1000,
+ 'CommissionAmount', 2,
+ 'FreezeDays', 7
+);
+
+-- 待结算
+INSERT INTO `t_referral_commission_detail` (
+ `ReferrerCustomerId`, `RefereeCustomerId`, `OrderId`, `OrderNo`,
+ `BasePayableAmount`, `CommissionAmount`, `Status`,
+ `EligibleTime`, `SettleTime`, `RuleSnapshotJson`, `CancelReason`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, @refereeId, 9008001, 'SEED-MKT-ORD-8001',
+ 3500.00, 6.00, 1,
+ DATE_SUB(NOW(), INTERVAL 2 DAY), NULL, @ruleSnap, NULL,
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 2 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_referral_commission_detail`
+ WHERE `OrderId` = 9008001 AND `ReferrerCustomerId` = @referrerId AND `IsDeleted` = 0
+);
+
+-- 已结算
+INSERT INTO `t_referral_commission_detail` (
+ `ReferrerCustomerId`, `RefereeCustomerId`, `OrderId`, `OrderNo`,
+ `BasePayableAmount`, `CommissionAmount`, `Status`,
+ `EligibleTime`, `SettleTime`, `RuleSnapshotJson`, `CancelReason`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, @refereeId, 9008002, 'SEED-MKT-ORD-8002',
+ 5200.00, 10.00, 2,
+ DATE_SUB(NOW(), INTERVAL 20 DAY), DATE_SUB(NOW(), INTERVAL 13 DAY), @ruleSnap, NULL,
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 20 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_referral_commission_detail`
+ WHERE `OrderId` = 9008002 AND `ReferrerCustomerId` = @referrerId AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_referral_commission_detail` (
+ `ReferrerCustomerId`, `RefereeCustomerId`, `OrderId`, `OrderNo`,
+ `BasePayableAmount`, `CommissionAmount`, `Status`,
+ `EligibleTime`, `SettleTime`, `RuleSnapshotJson`, `CancelReason`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, @refereeId, 9008003, 'SEED-MKT-ORD-8003',
+ 1800.00, 2.00, 2,
+ DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 28 DAY), @ruleSnap, NULL,
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 35 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_referral_commission_detail`
+ WHERE `OrderId` = 9008003 AND `ReferrerCustomerId` = @referrerId AND `IsDeleted` = 0
+);
+
+-- 已取消(人工调整演示)
+INSERT INTO `t_referral_commission_detail` (
+ `ReferrerCustomerId`, `RefereeCustomerId`, `OrderId`, `OrderNo`,
+ `BasePayableAmount`, `CommissionAmount`, `Status`,
+ `EligibleTime`, `SettleTime`, `RuleSnapshotJson`, `CancelReason`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, @refereeId, 9008004, 'SEED-MKT-ORD-8004',
+ 1000.00, 2.00, 3,
+ DATE_SUB(NOW(), INTERVAL 10 DAY), NULL, @ruleSnap, '种子:订单退款取消分成',
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 10 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_referral_commission_detail`
+ WHERE `OrderId` = 9008004 AND `ReferrerCustomerId` = @referrerId AND `IsDeleted` = 0
+);
+
+-- ---------------------------------------------------------------------------
+-- 9. 提现申请(提现审核 / 推荐收益)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_commission_withdraw_request` (
+ `CustomerId`, `SerialNumber`, `Amount`, `WithdrawType`, `Status`,
+ `Remark`, `AuditRemark`, `AuditBy`, `AuditTime`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, 'SEED-WD-9-PENDING-01', 50.00, 1, 1,
+ '种子:申请提现到外部账户', NULL, NULL, NULL,
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 1 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_commission_withdraw_request`
+ WHERE `SerialNumber` = 'SEED-WD-9-PENDING-01' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_commission_withdraw_request` (
+ `CustomerId`, `SerialNumber`, `Amount`, `WithdrawType`, `Status`,
+ `Remark`, `AuditRemark`, `AuditBy`, `AuditTime`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, 'SEED-WD-9-APPROVED-01', 30.00, 2, 2,
+ '种子:转入余额', '审核通过-种子数据', 'admin', DATE_SUB(NOW(), INTERVAL 5 DAY),
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 6 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_commission_withdraw_request`
+ WHERE `SerialNumber` = 'SEED-WD-9-APPROVED-01' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_commission_withdraw_request` (
+ `CustomerId`, `SerialNumber`, `Amount`, `WithdrawType`, `Status`,
+ `Remark`, `AuditRemark`, `AuditBy`, `AuditTime`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, 'SEED-WD-9-REJECTED-01', 20.00, 1, 3,
+ '种子:提现申请', '驳回原因:收款信息不完整(种子)', 'admin', DATE_SUB(NOW(), INTERVAL 8 DAY),
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 9 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_commission_withdraw_request`
+ WHERE `SerialNumber` = 'SEED-WD-9-REJECTED-01' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_commission_withdraw_request` (
+ `CustomerId`, `SerialNumber`, `Amount`, `WithdrawType`, `Status`,
+ `Remark`, `AuditRemark`, `AuditBy`, `AuditTime`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT @referrerId, 'SEED-WD-9-PAID-01', 40.00, 1, 4,
+ '种子:已打款', '已打款-种子数据', 'admin', DATE_SUB(NOW(), INTERVAL 15 DAY),
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 16 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_commission_withdraw_request`
+ WHERE `SerialNumber` = 'SEED-WD-9-PAID-01' AND `IsDeleted` = 0
+);
+
+-- ---------------------------------------------------------------------------
+-- 10. 审计日志(高危操作痕迹,便于联调)
+-- ---------------------------------------------------------------------------
+INSERT INTO `t_marketing_audit_log` (
+ `Action`, `OperatorId`, `TargetType`, `TargetId`,
+ `BeforeJson`, `AfterJson`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'grantCoupon', 0, 'customer', @referrerId,
+ NULL,
+ JSON_OBJECT('templateCode', 'SEED-FIXED-20', 'couponCode', 'SEED9-FIXED-AVAIL-01'),
+ '种子:批量发券演示',
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 5 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_marketing_audit_log`
+ WHERE `Action` = 'grantCoupon' AND `Remark` = '种子:批量发券演示' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_marketing_audit_log` (
+ `Action`, `OperatorId`, `TargetType`, `TargetId`,
+ `BeforeJson`, `AfterJson`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'adjustPoints', 0, 'customer', @referrerId,
+ JSON_OBJECT('availablePoints', 24000),
+ JSON_OBJECT('availablePoints', 24500, 'delta', 500),
+ '种子:人工调账加分(演示用不少于十字原因)',
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 3 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_marketing_audit_log`
+ WHERE `Action` = 'adjustPoints' AND `TargetId` = @referrerId
+ AND `Remark` LIKE '种子:人工调账%' AND `IsDeleted` = 0
+);
+
+INSERT INTO `t_marketing_audit_log` (
+ `Action`, `OperatorId`, `TargetType`, `TargetId`,
+ `BeforeJson`, `AfterJson`, `Remark`,
+ `IsDeleted`, `ConcurrencyStamp`, `CreateUserId`, `CreateTime`
+)
+SELECT
+ 'bindReferral', 0, 'customer', @refereeId,
+ JSON_OBJECT('referrerCustomerId', NULL),
+ JSON_OBJECT('referrerCustomerId', @referrerId, 'channel', 'link'),
+ '种子:绑定引荐关系 8←9',
+ 0, UUID(), 0, DATE_SUB(NOW(), INTERVAL 45 DAY)
+FROM DUAL WHERE NOT EXISTS (
+ SELECT 1 FROM `t_marketing_audit_log`
+ WHERE `Action` = 'bindReferral' AND `TargetId` = @refereeId AND `IsDeleted` = 0
+);
+
+-- ---------------------------------------------------------------------------
+-- 11. 结果核对
+-- ---------------------------------------------------------------------------
+SELECT 'precheck_customers' AS item,
+ (SELECT COUNT(*) FROM `t_customer` WHERE `Id` IN (@referrerId, @refereeId) AND `IsDeleted` = 0) AS cnt
+UNION ALL
+SELECT 'referral_bind_8_to_9',
+ (SELECT COUNT(*) FROM `t_customer`
+ WHERE `Id` = @refereeId AND `ReferrerCustomerId` = @referrerId AND `ParentCustomerId` IS NULL AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'referral_codes',
+ (SELECT COUNT(*) FROM `t_customer_referral_code`
+ WHERE `CustomerId` IN (@referrerId, @refereeId) AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'points_accounts',
+ (SELECT COUNT(*) FROM `t_customer_points_account`
+ WHERE `CustomerId` IN (@referrerId, @refereeId) AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'points_flows',
+ (SELECT COUNT(*) FROM `t_customer_points_flow`
+ WHERE `CustomerId` IN (@referrerId, @refereeId) AND `BizNo` LIKE 'SEED-MKT-%' AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'coupon_templates',
+ (SELECT COUNT(*) FROM `t_coupon_template`
+ WHERE `TemplateCode` LIKE 'SEED-%' AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'coupon_instances',
+ (SELECT COUNT(*) FROM `t_coupon_instance`
+ WHERE `CouponCode` LIKE 'SEED%' AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'commission_details',
+ (SELECT COUNT(*) FROM `t_referral_commission_detail`
+ WHERE `ReferrerCustomerId` = @referrerId AND `RefereeCustomerId` = @refereeId AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'withdraw_requests',
+ (SELECT COUNT(*) FROM `t_commission_withdraw_request`
+ WHERE `CustomerId` = @referrerId AND `SerialNumber` LIKE 'SEED-WD-9-%' AND `IsDeleted` = 0)
+UNION ALL
+SELECT 'audit_logs',
+ (SELECT COUNT(*) FROM `t_marketing_audit_log`
+ WHERE `Remark` LIKE '种子:%' AND `IsDeleted` = 0);
diff --git a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
index 9a503a4..e60808d 100644
--- a/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CommissionDtos.cs
@@ -1,218 +1,225 @@
-using System.ComponentModel.DataAnnotations;
-using Oms.Domain.MarketingAggregate.Enums;
-using Oms.Domain.Shared.Enums;
-
-namespace Oms.Application.Dtos.Marketing;
-
-public class ReferralCommissionRuleOutput
-{
- public int Id { get; set; }
-
- public string RuleName { get; set; } = string.Empty;
-
- public string CommissionMode { get; set; } = string.Empty;
-
- public decimal BaseAmount { get; set; }
-
- public decimal CommissionAmount { get; set; }
-
- public int FreezeDays { get; set; }
-
- public string SettleTriggerOrderStatus { get; set; } = string.Empty;
-
- public decimal? MonthlyCap { get; set; }
-
- public EnableStatus Status { get; set; }
-
- public string StatusName { get; set; } = string.Empty;
-
- public DateTime CreateTime { get; set; }
-
- public DateTime? UpdateTime { get; set; }
-}
-
-public class ReferralCommissionRuleSaveInput
-{
- public int? Id { get; set; }
-
- [Required]
- public string RuleName { get; set; } = string.Empty;
-
- public string CommissionMode { get; set; } = "per_amount";
-
- public decimal BaseAmount { get; set; } = 1000M;
-
- public decimal CommissionAmount { get; set; } = 2M;
-
- public int FreezeDays { get; set; } = 7;
-
- public string SettleTriggerOrderStatus { get; set; } = "deliverySuccess";
-
- public decimal? MonthlyCap { get; set; }
-
- public EnableStatus Status { get; set; } = EnableStatus.Enabled;
-}
-
-public class CommissionDetailListInput
-{
- public int? ReferrerCustomerId { get; set; }
-
- public int? RefereeCustomerId { get; set; }
-
- public CommissionStatus? Status { get; set; }
-
- public string? OrderNo { get; set; }
-
- public DateTime? StartTime { get; set; }
-
- public DateTime? EndTime { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class CommissionDetailOutput
-{
- public int Id { get; set; }
-
- public int ReferrerCustomerId { get; set; }
-
- public string? ReferrerCustomerCode { get; set; }
-
- public int RefereeCustomerId { get; set; }
-
- public string? RefereeCustomerCode { get; set; }
-
- public int OrderId { get; set; }
-
- public string OrderNo { get; set; } = string.Empty;
-
- public decimal BasePayableAmount { get; set; }
-
- public decimal CommissionAmount { get; set; }
-
- public CommissionStatus Status { get; set; }
-
- public string StatusName { get; set; } = string.Empty;
-
- public DateTime? EligibleTime { get; set; }
-
- public DateTime? SettleTime { get; set; }
-
- public string? CancelReason { get; set; }
-
- public DateTime CreateTime { get; set; }
-}
-
-public class CommissionAdjustInput
-{
- [Required]
- public int CustomerId { get; set; }
-
- public decimal Amount { get; set; }
-
- public int? DetailId { get; set; }
-
- [Required]
- [StringLength(512, MinimumLength = 10)]
- public string Remark { get; set; } = string.Empty;
-}
-
-public class CommissionWithdrawListInput
-{
- public int? CustomerId { get; set; }
-
- public CommissionWithdrawType? WithdrawType { get; set; }
-
- public WithdrawStatus? Status { get; set; }
-
- public DateTime? StartTime { get; set; }
-
- public DateTime? EndTime { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class CommissionWithdrawOutput
-{
- public int Id { get; set; }
-
- public int CustomerId { get; set; }
-
- public string? CustomerCode { get; set; }
-
- public string SerialNumber { get; set; } = string.Empty;
-
- public decimal Amount { get; set; }
-
- public CommissionWithdrawType WithdrawType { get; set; }
-
- public string WithdrawTypeName { get; set; } = string.Empty;
-
- public WithdrawStatus Status { get; set; }
-
- public string StatusName { get; set; } = string.Empty;
-
- public string? Remark { get; set; }
-
- public string? AuditRemark { get; set; }
-
- public string? AuditBy { get; set; }
-
- public DateTime? AuditTime { get; set; }
-
- public DateTime CreateTime { get; set; }
-}
-
-public class CommissionWithdrawApproveInput
-{
- [Required]
- public int Id { get; set; }
-
- public string? AuditRemark { get; set; }
-}
-
-public class CommissionWithdrawRejectInput
-{
- [Required]
- public int Id { get; set; }
-
- [Required]
- [StringLength(512, MinimumLength = 5)]
- public string AuditRemark { get; set; } = string.Empty;
-}
-
-public class CommissionOverviewOutput
-{
- public decimal CommissionBalance { get; set; }
-
- public decimal PendingCommission { get; set; }
-
- public decimal SettledThisMonth { get; set; }
-
- public int RefereeCount { get; set; }
-
- public decimal FrozenByPendingWithdraw { get; set; }
-
- public decimal AvailableCommission { get; set; }
-}
-
-public class CommissionTransferInput
-{
- [Range(typeof(decimal), "0.01", "999999999999")]
- public decimal Amount { get; set; }
-
- public string? Remark { get; set; }
-}
-
-public class CommissionWithdrawApplyInput
-{
- [Range(typeof(decimal), "0.01", "999999999999")]
- public decimal Amount { get; set; }
-
- public string? Remark { get; set; }
-}
-
\ No newline at end of file
+using System.ComponentModel.DataAnnotations;
+using Oms.Domain.MarketingAggregate.Enums;
+using Oms.Domain.Shared.Enums;
+
+namespace Oms.Application.Dtos.Marketing;
+
+public class ReferralCommissionRuleOutput
+{
+ public int Id { get; set; }
+
+ public string RuleName { get; set; } = string.Empty;
+
+ public string CommissionMode { get; set; } = string.Empty;
+
+ public decimal BaseAmount { get; set; }
+
+ public decimal CommissionAmount { get; set; }
+
+ public int FreezeDays { get; set; }
+
+ public string SettleTriggerOrderStatus { get; set; } = string.Empty;
+
+ public decimal? MonthlyCap { get; set; }
+
+ public EnableStatus Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public DateTime CreateTime { get; set; }
+
+ public DateTime? UpdateTime { get; set; }
+}
+
+public class ReferralCommissionRuleSaveInput
+{
+ public int? Id { get; set; }
+
+ [Required]
+ public string RuleName { get; set; } = string.Empty;
+
+ public string CommissionMode { get; set; } = "per_amount";
+
+ public decimal BaseAmount { get; set; } = 1000M;
+
+ public decimal CommissionAmount { get; set; } = 2M;
+
+ public int FreezeDays { get; set; } = 7;
+
+ public string SettleTriggerOrderStatus { get; set; } = "deliverySuccess";
+
+ public decimal? MonthlyCap { get; set; }
+
+ public EnableStatus Status { get; set; } = EnableStatus.Enabled;
+}
+
+public class CommissionDetailListInput
+{
+ public int? ReferrerCustomerId { get; set; }
+
+ public int? RefereeCustomerId { get; set; }
+
+ public CommissionStatus? Status { get; set; }
+
+ public string? OrderNo { get; set; }
+
+ public DateTime? StartTime { get; set; }
+
+ public DateTime? EndTime { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class CommissionDetailOutput
+{
+ public int Id { get; set; }
+
+ public int ReferrerCustomerId { get; set; }
+
+ public string? ReferrerCustomerCode { get; set; }
+
+ public int RefereeCustomerId { get; set; }
+
+ public string? RefereeCustomerCode { get; set; }
+
+ public int OrderId { get; set; }
+
+ public string OrderNo { get; set; } = string.Empty;
+
+ public decimal BasePayableAmount { get; set; }
+
+ public decimal CommissionAmount { get; set; }
+
+ public CommissionStatus Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public DateTime? EligibleTime { get; set; }
+
+ public DateTime? SettleTime { get; set; }
+
+ public string? CancelReason { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class CommissionAdjustInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public decimal Amount { get; set; }
+
+ public int? DetailId { get; set; }
+
+ [Required]
+ [StringLength(512, MinimumLength = 10)]
+ public string Remark { get; set; } = string.Empty;
+}
+
+public class CommissionWithdrawListInput
+{
+ public int? CustomerId { get; set; }
+
+ /// 客户编号(解析为 CustomerId 再筛选)。
+ public string? CustomerCode { get; set; }
+
+ public CommissionWithdrawType? WithdrawType { get; set; }
+
+ public WithdrawStatus? Status { get; set; }
+
+ public DateTime? StartTime { get; set; }
+
+ public DateTime? EndTime { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class CommissionWithdrawOutput
+{
+ public int Id { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public string SerialNumber { get; set; } = string.Empty;
+
+ public decimal Amount { get; set; }
+
+ /// 客户当前佣金余额(审核参考)。
+ public decimal CommissionBalance { get; set; }
+
+ public CommissionWithdrawType WithdrawType { get; set; }
+
+ public string WithdrawTypeName { get; set; } = string.Empty;
+
+ public WithdrawStatus Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public string? Remark { get; set; }
+
+ public string? AuditRemark { get; set; }
+
+ public string? AuditBy { get; set; }
+
+ public DateTime? AuditTime { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class CommissionWithdrawApproveInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ public string? AuditRemark { get; set; }
+}
+
+public class CommissionWithdrawRejectInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ [Required]
+ [StringLength(512, MinimumLength = 5)]
+ public string AuditRemark { get; set; } = string.Empty;
+}
+
+public class CommissionOverviewOutput
+{
+ public decimal CommissionBalance { get; set; }
+
+ public decimal PendingCommission { get; set; }
+
+ public decimal SettledThisMonth { get; set; }
+
+ public int RefereeCount { get; set; }
+
+ public decimal FrozenByPendingWithdraw { get; set; }
+
+ public decimal AvailableCommission { get; set; }
+}
+
+public class CommissionTransferInput
+{
+ [Range(typeof(decimal), "0.01", "999999999999")]
+ public decimal Amount { get; set; }
+
+ public string? Remark { get; set; }
+}
+
+public class CommissionWithdrawApplyInput
+{
+ [Range(typeof(decimal), "0.01", "999999999999")]
+ public decimal Amount { get; set; }
+
+ public string? Remark { get; set; }
+}
+
diff --git a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
index 984f7f7..02e961e 100644
--- a/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/CouponDtos.cs
@@ -270,6 +270,11 @@ public class CouponInstanceListInput
+ /// 客户编号(优先解析为主客户 Id 再筛选)。
+ public string? CustomerCode { get; set; }
+
+
+
public CouponInstanceStatus? Status { get; set; }
diff --git a/src/Oms.Application/Dtos/Marketing/PointsRuleDtos.cs b/src/Oms.Application/Dtos/Marketing/PointsRuleDtos.cs
index e517830..98e6d6f 100644
--- a/src/Oms.Application/Dtos/Marketing/PointsRuleDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/PointsRuleDtos.cs
@@ -1,95 +1,100 @@
-using System.ComponentModel.DataAnnotations;
-using Oms.Domain.MarketingAggregate.Enums;
-using Oms.Domain.Shared.Enums;
-
-namespace Oms.Application.Dtos.Marketing;
-
-public class PointsRuleOutput
-{
- public int Id { get; set; }
-
- public string RuleName { get; set; } = string.Empty;
-
- public decimal PointsPerYuan { get; set; }
-
- public decimal FirstOrderMultiplier { get; set; }
-
- public int MaxPointsPerOrder { get; set; }
-
- public EnableStatus Status { get; set; }
-
- public string StatusName { get; set; } = string.Empty;
-
- public DateTime CreateTime { get; set; }
-
- public DateTime? UpdateTime { get; set; }
-}
-
-public class PointsRuleSaveInput
-{
- public int? Id { get; set; }
-
- [Required]
- public string RuleName { get; set; } = string.Empty;
-
- public decimal PointsPerYuan { get; set; } = 1M;
-
- public decimal FirstOrderMultiplier { get; set; } = 1M;
-
- public int MaxPointsPerOrder { get; set; } = 10000;
-
- public EnableStatus Status { get; set; } = EnableStatus.Enabled;
-}
-
-public class PointsFlowListInput
-{
- public int? CustomerId { get; set; }
-
- public PointsChangeType? ChangeType { get; set; }
-
- public DateTime? StartTime { get; set; }
-
- public DateTime? EndTime { get; set; }
-
- public int Page { get; set; }
-
- public int PageSize { get; set; }
-}
-
-public class PointsFlowOutput
-{
- public int Id { get; set; }
-
- public int CustomerId { get; set; }
-
- public string? CustomerCode { get; set; }
-
- public PointsChangeType ChangeType { get; set; }
-
- public string ChangeTypeName { get; set; } = string.Empty;
-
- public int Points { get; set; }
-
- public string BizType { get; set; } = string.Empty;
-
- public int? BizId { get; set; }
-
- public string? BizNo { get; set; }
-
- public string? Remark { get; set; }
-
- public DateTime CreateTime { get; set; }
-}
-
-public class PointsAdjustInput
-{
- [Required]
- public int CustomerId { get; set; }
-
- public int Points { get; set; }
-
- [Required]
- [StringLength(512, MinimumLength = 10)]
- public string Remark { get; set; } = string.Empty;
-}
+using System.ComponentModel.DataAnnotations;
+using Oms.Domain.MarketingAggregate.Enums;
+using Oms.Domain.Shared.Enums;
+
+namespace Oms.Application.Dtos.Marketing;
+
+public class PointsRuleOutput
+{
+ public int Id { get; set; }
+
+ public string RuleName { get; set; } = string.Empty;
+
+ public decimal PointsPerYuan { get; set; }
+
+ public decimal FirstOrderMultiplier { get; set; }
+
+ public int MaxPointsPerOrder { get; set; }
+
+ public EnableStatus Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public DateTime CreateTime { get; set; }
+
+ public DateTime? UpdateTime { get; set; }
+}
+
+public class PointsRuleSaveInput
+{
+ public int? Id { get; set; }
+
+ [Required]
+ public string RuleName { get; set; } = string.Empty;
+
+ public decimal PointsPerYuan { get; set; } = 1M;
+
+ public decimal FirstOrderMultiplier { get; set; } = 1M;
+
+ public int MaxPointsPerOrder { get; set; } = 10000;
+
+ public EnableStatus Status { get; set; } = EnableStatus.Enabled;
+}
+
+public class PointsFlowListInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public PointsChangeType? ChangeType { get; set; }
+
+ public DateTime? StartTime { get; set; }
+
+ public DateTime? EndTime { get; set; }
+
+ public int Page { get; set; }
+
+ public int PageSize { get; set; }
+}
+
+public class PointsFlowOutput
+{
+ public int Id { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public string? CustomerName { get; set; }
+
+ public PointsChangeType ChangeType { get; set; }
+
+ public string ChangeTypeName { get; set; } = string.Empty;
+
+ public int Points { get; set; }
+
+ public string BizType { get; set; } = string.Empty;
+
+ public int? BizId { get; set; }
+
+ public string? BizNo { get; set; }
+
+ public string? Remark { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class PointsAdjustInput
+{
+ public int? CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public int Points { get; set; }
+
+ [Required]
+ [StringLength(512, MinimumLength = 10)]
+ public string Remark { get; set; } = string.Empty;
+}
\ 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 ac8068e..7c3a326 100644
--- a/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
+++ b/src/Oms.Application/Dtos/Marketing/ReferralDtos.cs
@@ -1,92 +1,123 @@
-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 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 DateTime CreateTime { get; set; }
-
- public DateTime? ReferralBindTime { get; set; }
-
- public string? ReferralChannel { get; set; }
-}
-
-public class ReferralRebindInput
-{
- [Required]
- public int CustomerId { get; set; }
-
- public int? NewReferrerCustomerId { get; set; }
-
- public string? NewInviteCode { get; set; }
-
- [Required]
- [StringLength(512, MinimumLength = 20)]
- public string Reason { get; set; } = string.Empty;
-}
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Marketing;
+
+public class ReferralMyInviteOutput
+{
+ public string InviteCode { get; set; } = string.Empty;
+
+ public byte Status { get; set; }
+
+ public string StatusName { get; set; } = string.Empty;
+
+ public int RefereeCount { get; set; }
+}
+
+public 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
diff --git a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
index f27c278..e164659 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCommissionAdminService.cs
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Marketing;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Consts;
using Oms.Domain.MarketingAggregate.Entities;
@@ -64,8 +65,7 @@ public class MarketingCommissionAdminService : ApplicationService
throw new BusinessException("Remark must be at least 10 characters");
}
- var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId)
- ?? throw new BusinessException("Customer not found");
+ var customer = await ResolveCustomerAsync(input.CustomerId, input.CustomerCode);
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("Adjust main customer only");
@@ -88,6 +88,7 @@ public class MarketingCommissionAdminService : ApplicationService
[HttpPost("/marketing/commission-withdraw/list")]
public async Task> GetWithdrawListAsync(CommissionWithdrawListInput input)
{
+ await ResolveWithdrawCustomerIdAsync(input);
var predicate = BuildWithdrawPredicate(input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
@@ -104,6 +105,24 @@ public class MarketingCommissionAdminService : ApplicationService
};
}
+ private async Task ResolveWithdrawCustomerIdAsync(CommissionWithdrawListInput input)
+ {
+ if (input.CustomerId is > 0)
+ {
+ return;
+ }
+
+ var code = input.CustomerCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ return;
+ }
+
+ var customer = await CustomerRepository.GetByCustomerCodeAsync(code)
+ ?? throw new BusinessException($"???? {code} ???");
+ input.CustomerId = customer.ParentCustomerId ?? customer.Id;
+ }
+
[HttpPost("/marketing/commission-withdraw/approve")]
public async Task ApproveWithdrawAsync(CommissionWithdrawApproveInput input)
{
@@ -231,6 +250,7 @@ public class MarketingCommissionAdminService : ApplicationService
CustomerCode = customer?.CustomerCode,
SerialNumber = entity.SerialNumber,
Amount = entity.Amount,
+ CommissionBalance = customer?.CommissionBalance ?? 0,
WithdrawType = entity.WithdrawType,
WithdrawTypeName = MarketingEnumNames.Of(entity.WithdrawType),
Status = entity.Status,
@@ -246,13 +266,15 @@ public class MarketingCommissionAdminService : ApplicationService
internal static CommissionWithdrawOutput MapWithdrawOutput(
CommissionWithdrawRequest entity,
- string? customerCode) => new()
+ string? customerCode,
+ decimal commissionBalance = 0) => new()
{
Id = entity.Id,
CustomerId = entity.CustomerId,
CustomerCode = customerCode,
SerialNumber = entity.SerialNumber,
Amount = entity.Amount,
+ CommissionBalance = commissionBalance,
WithdrawType = entity.WithdrawType,
WithdrawTypeName = MarketingEnumNames.Of(entity.WithdrawType),
Status = entity.Status,
@@ -264,6 +286,26 @@ public class MarketingCommissionAdminService : ApplicationService
CreateTime = entity.CreateTime
};
+ private async Task ResolveCustomerAsync(
+ int? customerId,
+ string? customerCode)
+ {
+ if (customerId is > 0)
+ {
+ return await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value)
+ ?? throw new BusinessException("Customer not found");
+ }
+
+ var code = customerCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new BusinessException("CustomerId or CustomerCode required");
+ }
+
+ return await CustomerRepository.GetByCustomerCodeAsync(code)
+ ?? throw new BusinessException("Customer not found");
+ }
+
private async Task GetWithdrawOrThrowAsync(int id)
{
return await WithdrawRepository.FirstOrDefaultAsync(x => x.Id == id)
diff --git a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
index 2d0a667..e9af4de 100644
--- a/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
@@ -84,11 +84,12 @@ public class MarketingCouponGrantService : ApplicationService
}
///
- /// 分页查询券实例。
+ /// 分页查询券实例(发放记录)。
///
[HttpPost("/marketing/coupon-instance/list")]
public async Task> GetInstanceListAsync(CouponInstanceListInput input)
{
+ await ResolveInstanceCustomerIdAsync(input);
var predicate = BuildInstancePredicate(input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
@@ -109,6 +110,24 @@ public class MarketingCouponGrantService : ApplicationService
};
}
+ private async Task ResolveInstanceCustomerIdAsync(CouponInstanceListInput input)
+ {
+ if (input.CustomerId is > 0)
+ {
+ return;
+ }
+
+ var code = input.CustomerCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ return;
+ }
+
+ var customer = await CustomerRepository.GetByCustomerCodeAsync(code)
+ ?? throw new BusinessException($"客户编号 {code} 不存在");
+ input.CustomerId = await CouponManager.ResolveMainCustomerIdAsync(customer.Id);
+ }
+
private async Task> ResolveGrantMainCustomerIdsAsync(IEnumerable customerCodes)
{
var grantedMainIds = new HashSet();
diff --git a/src/Oms.Application/Services/Marketing/MarketingPointsRuleService.cs b/src/Oms.Application/Services/Marketing/MarketingPointsRuleService.cs
index 95a52eb..468142b 100644
--- a/src/Oms.Application/Services/Marketing/MarketingPointsRuleService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingPointsRuleService.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Marketing;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
+using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Entities;
using Oms.Domain.MarketingAggregate.Repositories;
@@ -67,6 +68,7 @@ public class MarketingPointsRuleService : ApplicationService
[HttpPost("/marketing/points-flow/list")]
public async Task> GetFlowListAsync(PointsFlowListInput input)
{
+ await ResolveCustomerIdAsync(input);
var predicate = BuildFlowPredicate(input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
@@ -96,6 +98,7 @@ public class MarketingPointsRuleService : ApplicationService
Id = entity.Id,
CustomerId = entity.CustomerId,
CustomerCode = customer?.CustomerCode,
+ CustomerName = customer?.CustomerName,
ChangeType = entity.ChangeType,
ChangeTypeName = MarketingEnumNames.Of(entity.ChangeType),
Points = entity.Points,
@@ -113,14 +116,51 @@ public class MarketingPointsRuleService : ApplicationService
[HttpPost("/marketing/points/adjust")]
public async Task AdjustAsync(PointsAdjustInput input)
{
- var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId)
- ?? throw new BusinessException("Customer not found");
+ var customer = await ResolveCustomerAsync(input.CustomerId, input.CustomerCode);
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("Adjust main customer only");
}
- await PointsManager.AdjustAsync(input.CustomerId, input.Points, input.Remark);
+ await PointsManager.AdjustAsync(customer.Id, input.Points, input.Remark);
+ }
+
+ private async Task ResolveCustomerIdAsync(PointsFlowListInput input)
+ {
+ if (input.CustomerId is > 0)
+ {
+ return;
+ }
+
+ var code = input.CustomerCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ return;
+ }
+
+ var customer = await CustomerRepository.GetByCustomerCodeAsync(code)
+ ?? throw new BusinessException("Customer not found");
+ input.CustomerId = customer.Id;
+ }
+
+ private async Task ResolveCustomerAsync(
+ int? customerId,
+ string? customerCode)
+ {
+ if (customerId is > 0)
+ {
+ return await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value)
+ ?? throw new BusinessException("Customer not found");
+ }
+
+ var code = customerCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new BusinessException("CustomerId or CustomerCode required");
+ }
+
+ return await CustomerRepository.GetByCustomerCodeAsync(code)
+ ?? throw new BusinessException("Customer not found");
}
private static void ValidateRuleInput(PointsRuleSaveInput input)
diff --git a/src/Oms.Application/Services/Marketing/MarketingReferralAdminService.cs b/src/Oms.Application/Services/Marketing/MarketingReferralAdminService.cs
index 6ecad30..11d429f 100644
--- a/src/Oms.Application/Services/Marketing/MarketingReferralAdminService.cs
+++ b/src/Oms.Application/Services/Marketing/MarketingReferralAdminService.cs
@@ -27,6 +27,44 @@ public class MarketingReferralAdminService : ApplicationService
private IMarketingAuditLogRepository AuditLogRepository =>
LazyServiceProvider.GetRequiredService();
+ [HttpPost("/marketing/referral/referrer-list")]
+ public async Task> GetReferrerListAsync(ReferralReferrerListInput input)
+ {
+ var pageIndex = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var (rows, total) = await CustomerRepository.GetReferrerSummariesPagedAsync(
+ input.Keyword,
+ pageIndex,
+ pageSize);
+
+ if (rows.Count == 0)
+ {
+ return new OmsPageResult { Items = [], Total = total };
+ }
+
+ var ids = rows.Select(x => x.ReferrerId).ToArray();
+ var customers = await CustomerRepository.GetListAsync(x => ids.Contains(x.Id));
+ var map = customers.ToDictionary(x => x.Id);
+
+ return new OmsPageResult
+ {
+ Items = rows.Select(row =>
+ {
+ map.TryGetValue(row.ReferrerId, out var customer);
+ return new ReferralReferrerOutput
+ {
+ CustomerId = row.ReferrerId,
+ CustomerCode = customer?.CustomerCode ?? string.Empty,
+ CustomerName = customer?.CustomerName ?? string.Empty,
+ Phone = customer?.Phone,
+ RefereeCount = row.RefereeCount,
+ LatestBindTime = row.LatestBindTime
+ };
+ }).ToList(),
+ Total = total
+ };
+ }
+
[HttpPost("/marketing/referral/tree")]
public async Task> GetTreeAsync(ReferralTreeInput input)
{
@@ -42,7 +80,7 @@ public class MarketingReferralAdminService : ApplicationService
var total = await CustomerRepository.CountAsync(predicate);
return new OmsPageResult
{
- Items = entities.Select(MapTreeNode).ToList(),
+ Items = entities.Select(x => MapTreeNode(x, referrer.CustomerCode)).ToList(),
Total = total
};
}
@@ -56,8 +94,7 @@ public class MarketingReferralAdminService : ApplicationService
throw new BusinessException("Reason must be at least 20 characters");
}
- var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId)
- ?? throw new BusinessException("Customer not found");
+ var customer = await ResolveCustomerAsync(input.CustomerId, input.CustomerCode);
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("Rebind main customer only");
@@ -98,22 +135,27 @@ public class MarketingReferralAdminService : ApplicationService
});
}
+ private async Task ResolveCustomerAsync(int? customerId, string? customerCode)
+ {
+ if (customerId is > 0)
+ {
+ return await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value)
+ ?? throw new BusinessException("Customer not found");
+ }
+
+ var code = customerCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new BusinessException("CustomerId or CustomerCode required");
+ }
+
+ return await CustomerRepository.GetByCustomerCodeAsync(code)
+ ?? throw new BusinessException("Customer not found");
+ }
+
private async Task ResolveReferrerAsync(ReferralTreeInput input)
{
- if (input.CustomerId is > 0)
- {
- return await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value)
- ?? throw new BusinessException("Customer not found");
- }
-
- var code = input.CustomerCode?.Trim();
- if (!string.IsNullOrWhiteSpace(code))
- {
- return await CustomerRepository.GetByCustomerCodeAsync(code)
- ?? throw new BusinessException("Customer not found");
- }
-
- throw new BusinessException("CustomerId or CustomerCode required");
+ return await ResolveCustomerAsync(input.CustomerId, input.CustomerCode);
}
private static Expression> BuildTreePredicate(int referrerCustomerId)
@@ -140,10 +182,28 @@ public class MarketingReferralAdminService : ApplicationService
return referrer.Id;
}
+ var referrerCode = input.NewReferrerCustomerCode?.Trim();
+ if (!string.IsNullOrWhiteSpace(referrerCode))
+ {
+ var referrer = await CustomerRepository.GetByCustomerCodeAsync(referrerCode)
+ ?? throw new BusinessException("New referrer not found");
+ if (referrer.Id == customerId)
+ {
+ throw new BusinessException("Self referral not allowed");
+ }
+
+ if (referrer.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("Referrer must be main customer");
+ }
+
+ return referrer.Id;
+ }
+
var code = input.NewInviteCode?.Trim();
if (string.IsNullOrWhiteSpace(code))
{
- throw new BusinessException("NewReferrerCustomerId or NewInviteCode required");
+ throw new BusinessException("NewReferrerCustomerId, NewReferrerCustomerCode or NewInviteCode required");
}
var referral = await ReferralCodeRepository.GetByInviteCodeAsync(code)
@@ -161,12 +221,14 @@ public class MarketingReferralAdminService : ApplicationService
return referral.CustomerId;
}
- private static ReferralTreeNodeOutput MapTreeNode(Customer entity) => new()
+ private static ReferralTreeNodeOutput MapTreeNode(Customer entity, string? referrerCustomerCode) => new()
{
CustomerId = entity.Id,
CustomerCode = entity.CustomerCode,
CustomerName = entity.CustomerName,
Phone = entity.Phone,
+ ReferrerCustomerId = entity.ReferrerCustomerId,
+ ReferrerCustomerCode = referrerCustomerCode,
CreateTime = entity.CreateTime,
ReferralBindTime = entity.ReferralBindTime,
ReferralChannel = entity.ReferralChannel
diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs
index 6d1ed8d..7292f30 100644
--- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs
+++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs
@@ -27,4 +27,14 @@ public interface ICustomerRepository : IRepository
Task> ListCustomerCodesByPrefixAsync(
string prefix,
CancellationToken cancellationToken = default);
+
+ ///
+ /// 分页查询「有一级下级」的引荐人摘要(RefereeCount)。
+ ///
+ Task<(IReadOnlyList<(int ReferrerId, int RefereeCount, DateTime? LatestBindTime)> Items, int Total)>
+ GetReferrerSummariesPagedAsync(
+ string? keyword,
+ int pageIndex,
+ int pageSize,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs
index 775e299..22ed7bc 100644
--- a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs
+++ b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs
@@ -78,4 +78,50 @@ public class CustomerRepository : OmsSqlSugarRepository, ICustomerRepo
.Select(x => x.CustomerCode)
.ToListAsync(cancellationToken);
}
+
+ public async Task<(IReadOnlyList<(int ReferrerId, int RefereeCount, DateTime? LatestBindTime)> Items, int Total)>
+ GetReferrerSummariesPagedAsync(
+ string? keyword,
+ int pageIndex,
+ int pageSize,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var kw = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
+
+ var query = db.Queryable()
+ .Where(x => x.ReferrerCustomerId != null && x.ParentCustomerId == null)
+ .GroupBy(x => x.ReferrerCustomerId)
+ .Select(x => new
+ {
+ ReferrerId = x.ReferrerCustomerId!.Value,
+ RefereeCount = SqlSugar.SqlFunc.AggregateCount(x.Id),
+ LatestBindTime = SqlSugar.SqlFunc.AggregateMax(x.ReferralBindTime)
+ })
+ .MergeTable()
+ .InnerJoin((g, c) => g.ReferrerId == c.Id)
+ .Where((g, c) => c.ParentCustomerId == null);
+
+ if (kw != null)
+ {
+ query = query.Where((g, c) =>
+ c.CustomerCode.Contains(kw) ||
+ c.CustomerName.Contains(kw) ||
+ c.Phone.Contains(kw));
+ }
+
+ var total = await query.CountAsync(cancellationToken);
+ var rows = await query
+ .OrderByDescending((g, c) => g.LatestBindTime)
+ .OrderByDescending((g, c) => g.RefereeCount)
+ .Select((g, c) => new
+ {
+ g.ReferrerId,
+ g.RefereeCount,
+ g.LatestBindTime
+ })
+ .ToPageListAsync(pageIndex, pageSize, cancellationToken);
+
+ return (rows.Select(x => (x.ReferrerId, x.RefereeCount, x.LatestBindTime)).ToList(), total);
+ }
}