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] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=B3=A8=E5=86=8C=E5=BD=92=E5=9B=A0=E4=B8=8E?= =?UTF-8?q?=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,