From 8f043599ecae42d63bce99c028af088d07cc3abd Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 24 Jul 2026 10:44:26 +0800 Subject: [PATCH] feat: enrich marketing admin APIs and add seed for customer 9/8 Add referrer-list aggregation, customer-code filters, and idempotent marketing seed data with customer 9 as referrer and 8 as referee. Co-authored-by: Cursor --- sql/seed_marketing_customer9.sql | 621 ++++++++++++++++++ .../Dtos/Marketing/CommissionDtos.cs | 443 +++++++------ .../Dtos/Marketing/CouponDtos.cs | 5 + .../Dtos/Marketing/PointsRuleDtos.cs | 193 +++--- .../Dtos/Marketing/ReferralDtos.cs | 213 +++--- .../MarketingCommissionAdminService.cs | 48 +- .../Marketing/MarketingCouponGrantService.cs | 21 +- .../Marketing/MarketingPointsRuleService.cs | 46 +- .../MarketingReferralAdminService.cs | 100 ++- .../Repositories/ICustomerRepository.cs | 10 + .../Customers/CustomerRepository.cs | 46 ++ 11 files changed, 1317 insertions(+), 429 deletions(-) create mode 100644 sql/seed_marketing_customer9.sql 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); + } }