feat: 新增渠道代理注册归因与返佣明细
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
独立 Agent 主体与 loginType=2,注册绑定/下单快照,派送成功失败计提明细,不含提现入账。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
221a2c8a66
commit
1d987cde6e
78
sql/migrate_agent_module_20261029.sql
Normal file
78
sql/migrate_agent_module_20261029.sql
Normal file
@ -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;
|
||||||
234
src/Oms.Application/Dtos/Agents/AgentDtos.cs
Normal file
234
src/Oms.Application/Dtos/Agents/AgentDtos.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>freight / receivable。</summary>
|
||||||
|
[Required]
|
||||||
|
[StringLength(32)]
|
||||||
|
public string BaseType { get; set; } = "freight";
|
||||||
|
|
||||||
|
[Range(0, 1)]
|
||||||
|
public decimal CommissionRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可选销售产品 Id,JSON 或逗号分隔;空=不限。</summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>不传则不改密码。</summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>空则解绑。</summary>
|
||||||
|
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<int> 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; }
|
||||||
|
}
|
||||||
@ -124,4 +124,10 @@ public class CreateCustomerInput
|
|||||||
[StringLength(64)]
|
[StringLength(64)]
|
||||||
public string? AdvisorCode { get; set; }
|
public string? AdvisorCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(32)]
|
||||||
|
public string? AgentCode { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ public sealed class TrackChangeOrderStatusApplier(
|
|||||||
IOrderRepository orderRepository,
|
IOrderRepository orderRepository,
|
||||||
OrderOperationLogWriter operationLogWriter,
|
OrderOperationLogWriter operationLogWriter,
|
||||||
ReferralCommissionManager referralCommissionManager,
|
ReferralCommissionManager referralCommissionManager,
|
||||||
|
AgentCommissionManager agentCommissionManager,
|
||||||
MarketingCouponManager marketingCouponManager,
|
MarketingCouponManager marketingCouponManager,
|
||||||
IUnitOfWorkManager unitOfWorkManager,
|
IUnitOfWorkManager unitOfWorkManager,
|
||||||
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
|
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
|
||||||
@ -132,6 +133,17 @@ public sealed class TrackChangeOrderStatusApplier(
|
|||||||
// 重新加载,避免营销侧依赖最新字段
|
// 重新加载,避免营销侧依赖最新字段
|
||||||
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
|
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
|
||||||
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
|
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;
|
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
|
||||||
|
|||||||
414
src/Oms.Application/Services/Agents/AgentAdminService.cs
Normal file
414
src/Oms.Application/Services/Agents/AgentAdminService.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道代理管理端 API(开户、规则、改绑、返佣明细)。
|
||||||
|
/// </summary>
|
||||||
|
[ApiExplorerSettings(GroupName = "OMS-Agent")]
|
||||||
|
public class AgentAdminService : ApplicationService
|
||||||
|
{
|
||||||
|
public const string AgentRoleCode = "Agent";
|
||||||
|
|
||||||
|
private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||||
|
|
||||||
|
private IAgentCommissionDetailRepository DetailRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IAgentCommissionDetailRepository>();
|
||||||
|
|
||||||
|
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||||
|
|
||||||
|
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||||
|
|
||||||
|
private UserService UserService => LazyServiceProvider.GetRequiredService<UserService>();
|
||||||
|
|
||||||
|
private RoleService RoleService => LazyServiceProvider.GetRequiredService<RoleService>();
|
||||||
|
|
||||||
|
private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService<FakeRbacDbContext>();
|
||||||
|
|
||||||
|
/// <summary>分页查询代理。</summary>
|
||||||
|
[HttpPost("/agent/admin/list")]
|
||||||
|
public async Task<OmsPageResult<AgentOutput>> 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<string, bool> { [nameof(Agent.CreateTime)] = false, [nameof(Agent.Id)] = false });
|
||||||
|
var total = await AgentRepository.CountAsync(predicate);
|
||||||
|
return new OmsPageResult<AgentOutput>
|
||||||
|
{
|
||||||
|
Items = entities.Select(MapAgent).ToList(),
|
||||||
|
Total = total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>创建代理(同步 RBAC 用户 + Agent 角色)。</summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>更新代理资料与返佣规则。</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>改绑主客户所属代理(仅影响之后新单)。</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>分页查询返佣明细。</summary>
|
||||||
|
[HttpPost("/agent/admin/commission/list")]
|
||||||
|
public async Task<OmsPageResult<AgentCommissionDetailOutput>> 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<string, bool>
|
||||||
|
{
|
||||||
|
[nameof(AgentCommissionDetail.CreateTime)] = false,
|
||||||
|
[nameof(AgentCommissionDetail.Id)] = false
|
||||||
|
});
|
||||||
|
var total = await DetailRepository.CountAsync(predicate);
|
||||||
|
return new OmsPageResult<AgentCommissionDetailOutput>
|
||||||
|
{
|
||||||
|
Items = await MapCommissionOutputsAsync(entities),
|
||||||
|
Total = total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>标记返佣明细为已打款。</summary>
|
||||||
|
[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<Func<Agent, bool>> 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<Func<AgentCommissionDetail, bool>> 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<List<AgentCommissionDetailOutput>> MapCommissionOutputsAsync(
|
||||||
|
List<AgentCommissionDetail> 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<Guid> 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<int> GetRbacUserMapIdOrThrowAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
|
||||||
|
var mapId = await RbacDbContext.Database.SqlQueryRaw<int?>(sql, userId).FirstOrDefaultAsync();
|
||||||
|
if (!mapId.HasValue || mapId.Value <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("RBAC 用户映射失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapId.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/Oms.Application/Services/Agents/AgentPortalService.cs
Normal file
258
src/Oms.Application/Services/Agents/AgentPortalService.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道代理门户只读 API(名下客户 / 订单 / 返佣)。
|
||||||
|
/// </summary>
|
||||||
|
[ApiExplorerSettings(GroupName = "OMS-Agent")]
|
||||||
|
public class AgentPortalService : ApplicationService
|
||||||
|
{
|
||||||
|
private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||||
|
|
||||||
|
private IAgentCommissionDetailRepository DetailRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IAgentCommissionDetailRepository>();
|
||||||
|
|
||||||
|
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||||
|
|
||||||
|
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||||
|
|
||||||
|
/// <summary>当前代理资料。</summary>
|
||||||
|
[HttpGet("/agent/portal/profile")]
|
||||||
|
public async Task<AgentOutput> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>名下客户分页(含主客户 AgentId=我)。</summary>
|
||||||
|
[HttpPost("/agent/portal/customers/list")]
|
||||||
|
public async Task<OmsPageResult<AgentPortalCustomerOutput>> GetCustomersAsync(AgentPortalPageInput input)
|
||||||
|
{
|
||||||
|
var agent = await GetCurrentAgentOrThrowAsync();
|
||||||
|
var keyword = input.Keyword?.Trim();
|
||||||
|
Expression<Func<Customer, bool>> 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<string, bool>
|
||||||
|
{
|
||||||
|
[nameof(Customer.CreateTime)] = false,
|
||||||
|
[nameof(Customer.Id)] = false
|
||||||
|
});
|
||||||
|
var total = await CustomerRepository.CountAsync(predicate);
|
||||||
|
return new OmsPageResult<AgentPortalCustomerOutput>
|
||||||
|
{
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>名下订单分页(强制 Order.AgentId;不含应付)。</summary>
|
||||||
|
[HttpPost("/agent/portal/orders/list")]
|
||||||
|
public async Task<OmsPageResult<AgentPortalOrderOutput>> GetOrdersAsync(AgentPortalPageInput input)
|
||||||
|
{
|
||||||
|
var agent = await GetCurrentAgentOrThrowAsync();
|
||||||
|
var keyword = input.Keyword?.Trim();
|
||||||
|
Expression<Func<Order, bool>> 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<string, bool>
|
||||||
|
{
|
||||||
|
[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<AgentPortalOrderOutput>
|
||||||
|
{
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>名下返佣明细。</summary>
|
||||||
|
[HttpPost("/agent/portal/commission/list")]
|
||||||
|
public async Task<OmsPageResult<AgentCommissionDetailOutput>> GetCommissionListAsync(AgentPortalPageInput input)
|
||||||
|
{
|
||||||
|
var agent = await GetCurrentAgentOrThrowAsync();
|
||||||
|
Expression<Func<AgentCommissionDetail, bool>> 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<string, bool>
|
||||||
|
{
|
||||||
|
[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<AgentCommissionDetailOutput>
|
||||||
|
{
|
||||||
|
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<Agent> GetCurrentAgentOrThrowAsync()
|
||||||
|
{
|
||||||
|
var mapId = CurrentUser.GetId<int>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ using Oms.Application.Shared;
|
|||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
|
using Oms.Domain.AgentsAggregate.Repositories;
|
||||||
using Oms.Domain.MarketingAggregate.Repositories;
|
using Oms.Domain.MarketingAggregate.Repositories;
|
||||||
using Oms.Domain.Shared.Enums;
|
using Oms.Domain.Shared.Enums;
|
||||||
|
|
||||||
@ -36,6 +37,9 @@ public class CustomerService : ApplicationService
|
|||||||
private ICustomerReferralCodeRepository ReferralCodeRepository =>
|
private ICustomerReferralCodeRepository ReferralCodeRepository =>
|
||||||
LazyServiceProvider.GetRequiredService<ICustomerReferralCodeRepository>();
|
LazyServiceProvider.GetRequiredService<ICustomerReferralCodeRepository>();
|
||||||
|
|
||||||
|
private IAgentRepository AgentRepository =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||||
|
|
||||||
private MarketingCouponManager CouponManager =>
|
private MarketingCouponManager CouponManager =>
|
||||||
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||||
|
|
||||||
@ -240,6 +244,10 @@ public class CustomerService : ApplicationService
|
|||||||
var entity = BuildCustomerEntity(input, normalized, null, userId);
|
var entity = BuildCustomerEntity(input, normalized, null, userId);
|
||||||
entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId);
|
entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId);
|
||||||
entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey);
|
entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey);
|
||||||
|
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
|
||||||
|
// 有代理编号时不走 C2C 邀请,避免双发佣金
|
||||||
|
if (!entity.AgentId.HasValue)
|
||||||
|
{
|
||||||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||||||
entity,
|
entity,
|
||||||
input.InviteCode,
|
input.InviteCode,
|
||||||
@ -247,6 +255,7 @@ public class CustomerService : ApplicationService
|
|||||||
CustomerRepository,
|
CustomerRepository,
|
||||||
MemberLevelRepository,
|
MemberLevelRepository,
|
||||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
|
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
|
||||||
|
}
|
||||||
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
||||||
entity,
|
entity,
|
||||||
input.AdvisorCode,
|
input.AdvisorCode,
|
||||||
|
|||||||
@ -138,6 +138,10 @@ public partial class OrderService
|
|||||||
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
|
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
|
||||||
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
|
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
|
||||||
input.CustomerId = sessionCustomer.Id;
|
input.CustomerId = sessionCustomer.Id;
|
||||||
|
var mainCustomerForAgent = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||||
|
CustomerRepository,
|
||||||
|
sessionCustomer);
|
||||||
|
var orderAgentId = mainCustomerForAgent.AgentId;
|
||||||
|
|
||||||
await ValidateParcelCreateInputAsync(input);
|
await ValidateParcelCreateInputAsync(input);
|
||||||
|
|
||||||
@ -180,6 +184,7 @@ public partial class OrderService
|
|||||||
snapshotBinding,
|
snapshotBinding,
|
||||||
serviceNo,
|
serviceNo,
|
||||||
createContext);
|
createContext);
|
||||||
|
order.AgentId = orderAgentId;
|
||||||
order = await OrderRepository.InsertAsync(order);
|
order = await OrderRepository.InsertAsync(order);
|
||||||
|
|
||||||
var weights = BuildInitialWeightRows(order.Id, input);
|
var weights = BuildInitialWeightRows(order.Id, input);
|
||||||
|
|||||||
@ -297,6 +297,11 @@ public partial class OrderService : ApplicationService
|
|||||||
order = await GetOrderOrThrowAsync(order.Id);
|
order = await GetOrderOrThrowAsync(order.Id);
|
||||||
await ApplyDeliverySuccessMarketingAsync(order);
|
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);
|
order = await GetOrderOrThrowAsync(order.Id);
|
||||||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
|
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
|
||||||
@ -340,6 +345,14 @@ public partial class OrderService : ApplicationService
|
|||||||
|
|
||||||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||||
await couponManager.OnFirstDeliverySuccessAsync(order);
|
await couponManager.OnFirstDeliverySuccessAsync(order);
|
||||||
|
|
||||||
|
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus)
|
||||||
|
{
|
||||||
|
var agentCommission = LazyServiceProvider.GetRequiredService<AgentCommissionManager>();
|
||||||
|
await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
40
src/Oms.Application/Shared/AgentBinder.cs
Normal file
40
src/Oms.Application/Shared/AgentBinder.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册时绑定渠道代理。与 C2C 邀请互斥:有 agent 时由调用方跳过邀请绑定。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/Oms.Application/Shared/AgentCommissionManager.cs
Normal file
203
src/Oms.Application/Shared/AgentCommissionManager.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。
|
||||||
|
/// </summary>
|
||||||
|
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<Domain.OrdersAggregate.Entities.OrderFee> 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<int> ParseProductIds(string? raw)
|
||||||
|
{
|
||||||
|
var set = new HashSet<int>();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = raw.Trim();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (text.StartsWith('['))
|
||||||
|
{
|
||||||
|
var list = JsonSerializer.Deserialize<List<int>>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
namespace Oms.Domain.AgentsAggregate.Consts;
|
||||||
|
|
||||||
|
/// <summary>代理返佣基数类型(首版仅运费/应收)。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace Oms.Domain.AgentsAggregate.Consts;
|
||||||
|
|
||||||
|
/// <summary>代理返佣明细状态(首版)。</summary>
|
||||||
|
public static class AgentCommissionStatusCodes
|
||||||
|
{
|
||||||
|
public const string Pending = "pending";
|
||||||
|
public const string Paid = "paid";
|
||||||
|
}
|
||||||
45
src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
Normal file
45
src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
using Oms.Domain.Shared;
|
||||||
|
using Oms.Domain.Shared.Enums;
|
||||||
|
using SqlSugar;
|
||||||
|
|
||||||
|
namespace Oms.Domain.AgentsAggregate.Entities;
|
||||||
|
|
||||||
|
/// <summary>渠道代理主数据。</summary>
|
||||||
|
[SugarTable("t_agent")]
|
||||||
|
public class Agent : OmsAuditedAggregateRoot
|
||||||
|
{
|
||||||
|
/// <summary>RBAC user.userid / JWT mapId。</summary>
|
||||||
|
public int UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>登录账号 + 注册链接编码。</summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>MD5 密码(对齐客户)。</summary>
|
||||||
|
[SugarColumn(Length = 64)]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
|
||||||
|
|
||||||
|
/// <summary>freight / receivable。</summary>
|
||||||
|
[SugarColumn(Length = 32)]
|
||||||
|
public string BaseType { get; set; } = "freight";
|
||||||
|
|
||||||
|
public decimal CommissionRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可选销售产品 Id 列表(JSON 或逗号);空=不限产品。</summary>
|
||||||
|
[SugarColumn(Length = 512, IsNullable = true)]
|
||||||
|
public string? SalesProductIds { get; set; }
|
||||||
|
|
||||||
|
[SugarColumn(Length = 512, IsNullable = true)]
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
using Oms.Domain.Shared;
|
||||||
|
using SqlSugar;
|
||||||
|
|
||||||
|
namespace Oms.Domain.AgentsAggregate.Entities;
|
||||||
|
|
||||||
|
/// <summary>代理返佣明细(首版无余额入账)。</summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>pending / paid。</summary>
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
using Fake.Domain.Repositories;
|
||||||
|
using Oms.Domain.AgentsAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Domain.AgentsAggregate.Repositories;
|
||||||
|
|
||||||
|
public interface IAgentRepository : IRepository<Agent>
|
||||||
|
{
|
||||||
|
Task<Agent?> GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Agent?> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> ExistsAgentCodeAsync(string agentCode, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAgentCommissionDetailRepository : IRepository<AgentCommissionDetail>
|
||||||
|
{
|
||||||
|
Task<AgentCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot
|
|||||||
|
|
||||||
public decimal CommissionBalance { get; set; }
|
public decimal CommissionBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>所属渠道代理(建议写在主客户上)。</summary>
|
||||||
|
public int? AgentId { get; set; }
|
||||||
|
|
||||||
|
public DateTime? AgentBindTime { get; set; }
|
||||||
|
|
||||||
public string? LastLoginIp { get; set; }
|
public string? LastLoginIp { get; set; }
|
||||||
|
|
||||||
public DateTime? LastLoginTime { get; set; }
|
public DateTime? LastLoginTime { get; set; }
|
||||||
|
|||||||
@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot
|
|||||||
|
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>下单时代理快照(跟主客户 AgentId)。</summary>
|
||||||
|
public int? AgentId { get; set; }
|
||||||
|
|
||||||
public int? MemberLevelId { get; set; }
|
public int? MemberLevelId { get; set; }
|
||||||
|
|
||||||
public string? PriceTierKey { get; set; }
|
public string? PriceTierKey { get; set; }
|
||||||
|
|||||||
37
src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
Normal file
37
src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
Normal file
@ -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<Agent>, IAgentRepository, IScopedDependency
|
||||||
|
{
|
||||||
|
public Task<Agent?> GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default)
|
||||||
|
=> FirstOrDefaultAsync(x => x.AgentCode == agentCode, cancellationToken);
|
||||||
|
|
||||||
|
public Task<Agent?> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default)
|
||||||
|
=> FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<bool> 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<AgentCommissionDetail>,
|
||||||
|
IAgentCommissionDetailRepository,
|
||||||
|
IScopedDependency
|
||||||
|
{
|
||||||
|
public Task<AgentCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
|
||||||
|
=> FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
|
||||||
|
}
|
||||||
32
src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
Normal file
32
src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
|
|||||||
public enum OmsLoginType
|
public enum OmsLoginType
|
||||||
{
|
{
|
||||||
User = 0,
|
User = 0,
|
||||||
Customer = 1
|
Customer = 1,
|
||||||
|
Agent = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OmsLoginInput
|
public class OmsLoginInput
|
||||||
|
|||||||
@ -21,9 +21,11 @@ using Fake.ObjectStorage;
|
|||||||
using Oms.Application;
|
using Oms.Application;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
|
using Oms.Domain.AgentsAggregate.Repositories;
|
||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
|
using Oms.Domain.Shared.Enums;
|
||||||
using SimpleAdmin.Api.WeChat.Dtos;
|
using SimpleAdmin.Api.WeChat.Dtos;
|
||||||
using SimpleAdmin.Api.WeChat.Services;
|
using SimpleAdmin.Api.WeChat.Services;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
@ -68,6 +70,7 @@ public partial class AuthService(
|
|||||||
{
|
{
|
||||||
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
|
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
|
||||||
OmsLoginType.Customer => await LoginCustomerAsync(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("不支持的登录类型")
|
_ => throw new BusinessException("不支持的登录类型")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -284,6 +287,11 @@ public partial class AuthService(
|
|||||||
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
|
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loginType == OmsLoginType.Agent)
|
||||||
|
{
|
||||||
|
return await RefreshAgentTokenAsync(token.Claims, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (!Guid.TryParse(userIdText, out var userId))
|
if (!Guid.TryParse(userIdText, out var userId))
|
||||||
{
|
{
|
||||||
throw new BusinessException("无效的刷新令牌");
|
throw new BusinessException("无效的刷新令牌");
|
||||||
@ -438,6 +446,60 @@ public partial class AuthService(
|
|||||||
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
|
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<OmsLoginResultDto> LoginAgentAsync(string account, string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var agentRepository = LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||||
|
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<OmsLoginResultDto> RefreshAgentTokenAsync(IEnumerable<Claim> 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<IAgentRepository>();
|
||||||
|
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<OmsLoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
|
private async Task<OmsLoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedAccount = account.Trim();
|
var normalizedAccount = account.Trim();
|
||||||
|
|||||||
@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? AdvisorCode { get; set; }
|
public string? AdvisorCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。
|
||||||
|
/// </summary>
|
||||||
|
public string? AgentCode { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public WeChatProfileInput Wechat { get; set; } = new();
|
public WeChatProfileInput Wechat { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ using Oms.Application.Shared;
|
|||||||
using Oms.Domain.CustomersAggregate.Entities;
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
using Oms.Domain.CustomersAggregate.Repositories;
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
|
using Oms.Domain.AgentsAggregate.Repositories;
|
||||||
using Oms.Domain.MarketingAggregate.Repositories;
|
using Oms.Domain.MarketingAggregate.Repositories;
|
||||||
using SimpleAdmin.Api.WeChat.Dtos;
|
using SimpleAdmin.Api.WeChat.Dtos;
|
||||||
using SimpleAdmin.Api.WeChat.Services;
|
using SimpleAdmin.Api.WeChat.Services;
|
||||||
@ -30,6 +31,7 @@ public class WeChatCustomerService(
|
|||||||
ICustomerRepository customerRepository,
|
ICustomerRepository customerRepository,
|
||||||
ICustomerReferralCodeRepository referralCodeRepository,
|
ICustomerReferralCodeRepository referralCodeRepository,
|
||||||
IMemberLevelRepository memberLevelRepository,
|
IMemberLevelRepository memberLevelRepository,
|
||||||
|
IAgentRepository agentRepository,
|
||||||
MarketingCouponManager couponManager,
|
MarketingCouponManager couponManager,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
RoleService roleService,
|
RoleService roleService,
|
||||||
@ -156,6 +158,9 @@ public class WeChatCustomerService(
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey
|
PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey
|
||||||
};
|
};
|
||||||
|
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, agentRepository, cancellationToken);
|
||||||
|
if (!entity.AgentId.HasValue)
|
||||||
|
{
|
||||||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||||||
entity,
|
entity,
|
||||||
input.InviteCode,
|
input.InviteCode,
|
||||||
@ -168,6 +173,7 @@ public class WeChatCustomerService(
|
|||||||
{
|
{
|
||||||
entity.ReferralChannel = "wechat";
|
entity.ReferralChannel = "wechat";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
||||||
entity,
|
entity,
|
||||||
input.AdvisorCode,
|
input.AdvisorCode,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user