From 8a4848644f7dd60f4a7b049b7e9d0e6a9b82c0cc Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 29 Oct 2026 16:04:02 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20KPI=E5=88=86=E5=8C=BA=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=90=8E=E7=AB=AF=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EOF Co-authored-by: Cursor --- .../Dtos/Channels/KpiAreaInfoListInput.cs | 35 ++++++++ .../Services/Channels/KpiAreaService.cs | 17 +++- .../Repositories/IKpiAreaRepository.cs | 26 +++++- .../Channels/KpiAreaRepository.cs | 81 +++++++++++++++++-- 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs index 120c115..6f9152c 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs @@ -19,4 +19,39 @@ public class KpiAreaInfoListInput /// 每页条数。 /// public int PageSize { get; set; } + + /// + /// 国家/地区(匹配国家名称或国家代码,模糊)。 + /// + public string? Country { get; set; } + + /// + /// 匹配维度(如 zip/state/city/country,模糊)。 + /// + public string? MatchDim { get; set; } + + /// + /// 匹配方式(如 prefix/exact/range/all,模糊)。 + /// + public string? MatchMode { get; set; } + + /// + /// 州/省(模糊)。 + /// + public string? Province { get; set; } + + /// + /// 城市(模糊)。 + /// + public string? City { get; set; } + + /// + /// 起始邮编(模糊)。 + /// + public string? LeftPostcode { get; set; } + + /// + /// 截止邮编(模糊)。 + /// + public string? RightPostcode { get; set; } } diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 95f6061..6ec6e34 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -106,8 +106,9 @@ public class KpiAreaService : ApplicationService await GetAreaOrThrowAsync(input.AreaId); var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); - var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize); - var total = await KpiAreaRepository.CountInfosAsync(input.AreaId); + var filter = BuildInfoQueryFilter(input); + var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize, filter); + var total = await KpiAreaRepository.CountInfosAsync(input.AreaId, filter); return new OmsPageResult { Items = entities.Select(MapInfoOutput).ToList(), @@ -115,6 +116,18 @@ public class KpiAreaService : ApplicationService }; } + private static KpiAreaInfoQueryFilter BuildInfoQueryFilter(KpiAreaInfoListInput input) => + new() + { + Country = input.Country, + MatchDim = input.MatchDim, + MatchMode = input.MatchMode, + Province = input.Province, + City = input.City, + LeftPostcode = input.LeftPostcode, + RightPostcode = input.RightPostcode + }; + /// /// 新增KPI分区明细。 /// diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs index 6dce96b..183980d 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs @@ -3,15 +3,37 @@ using Oms.Domain.ChannelsAggregate.Entities; namespace Oms.Domain.ChannelsAggregate.Repositories; +/// +/// KPI 分区明细列表筛选条件(字段均为可选,模糊匹配)。 +/// +public class KpiAreaInfoQueryFilter +{ + public string? Country { get; set; } + public string? MatchDim { get; set; } + public string? MatchMode { get; set; } + public string? Province { get; set; } + public string? City { get; set; } + public string? LeftPostcode { get; set; } + public string? RightPostcode { get; set; } +} + public interface IKpiAreaRepository : IRepository, IKpiAreaPublishRepository { Task GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default); Task ExistsNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default); - Task> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default); + Task> GetInfosAsync( + int areaId, + int pageIndex, + int pageSize, + KpiAreaInfoQueryFilter? filter = null, + CancellationToken cancellationToken = default); - Task CountInfosAsync(int areaId, CancellationToken cancellationToken = default); + Task CountInfosAsync( + int areaId, + KpiAreaInfoQueryFilter? filter = null, + CancellationToken cancellationToken = default); Task InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default); diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index e50b197..057a9dd 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -18,20 +18,91 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit return AnyAsync(x => x.AppKey == appKey && x.AreaType == areaType && x.Name == name, cancellationToken); } - public async Task> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default) + public async Task> GetInfosAsync( + int areaId, + int pageIndex, + int pageSize, + KpiAreaInfoQueryFilter? filter = null, + CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); // 依赖 idx_kpi_info_area_id (AreaId, Id),避免大分区 filesort - return await db.Queryable() - .Where(x => x.AreaId == areaId) + return await BuildInfoQueryable(db, areaId, filter) .OrderBy(x => x.Id, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize, cancellationToken); } - public async Task CountInfosAsync(int areaId, CancellationToken cancellationToken = default) + public async Task CountInfosAsync( + int areaId, + KpiAreaInfoQueryFilter? filter = null, + CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - return await db.Queryable().Where(x => x.AreaId == areaId).CountAsync(cancellationToken); + return await BuildInfoQueryable(db, areaId, filter).CountAsync(cancellationToken); + } + + private static ISugarQueryable BuildInfoQueryable( + ISqlSugarClient db, + int areaId, + KpiAreaInfoQueryFilter? filter) + { + var query = db.Queryable().Where(x => x.AreaId == areaId); + if (filter == null) + { + return query; + } + + var country = TrimOrNull(filter.Country); + if (country != null) + { + query = query.Where(x => + (x.CountryName != null && x.CountryName.Contains(country)) + || (x.CountryCode != null && x.CountryCode.Contains(country))); + } + + var matchDim = TrimOrNull(filter.MatchDim); + if (matchDim != null) + { + query = query.Where(x => x.MatchDim != null && x.MatchDim.Contains(matchDim)); + } + + var matchMode = TrimOrNull(filter.MatchMode); + if (matchMode != null) + { + query = query.Where(x => x.MatchMode != null && x.MatchMode.Contains(matchMode)); + } + + var province = TrimOrNull(filter.Province); + if (province != null) + { + query = query.Where(x => x.Province != null && x.Province.Contains(province)); + } + + var city = TrimOrNull(filter.City); + if (city != null) + { + query = query.Where(x => x.City != null && x.City.Contains(city)); + } + + var leftPostcode = TrimOrNull(filter.LeftPostcode); + if (leftPostcode != null) + { + query = query.Where(x => x.LeftPostcode != null && x.LeftPostcode.Contains(leftPostcode)); + } + + var rightPostcode = TrimOrNull(filter.RightPostcode); + if (rightPostcode != null) + { + query = query.Where(x => x.RightPostcode != null && x.RightPostcode.Contains(rightPostcode)); + } + + return query; + } + + private static string? TrimOrNull(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; } public async Task InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default) -- 2.45.2 From 221a2c8a662e429bc1610fcf6f699b3f2a18b20c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 29 Oct 2026 16:37:33 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20KPI=E5=88=86=E5=8C=BA=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81=E8=B5=B7=E5=A7=8B?= =?UTF-8?q?/=E6=88=AA=E6=AD=A2=E9=82=AE=E7=BC=96=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Channels/KpiAreaInfoListInput.cs | 10 +++++ .../Services/Channels/KpiAreaService.cs | 37 ++++++++++++++++++- .../Repositories/IKpiAreaRepository.cs | 6 +++ .../Channels/KpiAreaRepository.cs | 25 +++++++++++-- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs index 6f9152c..b073063 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs @@ -54,4 +54,14 @@ public class KpiAreaInfoListInput /// 截止邮编(模糊)。 /// public string? RightPostcode { get; set; } + + /// + /// 排序字段:leftPostcode / rightPostcode;空则按 Id 降序。 + /// + public string? SortField { get; set; } + + /// + /// 排序方向:asc / desc。 + /// + public string? SortOrder { get; set; } } diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 6ec6e34..b7eb21b 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -116,6 +116,12 @@ public class KpiAreaService : ApplicationService }; } + private static readonly HashSet InfoSortFieldWhitelist = new(StringComparer.OrdinalIgnoreCase) + { + "leftPostcode", + "rightPostcode", + }; + private static KpiAreaInfoQueryFilter BuildInfoQueryFilter(KpiAreaInfoListInput input) => new() { @@ -125,9 +131,38 @@ public class KpiAreaService : ApplicationService Province = input.Province, City = input.City, LeftPostcode = input.LeftPostcode, - RightPostcode = input.RightPostcode + RightPostcode = input.RightPostcode, + SortField = NormalizeInfoSortField(input.SortField), + SortOrder = NormalizeInfoSortOrder(input.SortOrder), }; + private static string? NormalizeInfoSortField(string? sortField) + { + var field = sortField?.Trim(); + if (string.IsNullOrWhiteSpace(field) || !InfoSortFieldWhitelist.Contains(field)) + { + return null; + } + + return field; + } + + private static string? NormalizeInfoSortOrder(string? sortOrder) + { + var order = sortOrder?.Trim(); + if (string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase)) + { + return "asc"; + } + + if (string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase)) + { + return "desc"; + } + + return null; + } + /// /// 新增KPI分区明细。 /// diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs index 183980d..aa5de53 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs @@ -15,6 +15,12 @@ public class KpiAreaInfoQueryFilter public string? City { get; set; } public string? LeftPostcode { get; set; } public string? RightPostcode { get; set; } + + /// 排序字段:leftPostcode / rightPostcode。 + public string? SortField { get; set; } + + /// 排序方向:asc / desc。 + public string? SortOrder { get; set; } } public interface IKpiAreaRepository : IRepository, IKpiAreaPublishRepository diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs index 057a9dd..5466625 100644 --- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs @@ -26,9 +26,8 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - // 依赖 idx_kpi_info_area_id (AreaId, Id),避免大分区 filesort - return await BuildInfoQueryable(db, areaId, filter) - .OrderBy(x => x.Id, OrderByType.Desc) + // 默认按 Id 降序,可走 idx_kpi_info_area_id;邮编排序可能 filesort,分区过大时注意性能 + return await ApplyInfoOrder(BuildInfoQueryable(db, areaId, filter), filter) .ToPageListAsync(pageIndex, pageSize, cancellationToken); } @@ -99,6 +98,26 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit return query; } + private static ISugarQueryable ApplyInfoOrder( + ISugarQueryable query, + KpiAreaInfoQueryFilter? filter) + { + var field = filter?.SortField?.Trim().ToLowerInvariant(); + var asc = string.Equals(filter?.SortOrder?.Trim(), "asc", StringComparison.OrdinalIgnoreCase); + var order = asc ? OrderByType.Asc : OrderByType.Desc; + + return field switch + { + "leftpostcode" => query + .OrderBy(x => x.LeftPostcode, order) + .OrderBy(x => x.Id, OrderByType.Desc), + "rightpostcode" => query + .OrderBy(x => x.RightPostcode, order) + .OrderBy(x => x.Id, OrderByType.Desc), + _ => query.OrderBy(x => x.Id, OrderByType.Desc), + }; + } + private static string? TrimOrNull(string? value) { var trimmed = value?.Trim(); -- 2.45.2 From 1d987cde6e8a28d290de229118bbc7934a8e6468 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 29 Oct 2026 17:18:07 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E4=BB=A3=E7=90=86=E6=B3=A8=E5=86=8C=E5=BD=92=E5=9B=A0?= =?UTF-8?q?=E4=B8=8E=E8=BF=94=E4=BD=A3=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立 Agent 主体与 loginType=2,注册绑定/下单快照,派送成功失败计提明细,不含提现入账。 Co-authored-by: Cursor --- sql/migrate_agent_module_20261029.sql | 78 ++++ src/Oms.Application/Dtos/Agents/AgentDtos.cs | 234 ++++++++++ .../Dtos/Customers/CreateCustomerInput.cs | 6 + .../TrackChangeOrderStatusApplier.cs | 12 + .../Services/Agents/AgentAdminService.cs | 414 ++++++++++++++++++ .../Services/Agents/AgentPortalService.cs | 258 +++++++++++ .../Services/Customers/CustomerService.cs | 23 +- .../Orders/OrderService.ParcelBilling.cs | 5 + .../Services/Orders/OrderService.cs | 13 + src/Oms.Application/Shared/AgentBinder.cs | 40 ++ .../Shared/AgentCommissionManager.cs | 203 +++++++++ .../Consts/AgentCommissionBaseTypes.cs | 12 + .../Consts/AgentCommissionStatusCodes.cs | 8 + .../AgentsAggregate/Entities/Agent.cs | 45 ++ .../Entities/AgentCommissionDetail.cs | 38 ++ .../Repositories/IAgentRepositories.cs | 18 + .../CustomersAggregate/Entities/Customer.cs | 5 + .../OrdersAggregate/Entities/Order.cs | 3 + .../Agents/AgentRepositories.cs | 37 ++ .../Auth/AgentRoleSeedContributor.cs | 32 ++ src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs | 3 +- src/SimpleAdmin.Api/Auth/AuthService.cs | 62 +++ src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs | 5 + .../WeChat/WeChatCustomerService.cs | 26 +- 24 files changed, 1562 insertions(+), 18 deletions(-) create mode 100644 sql/migrate_agent_module_20261029.sql create mode 100644 src/Oms.Application/Dtos/Agents/AgentDtos.cs create mode 100644 src/Oms.Application/Services/Agents/AgentAdminService.cs create mode 100644 src/Oms.Application/Services/Agents/AgentPortalService.cs create mode 100644 src/Oms.Application/Shared/AgentBinder.cs create mode 100644 src/Oms.Application/Shared/AgentCommissionManager.cs create mode 100644 src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs create mode 100644 src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs create mode 100644 src/Oms.Domain/AgentsAggregate/Entities/Agent.cs create mode 100644 src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs create mode 100644 src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs create mode 100644 src/Oms.SqlSugarCore/Agents/AgentRepositories.cs create mode 100644 src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs diff --git a/sql/migrate_agent_module_20261029.sql b/sql/migrate_agent_module_20261029.sql new file mode 100644 index 0000000..78a5ba3 --- /dev/null +++ b/sql/migrate_agent_module_20261029.sql @@ -0,0 +1,78 @@ +-- 渠道代理与返佣(首版轻量)。可重复执行。 +-- 含:t_agent、t_agent_commission_detail;t_customer / t_order 扩展 AgentId。 + +SET @db := DATABASE(); + +-- ---------- t_agent ---------- +CREATE TABLE IF NOT EXISTS `t_agent` ( + `Id` INT NOT NULL AUTO_INCREMENT, + `UserId` INT NOT NULL COMMENT 'RBAC user.userid / JWT mapId', + `AgentCode` VARCHAR(32) NOT NULL COMMENT '登录账号+注册链接编码', + `AgentName` VARCHAR(64) NOT NULL COMMENT '显示名', + `Phone` VARCHAR(32) NULL, + `Email` VARCHAR(128) NULL, + `Password` VARCHAR(64) NOT NULL COMMENT 'MD5 密码', + `Status` TINYINT NOT NULL DEFAULT 2 COMMENT '1停用 2启用', + `BaseType` VARCHAR(32) NOT NULL DEFAULT 'freight' COMMENT 'freight/receivable', + `CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返佣比例', + `SalesProductIds` VARCHAR(512) NULL COMMENT '可选销售产品Id列表 JSON/[1,2] 或逗号;空=不限', + `Remark` VARCHAR(512) NULL, + `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0, + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NOT NULL DEFAULT 0, + `UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`Id`), + KEY `ix_agent_code` (`AgentCode`), + KEY `ix_agent_userid` (`UserId`) +) COMMENT='渠道代理主数据'; + +-- ---------- t_agent_commission_detail ---------- +CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` ( + `Id` INT NOT NULL AUTO_INCREMENT, + `AgentId` INT NOT NULL, + `OrderId` INT NOT NULL, + `CustomerId` INT NOT NULL, + `SalesProductId` INT NULL, + `BaseType` VARCHAR(32) NOT NULL, + `BaseAmount` DECIMAL(18,4) NOT NULL DEFAULT 0, + `CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0, + `CommissionAmount` DECIMAL(18,4) NOT NULL DEFAULT 0, + `Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid', + `TriggerOrderStatus` VARCHAR(32) NOT NULL, + `PaidTime` DATETIME NULL, + `Remark` VARCHAR(512) NULL, + `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0, + `CreateUserId` INT NOT NULL DEFAULT 0, + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UpdateUserId` INT NOT NULL DEFAULT 0, + `UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`Id`), + KEY `ix_agent_comm_agent` (`AgentId`), + KEY `ix_agent_comm_order` (`OrderId`), + KEY `ix_agent_comm_status` (`Status`) +) COMMENT='代理返佣明细(首版无余额入账)'; + +-- ---------- t_customer.AgentId ---------- +SET @exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_customer' AND COLUMN_NAME = 'AgentId' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_customer` ADD COLUMN `AgentId` INT NULL COMMENT ''所属渠道代理'' AFTER `CommissionBalance`, ADD COLUMN `AgentBindTime` DATETIME NULL COMMENT ''代理绑定时间'' AFTER `AgentId`, ADD KEY `ix_customer_agent` (`AgentId`)', + 'SELECT ''t_customer.AgentId already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------- t_order.AgentId ---------- +SET @exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'AgentId' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `AgentId` INT NULL COMMENT ''下单时代理快照'' AFTER `CustomerId`, ADD KEY `ix_order_agent` (`AgentId`)', + 'SELECT ''t_order.AgentId already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs new file mode 100644 index 0000000..8e849e3 --- /dev/null +++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs @@ -0,0 +1,234 @@ +using System.ComponentModel.DataAnnotations; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Dtos.Agents; + +public class AgentListInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + public string? Keyword { get; set; } + + public EnableStatus? Status { get; set; } +} + +public class AgentOutput +{ + public int Id { get; set; } + + public string AgentCode { get; set; } = string.Empty; + + public string AgentName { get; set; } = string.Empty; + + public string? Phone { get; set; } + + public string? Email { get; set; } + + public EnableStatus Status { get; set; } + + public string BaseType { get; set; } = string.Empty; + + public decimal CommissionRate { get; set; } + + public string? SalesProductIds { get; set; } + + public string? Remark { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class AgentCreateInput +{ + [Required] + [StringLength(32)] + public string AgentCode { get; set; } = string.Empty; + + [Required] + [StringLength(64)] + public string AgentName { get; set; } = string.Empty; + + [StringLength(32)] + public string? Phone { get; set; } + + [StringLength(128)] + public string? Email { get; set; } + + [Required] + [MinLength(6)] + public string Password { get; set; } = string.Empty; + + /// freight / receivable。 + [Required] + [StringLength(32)] + public string BaseType { get; set; } = "freight"; + + [Range(0, 1)] + public decimal CommissionRate { get; set; } + + /// 可选销售产品 Id,JSON 或逗号分隔;空=不限。 + [StringLength(512)] + public string? SalesProductIds { get; set; } + + [StringLength(512)] + public string? Remark { get; set; } +} + +public class AgentUpdateInput +{ + [Required] + public int Id { get; set; } + + [Required] + [StringLength(64)] + public string AgentName { get; set; } = string.Empty; + + [StringLength(32)] + public string? Phone { get; set; } + + [StringLength(128)] + public string? Email { get; set; } + + /// 不传则不改密码。 + [MinLength(6)] + public string? Password { get; set; } + + public EnableStatus Status { get; set; } = EnableStatus.Enabled; + + [Required] + [StringLength(32)] + public string BaseType { get; set; } = "freight"; + + [Range(0, 1)] + public decimal CommissionRate { get; set; } + + [StringLength(512)] + public string? SalesProductIds { get; set; } + + [StringLength(512)] + public string? Remark { get; set; } +} + +public class AgentRebindCustomerInput +{ + [Required] + public int CustomerId { get; set; } + + /// 空则解绑。 + public int? AgentId { get; set; } +} + +public class AgentCommissionListInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + public int? AgentId { get; set; } + + public int? OrderId { get; set; } + + public string? Status { get; set; } +} + +public class AgentCommissionDetailOutput +{ + public int Id { get; set; } + + public int AgentId { get; set; } + + public string? AgentCode { get; set; } + + public string? AgentName { get; set; } + + public int OrderId { get; set; } + + public string? ServiceNo { get; set; } + + public int CustomerId { get; set; } + + public string? CustomerCode { get; set; } + + public string? CustomerName { get; set; } + + public int? SalesProductId { get; set; } + + public string BaseType { get; set; } = string.Empty; + + public decimal BaseAmount { get; set; } + + public decimal CommissionRate { get; set; } + + public decimal CommissionAmount { get; set; } + + public string Status { get; set; } = string.Empty; + + public string TriggerOrderStatus { get; set; } = string.Empty; + + public DateTime? PaidTime { get; set; } + + public string? Remark { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class AgentCommissionMarkPaidInput +{ + [Required] + public List Ids { get; set; } = []; + + [StringLength(512)] + public string? Remark { get; set; } +} + +public class AgentPortalCustomerOutput +{ + public int Id { get; set; } + + public string CustomerCode { get; set; } = string.Empty; + + public string CustomerName { get; set; } = string.Empty; + + public string? Phone { get; set; } + + public DateTime? AgentBindTime { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class AgentPortalOrderOutput +{ + public int Id { get; set; } + + public string ServiceNo { get; set; } = string.Empty; + + public string? TrackNo { get; set; } + + public int? CustomerId { get; set; } + + public string? CustomerCode { get; set; } + + public string? CustomerName { get; set; } + + public string? ProductCode { get; set; } + + public string? OrderStatus { get; set; } + + public string? ConsigneeCountryName { get; set; } + + public decimal? ReceivableSubtotal { get; set; } + + public decimal? PayableAmount { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class AgentPortalPageInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + public string? Keyword { get; set; } +} diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs index 1c1ac13..a1c3d94 100644 --- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs @@ -124,4 +124,10 @@ public class CreateCustomerInput [StringLength(64)] public string? AdvisorCode { get; set; } + /// + /// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。 + /// + [StringLength(32)] + public string? AgentCode { get; set; } + } diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs index e82a28c..4a31055 100644 --- a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs @@ -16,6 +16,7 @@ public sealed class TrackChangeOrderStatusApplier( IOrderRepository orderRepository, OrderOperationLogWriter operationLogWriter, ReferralCommissionManager referralCommissionManager, + AgentCommissionManager agentCommissionManager, MarketingCouponManager marketingCouponManager, IUnitOfWorkManager unitOfWorkManager, ILogger logger) : ITrackChangeOrderStatusApplier, IScopedDependency @@ -132,6 +133,17 @@ public sealed class TrackChangeOrderStatusApplier( // 重新加载,避免营销侧依赖最新字段 order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken); + await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( + order.Id, + OrderStatusCodes.DeliverySuccess, + cancellationToken); + } + else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase)) + { + await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( + order.Id, + OrderStatusCodes.DeliveryFailed, + cancellationToken); } order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs new file mode 100644 index 0000000..a92323f --- /dev/null +++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs @@ -0,0 +1,414 @@ +using System.Linq.Expressions; +using Fake.Application; +using Fake.Domain.Exceptions; +using Fake.Rbac.Application.Dtos.Role; +using Fake.Rbac.Application.Dtos.User; +using Fake.Rbac.Application.Services; +using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Oms.Application.Dtos.Agents; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.AgentsAggregate.Consts; +using Oms.Domain.AgentsAggregate.Entities; +using Oms.Domain.AgentsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Services.Agents; + +/// +/// 渠道代理管理端 API(开户、规则、改绑、返佣明细)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Agent")] +public class AgentAdminService : ApplicationService +{ + public const string AgentRoleCode = "Agent"; + + private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService(); + + private IAgentCommissionDetailRepository DetailRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); + + private UserService UserService => LazyServiceProvider.GetRequiredService(); + + private RoleService RoleService => LazyServiceProvider.GetRequiredService(); + + private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService(); + + /// 分页查询代理。 + [HttpPost("/agent/admin/list")] + public async Task> GetListAsync(AgentListInput input) + { + var predicate = BuildAgentListPredicate(input); + var page = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await AgentRepository.GetPagedListAsync( + predicate, + page, + pageSize, + new Dictionary { [nameof(Agent.CreateTime)] = false, [nameof(Agent.Id)] = false }); + var total = await AgentRepository.CountAsync(predicate); + return new OmsPageResult + { + Items = entities.Select(MapAgent).ToList(), + Total = total + }; + } + + /// 创建代理(同步 RBAC 用户 + Agent 角色)。 + [HttpPost("/agent/admin/create")] + public async Task CreateAsync(AgentCreateInput input) + { + var code = input.AgentCode.Trim(); + if (await AgentRepository.ExistsAgentCodeAsync(code)) + { + throw new BusinessException("代理编号已存在"); + } + + EnsureBaseType(input.BaseType); + + var roleId = await GetAgentRoleIdOrThrowAsync(); + var user = await UserService.CreateAsync(new UserCreateDto + { + Name = input.AgentName.Trim(), + Account = code, + Password = input.Password, + Email = string.IsNullOrWhiteSpace(input.Email) + ? $"{code}@agent.local" + : input.Email.Trim(), + RoleIds = [] + }); + + try + { + await UserService.AssignRolesAsync(user.Id, [roleId]); + var userId = await GetRbacUserMapIdOrThrowAsync(user.Id); + await AgentRepository.InsertAsync(new Agent + { + UserId = userId, + AgentCode = code, + AgentName = input.AgentName.Trim(), + Phone = NormalizeOptional(input.Phone), + Email = NormalizeOptional(input.Email), + Password = OmsPasswordHelper.ToMd5(input.Password), + Status = EnableStatus.Enabled, + BaseType = input.BaseType.Trim().ToLowerInvariant(), + CommissionRate = input.CommissionRate, + SalesProductIds = NormalizeOptional(input.SalesProductIds), + Remark = NormalizeOptional(input.Remark) + }); + } + catch + { + try + { + await UserService.DeleteAsync(user.Id); + } + catch + { + // ignore rollback failure + } + + throw; + } + } + + /// 更新代理资料与返佣规则。 + [HttpPost("/agent/admin/update")] + public async Task UpdateAsync(AgentUpdateInput input) + { + var entity = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.Id) + ?? throw new BusinessException("代理不存在"); + EnsureBaseType(input.BaseType); + + entity.AgentName = input.AgentName.Trim(); + entity.Phone = NormalizeOptional(input.Phone); + entity.Email = NormalizeOptional(input.Email); + entity.Status = input.Status == EnableStatus.None ? EnableStatus.Enabled : input.Status; + entity.BaseType = input.BaseType.Trim().ToLowerInvariant(); + entity.CommissionRate = input.CommissionRate; + entity.SalesProductIds = NormalizeOptional(input.SalesProductIds); + entity.Remark = NormalizeOptional(input.Remark); + if (!string.IsNullOrWhiteSpace(input.Password)) + { + entity.Password = OmsPasswordHelper.ToMd5(input.Password); + } + + await AgentRepository.UpdateAsync(entity); + } + + /// 改绑主客户所属代理(仅影响之后新单)。 + [HttpPost("/agent/admin/rebind")] + public async Task RebindCustomerAsync(AgentRebindCustomerInput input) + { + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId) + ?? throw new BusinessException("客户不存在"); + if (customer.ParentCustomerId.HasValue) + { + throw new BusinessException("请改绑主客户"); + } + + if (input.AgentId.HasValue && input.AgentId.Value > 0) + { + var agent = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.AgentId.Value) + ?? throw new BusinessException("代理不存在"); + if (agent.Status != EnableStatus.Enabled) + { + throw new BusinessException("代理已停用"); + } + + customer.AgentId = agent.Id; + customer.AgentBindTime = DateTime.Now; + } + else + { + customer.AgentId = null; + customer.AgentBindTime = null; + } + + await CustomerRepository.UpdateAsync(customer); + } + + /// 分页查询返佣明细。 + [HttpPost("/agent/admin/commission/list")] + public async Task> GetCommissionListAsync(AgentCommissionListInput input) + { + var predicate = BuildCommissionPredicate(input); + + var page = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await DetailRepository.GetPagedListAsync( + predicate, + page, + pageSize, + new Dictionary + { + [nameof(AgentCommissionDetail.CreateTime)] = false, + [nameof(AgentCommissionDetail.Id)] = false + }); + var total = await DetailRepository.CountAsync(predicate); + return new OmsPageResult + { + Items = await MapCommissionOutputsAsync(entities), + Total = total + }; + } + + /// 标记返佣明细为已打款。 + [HttpPost("/agent/admin/commission/mark-paid")] + public async Task MarkCommissionPaidAsync(AgentCommissionMarkPaidInput input) + { + if (input.Ids == null || input.Ids.Count == 0) + { + throw new BusinessException("请选择明细"); + } + + var ids = input.Ids.Distinct().ToList(); + var list = await DetailRepository.GetListAsync(x => ids.Contains(x.Id)); + var now = DateTime.Now; + var remark = NormalizeOptional(input.Remark); + foreach (var item in list) + { + if (string.Equals(item.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + item.Status = AgentCommissionStatusCodes.Paid; + item.PaidTime = now; + if (!string.IsNullOrWhiteSpace(remark)) + { + item.Remark = remark; + } + + await DetailRepository.UpdateAsync(item); + } + } + + private static Expression> BuildAgentListPredicate(AgentListInput input) + { + var keyword = input.Keyword?.Trim(); + var hasStatus = input.Status.HasValue && input.Status.Value != EnableStatus.None; + if (hasStatus && !string.IsNullOrWhiteSpace(keyword)) + { + var status = input.Status!.Value; + return x => x.Status == status + && (x.AgentCode.Contains(keyword!) + || x.AgentName.Contains(keyword!) + || (x.Phone != null && x.Phone.Contains(keyword!))); + } + + if (hasStatus) + { + var status = input.Status!.Value; + return x => x.Status == status; + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + return x => x.AgentCode.Contains(keyword!) + || x.AgentName.Contains(keyword!) + || (x.Phone != null && x.Phone.Contains(keyword!)); + } + + return x => true; + } + + private static Expression> BuildCommissionPredicate(AgentCommissionListInput input) + { + var status = input.Status?.Trim(); + if (input.AgentId is > 0 && input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status)) + { + var agentId = input.AgentId.Value; + var orderId = input.OrderId.Value; + return x => x.AgentId == agentId && x.OrderId == orderId && x.Status == status; + } + + if (input.AgentId is > 0 && input.OrderId is > 0) + { + var agentId = input.AgentId.Value; + var orderId = input.OrderId.Value; + return x => x.AgentId == agentId && x.OrderId == orderId; + } + + if (input.AgentId is > 0 && !string.IsNullOrWhiteSpace(status)) + { + var agentId = input.AgentId.Value; + return x => x.AgentId == agentId && x.Status == status; + } + + if (input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status)) + { + var orderId = input.OrderId.Value; + return x => x.OrderId == orderId && x.Status == status; + } + + if (input.AgentId is > 0) + { + var agentId = input.AgentId.Value; + return x => x.AgentId == agentId; + } + + if (input.OrderId is > 0) + { + var orderId = input.OrderId.Value; + return x => x.OrderId == orderId; + } + + if (!string.IsNullOrWhiteSpace(status)) + { + return x => x.Status == status; + } + + return x => true; + } + + private async Task> MapCommissionOutputsAsync( + List entities) + { + if (entities.Count == 0) + { + return []; + } + + var agentIds = entities.Select(x => x.AgentId).Distinct().ToList(); + var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList(); + var orderIds = entities.Select(x => x.OrderId).Distinct().ToList(); + + var agents = await AgentRepository.GetListAsync(x => agentIds.Contains(x.Id)); + var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); + var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + + var agentMap = agents.ToDictionary(x => x.Id); + var customerMap = customers.ToDictionary(x => x.Id); + var orderMap = orders.ToDictionary(x => x.Id); + + return entities.Select(x => + { + agentMap.TryGetValue(x.AgentId, out var agent); + customerMap.TryGetValue(x.CustomerId, out var customer); + orderMap.TryGetValue(x.OrderId, out var order); + return new AgentCommissionDetailOutput + { + Id = x.Id, + AgentId = x.AgentId, + AgentCode = agent?.AgentCode, + AgentName = agent?.AgentName, + OrderId = x.OrderId, + ServiceNo = order?.ServiceNo, + CustomerId = x.CustomerId, + CustomerCode = customer?.CustomerCode, + CustomerName = customer?.CustomerName, + SalesProductId = x.SalesProductId, + BaseType = x.BaseType, + BaseAmount = x.BaseAmount, + CommissionRate = x.CommissionRate, + CommissionAmount = x.CommissionAmount, + Status = x.Status, + TriggerOrderStatus = x.TriggerOrderStatus, + PaidTime = x.PaidTime, + Remark = x.Remark, + CreateTime = x.CreateTime + }; + }).ToList(); + } + + private static AgentOutput MapAgent(Agent x) => new() + { + Id = x.Id, + AgentCode = x.AgentCode, + AgentName = x.AgentName, + Phone = x.Phone, + Email = x.Email, + Status = x.Status, + BaseType = x.BaseType, + CommissionRate = x.CommissionRate, + SalesProductIds = x.SalesProductIds, + Remark = x.Remark, + CreateTime = x.CreateTime + }; + + private static void EnsureBaseType(string? baseType) + { + if (!AgentCommissionBaseTypes.IsSupported(baseType)) + { + throw new BusinessException("基数类型仅支持 freight(运费)或 receivable(应收)"); + } + } + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private async Task GetAgentRoleIdOrThrowAsync() + { + var roles = await RoleService.GetAllRolesAsync(); + var role = roles.FirstOrDefault(x => + string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase)); + if (role == null) + { + throw new BusinessException("未找到 Agent 角色,请先执行种子或手动创建"); + } + + return role.Id; + } + + private async Task GetRbacUserMapIdOrThrowAsync(Guid userId) + { + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var mapId = await RbacDbContext.Database.SqlQueryRaw(sql, userId).FirstOrDefaultAsync(); + if (!mapId.HasValue || mapId.Value <= 0) + { + throw new BusinessException("RBAC 用户映射失败"); + } + + return mapId.Value; + } +} diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs new file mode 100644 index 0000000..ba05bf9 --- /dev/null +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -0,0 +1,258 @@ +using System.Linq.Expressions; +using Fake.Application; +using Fake.Domain.Exceptions; +using Fake.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Agents; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.AgentsAggregate.Entities; +using Oms.Domain.AgentsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.Agents; + +/// +/// 渠道代理门户只读 API(名下客户 / 订单 / 返佣)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Agent")] +public class AgentPortalService : ApplicationService +{ + private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService(); + + private IAgentCommissionDetailRepository DetailRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + + private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); + + /// 当前代理资料。 + [HttpGet("/agent/portal/profile")] + public async Task GetProfileAsync() + { + var agent = await GetCurrentAgentOrThrowAsync(); + return new AgentOutput + { + Id = agent.Id, + AgentCode = agent.AgentCode, + AgentName = agent.AgentName, + Phone = agent.Phone, + Email = agent.Email, + Status = agent.Status, + BaseType = agent.BaseType, + CommissionRate = agent.CommissionRate, + SalesProductIds = agent.SalesProductIds, + Remark = agent.Remark, + CreateTime = agent.CreateTime + }; + } + + /// 名下客户分页(含主客户 AgentId=我)。 + [HttpPost("/agent/portal/customers/list")] + public async Task> GetCustomersAsync(AgentPortalPageInput input) + { + var agent = await GetCurrentAgentOrThrowAsync(); + var keyword = input.Keyword?.Trim(); + Expression> predicate = string.IsNullOrWhiteSpace(keyword) + ? x => x.AgentId == agent.Id && x.ParentCustomerId == null + : x => x.AgentId == agent.Id + && x.ParentCustomerId == null + && (x.CustomerCode.Contains(keyword!) + || x.CustomerName.Contains(keyword!) + || (x.Phone != null && x.Phone.Contains(keyword!))); + + var page = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await CustomerRepository.GetPagedListAsync( + predicate, + page, + pageSize, + new Dictionary + { + [nameof(Customer.CreateTime)] = false, + [nameof(Customer.Id)] = false + }); + var total = await CustomerRepository.CountAsync(predicate); + return new OmsPageResult + { + Items = entities.Select(x => new AgentPortalCustomerOutput + { + Id = x.Id, + CustomerCode = x.CustomerCode, + CustomerName = MaskName(x.CustomerName) ?? string.Empty, + Phone = MaskPhone(x.Phone), + AgentBindTime = x.AgentBindTime, + CreateTime = x.CreateTime + }).ToList(), + Total = total + }; + } + + /// 名下订单分页(强制 Order.AgentId;不含应付)。 + [HttpPost("/agent/portal/orders/list")] + public async Task> GetOrdersAsync(AgentPortalPageInput input) + { + var agent = await GetCurrentAgentOrThrowAsync(); + var keyword = input.Keyword?.Trim(); + Expression> predicate = string.IsNullOrWhiteSpace(keyword) + ? x => x.AgentId == agent.Id + : x => x.AgentId == agent.Id + && (x.ServiceNo.Contains(keyword!) + || (x.TrackNo != null && x.TrackNo.Contains(keyword!)) + || (x.ProductCode != null && x.ProductCode.Contains(keyword!))); + + var page = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await OrderRepository.GetPagedListAsync( + predicate, + page, + pageSize, + new Dictionary + { + [nameof(Order.CreateTime)] = false, + [nameof(Order.Id)] = false + }); + var total = await OrderRepository.CountAsync(predicate); + + var customerIds = entities.Where(x => x.CustomerId.HasValue).Select(x => x.CustomerId!.Value).Distinct().ToList(); + var customers = customerIds.Count == 0 + ? [] + : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); + var customerMap = customers.ToDictionary(x => x.Id); + + return new OmsPageResult + { + Items = entities.Select(x => + { + customerMap.TryGetValue(x.CustomerId ?? 0, out var customer); + return new AgentPortalOrderOutput + { + Id = x.Id, + ServiceNo = x.ServiceNo, + TrackNo = x.TrackNo, + CustomerId = x.CustomerId, + CustomerCode = customer?.CustomerCode, + CustomerName = MaskName(customer?.CustomerName), + ProductCode = x.ProductCode, + OrderStatus = x.OrderStatus, + ConsigneeCountryName = x.ConsigneeCountryName, + ReceivableSubtotal = x.ReceivableSubtotal, + // 门户不返回应付:固定 null + PayableAmount = null, + CreateTime = x.CreateTime + }; + }).ToList(), + Total = total + }; + } + + /// 名下返佣明细。 + [HttpPost("/agent/portal/commission/list")] + public async Task> GetCommissionListAsync(AgentPortalPageInput input) + { + var agent = await GetCurrentAgentOrThrowAsync(); + Expression> predicate = x => x.AgentId == agent.Id; + var page = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await DetailRepository.GetPagedListAsync( + predicate, + page, + pageSize, + new Dictionary + { + [nameof(AgentCommissionDetail.CreateTime)] = false, + [nameof(AgentCommissionDetail.Id)] = false + }); + var total = await DetailRepository.CountAsync(predicate); + + var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList(); + var orderIds = entities.Select(x => x.OrderId).Distinct().ToList(); + var customers = customerIds.Count == 0 + ? [] + : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); + var orders = orderIds.Count == 0 + ? [] + : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + var customerMap = customers.ToDictionary(x => x.Id); + var orderMap = orders.ToDictionary(x => x.Id); + + return new OmsPageResult + { + Items = entities.Select(x => + { + customerMap.TryGetValue(x.CustomerId, out var customer); + orderMap.TryGetValue(x.OrderId, out var order); + return new AgentCommissionDetailOutput + { + Id = x.Id, + AgentId = x.AgentId, + AgentCode = agent.AgentCode, + AgentName = agent.AgentName, + OrderId = x.OrderId, + ServiceNo = order?.ServiceNo, + CustomerId = x.CustomerId, + CustomerCode = customer?.CustomerCode, + CustomerName = MaskName(customer?.CustomerName), + SalesProductId = x.SalesProductId, + BaseType = x.BaseType, + BaseAmount = x.BaseAmount, + CommissionRate = x.CommissionRate, + CommissionAmount = x.CommissionAmount, + Status = x.Status, + TriggerOrderStatus = x.TriggerOrderStatus, + PaidTime = x.PaidTime, + Remark = x.Remark, + CreateTime = x.CreateTime + }; + }).ToList(), + Total = total + }; + } + + private async Task GetCurrentAgentOrThrowAsync() + { + var mapId = CurrentUser.GetId(); + if (mapId <= 0) + { + throw new BusinessException("代理未登录"); + } + + var agent = await AgentRepository.GetByUserIdAsync(mapId); + if (agent == null) + { + throw new BusinessException("代理不存在"); + } + + if (agent.Status != Domain.Shared.Enums.EnableStatus.Enabled) + { + throw new BusinessException("代理已停用"); + } + + return agent; + } + + private static string? MaskPhone(string? phone) + { + if (string.IsNullOrWhiteSpace(phone) || phone.Length < 7) + { + return phone; + } + + return string.Concat(phone.AsSpan(0, 3), "****", phone.AsSpan(phone.Length - 4)); + } + + private static string? MaskName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return name; + } + + var text = name.Trim(); + return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1)); + } +} diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 02d8ab0..fe27eed 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -15,6 +15,7 @@ using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.MarketingAggregate.Repositories; using Oms.Domain.Shared.Enums; @@ -36,6 +37,9 @@ public class CustomerService : ApplicationService private ICustomerReferralCodeRepository ReferralCodeRepository => LazyServiceProvider.GetRequiredService(); + private IAgentRepository AgentRepository => + LazyServiceProvider.GetRequiredService(); + private MarketingCouponManager CouponManager => LazyServiceProvider.GetRequiredService(); @@ -240,13 +244,18 @@ public class CustomerService : ApplicationService var entity = BuildCustomerEntity(input, normalized, null, userId); entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId); entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey); - await MarketingReferralBinder.BindReferrerOnCreateAsync( - entity, - input.InviteCode, - ReferralCodeRepository, - CustomerRepository, - MemberLevelRepository, - LazyServiceProvider.GetRequiredService()); + await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository); + // 有代理编号时不走 C2C 邀请,避免双发佣金 + if (!entity.AgentId.HasValue) + { + await MarketingReferralBinder.BindReferrerOnCreateAsync( + entity, + input.InviteCode, + ReferralCodeRepository, + CustomerRepository, + MemberLevelRepository, + LazyServiceProvider.GetRequiredService()); + } await StaffAdvisorBinder.BindAdvisorOnCreateAsync( entity, input.AdvisorCode, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 3237847..a2237c3 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -138,6 +138,10 @@ public partial class OrderService // 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。 var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId); input.CustomerId = sessionCustomer.Id; + var mainCustomerForAgent = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + CustomerRepository, + sessionCustomer); + var orderAgentId = mainCustomerForAgent.AgentId; await ValidateParcelCreateInputAsync(input); @@ -180,6 +184,7 @@ public partial class OrderService snapshotBinding, serviceNo, createContext); + order.AgentId = orderAgentId; order = await OrderRepository.InsertAsync(order); var weights = BuildInitialWeightRows(order.Id, input); diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index b8d113a..1938010 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -297,6 +297,11 @@ public partial class OrderService : ApplicationService order = await GetOrderOrThrowAsync(order.Id); await ApplyDeliverySuccessMarketingAsync(order); } + else if (string.Equals(target, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase)) + { + order = await GetOrderOrThrowAsync(order.Id); + await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliveryFailed); + } order = await GetOrderOrThrowAsync(order.Id); var afterSnap = await CaptureOrderAuditSnapshotAsync(order); @@ -340,6 +345,14 @@ public partial class OrderService : ApplicationService var couponManager = LazyServiceProvider.GetRequiredService(); await couponManager.OnFirstDeliverySuccessAsync(order); + + await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess); + } + + private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) + { + var agentCommission = LazyServiceProvider.GetRequiredService(); + await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus); } /// diff --git a/src/Oms.Application/Shared/AgentBinder.cs b/src/Oms.Application/Shared/AgentBinder.cs new file mode 100644 index 0000000..d4abe01 --- /dev/null +++ b/src/Oms.Application/Shared/AgentBinder.cs @@ -0,0 +1,40 @@ +using Fake.Application; +using Fake.Domain.Exceptions; +using Oms.Domain.AgentsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Shared; + +/// +/// 注册时绑定渠道代理。与 C2C 邀请互斥:有 agent 时由调用方跳过邀请绑定。 +/// +public static class AgentBinder +{ + public static async Task BindAgentOnCreateAsync( + Customer entity, + string? agentCode, + IAgentRepository agentRepository, + CancellationToken cancellationToken = default) + { + if (entity.AgentId.HasValue && entity.AgentId.Value > 0) + { + return; + } + + var code = agentCode?.Trim(); + if (string.IsNullOrWhiteSpace(code)) + { + return; + } + + var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + throw new BusinessException("代理编号无效或已停用"); + } + + entity.AgentId = agent.Id; + entity.AgentBindTime = DateTime.Now; + } +} diff --git a/src/Oms.Application/Shared/AgentCommissionManager.cs b/src/Oms.Application/Shared/AgentCommissionManager.cs new file mode 100644 index 0000000..6684b59 --- /dev/null +++ b/src/Oms.Application/Shared/AgentCommissionManager.cs @@ -0,0 +1,203 @@ +using Fake.DependencyInjection; +using Oms.Domain.AgentsAggregate.Consts; +using Oms.Domain.AgentsAggregate.Entities; +using Oms.Domain.AgentsAggregate.Repositories; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; +using System.Globalization; +using System.Text.Json; + +namespace Oms.Application.Shared; + +/// +/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。 +/// +public class AgentCommissionManager : IScopedDependency +{ + private readonly IAgentRepository _agentRepository; + private readonly IAgentCommissionDetailRepository _detailRepository; + private readonly IOrderRepository _orderRepository; + private readonly ISalesProductRepository _salesProductRepository; + + public AgentCommissionManager( + IAgentRepository agentRepository, + IAgentCommissionDetailRepository detailRepository, + IOrderRepository orderRepository, + ISalesProductRepository salesProductRepository) + { + _agentRepository = agentRepository; + _detailRepository = detailRepository; + _orderRepository = orderRepository; + _salesProductRepository = salesProductRepository; + } + + public async Task TryAccrueOnDeliveryTerminalAsync( + int orderId, + string triggerOrderStatus, + CancellationToken cancellationToken = default) + { + if (!IsTerminalTrigger(triggerOrderStatus)) + { + return; + } + + var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); + if (existing != null) + { + return; + } + + var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); + if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue) + { + return; + } + + if (order.IsRefunded > 0) + { + return; + } + + var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken); + if (agent == null || agent.Status != Domain.Shared.Enums.EnableStatus.Enabled) + { + return; + } + + if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType)) + { + return; + } + + if (agent.CommissionRate <= 0) + { + return; + } + + int? salesProductId = null; + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + salesProductId = product?.Id; + } + + if (!IsProductAllowed(agent.SalesProductIds, salesProductId)) + { + return; + } + + var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]); + var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order); + if (baseAmount <= 0) + { + return; + } + + var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero); + if (amount <= 0) + { + return; + } + + // 并发双写时靠二次查重;无库级唯一索引 + existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); + if (existing != null) + { + return; + } + + await _detailRepository.InsertAsync(new AgentCommissionDetail + { + AgentId = agent.Id, + OrderId = order.Id, + CustomerId = order.CustomerId.Value, + SalesProductId = salesProductId, + BaseType = agent.BaseType.Trim().ToLowerInvariant(), + BaseAmount = baseAmount, + CommissionRate = agent.CommissionRate, + CommissionAmount = amount, + Status = AgentCommissionStatusCodes.Pending, + TriggerOrderStatus = triggerOrderStatus.Trim() + }, cancellationToken); + } + + private static bool IsTerminalTrigger(string? status) => + string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase); + + private static decimal ResolveBaseAmount( + string baseType, + List fees, + Domain.OrdersAggregate.Entities.Order order) + { + if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase)) + { + return MarketingFeeHelper.ResolvePaidBaseFreight( + MarketingFeeHelper.SumReceivableBaseFreight(fees), + order.LevelDiscountAmount, + order.CouponDiscountAmount); + } + + // receivable:折前应收小计(排除营销抵扣行) + var sum = MarketingFeeHelper.SumReceivable(fees); + if (sum > 0) + { + return sum; + } + + return Math.Max(0M, order.ReceivableSubtotal ?? 0M); + } + + internal static bool IsProductAllowed(string? salesProductIdsRaw, int? salesProductId) + { + var allowed = ParseProductIds(salesProductIdsRaw); + if (allowed.Count == 0) + { + return true; + } + + return salesProductId.HasValue && allowed.Contains(salesProductId.Value); + } + + internal static HashSet ParseProductIds(string? raw) + { + var set = new HashSet(); + if (string.IsNullOrWhiteSpace(raw)) + { + return set; + } + + var text = raw.Trim(); + try + { + if (text.StartsWith('[')) + { + var list = JsonSerializer.Deserialize>(text); + if (list != null) + { + foreach (var id in list.Where(x => x > 0)) + { + set.Add(id); + } + } + + return set; + } + } + catch (JsonException) + { + // fall through to comma split + } + + foreach (var part in text.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) && id > 0) + { + set.Add(id); + } + } + + return set; + } +} diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs new file mode 100644 index 0000000..36b1a26 --- /dev/null +++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs @@ -0,0 +1,12 @@ +namespace Oms.Domain.AgentsAggregate.Consts; + +/// 代理返佣基数类型(首版仅运费/应收)。 +public static class AgentCommissionBaseTypes +{ + public const string Freight = "freight"; + public const string Receivable = "receivable"; + + public static bool IsSupported(string? baseType) => + string.Equals(baseType, Freight, StringComparison.OrdinalIgnoreCase) + || string.Equals(baseType, Receivable, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs new file mode 100644 index 0000000..0639032 --- /dev/null +++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs @@ -0,0 +1,8 @@ +namespace Oms.Domain.AgentsAggregate.Consts; + +/// 代理返佣明细状态(首版)。 +public static class AgentCommissionStatusCodes +{ + public const string Pending = "pending"; + public const string Paid = "paid"; +} diff --git a/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs b/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs new file mode 100644 index 0000000..fb3ade8 --- /dev/null +++ b/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs @@ -0,0 +1,45 @@ +using Oms.Domain.Shared; +using Oms.Domain.Shared.Enums; +using SqlSugar; + +namespace Oms.Domain.AgentsAggregate.Entities; + +/// 渠道代理主数据。 +[SugarTable("t_agent")] +public class Agent : OmsAuditedAggregateRoot +{ + /// RBAC user.userid / JWT mapId。 + public int UserId { get; set; } + + /// 登录账号 + 注册链接编码。 + [SugarColumn(Length = 32)] + public string AgentCode { get; set; } = string.Empty; + + [SugarColumn(Length = 64)] + public string AgentName { get; set; } = string.Empty; + + [SugarColumn(Length = 32, IsNullable = true)] + public string? Phone { get; set; } + + [SugarColumn(Length = 128, IsNullable = true)] + public string? Email { get; set; } + + /// MD5 密码(对齐客户)。 + [SugarColumn(Length = 64)] + public string Password { get; set; } = string.Empty; + + public EnableStatus Status { get; set; } = EnableStatus.Enabled; + + /// freight / receivable。 + [SugarColumn(Length = 32)] + public string BaseType { get; set; } = "freight"; + + public decimal CommissionRate { get; set; } + + /// 可选销售产品 Id 列表(JSON 或逗号);空=不限产品。 + [SugarColumn(Length = 512, IsNullable = true)] + public string? SalesProductIds { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs new file mode 100644 index 0000000..dba8aec --- /dev/null +++ b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs @@ -0,0 +1,38 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.AgentsAggregate.Entities; + +/// 代理返佣明细(首版无余额入账)。 +[SugarTable("t_agent_commission_detail")] +public class AgentCommissionDetail : OmsAuditedAggregateRoot +{ + public int AgentId { get; set; } + + public int OrderId { get; set; } + + public int CustomerId { get; set; } + + public int? SalesProductId { get; set; } + + [SugarColumn(Length = 32)] + public string BaseType { get; set; } = string.Empty; + + public decimal BaseAmount { get; set; } + + public decimal CommissionRate { get; set; } + + public decimal CommissionAmount { get; set; } + + /// pending / paid。 + [SugarColumn(Length = 16)] + public string Status { get; set; } = "pending"; + + [SugarColumn(Length = 32)] + public string TriggerOrderStatus { get; set; } = string.Empty; + + public DateTime? PaidTime { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs b/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs new file mode 100644 index 0000000..66e61f4 --- /dev/null +++ b/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs @@ -0,0 +1,18 @@ +using Fake.Domain.Repositories; +using Oms.Domain.AgentsAggregate.Entities; + +namespace Oms.Domain.AgentsAggregate.Repositories; + +public interface IAgentRepository : IRepository +{ + Task GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default); + + Task GetByUserIdAsync(int userId, CancellationToken cancellationToken = default); + + Task ExistsAgentCodeAsync(string agentCode, int? excludeId = null, CancellationToken cancellationToken = default); +} + +public interface IAgentCommissionDetailRepository : IRepository +{ + Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs index 0f73967..22f87fa 100644 --- a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs +++ b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs @@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot public decimal CommissionBalance { get; set; } + /// 所属渠道代理(建议写在主客户上)。 + public int? AgentId { get; set; } + + public DateTime? AgentBindTime { get; set; } + public string? LastLoginIp { get; set; } public DateTime? LastLoginTime { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 2f2b77e..d0334bf 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot public int? CustomerId { get; set; } + /// 下单时代理快照(跟主客户 AgentId)。 + public int? AgentId { get; set; } + public int? MemberLevelId { get; set; } public string? PriceTierKey { get; set; } diff --git a/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs b/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs new file mode 100644 index 0000000..fce388b --- /dev/null +++ b/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs @@ -0,0 +1,37 @@ +using Fake.DependencyInjection; +using Oms.Domain.AgentsAggregate.Entities; +using Oms.Domain.AgentsAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Agents; + +public class AgentRepository : OmsSqlSugarRepository, IAgentRepository, IScopedDependency +{ + public Task GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default) + => FirstOrDefaultAsync(x => x.AgentCode == agentCode, cancellationToken); + + public Task GetByUserIdAsync(int userId, CancellationToken cancellationToken = default) + => FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken); + + public async Task ExistsAgentCodeAsync( + string agentCode, + int? excludeId = null, + CancellationToken cancellationToken = default) + { + if (excludeId.HasValue) + { + return await AnyAsync(x => x.AgentCode == agentCode && x.Id != excludeId.Value, cancellationToken); + } + + return await AnyAsync(x => x.AgentCode == agentCode, cancellationToken); + } +} + +public class AgentCommissionDetailRepository + : OmsSqlSugarRepository, + IAgentCommissionDetailRepository, + IScopedDependency +{ + public Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default) + => FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken); +} diff --git a/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs b/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs new file mode 100644 index 0000000..d40a217 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs @@ -0,0 +1,32 @@ +using Fake.Data.Seeding; +using Fake.Rbac.Application.Dtos.Role; +using Fake.Rbac.Application.Services; + +namespace SimpleAdmin.Api.Auth; + +public class AgentRoleSeedContributor(RoleService roleService) : IDataSeedContributor +{ + public const string AgentRoleCode = "Agent"; + public const string AgentRoleName = "渠道代理"; + + public async Task SeedAsync() + { + var roles = await roleService.GetAllRolesAsync(); + if (roles.Any(x => string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + await roleService.CreateAsync(new RoleCreateDto + { + Code = AgentRoleCode, + Name = AgentRoleName, + Permissions = + [ + "Agent.Portal", + "Agent.Customers.Query", + "Agent.Orders.Query" + ] + }); + } +} diff --git a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs index c1c40fb..54ed3b0 100644 --- a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs +++ b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs @@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth; public enum OmsLoginType { User = 0, - Customer = 1 + Customer = 1, + Agent = 2 } public class OmsLoginInput diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 5bef285..6809f4a 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -21,9 +21,11 @@ using Fake.ObjectStorage; using Oms.Application; using Oms.Application.Shared; using Oms.Application.Storage; +using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.Shared.Enums; using SimpleAdmin.Api.WeChat.Dtos; using SimpleAdmin.Api.WeChat.Services; using SixLabors.ImageSharp; @@ -68,6 +70,7 @@ public partial class AuthService( { OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken), OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken), + OmsLoginType.Agent => await LoginAgentAsync(input.Account, input.Password, cancellationToken), _ => throw new BusinessException("不支持的登录类型") }; } @@ -284,6 +287,11 @@ public partial class AuthService( return await RefreshCustomerTokenAsync(token.Claims, cancellationToken); } + if (loginType == OmsLoginType.Agent) + { + return await RefreshAgentTokenAsync(token.Claims, cancellationToken); + } + if (!Guid.TryParse(userIdText, out var userId)) { throw new BusinessException("无效的刷新令牌"); @@ -438,6 +446,60 @@ public partial class AuthService( return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken); } + private async Task LoginAgentAsync(string account, string password, CancellationToken cancellationToken) + { + var agentRepository = LazyServiceProvider.GetRequiredService(); + var code = account.Trim(); + var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken); + if (agent == null) + { + throw new BusinessException("代理账号或密码错误"); + } + + if (agent.Status != EnableStatus.Enabled) + { + throw new BusinessException("代理已停用"); + } + + if (!string.Equals(agent.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("代理账号或密码错误"); + } + + var userGuid = await GetRbacUserGuidByMapIdAsync(agent.UserId, cancellationToken); + if (!userGuid.HasValue) + { + throw new BusinessException("代理未关联用户"); + } + + return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Agent, cancellationToken); + } + + private async Task RefreshAgentTokenAsync(IEnumerable claims, CancellationToken cancellationToken) + { + var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId); + if (mapId <= 0) + { + throw new BusinessException("无效的刷新令牌"); + } + + var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken); + if (!userGuid.HasValue) + { + throw new BusinessException("用户不存在"); + } + + var agentRepository = LazyServiceProvider.GetRequiredService(); + var agent = await agentRepository.GetByUserIdAsync(mapId, cancellationToken); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + throw new BusinessException("代理不存在或已停用"); + } + + var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Agent); + return BuildTokenOnlyResult(userGuid.Value, refreshedClaims); + } + private async Task LoginCustomerAsync(string account, string password, CancellationToken cancellationToken) { var normalizedAccount = account.Trim(); diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs index d254466..246efac 100644 --- a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs +++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs @@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput /// public string? AdvisorCode { get; set; } + /// + /// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。 + /// + public string? AgentCode { get; set; } + [Required] public WeChatProfileInput Wechat { get; set; } = new(); } diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index e5c6ec2..5771985 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -14,6 +14,7 @@ using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.MarketingAggregate.Repositories; using SimpleAdmin.Api.WeChat.Dtos; using SimpleAdmin.Api.WeChat.Services; @@ -30,6 +31,7 @@ public class WeChatCustomerService( ICustomerRepository customerRepository, ICustomerReferralCodeRepository referralCodeRepository, IMemberLevelRepository memberLevelRepository, + IAgentRepository agentRepository, MarketingCouponManager couponManager, UserService userService, RoleService roleService, @@ -156,17 +158,21 @@ public class WeChatCustomerService( cancellationToken), PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey }; - await MarketingReferralBinder.BindReferrerOnCreateAsync( - entity, - input.InviteCode, - referralCodeRepository, - customerRepository, - memberLevelRepository, - LazyServiceProvider.GetRequiredService(), - cancellationToken); - if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue) + await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, agentRepository, cancellationToken); + if (!entity.AgentId.HasValue) { - entity.ReferralChannel = "wechat"; + await MarketingReferralBinder.BindReferrerOnCreateAsync( + entity, + input.InviteCode, + referralCodeRepository, + customerRepository, + memberLevelRepository, + LazyServiceProvider.GetRequiredService(), + cancellationToken); + if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue) + { + entity.ReferralChannel = "wechat"; + } } await StaffAdvisorBinder.BindAdvisorOnCreateAsync( entity, -- 2.45.2 From 0c89ec6ce5336c1555aaa29ceacea2c1c30efb79 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 29 Oct 2026 18:38:18 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E4=BB=A3=E7=90=86=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E8=A1=A5=E5=85=85=20ConcurrencyStamp?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=20MySQL=201674?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- sql/migrate_agent_module_20261029.sql | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/sql/migrate_agent_module_20261029.sql b/sql/migrate_agent_module_20261029.sql index 78a5ba3..ef74284 100644 --- a/sql/migrate_agent_module_20261029.sql +++ b/sql/migrate_agent_module_20261029.sql @@ -1,5 +1,6 @@ -- 渠道代理与返佣(首版轻量)。可重复执行。 -- 含:t_agent、t_agent_commission_detail;t_customer / t_order 扩展 AgentId。 +-- AggregateRoot 需要 ConcurrencyStamp。 SET @db := DATABASE(); @@ -22,11 +23,25 @@ CREATE TABLE IF NOT EXISTS `t_agent` ( `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `UpdateUserId` INT NOT NULL DEFAULT 0, `UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + -- 不用 DEFAULT(UUID()):statement 复制下会报 1674;运行时由 ORM 写入 + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳', PRIMARY KEY (`Id`), KEY `ix_agent_code` (`AgentCode`), KEY `ix_agent_userid` (`UserId`) ) COMMENT='渠道代理主数据'; +-- 已建表补 ConcurrencyStamp(可重复) +SET @exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent' AND COLUMN_NAME = 'ConcurrencyStamp' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_agent` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`', + 'SELECT ''t_agent.ConcurrencyStamp already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + -- ---------- t_agent_commission_detail ---------- CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` ( `Id` INT NOT NULL AUTO_INCREMENT, @@ -47,12 +62,24 @@ CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` ( `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `UpdateUserId` INT NOT NULL DEFAULT 0, `UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳', PRIMARY KEY (`Id`), KEY `ix_agent_comm_agent` (`AgentId`), KEY `ix_agent_comm_order` (`OrderId`), KEY `ix_agent_comm_status` (`Status`) ) COMMENT='代理返佣明细(首版无余额入账)'; +SET @exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent_commission_detail' AND COLUMN_NAME = 'ConcurrencyStamp' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_agent_commission_detail` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`', + 'SELECT ''t_agent_commission_detail.ConcurrencyStamp already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + -- ---------- t_customer.AgentId ---------- SET @exists := ( SELECT COUNT(1) FROM information_schema.COLUMNS -- 2.45.2 From b6831bad1d73268d9456e9268c34ab77bf8f3650 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 30 Oct 2026 10:18:32 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E4=BB=A3=E7=90=86=E9=97=A8?= =?UTF-8?q?=E6=88=B7=E8=83=BD=E5=8A=9B=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E4=BB=A3=E7=90=86=E6=94=B9=E7=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐工作台/名下客户订单返佣、注册校验与运营端客户 AgentId 改绑,订单列表按代理快照过滤。 Co-authored-by: Cursor --- src/Oms.Application/Dtos/Agents/AgentDtos.cs | 122 +++++ .../Dtos/Customers/CustomerOutput.cs | 11 +- .../Dtos/Customers/CustomerPaginatedInput.cs | 5 +- .../Dtos/Customers/CustomerPaginatedOutput.cs | 11 +- .../Dtos/Customers/UpdateCustomerInput.cs | 8 +- .../Services/Agents/AgentAdminService.cs | 48 ++ .../Services/Agents/AgentPortalService.cs | 460 +++++++++++++++--- .../Services/Customers/CustomerService.cs | 58 ++- .../Services/Orders/OrderService.Helpers.cs | 43 ++ .../Orders/OrderService.ParcelBilling.cs | 14 +- .../Services/Orders/OrderService.cs | 33 ++ src/Oms.Application/Shared/AgentBinder.cs | 32 ++ .../Shared/OmsSessionHelper.cs | 73 ++- .../OrdersAggregate/OrderListFilter.cs | 5 + .../Orders/OrderRepository.cs | 8 + 15 files changed, 854 insertions(+), 77 deletions(-) diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs index 8e849e3..8781ac0 100644 --- a/src/Oms.Application/Dtos/Agents/AgentDtos.cs +++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs @@ -30,10 +30,16 @@ public class AgentOutput public string BaseType { get; set; } = string.Empty; + /// 基数类型展示名(运费/应收)。 + public string? BaseTypeName { get; set; } + public decimal CommissionRate { get; set; } public string? SalesProductIds { get; set; } + /// 适用销售产品名称;空表示不限产品。 + public List SalesProductNames { get; set; } = []; + public string? Remark { get; set; } public DateTime CreateTime { get; set; } @@ -195,6 +201,33 @@ public class AgentPortalCustomerOutput public DateTime? AgentBindTime { get; set; } public DateTime CreateTime { get; set; } + + /// 最近下单时间(含名下子客户)。 + public DateTime? LastOrderTime { get; set; } + + /// 近 30 天运单数(含名下子客户,不含已删除)。 + public int OrderCount30d { get; set; } + + /// 待付款运单数(含名下子客户)。 + public int PendingPaymentCount { get; set; } +} + +/// 代理门户返佣明细分页筛选。 +public class AgentPortalCommissionListInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + /// pending / paid;空=全部。 + public string? Status { get; set; } + + /// 运单号关键字。 + public string? Keyword { get; set; } + + public DateTime? CreateTimeStart { get; set; } + + public DateTime? CreateTimeEnd { get; set; } } public class AgentPortalOrderOutput @@ -232,3 +265,92 @@ public class AgentPortalPageInput public string? Keyword { get; set; } } + +/// 匿名校验代理编号(客户注册归因)。 +public class AgentValidateCodeOutput +{ + public bool Valid { get; set; } + + public string? AgentCode { get; set; } + + /// 脱敏代理名称,供注册页展示。 + public string? AgentNameMasked { get; set; } + + public string? Message { get; set; } +} + +/// +/// 代理工作台统计概览。 +/// +public class AgentWorkplaceStatsOutput +{ + /// 名下主客户数。 + public int CustomerCount { get; set; } + + /// 今日代客下单数。 + public int TodayCreated { get; set; } + + /// 昨日代客下单数。 + public int YesterdayCreated { get; set; } + + /// 较昨日新增百分比。 + public decimal? DayOverDayGrowthPercent { get; set; } + + /// 待付款运单数。 + public int PendingPayment { get; set; } + + /// 运输中运单数。 + public int Shipping { get; set; } + + /// 问题件运单数。 + public int Problematic { get; set; } + + /// 派送成功运单数(全量状态桶)。 + public int DeliverySuccess { get; set; } + + /// 待结算返佣笔数。 + public int PendingCommissionCount { get; set; } + + /// 待结算返佣金额。 + public decimal PendingCommissionAmount { get; set; } + + /// 已打款返佣金额(累计)。 + public decimal PaidCommissionAmount { get; set; } + + /// 本周新增绑定主客户数(按 AgentBindTime)。 + public int WeekNewCustomers { get; set; } + + /// 派送失败运单数。 + public int DeliveryFailed { get; set; } + + /// 按 OrderStatus 分组数量。 + public Dictionary Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +/// +/// 代理工作台趋势点。 +/// +public class AgentWorkplaceTrendPoint +{ + public string Label { get; set; } = string.Empty; + + public DateTime Start { get; set; } + + public DateTime End { get; set; } + + /// 区间内代客下单数。 + public int OrderCount { get; set; } + + /// 区间内计提返佣金额。 + public decimal CommissionAmount { get; set; } +} + +/// +/// 代理工作台趋势。 +/// +public class AgentWorkplaceTrendOutput +{ + public string Dimension { get; set; } = "day"; + + public List Points { get; set; } = []; +} diff --git a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs index 014262e..71dc952 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs @@ -78,7 +78,7 @@ public class CustomerOutput public string? Remark { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留字段,管理端已改用 AgentId)。 /// public string? Salesman { get; set; } @@ -87,6 +87,15 @@ public class CustomerOutput /// public string? CustomerService { get; set; } + /// 绑定渠道代理 Id。 + public int? AgentId { get; set; } + + /// 绑定代理编号。 + public string? AgentCode { get; set; } + + /// 绑定代理名称。 + public string? AgentName { get; set; } + /// /// 客户分组。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs index a765c86..301170e 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs @@ -33,7 +33,7 @@ public class CustomerPaginatedInput public CustomerGroup? CustomerGroup { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留筛选)。 /// public string? Salesman { get; set; } @@ -42,6 +42,9 @@ public class CustomerPaginatedInput /// public string? CustomerService { get; set; } + /// 按绑定代理 Id 筛选。 + public int? AgentId { get; set; } + /// /// 排序字段:createTime / updateTime / customerCode / customerName / balance。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs index 63fb35e..c20b23d 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs @@ -78,7 +78,7 @@ public class CustomerPaginatedOutput public string? Remark { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留字段,管理端已改用 AgentId)。 /// public string? Salesman { get; set; } @@ -87,6 +87,15 @@ public class CustomerPaginatedOutput /// public string? CustomerService { get; set; } + /// 绑定渠道代理 Id。 + public int? AgentId { get; set; } + + /// 绑定代理编号。 + public string? AgentCode { get; set; } + + /// 绑定代理名称。 + public string? AgentName { get; set; } + /// /// 客户分组。 /// diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs index 2bd683d..7e9fc36 100644 --- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs @@ -81,7 +81,7 @@ public class UpdateCustomerInput public string? Remark { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留;新流程请用 AgentCode)。 /// [StringLength(32)] public string? Salesman { get; set; } @@ -92,6 +92,12 @@ public class UpdateCustomerInput [StringLength(32)] public string? CustomerService { get; set; } + /// + /// 渠道代理编号;传空串解绑;不传则保留原绑定。仅主客户可改。 + /// + [StringLength(32)] + public string? AgentCode { get; set; } + /// /// 客户分组(遗留字段,计价优先 MemberLevelId)。 /// diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs index a92323f..4b1b670 100644 --- a/src/Oms.Application/Services/Agents/AgentAdminService.cs +++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs @@ -6,6 +6,7 @@ using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; using Fake.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Agents; @@ -43,6 +44,42 @@ public class AgentAdminService : ApplicationService private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService(); + /// + /// 匿名校验代理编号(客户注册页归因 / 手填兜底)。 + /// + [AllowAnonymous] + [HttpGet("/agent/public/validate-code")] + public async Task ValidateCodeAsync([FromQuery] string code) + { + var trimmed = code?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return new AgentValidateCodeOutput + { + Valid = false, + Message = "代理编号不能为空" + }; + } + + var agent = await AgentRepository.GetByAgentCodeAsync(trimmed); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + return new AgentValidateCodeOutput + { + Valid = false, + Message = "代理编号无效或已停用" + }; + } + + return new AgentValidateCodeOutput + { + Valid = true, + AgentCode = agent.AgentCode, + AgentNameMasked = MaskAgentName(agent.AgentName), + Message = null + }; + } + /// 分页查询代理。 [HttpPost("/agent/admin/list")] public async Task> GetListAsync(AgentListInput input) @@ -411,4 +448,15 @@ public class AgentAdminService : ApplicationService return mapId.Value; } + + private static string MaskAgentName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return "渠道代理"; + } + + var text = name.Trim(); + return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1)); + } } diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs index ba05bf9..20c43ba 100644 --- a/src/Oms.Application/Services/Agents/AgentPortalService.cs +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -1,22 +1,26 @@ using System.Linq.Expressions; using Fake.Application; using Fake.Domain.Exceptions; -using Fake.Security.Claims; using Microsoft.AspNetCore.Mvc; +using MiniExcelLibs; using Oms.Application.Dtos.Agents; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Domain.AgentsAggregate.Consts; using Oms.Domain.AgentsAggregate.Entities; using Oms.Domain.AgentsAggregate.Repositories; +using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate; +using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; namespace Oms.Application.Services.Agents; /// -/// 渠道代理门户只读 API(名下客户 / 订单 / 返佣)。 +/// 渠道代理门户 API(名下客户 / 订单 / 返佣 / 工作台)。 /// [ApiExplorerSettings(GroupName = "OMS-Agent")] public class AgentPortalService : ApplicationService @@ -30,11 +34,15 @@ public class AgentPortalService : ApplicationService private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); - /// 当前代理资料。 + private ISalesProductRepository SalesProductRepository => + LazyServiceProvider.GetRequiredService(); + + /// 当前代理资料(含返佣规则展示字段)。 [HttpGet("/agent/portal/profile")] public async Task GetProfileAsync() { var agent = await GetCurrentAgentOrThrowAsync(); + var productNames = await ResolveSalesProductNamesAsync(agent.SalesProductIds); return new AgentOutput { Id = agent.Id, @@ -44,14 +52,16 @@ public class AgentPortalService : ApplicationService Email = agent.Email, Status = agent.Status, BaseType = agent.BaseType, + BaseTypeName = ResolveBaseTypeName(agent.BaseType), CommissionRate = agent.CommissionRate, SalesProductIds = agent.SalesProductIds, + SalesProductNames = productNames, Remark = agent.Remark, CreateTime = agent.CreateTime }; } - /// 名下客户分页(含主客户 AgentId=我)。 + /// 名下客户分页(含活跃度:最近下单 / 近30天票数 / 待付款数)。 [HttpPost("/agent/portal/customers/list")] public async Task> GetCustomersAsync(AgentPortalPageInput input) { @@ -77,16 +87,25 @@ public class AgentPortalService : ApplicationService [nameof(Customer.Id)] = false }); var total = await CustomerRepository.CountAsync(predicate); + var activityMap = await BuildCustomerActivityMapAsync(agent.Id, entities.Select(x => x.Id).ToList()); + return new OmsPageResult { - Items = entities.Select(x => new AgentPortalCustomerOutput + Items = entities.Select(x => { - Id = x.Id, - CustomerCode = x.CustomerCode, - CustomerName = MaskName(x.CustomerName) ?? string.Empty, - Phone = MaskPhone(x.Phone), - AgentBindTime = x.AgentBindTime, - CreateTime = x.CreateTime + activityMap.TryGetValue(x.Id, out var activity); + return new AgentPortalCustomerOutput + { + Id = x.Id, + CustomerCode = x.CustomerCode, + CustomerName = MaskName(x.CustomerName) ?? string.Empty, + Phone = MaskPhone(x.Phone), + AgentBindTime = x.AgentBindTime, + CreateTime = x.CreateTime, + LastOrderTime = activity.LastOrderTime, + OrderCount30d = activity.OrderCount30d, + PendingPaymentCount = activity.PendingPaymentCount + }; }).ToList(), Total = total }; @@ -141,7 +160,6 @@ public class AgentPortalService : ApplicationService OrderStatus = x.OrderStatus, ConsigneeCountryName = x.ConsigneeCountryName, ReceivableSubtotal = x.ReceivableSubtotal, - // 门户不返回应付:固定 null PayableAmount = null, CreateTime = x.CreateTime }; @@ -150,72 +168,384 @@ public class AgentPortalService : ApplicationService }; } - /// 名下返佣明细。 - [HttpPost("/agent/portal/commission/list")] - public async Task> GetCommissionListAsync(AgentPortalPageInput input) + /// + /// 代理工作台统计:名下客户、运单状态桶、待结算/已打款返佣、本周新增绑定。 + /// + [HttpGet("/agent/portal/workplace/stats")] + public async Task GetWorkplaceStatsAsync() { var agent = await GetCurrentAgentOrThrowAsync(); - Expression> predicate = x => x.AgentId == agent.Id; + var filter = new OrderListFilter { AgentId = agent.Id }; + var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter); + var counts = rows + .Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Status, x => x.Count, StringComparer.OrdinalIgnoreCase); + + int GetCount(string status) => + counts.TryGetValue(status, out var value) ? value : 0; + + var today = DateTime.Today; + var tomorrow = today.AddDays(1); + var yesterday = today.AddDays(-1); + var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow); + var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today); + + decimal? growth = null; + if (yesterdayCreated > 0) + { + growth = Math.Round((todayCreated - yesterdayCreated) * 100m / yesterdayCreated, 1); + } + else if (todayCreated > 0) + { + growth = 100m; + } + + var customerCount = await CustomerRepository.CountAsync(x => + x.AgentId == agent.Id && x.ParentCustomerId == null); + + var weekStart = today.AddDays(-(((int)today.DayOfWeek + 6) % 7)); + var weekNewCustomers = await CustomerRepository.CountAsync(x => + x.AgentId == agent.Id + && x.ParentCustomerId == null + && x.AgentBindTime != null + && x.AgentBindTime >= weekStart + && x.AgentBindTime < tomorrow); + + var pendingDetails = await DetailRepository.GetListAsync(x => + x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending); + var paidDetails = await DetailRepository.GetListAsync(x => + x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Paid); + + return new AgentWorkplaceStatsOutput + { + CustomerCount = customerCount, + TodayCreated = todayCreated, + YesterdayCreated = yesterdayCreated, + DayOverDayGrowthPercent = growth, + PendingPayment = GetCount(OrderStatusCodes.PendingPayment), + Shipping = GetCount(OrderStatusCodes.Shipping) + GetCount(OrderStatusCodes.PendingOnline), + Problematic = GetCount(OrderStatusCodes.Problematic), + DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess), + DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed), + PendingCommissionCount = pendingDetails.Count, + PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount), + PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount), + WeekNewCustomers = weekNewCustomers, + Counts = counts, + }; + } + + /// + /// 代理工作台趋势:按日统计代客下单数与计提返佣金额。 + /// + /// 天数,默认 7,最大 31。 + [HttpGet("/agent/portal/workplace/trend")] + public async Task GetWorkplaceTrendAsync([FromQuery] int days = 7) + { + var agent = await GetCurrentAgentOrThrowAsync(); + days = Math.Clamp(days, 1, 31); + var filter = new OrderListFilter { AgentId = agent.Id }; + var today = DateTime.Today; + var rangeStart = today.AddDays(-(days - 1)); + var rangeEnd = today.AddDays(1); + + var commissions = await DetailRepository.GetListAsync(x => + x.AgentId == agent.Id + && x.CreateTime >= rangeStart + && x.CreateTime < rangeEnd); + + var points = new List(days); + for (var i = 0; i < days; i++) + { + var start = rangeStart.AddDays(i); + var end = start.AddDays(1); + var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, start, end); + var commissionAmount = commissions + .Where(x => x.CreateTime >= start && x.CreateTime < end) + .Sum(x => x.CommissionAmount); + points.Add(new AgentWorkplaceTrendPoint + { + Label = start.ToString("MM-dd"), + Start = start, + End = end, + OrderCount = orderCount, + CommissionAmount = commissionAmount, + }); + } + + return new AgentWorkplaceTrendOutput + { + Dimension = "day", + Points = points, + }; + } + + /// 名下返佣明细(支持状态 / 运单号 / 计提时间筛选)。 + [HttpPost("/agent/portal/commission/list")] + public async Task> GetCommissionListAsync( + AgentPortalCommissionListInput input) + { + var agent = await GetCurrentAgentOrThrowAsync(); + var details = await QueryCommissionDetailsAsync(agent, input); var page = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); - var entities = await DetailRepository.GetPagedListAsync( - predicate, - page, - pageSize, - new Dictionary - { - [nameof(AgentCommissionDetail.CreateTime)] = false, - [nameof(AgentCommissionDetail.Id)] = false - }); - var total = await DetailRepository.CountAsync(predicate); - - var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList(); - var orderIds = entities.Select(x => x.OrderId).Distinct().ToList(); - var customers = customerIds.Count == 0 - ? [] - : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); - var orders = orderIds.Count == 0 - ? [] - : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); - var customerMap = customers.ToDictionary(x => x.Id); - var orderMap = orders.ToDictionary(x => x.Id); + var total = details.Count; + var pageItems = details + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); return new OmsPageResult { - Items = entities.Select(x => - { - customerMap.TryGetValue(x.CustomerId, out var customer); - orderMap.TryGetValue(x.OrderId, out var order); - return new AgentCommissionDetailOutput - { - Id = x.Id, - AgentId = x.AgentId, - AgentCode = agent.AgentCode, - AgentName = agent.AgentName, - OrderId = x.OrderId, - ServiceNo = order?.ServiceNo, - CustomerId = x.CustomerId, - CustomerCode = customer?.CustomerCode, - CustomerName = MaskName(customer?.CustomerName), - SalesProductId = x.SalesProductId, - BaseType = x.BaseType, - BaseAmount = x.BaseAmount, - CommissionRate = x.CommissionRate, - CommissionAmount = x.CommissionAmount, - Status = x.Status, - TriggerOrderStatus = x.TriggerOrderStatus, - PaidTime = x.PaidTime, - Remark = x.Remark, - CreateTime = x.CreateTime - }; - }).ToList(), + Items = await MapCommissionOutputsAsync(agent, pageItems), Total = total }; } + /// 导出名下返佣明细(Excel,最多 5000 条)。 + [HttpPost("/agent/portal/commission/export")] + public async Task ExportCommissionAsync(AgentPortalCommissionListInput input) + { + var agent = await GetCurrentAgentOrThrowAsync(); + var details = await QueryCommissionDetailsAsync(agent, input); + const int maxRows = 5000; + if (details.Count > maxRows) + { + throw new BusinessException($"导出结果超过 {maxRows} 条(当前 {details.Count}),请缩小筛选条件后再导出"); + } + + var outputs = await MapCommissionOutputsAsync(agent, details); + var rows = outputs.Select(x => new + { + 运单号 = x.ServiceNo, + 客户编号 = x.CustomerCode, + 客户名称 = x.CustomerName, + 基数类型 = ResolveBaseTypeName(x.BaseType), + 基数金额 = x.BaseAmount, + 返佣比例 = x.CommissionRate, + 返佣金额 = x.CommissionAmount, + 状态 = string.Equals(x.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase) + ? "已打款" + : "待结算", + 触发状态 = x.TriggerOrderStatus, + 计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"), + 打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "", + }).ToList(); + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, rows); + return new FileContentResult( + stream.ToArray(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = $"agent-commission-{DateTime.Now:yyyyMMddHHmmss}.xlsx" + }; + } + + private async Task> QueryCommissionDetailsAsync( + Agent agent, + AgentPortalCommissionListInput input) + { + var status = input.Status?.Trim(); + var keyword = input.Keyword?.Trim(); + var start = input.CreateTimeStart; + var end = input.CreateTimeEnd; + + Expression> predicate = x => x.AgentId == agent.Id; + if (!string.IsNullOrWhiteSpace(status)) + { + predicate = x => x.AgentId == agent.Id && x.Status == status; + } + + var list = await DetailRepository.GetListAsync(predicate); + if (start.HasValue) + { + list = list.Where(x => x.CreateTime >= start.Value).ToList(); + } + + if (end.HasValue) + { + var endExclusive = end.Value.Date.AddDays(1); + list = list.Where(x => x.CreateTime < endExclusive).ToList(); + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var orderIds = list.Select(x => x.OrderId).Distinct().ToList(); + var orders = orderIds.Count == 0 + ? [] + : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + var matchedOrderIds = orders + .Where(o => o.ServiceNo.Contains(keyword, StringComparison.OrdinalIgnoreCase) + || (o.TrackNo != null && o.TrackNo.Contains(keyword, StringComparison.OrdinalIgnoreCase))) + .Select(o => o.Id) + .ToHashSet(); + list = list.Where(x => matchedOrderIds.Contains(x.OrderId)).ToList(); + } + + return list + .OrderByDescending(x => x.CreateTime) + .ThenByDescending(x => x.Id) + .ToList(); + } + + private async Task> MapCommissionOutputsAsync( + Agent agent, + IReadOnlyCollection entities) + { + if (entities.Count == 0) + { + return []; + } + + var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList(); + var orderIds = entities.Select(x => x.OrderId).Distinct().ToList(); + var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); + var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + var customerMap = customers.ToDictionary(x => x.Id); + var orderMap = orders.ToDictionary(x => x.Id); + + return entities.Select(x => + { + customerMap.TryGetValue(x.CustomerId, out var customer); + orderMap.TryGetValue(x.OrderId, out var order); + return new AgentCommissionDetailOutput + { + Id = x.Id, + AgentId = x.AgentId, + AgentCode = agent.AgentCode, + AgentName = agent.AgentName, + OrderId = x.OrderId, + ServiceNo = order?.ServiceNo, + CustomerId = x.CustomerId, + CustomerCode = customer?.CustomerCode, + CustomerName = MaskName(customer?.CustomerName), + SalesProductId = x.SalesProductId, + BaseType = x.BaseType, + BaseAmount = x.BaseAmount, + CommissionRate = x.CommissionRate, + CommissionAmount = x.CommissionAmount, + Status = x.Status, + TriggerOrderStatus = x.TriggerOrderStatus, + PaidTime = x.PaidTime, + Remark = x.Remark, + CreateTime = x.CreateTime + }; + }).ToList(); + } + + private async Task> + BuildCustomerActivityMapAsync(int agentId, List mainCustomerIds) + { + var result = mainCustomerIds.ToDictionary( + id => id, + _ => ((DateTime?)null, 0, 0)); + if (mainCustomerIds.Count == 0) + { + return result; + } + + var children = await CustomerRepository.GetListAsync(x => + x.ParentCustomerId != null && mainCustomerIds.Contains(x.ParentCustomerId.Value)); + var mainByCustomerId = new Dictionary(); + foreach (var id in mainCustomerIds) + { + mainByCustomerId[id] = id; + } + + foreach (var child in children) + { + if (child.ParentCustomerId is > 0) + { + mainByCustomerId[child.Id] = child.ParentCustomerId.Value; + } + } + + var allIds = mainByCustomerId.Keys.ToList(); + var since30 = DateTime.Today.AddDays(-30); + var orders = await OrderRepository.GetListAsync(x => + x.AgentId == agentId + && x.CustomerId != null + && allIds.Contains(x.CustomerId.Value) + && x.OrderStatus != OrderStatusCodes.Deleted); + + foreach (var group in orders.GroupBy(o => mainByCustomerId[o.CustomerId!.Value])) + { + var list = group.ToList(); + result[group.Key] = ( + list.Max(o => (DateTime?)o.CreateTime), + list.Count(o => o.CreateTime >= since30), + list.Count(o => string.Equals( + o.OrderStatus, + OrderStatusCodes.PendingPayment, + StringComparison.OrdinalIgnoreCase))); + } + + return result; + } + + private async Task> ResolveSalesProductNamesAsync(string? salesProductIds) + { + var ids = ParseSalesProductIds(salesProductIds); + if (ids.Count == 0) + { + return []; + } + + var products = await SalesProductRepository.GetListAsync(x => ids.Contains(x.Id)); + var map = products.ToDictionary(x => x.Id); + return ids + .Select(id => map.TryGetValue(id, out var p) ? $"{p.ProductName}({p.ProductCode})" : $"#{id}") + .ToList(); + } + + private static List ParseSalesProductIds(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return []; + } + + var text = raw.Trim(); + if (text.StartsWith('[')) + { + try + { + var arr = global::System.Text.Json.JsonSerializer.Deserialize>(text); + return arr?.Where(x => x > 0).Distinct().ToList() ?? []; + } + catch + { + // fall through + } + } + + return text + .Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => int.TryParse(x, out var id) ? id : 0) + .Where(x => x > 0) + .Distinct() + .ToList(); + } + + private static string ResolveBaseTypeName(string? baseType) + { + if (string.Equals(baseType, "receivable", StringComparison.OrdinalIgnoreCase)) + { + return "应收"; + } + + if (string.Equals(baseType, "freight", StringComparison.OrdinalIgnoreCase)) + { + return "运费"; + } + + return string.IsNullOrWhiteSpace(baseType) ? "运费" : baseType; + } + private async Task GetCurrentAgentOrThrowAsync() { - var mapId = CurrentUser.GetId(); + var mapId = OmsSessionHelper.ResolveMapUserId(CurrentUser); if (mapId <= 0) { throw new BusinessException("代理未登录"); diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index fe27eed..19ab5ac 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -78,6 +78,8 @@ public class CustomerService : ApplicationService entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId })); var memberLevelNameMap = await ResolveMemberLevelNameMapAsync( entities.Where(x => x.MemberLevelId.HasValue).Select(x => x.MemberLevelId!.Value)); + var agentMap = await ResolveAgentDisplayMapAsync( + entities.Where(x => x.AgentId.HasValue).Select(x => x.AgentId!.Value)); var items = entities.Select(entity => { var output = ObjectMapper.Map(entity); @@ -90,6 +92,7 @@ public class CustomerService : ApplicationService output.MemberLevelName = levelName; } + FillAgentDisplay(output, entity.AgentId, agentMap); return output; }).ToList(); @@ -121,6 +124,12 @@ public class CustomerService : ApplicationService output.MemberLevelName = level?.LevelName; } + if (entity.AgentId.HasValue) + { + var agentMap = await ResolveAgentDisplayMapAsync([entity.AgentId.Value]); + FillAgentDisplay(output, entity.AgentId, agentMap); + } + return output; } @@ -401,6 +410,11 @@ public class CustomerService : ApplicationService entity.CustomerService = NormalizeOptionalText(input.CustomerService); } + if (input.AgentCode != null) + { + await AgentBinder.ApplyAgentBindingAsync(entity, input.AgentCode, AgentRepository); + } + if (input.CustomerGroup.HasValue) { entity.CustomerGroup = input.CustomerGroup; @@ -500,6 +514,8 @@ public class CustomerService : ApplicationService var statusValue = input.Status.GetValueOrDefault(); var hasCustomerGroupFilter = input.CustomerGroup.HasValue; var customerGroupValue = input.CustomerGroup.GetValueOrDefault(); + var hasAgentFilter = input.AgentId is > 0; + var agentId = input.AgentId.GetValueOrDefault(); return customer => (string.IsNullOrWhiteSpace(keyword) || @@ -510,7 +526,47 @@ public class CustomerService : ApplicationService (!hasStatusFilter || customer.Status == statusValue) && (!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) && (string.IsNullOrWhiteSpace(salesman) || customer.Salesman == salesman) && - (string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService); + (string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService) && + (!hasAgentFilter || customer.AgentId == agentId); + } + + private async Task> ResolveAgentDisplayMapAsync( + IEnumerable agentIds) + { + var ids = agentIds.Distinct().ToList(); + if (ids.Count == 0) + { + return new Dictionary(); + } + + var agents = await AgentRepository.GetListAsync(x => ids.Contains(x.Id)); + return agents.ToDictionary(x => x.Id, x => (x.AgentCode, x.AgentName)); + } + + private static void FillAgentDisplay( + CustomerPaginatedOutput output, + int? agentId, + IReadOnlyDictionary agentMap) + { + output.AgentId = agentId; + if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent)) + { + output.AgentCode = agent.Code; + output.AgentName = agent.Name; + } + } + + private static void FillAgentDisplay( + CustomerOutput output, + int? agentId, + IReadOnlyDictionary agentMap) + { + output.AgentId = agentId; + if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent)) + { + output.AgentCode = agent.Code; + output.AgentName = agent.Name; + } } private static Dictionary BuildCustomerSortDictionary(CustomerPaginatedInput input) diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index c64ecff..b1ed712 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -1208,6 +1208,12 @@ public partial class OrderService return; } + if (OmsSessionHelper.IsAgentLogin(CurrentUser)) + { + await ApplyAgentOrderScopeAsync(filter); + return; + } + var userGuidRaw = CurrentUser.FindClaimValueOrNull(global::System.Security.Claims.ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userGuidRaw) || !Guid.TryParse(userGuidRaw, out var userGuid)) { @@ -1217,4 +1223,41 @@ public partial class OrderService var context = await DataScopeBuilder.BuildAsync(userGuid, DataScopeApplier.OrderResourceCode); DataScopeApplier.ApplyToOrderListFilter(filter, context); } + + /// + /// 渠道代理门户:列表仅可见下单快照 AgentId=本代理的运单。 + /// + private async Task ApplyAgentOrderScopeAsync(OrderListFilter filter) + { + var agentRepository = + LazyServiceProvider.GetRequiredService(); + var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser); + if (userId <= 0) + { + throw new BusinessException("代理未登录"); + } + + var agent = await agentRepository.GetByUserIdAsync(userId); + if (agent == null) + { + throw new BusinessException("代理不存在"); + } + + filter.AgentId = agent.Id; + + if (filter.CustomerId is > 0) + { + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == filter.CustomerId.Value); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + await OmsSessionHelper.EnsureAgentOwnsCustomerAsync( + CustomerRepository, + agentRepository, + CurrentUser, + customer); + } + } } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index a2237c3..e7bfc51 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3508,13 +3508,19 @@ public partial class OrderService } /// - /// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。 + /// 从会话解析有效客户(客户登录自动补全;管理员/代理须传 customerId;代理仅限名下客户)。 /// - private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) => - OmsSessionHelper.ResolveSessionCustomerAsync( + private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) + { + var agentRepository = OmsSessionHelper.IsAgentLogin(CurrentUser) + ? LazyServiceProvider.GetRequiredService() + : null; + return OmsSessionHelper.ResolveSessionCustomerAsync( CustomerRepository, CurrentUser, - requestedCustomerId); + requestedCustomerId, + agentRepository); + } private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId) { diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 1938010..154f1b7 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -704,6 +704,25 @@ public partial class OrderService : ApplicationService { input.CustomerId = await ResolvePriceCheckCustomerIdAsync(); } + else if (OmsSessionHelper.IsAgentLogin(CurrentUser)) + { + // 代理代客查价:仅允许名下绑定客户 + var agentRepository = + LazyServiceProvider.GetRequiredService(); + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + await OmsSessionHelper.EnsureAgentOwnsCustomerAsync( + CustomerRepository, + agentRepository, + CurrentUser, + customer); + input.CustomerId = + await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(CustomerRepository, customer); + } return await BuildPriceCheckResultAsync(input); } @@ -1335,6 +1354,20 @@ public partial class OrderService : ApplicationService private async Task EnsureCustomerCanAccessOrderAsync(Order order) { + if (OmsSessionHelper.IsAgentLogin(CurrentUser)) + { + var agentRepository = + LazyServiceProvider.GetRequiredService(); + var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser); + var agent = userId > 0 ? await agentRepository.GetByUserIdAsync(userId) : null; + if (agent == null || order.AgentId != agent.Id) + { + throw new BusinessException("订单不存在"); + } + + return; + } + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( CustomerRepository, CurrentUser, diff --git a/src/Oms.Application/Shared/AgentBinder.cs b/src/Oms.Application/Shared/AgentBinder.cs index d4abe01..0613642 100644 --- a/src/Oms.Application/Shared/AgentBinder.cs +++ b/src/Oms.Application/Shared/AgentBinder.cs @@ -37,4 +37,36 @@ public static class AgentBinder entity.AgentId = agent.Id; entity.AgentBindTime = DateTime.Now; } + + /// + /// 运营改绑:按代理编号覆盖;空串解绑。子客户不可改(跟主客户)。 + /// + public static async Task ApplyAgentBindingAsync( + Customer entity, + string? agentCode, + IAgentRepository agentRepository, + CancellationToken cancellationToken = default) + { + if (entity.ParentCustomerId.HasValue) + { + throw new BusinessException("请改绑主客户"); + } + + var code = agentCode?.Trim(); + if (string.IsNullOrEmpty(code)) + { + entity.AgentId = null; + entity.AgentBindTime = null; + return; + } + + var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + throw new BusinessException("代理编号无效或已停用"); + } + + entity.AgentId = agent.Id; + entity.AgentBindTime = DateTime.Now; + } } diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs index c5ff2b4..0351e04 100644 --- a/src/Oms.Application/Shared/OmsSessionHelper.cs +++ b/src/Oms.Application/Shared/OmsSessionHelper.cs @@ -1,13 +1,14 @@ using System.Security.Claims; using Fake.Application; using Fake.Users; +using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 从 JWT Claims 解析当前会话类型(管理员 / 客户)。 +/// 从 JWT Claims 解析当前会话类型(管理员 / 客户 / 渠道代理)。 /// public static class OmsSessionHelper { @@ -22,7 +23,9 @@ public static class OmsSessionHelper public const string MapIdClaim = "mapId"; private const string CustomerLoginType = "1"; + private const string AgentLoginType = "2"; private const string CustomerRoleName = "Customer"; + private const string AgentRoleName = "Agent"; /// /// 当前会话是否为客户门户登录。 @@ -43,6 +46,24 @@ public static class OmsSessionHelper return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase); } + /// + /// 当前会话是否为渠道代理门户登录。 + /// + public static bool IsAgentLogin(ICurrentUser currentUser) + { + if (string.Equals( + currentUser.FindClaimOrNull(LoginTypeClaim)?.Value, + AgentLoginType, + StringComparison.Ordinal)) + { + return true; + } + + var role = currentUser.FindClaimOrNull(ClaimTypes.Role)?.Value + ?? currentUser.FindClaimOrNull("role")?.Value; + return string.Equals(role, AgentRoleName, StringComparison.OrdinalIgnoreCase); + } + /// /// 解析当前会话的 RBAC mapId(user.userid)。 /// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。 @@ -85,12 +106,14 @@ public static class OmsSessionHelper TryGetCustomerMapId(currentUser); /// - /// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员可按 CustomerId 代提交)。 + /// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员/代理可按 CustomerId 代提交)。 + /// 代理须传入 ,且仅允许操作名下绑定客户。 /// public static async Task ResolveSessionCustomerAsync( ICustomerRepository customerRepository, ICurrentUser currentUser, - int? requestedCustomerId = null) + int? requestedCustomerId = null, + IAgentRepository? agentRepository = null) { var mapId = TryGetCustomerMapId(currentUser); if (mapId.HasValue) @@ -121,9 +144,53 @@ public static class OmsSessionHelper throw new BusinessException("客户不存在"); } + if (IsAgentLogin(currentUser)) + { + await EnsureAgentOwnsCustomerAsync(customerRepository, agentRepository, currentUser, customer); + } + return customer; } + /// + /// 渠道代理仅可操作名下绑定客户(主客户 AgentId=本代理;子客户跟主客户)。 + /// + public static async Task EnsureAgentOwnsCustomerAsync( + ICustomerRepository customerRepository, + IAgentRepository? agentRepository, + ICurrentUser currentUser, + Customer customer) + { + if (agentRepository == null) + { + throw new BusinessException("代理会话缺少代理仓储"); + } + + var userId = ResolveMapUserId(currentUser); + if (userId <= 0) + { + throw new BusinessException("代理未登录"); + } + + var agent = await agentRepository.GetByUserIdAsync(userId); + if (agent == null) + { + throw new BusinessException("代理不存在"); + } + + int? bindAgentId = customer.AgentId; + if (customer.ParentCustomerId is > 0) + { + var main = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value); + bindAgentId = main?.AgentId; + } + + if (bindAgentId != agent.Id) + { + throw new BusinessException("只能为名下绑定客户操作"); + } + } + /// /// 客户登录时强制返回 t_customer.Id;管理员返回入参 customerId。 /// diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs index 0cbfc0e..419d536 100644 --- a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs +++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs @@ -34,6 +34,11 @@ public class OrderListFilter public int? CustomerId { get; set; } + /// + /// 渠道代理 Id(门户列表强制按下单快照 AgentId 过滤)。 + /// + public int? AgentId { get; set; } + /// /// 发货仓 ID(订单或绑定行所属仓)。 /// diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index df1e482..9ef3900 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -47,6 +47,12 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I query = query.Where(x => x.CustomerId == customerId); } + if (filter.AgentId.HasValue) + { + var agentId = filter.AgentId.Value; + query = query.Where(x => x.AgentId == agentId); + } + var status = filter.Status?.Trim(); var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase); if (isDeletedTab) @@ -1503,6 +1509,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CreateTimeStart = filter.CreateTimeStart, CreateTimeEnd = filter.CreateTimeEnd, CustomerId = filter.CustomerId, + AgentId = filter.AgentId, FieldFilters = filter.FieldFilters, SortField = filter.SortField, SortOrder = filter.SortOrder, @@ -1549,6 +1556,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CreateTimeStart = filter.CreateTimeStart, CreateTimeEnd = filter.CreateTimeEnd, CustomerId = filter.CustomerId, + AgentId = filter.AgentId, WarehouseId = filter.WarehouseId, FulfillmentType = filter.FulfillmentType, StockBindStatus = filter.StockBindStatus, -- 2.45.2 From 4b237c1374819a49a7d0c46be384d94e9471e50d Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 30 Oct 2026 10:52:16 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E4=BB=A3=E7=90=86=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8C=89=E5=90=8D=E4=B8=8B=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 门户可见范围改用客户当前 AgentId;返佣仍认订单快照 AgentId。 Co-authored-by: Cursor --- .../Services/Agents/AgentPortalService.cs | 29 +++++++------------ .../Services/Orders/OrderService.Helpers.cs | 6 ++-- .../Services/Orders/OrderService.cs | 23 +++++++++++++-- .../OrdersAggregate/OrderListFilter.cs | 7 ++++- .../Orders/OrderRepository.cs | 19 ++++++++++++ 5 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs index 20c43ba..f863acf 100644 --- a/src/Oms.Application/Services/Agents/AgentPortalService.cs +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -111,31 +111,22 @@ public class AgentPortalService : ApplicationService }; } - /// 名下订单分页(强制 Order.AgentId;不含应付)。 + /// 名下订单分页(按当前绑定客户可见;不含应付)。 [HttpPost("/agent/portal/orders/list")] public async Task> GetOrdersAsync(AgentPortalPageInput input) { var agent = await GetCurrentAgentOrThrowAsync(); var keyword = input.Keyword?.Trim(); - Expression> predicate = string.IsNullOrWhiteSpace(keyword) - ? x => x.AgentId == agent.Id - : x => x.AgentId == agent.Id - && (x.ServiceNo.Contains(keyword!) - || (x.TrackNo != null && x.TrackNo.Contains(keyword!)) - || (x.ProductCode != null && x.ProductCode.Contains(keyword!))); + var filter = new OrderListFilter + { + OwnedByAgentId = agent.Id, + Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword, + Status = "all" + }; var page = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); - var entities = await OrderRepository.GetPagedListAsync( - predicate, - page, - pageSize, - new Dictionary - { - [nameof(Order.CreateTime)] = false, - [nameof(Order.Id)] = false - }); - var total = await OrderRepository.CountAsync(predicate); + var (entities, total) = await OrderRepository.GetFilteredPagedListAsync(page, pageSize, filter); var customerIds = entities.Where(x => x.CustomerId.HasValue).Select(x => x.CustomerId!.Value).Distinct().ToList(); var customers = customerIds.Count == 0 @@ -175,7 +166,7 @@ public class AgentPortalService : ApplicationService public async Task GetWorkplaceStatsAsync() { var agent = await GetCurrentAgentOrThrowAsync(); - var filter = new OrderListFilter { AgentId = agent.Id }; + var filter = new OrderListFilter { OwnedByAgentId = agent.Id }; var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter); var counts = rows .Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) @@ -244,7 +235,7 @@ public class AgentPortalService : ApplicationService { var agent = await GetCurrentAgentOrThrowAsync(); days = Math.Clamp(days, 1, 31); - var filter = new OrderListFilter { AgentId = agent.Id }; + var filter = new OrderListFilter { OwnedByAgentId = agent.Id }; var today = DateTime.Today; var rangeStart = today.AddDays(-(days - 1)); var rangeEnd = today.AddDays(1); diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index b1ed712..a3d5ca0 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -1225,7 +1225,7 @@ public partial class OrderService } /// - /// 渠道代理门户:列表仅可见下单快照 AgentId=本代理的运单。 + /// 渠道代理门户:列表按名下客户过滤(客户/主客户当前 AgentId);返佣仍认订单快照 AgentId。 /// private async Task ApplyAgentOrderScopeAsync(OrderListFilter filter) { @@ -1243,7 +1243,9 @@ public partial class OrderService throw new BusinessException("代理不存在"); } - filter.AgentId = agent.Id; + filter.OwnedByAgentId = agent.Id; + // 避免与快照字段叠滤,导致换绑前历史单被挡掉 + filter.AgentId = null; if (filter.CustomerId is > 0) { diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 154f1b7..b42546b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -1358,9 +1358,26 @@ public partial class OrderService : ApplicationService { var agentRepository = LazyServiceProvider.GetRequiredService(); - var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser); - var agent = userId > 0 ? await agentRepository.GetByUserIdAsync(userId) : null; - if (agent == null || order.AgentId != agent.Id) + if (order.CustomerId is not > 0) + { + throw new BusinessException("订单不存在"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); + if (customer == null) + { + throw new BusinessException("订单不存在"); + } + + try + { + await OmsSessionHelper.EnsureAgentOwnsCustomerAsync( + CustomerRepository, + agentRepository, + CurrentUser, + customer); + } + catch (BusinessException) { throw new BusinessException("订单不存在"); } diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs index 419d536..429d54c 100644 --- a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs +++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs @@ -35,10 +35,15 @@ public class OrderListFilter public int? CustomerId { get; set; } /// - /// 渠道代理 Id(门户列表强制按下单快照 AgentId 过滤)。 + /// 按下单快照 Order.AgentId 过滤(返佣归属;历史单换绑后不变)。 /// public int? AgentId { get; set; } + /// + /// 按名下客户可见:订单客户(或主客户)当前 AgentId = 此值。代理门户运单列表用此字段。 + /// + public int? OwnedByAgentId { get; set; } + /// /// 发货仓 ID(订单或绑定行所属仓)。 /// diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 9ef3900..8a46cbf 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -53,6 +53,23 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I query = query.Where(x => x.AgentId == agentId); } + // 名下客户可见:本客户 AgentId,或子客户的主客户 AgentId + if (filter.OwnedByAgentId.HasValue) + { + var ownedByAgentId = filter.OwnedByAgentId.Value; + query = query.Where(o => + SqlFunc.Subqueryable() + .Where(c => c.Id == o.CustomerId && c.AgentId == ownedByAgentId) + .Any() + || SqlFunc.Subqueryable() + .Where(c => c.Id == o.CustomerId + && c.ParentCustomerId != null + && SqlFunc.Subqueryable() + .Where(m => m.Id == c.ParentCustomerId && m.AgentId == ownedByAgentId) + .Any()) + .Any()); + } + var status = filter.Status?.Trim(); var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase); if (isDeletedTab) @@ -1510,6 +1527,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CreateTimeEnd = filter.CreateTimeEnd, CustomerId = filter.CustomerId, AgentId = filter.AgentId, + OwnedByAgentId = filter.OwnedByAgentId, FieldFilters = filter.FieldFilters, SortField = filter.SortField, SortOrder = filter.SortOrder, @@ -1557,6 +1575,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CreateTimeEnd = filter.CreateTimeEnd, CustomerId = filter.CustomerId, AgentId = filter.AgentId, + OwnedByAgentId = filter.OwnedByAgentId, WarehouseId = filter.WarehouseId, FulfillmentType = filter.FulfillmentType, StockBindStatus = filter.StockBindStatus, -- 2.45.2 From 07f589d57b2c923c5ea0340640119cf11eadc116 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 30 Oct 2026 11:06:51 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E4=BB=A3=E7=90=86=E8=BF=94?= =?UTF-8?q?=E4=BD=A3=E4=B8=8B=E5=8D=95=E9=A2=84=E8=AE=A1=E4=B8=8E=20Outbox?= =?UTF-8?q?=20=E5=BC=82=E6=AD=A5=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下单写 estimated、终态锁定 pending、取消/退款 void;打款仅认 pending,写入走集成事件异步消费。 Co-authored-by: Cursor --- src/Oms.Application/Dtos/Agents/AgentDtos.cs | 6 + .../Agents/AgentCommissionJobActionCodes.cs | 14 + .../Agents/AgentCommissionJobEventHandler.cs | 75 +++++ .../AgentCommissionJobIntegrationEvent.cs | 21 ++ .../TrackChangeOrderStatusApplier.cs | 24 +- .../Services/Agents/AgentAdminService.cs | 3 +- .../Services/Agents/AgentPortalService.cs | 8 +- .../Orders/OrderService.ParcelBilling.cs | 55 ++++ .../Services/Orders/OrderService.cs | 44 ++- .../Shared/AgentCommissionManager.cs | 301 +++++++++++++++--- .../Consts/AgentCommissionStatusCodes.cs | 25 +- .../Entities/AgentCommissionDetail.cs | 4 +- 12 files changed, 508 insertions(+), 72 deletions(-) create mode 100644 src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs create mode 100644 src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs create mode 100644 src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs index 8781ac0..23ad7d3 100644 --- a/src/Oms.Application/Dtos/Agents/AgentDtos.cs +++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs @@ -308,6 +308,12 @@ public class AgentWorkplaceStatsOutput /// 派送成功运单数(全量状态桶)。 public int DeliverySuccess { get; set; } + /// 预计返佣笔数(未锁定)。 + public int EstimatedCommissionCount { get; set; } + + /// 预计返佣金额(未锁定,不计入应打款)。 + public decimal EstimatedCommissionAmount { get; set; } + /// 待结算返佣笔数。 public int PendingCommissionCount { get; set; } diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs new file mode 100644 index 0000000..ca11e1d --- /dev/null +++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs @@ -0,0 +1,14 @@ +namespace Oms.Application.Messaging.Agents; + +/// 代理返佣异步任务动作。 +public static class AgentCommissionJobActionCodes +{ + /// 下单/计费后写或刷新预计返佣。 + public const string Estimate = "estimate"; + + /// 派送终态锁定为待结算。 + public const string Finalize = "finalize"; + + /// 删单/全额退作废。 + public const string Void = "void"; +} diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs new file mode 100644 index 0000000..8a5866c --- /dev/null +++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs @@ -0,0 +1,75 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Oms.Application.Shared; + +namespace Oms.Application.Messaging.Agents; + +/// 消费代理返佣异步任务。 +public sealed class AgentCommissionJobEventHandler( + IServiceScopeFactory scopeFactory, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + AgentCommissionJobIntegrationEvent @event, + CancellationToken cancellationToken) + { + if (@event.OrderId <= 0 || string.IsNullOrWhiteSpace(@event.Action)) + { + logger.LogWarning( + "AgentCommission job skipped invalid payload OrderId={OrderId} Action={Action}", + @event.OrderId, + @event.Action); + return; + } + + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + using var scope = scopeFactory.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + + try + { + var action = @event.Action.Trim(); + if (string.Equals(action, AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase)) + { + await manager.TryEstimateOnOrderCreatedAsync(@event.OrderId, cancellationToken); + } + else if (string.Equals(action, AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase)) + { + await manager.TryAccrueOnDeliveryTerminalAsync( + @event.OrderId, + @event.TriggerOrderStatus ?? string.Empty, + cancellationToken); + } + else if (string.Equals(action, AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase)) + { + await manager.TryVoidOnOrderCancelledAsync( + @event.OrderId, + @event.Reason ?? "异步作废返佣", + cancellationToken); + } + else + { + logger.LogWarning( + "AgentCommission job unknown Action={Action} OrderId={OrderId}", + @event.Action, + @event.OrderId); + } + } + catch (Exception ex) + { + // 失败 Ack,避免打满队列;明细可后续补偿 + logger.LogWarning( + ex, + "AgentCommission job failed OrderId={OrderId} Action={Action}", + @event.OrderId, + @event.Action); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs new file mode 100644 index 0000000..a5b168d --- /dev/null +++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs @@ -0,0 +1,21 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.Agents; + +/// +/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。 +/// +[Serializable] +public class AgentCommissionJobIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + /// 。 + public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate; + + /// finalize 时的触发运单状态。 + public string? TriggerOrderStatus { get; set; } + + /// void 时的原因备注。 + public string? Reason { get; set; } +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs index 4a31055..9a973e6 100644 --- a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs @@ -1,6 +1,8 @@ using Fake.DependencyInjection; +using Fake.EventBus.Distributed; using Fake.UnitOfWork; using Microsoft.Extensions.Logging; +using Oms.Application.Messaging.Agents; using Oms.Application.Services.Orders; using Oms.Application.Shared; using Oms.Domain.OrdersAggregate.Consts; @@ -16,7 +18,7 @@ public sealed class TrackChangeOrderStatusApplier( IOrderRepository orderRepository, OrderOperationLogWriter operationLogWriter, ReferralCommissionManager referralCommissionManager, - AgentCommissionManager agentCommissionManager, + IDistributedEventBus eventBus, MarketingCouponManager marketingCouponManager, IUnitOfWorkManager unitOfWorkManager, ILogger logger) : ITrackChangeOrderStatusApplier, IScopedDependency @@ -133,17 +135,21 @@ public sealed class TrackChangeOrderStatusApplier( // 重新加载,避免营销侧依赖最新字段 order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken); - await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( - order.Id, - OrderStatusCodes.DeliverySuccess, - cancellationToken); + await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent + { + OrderId = order.Id, + Action = AgentCommissionJobActionCodes.Finalize, + TriggerOrderStatus = OrderStatusCodes.DeliverySuccess + }); } else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase)) { - await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( - order.Id, - OrderStatusCodes.DeliveryFailed, - cancellationToken); + await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent + { + OrderId = order.Id, + Action = AgentCommissionJobActionCodes.Finalize, + TriggerOrderStatus = OrderStatusCodes.DeliveryFailed + }); } order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs index 4b1b670..f354267 100644 --- a/src/Oms.Application/Services/Agents/AgentAdminService.cs +++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs @@ -254,7 +254,8 @@ public class AgentAdminService : ApplicationService var remark = NormalizeOptional(input.Remark); foreach (var item in list) { - if (string.Equals(item.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)) + // 仅「待结算」可标记打款;预计/作废/已打款跳过 + if (!string.Equals(item.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs index f863acf..de09879 100644 --- a/src/Oms.Application/Services/Agents/AgentPortalService.cs +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -202,6 +202,8 @@ public class AgentPortalService : ApplicationService && x.AgentBindTime >= weekStart && x.AgentBindTime < tomorrow); + var estimatedDetails = await DetailRepository.GetListAsync(x => + x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Estimated); var pendingDetails = await DetailRepository.GetListAsync(x => x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending); var paidDetails = await DetailRepository.GetListAsync(x => @@ -218,6 +220,8 @@ public class AgentPortalService : ApplicationService Problematic = GetCount(OrderStatusCodes.Problematic), DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess), DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed), + EstimatedCommissionCount = estimatedDetails.Count, + EstimatedCommissionAmount = estimatedDetails.Sum(x => x.CommissionAmount), PendingCommissionCount = pendingDetails.Count, PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount), PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount), @@ -315,9 +319,7 @@ public class AgentPortalService : ApplicationService 基数金额 = x.BaseAmount, 返佣比例 = x.CommissionRate, 返佣金额 = x.CommissionAmount, - 状态 = string.Equals(x.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase) - ? "已打款" - : "待结算", + 状态 = AgentCommissionStatusCodes.DisplayName(x.Status), 触发状态 = x.TriggerOrderStatus, 计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"), 打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "", diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index e7bfc51..68bd43e 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -186,6 +186,9 @@ public partial class OrderService createContext); order.AgentId = orderAgentId; order = await OrderRepository.InsertAsync(order); + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Estimate); var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); @@ -2955,6 +2958,9 @@ public partial class OrderService order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Estimate); // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 @@ -3522,6 +3528,55 @@ public partial class OrderService agentRepository); } + /// + /// 代理返佣异步投递:有事务 Outbox 则发事件;否则同步兜底(无 UoW 场景)。 + /// + private async Task EnqueueAgentCommissionJobAsync( + int orderId, + string action, + string? triggerOrderStatus = null, + string? reason = null) + { + if (orderId <= 0 || string.IsNullOrWhiteSpace(action)) + { + return; + } + + var uowManager = LazyServiceProvider.GetRequiredService(); + if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager)) + { + await EventBus.PublishAsync(new Messaging.Agents.AgentCommissionJobIntegrationEvent + { + OrderId = orderId, + Action = action.Trim(), + TriggerOrderStatus = triggerOrderStatus, + Reason = reason + }); + return; + } + + try + { + var mgr = LazyServiceProvider.GetRequiredService(); + if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase)) + { + await mgr.TryEstimateOnOrderCreatedAsync(orderId); + } + else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase)) + { + await mgr.TryAccrueOnDeliveryTerminalAsync(orderId, triggerOrderStatus ?? string.Empty); + } + else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase)) + { + await mgr.TryVoidOnOrderCancelledAsync(orderId, reason ?? "同步作废返佣"); + } + } + catch + { + // 返佣失败不阻断主业务 + } + } + private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index b42546b..2a4e99c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -349,11 +349,11 @@ public partial class OrderService : ApplicationService await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess); } - private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) - { - var agentCommission = LazyServiceProvider.GetRequiredService(); - await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus); - } + private Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) => + EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Finalize, + triggerOrderStatus: triggerStatus); /// /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。 @@ -376,19 +376,27 @@ public partial class OrderService : ApplicationService var commissionManager = LazyServiceProvider.GetRequiredService(); await commissionManager.ClawbackOnOrderRefundAsync(order); - if (input.IsFullRefund && order.CustomerId.HasValue) + if (input.IsFullRefund) { - var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); - if (customer != null) + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Void, + reason: "订单全额退款作废返佣"); + + if (order.CustomerId.HasValue) { - var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( - CustomerRepository, customer); - var pointsManager = LazyServiceProvider.GetRequiredService(); - await pointsManager.ClawbackOnOrderAsync( - mainCustomer.Id, - order.Id, - order.ServiceNo, - "订单退款扣回积分"); + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); + if (customer != null) + { + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + CustomerRepository, customer); + var pointsManager = LazyServiceProvider.GetRequiredService(); + await pointsManager.ClawbackOnOrderAsync( + mainCustomer.Id, + order.Id, + order.ServiceNo, + "订单退款扣回积分"); + } } } } @@ -477,6 +485,10 @@ public partial class OrderService : ApplicationService } await OrderRepository.UpdateAsync(order); + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Void, + reason: "订单删除作废返佣"); } /// diff --git a/src/Oms.Application/Shared/AgentCommissionManager.cs b/src/Oms.Application/Shared/AgentCommissionManager.cs index 6684b59..d330e67 100644 --- a/src/Oms.Application/Shared/AgentCommissionManager.cs +++ b/src/Oms.Application/Shared/AgentCommissionManager.cs @@ -4,14 +4,16 @@ using Oms.Domain.AgentsAggregate.Entities; using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.Shared.Enums; using System.Globalization; using System.Text.Json; namespace Oms.Application.Shared; /// -/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。 +/// 渠道代理返佣:下单写预计 → 派送终态锁定待结算 → 取消/退款作废;不入余额。 /// public class AgentCommissionManager : IScopedDependency { @@ -32,6 +34,27 @@ public class AgentCommissionManager : IScopedDependency _salesProductRepository = salesProductRepository; } + /// 下单后写入/刷新预计返佣(仅 estimated;已锁定/已打款/已作废不改)。 + public async Task TryEstimateOnOrderCreatedAsync( + int orderId, + CancellationToken cancellationToken = default) + { + var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); + if (order == null) + { + return; + } + + await UpsertEstimatedAsync(order, cancellationToken); + } + + /// 预报计费完成后刷新预计金额(仍为 estimated)。 + public Task TryRefreshEstimateAfterBillingAsync( + int orderId, + CancellationToken cancellationToken = default) => + TryEstimateOnOrderCreatedAsync(orderId, cancellationToken); + + /// 派送成功/失败:重算锁定为 pending。 public async Task TryAccrueOnDeliveryTerminalAsync( int orderId, string triggerOrderStatus, @@ -42,12 +65,6 @@ public class AgentCommissionManager : IScopedDependency return; } - var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); - if (existing != null) - { - return; - } - var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue) { @@ -59,44 +76,50 @@ public class AgentCommissionManager : IScopedDependency return; } + var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); + if (existing != null + && (string.Equals(existing.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase) + || string.Equals(existing.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase))) + { + // pending 已锁定;paid 不回写 + return; + } + + if (existing != null + && string.Equals(existing.Status, AgentCommissionStatusCodes.Void, StringComparison.OrdinalIgnoreCase)) + { + return; + } + var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken); - if (agent == null || agent.Status != Domain.Shared.Enums.EnableStatus.Enabled) + if (agent == null || agent.Status != EnableStatus.Enabled) { return; } - if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType)) + var built = await TryBuildAmountsAsync(agent, order, cancellationToken); + if (built == null) { + // 终态仍算不出金额:若已有预计行则作废,避免脏数据进应结 + if (existing != null + && string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + existing.Status = AgentCommissionStatusCodes.Void; + existing.TriggerOrderStatus = triggerOrderStatus.Trim(); + existing.Remark = AppendRemark(existing.Remark, "终态基数为0,自动作废"); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + return; } - if (agent.CommissionRate <= 0) - { - return; - } - - int? salesProductId = null; - if (!string.IsNullOrWhiteSpace(order.ProductCode)) - { - var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); - salesProductId = product?.Id; - } - - if (!IsProductAllowed(agent.SalesProductIds, salesProductId)) - { - return; - } - - var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]); - var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order); - if (baseAmount <= 0) - { - return; - } - - var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero); - if (amount <= 0) + if (existing != null) { + ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount); + existing.Status = AgentCommissionStatusCodes.Pending; + existing.TriggerOrderStatus = triggerOrderStatus.Trim(); + existing.Remark = AppendRemark(existing.Remark, "派送终态锁定"); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); return; } @@ -104,6 +127,132 @@ public class AgentCommissionManager : IScopedDependency existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); if (existing != null) { + if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount); + existing.Status = AgentCommissionStatusCodes.Pending; + existing.TriggerOrderStatus = triggerOrderStatus.Trim(); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + + return; + } + + await _detailRepository.InsertAsync(new AgentCommissionDetail + { + AgentId = agent.Id, + OrderId = order.Id, + CustomerId = order.CustomerId.Value, + SalesProductId = built.Value.SalesProductId, + BaseType = agent.BaseType.Trim().ToLowerInvariant(), + BaseAmount = built.Value.BaseAmount, + CommissionRate = agent.CommissionRate, + CommissionAmount = built.Value.Amount, + Status = AgentCommissionStatusCodes.Pending, + TriggerOrderStatus = triggerOrderStatus.Trim() + }, cancellationToken); + } + + /// 订单删除 / 全额退款:预计或待结算作废;已打款不动。 + public async Task TryVoidOnOrderCancelledAsync( + int orderId, + string reason, + CancellationToken cancellationToken = default) + { + var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); + if (existing == null || !AgentCommissionStatusCodes.IsOpen(existing.Status)) + { + return; + } + + existing.Status = AgentCommissionStatusCodes.Void; + existing.Remark = AppendRemark(existing.Remark, reason); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + + private async Task UpsertEstimatedAsync(Order order, CancellationToken cancellationToken) + { + if (!order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue) + { + return; + } + + if (order.IsRefunded > 0 + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken); + if (existing != null + && !string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + return; + } + + var built = await TryBuildAmountsAsync(agent, order, cancellationToken); + int? salesProductId; + decimal baseAmount; + decimal amount; + if (built == null) + { + // 计费未出前也落一条预计(金额 0),方便代理看到「已关联」 + if (agent.CommissionRate <= 0 || !AgentCommissionBaseTypes.IsSupported(agent.BaseType)) + { + return; + } + + int? productId = null; + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + productId = product?.Id; + } + + if (!IsProductAllowed(agent.SalesProductIds, productId)) + { + return; + } + + salesProductId = productId; + baseAmount = 0M; + amount = 0M; + } + else + { + salesProductId = built.Value.SalesProductId; + baseAmount = built.Value.BaseAmount; + amount = built.Value.Amount; + } + + if (existing != null) + { + ApplyAmounts(existing, agent, salesProductId, baseAmount, amount); + existing.Status = AgentCommissionStatusCodes.Estimated; + if (string.IsNullOrWhiteSpace(existing.TriggerOrderStatus)) + { + existing.TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty; + } + + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + return; + } + + existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken); + if (existing != null) + { + if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + ApplyAmounts(existing, agent, salesProductId, baseAmount, amount); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + return; } @@ -117,19 +266,92 @@ public class AgentCommissionManager : IScopedDependency BaseAmount = baseAmount, CommissionRate = agent.CommissionRate, CommissionAmount = amount, - Status = AgentCommissionStatusCodes.Pending, - TriggerOrderStatus = triggerOrderStatus.Trim() + Status = AgentCommissionStatusCodes.Estimated, + TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty }, cancellationToken); } + private async Task<(int? SalesProductId, decimal BaseAmount, decimal Amount)?> TryBuildAmountsAsync( + Agent agent, + Order order, + CancellationToken cancellationToken) + { + if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType) || agent.CommissionRate <= 0) + { + return null; + } + + int? salesProductId = null; + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + salesProductId = product?.Id; + } + + if (!IsProductAllowed(agent.SalesProductIds, salesProductId)) + { + return null; + } + + var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]); + var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order); + if (baseAmount <= 0) + { + return null; + } + + var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero); + if (amount <= 0) + { + return null; + } + + return (salesProductId, baseAmount, amount); + } + + private static void ApplyAmounts( + AgentCommissionDetail detail, + Agent agent, + int? salesProductId, + decimal baseAmount, + decimal amount) + { + detail.SalesProductId = salesProductId; + detail.BaseType = agent.BaseType.Trim().ToLowerInvariant(); + detail.BaseAmount = baseAmount; + detail.CommissionRate = agent.CommissionRate; + detail.CommissionAmount = amount; + } + + private static string? AppendRemark(string? existing, string note) + { + var n = note?.Trim(); + if (string.IsNullOrWhiteSpace(n)) + { + return existing; + } + + if (string.IsNullOrWhiteSpace(existing)) + { + return n; + } + + if (existing.Contains(n, StringComparison.Ordinal)) + { + return existing; + } + + return $"{existing}; {n}"; + } + private static bool IsTerminalTrigger(string? status) => string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase) || string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase); private static decimal ResolveBaseAmount( string baseType, - List fees, - Domain.OrdersAggregate.Entities.Order order) + List fees, + Order order) { if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase)) { @@ -139,7 +361,6 @@ public class AgentCommissionManager : IScopedDependency order.CouponDiscountAmount); } - // receivable:折前应收小计(排除营销抵扣行) var sum = MarketingFeeHelper.SumReceivable(fees); if (sum > 0) { diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs index 0639032..407ed2c 100644 --- a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs +++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs @@ -1,8 +1,31 @@ namespace Oms.Domain.AgentsAggregate.Consts; -/// 代理返佣明细状态(首版)。 +/// 代理返佣明细结算状态。 public static class AgentCommissionStatusCodes { + /// 下单后预计返佣(金额可随计费刷新,不计入应打款)。 + public const string Estimated = "estimated"; + + /// 派送终态锁定,待运营线下打款。 public const string Pending = "pending"; + + /// 已打款。 public const string Paid = "paid"; + + /// 订单取消/全额退等作废。 + public const string Void = "void"; + + public static bool IsOpen(string? status) => + string.Equals(status, Estimated, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, Pending, StringComparison.OrdinalIgnoreCase); + + public static string DisplayName(string? status) => + status?.Trim().ToLowerInvariant() switch + { + Estimated => "预计返佣", + Pending => "待结算", + Paid => "已打款", + Void => "已作废", + _ => string.IsNullOrWhiteSpace(status) ? "--" : status + }; } diff --git a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs index dba8aec..1e8a65d 100644 --- a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs +++ b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs @@ -24,9 +24,9 @@ public class AgentCommissionDetail : OmsAuditedAggregateRoot public decimal CommissionAmount { get; set; } - /// pending / paid。 + /// estimated / pending / paid / void。 [SugarColumn(Length = 16)] - public string Status { get; set; } = "pending"; + public string Status { get; set; } = "estimated"; [SugarColumn(Length = 32)] public string TriggerOrderStatus { get; set; } = string.Empty; -- 2.45.2 From 1f066f63c82a55078be223890ea2dadeb8ec71d1 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 30 Oct 2026 12:31:37 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=BB=A3=E7=90=86=E8=BF=94=E4=BD=A3?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E8=A7=A6=E5=8F=91=E7=8A=B6=E6=80=81=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=B8=AD=E6=96=87=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出明细不再直接写英文/原始状态码,与门户展示口径一致。 Co-authored-by: Cursor --- src/Oms.Application/Services/Agents/AgentPortalService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs index de09879..3b30f9a 100644 --- a/src/Oms.Application/Services/Agents/AgentPortalService.cs +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -320,7 +320,7 @@ public class AgentPortalService : ApplicationService 返佣比例 = x.CommissionRate, 返佣金额 = x.CommissionAmount, 状态 = AgentCommissionStatusCodes.DisplayName(x.Status), - 触发状态 = x.TriggerOrderStatus, + 触发状态 = OmsDocHelper.GetOrderStatusName(x.TriggerOrderStatus) ?? x.TriggerOrderStatus, 计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"), 打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "", }).ToList(); -- 2.45.2 From 9be7fcd1439a8fe68eda8795297ec54b7867b606 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 30 Oct 2026 17:33:04 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=E5=AE=A2=E6=88=B7=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=8E=A5=E5=8F=A3=E5=85=81=E8=AE=B8=E5=8C=BF?= =?UTF-8?q?=E5=90=8D=E8=AE=BF=E9=97=AE=EF=BC=8C=E9=81=BF=E5=85=8D=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E9=A1=B5=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/Oms.Application/Services/Customers/CustomerService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 19ab5ac..009bedc 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -6,6 +6,7 @@ using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; using Fake.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Customers; @@ -228,10 +229,11 @@ public class CustomerService : ApplicationService } /// - /// 新增客户。 + /// 新增客户(公开注册页可匿名调用;管理端也可带 token 代开户)。 /// /// 入参:客户创建参数。 /// 无返回体,仅完成新增客户。 + [AllowAnonymous] [HttpPost("/customer/create")] public async Task CreateAsync(CreateCustomerInput input) { -- 2.45.2