20261030 #18

Merged
zzh merged 9 commits from test into master 2026-10-30 17:37:30 +08:00
39 changed files with 3084 additions and 53 deletions

View File

@ -0,0 +1,105 @@
-- 渠道代理与返佣(首版轻量)。可重复执行。
-- 含:t_agent、t_agent_commission_detail;t_customer / t_order 扩展 AgentId。
-- AggregateRoot 需要 ConcurrencyStamp。
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,
-- 不用 DEFAULT(UUID()):statement 复制下会报 1674;运行时由 ORM 写入
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
PRIMARY KEY (`Id`),
KEY `ix_agent_code` (`AgentCode`),
KEY `ix_agent_userid` (`UserId`)
) COMMENT='渠道代理主数据';
-- 已建表补 ConcurrencyStamp(可重复)
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent' AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_agent` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`',
'SELECT ''t_agent.ConcurrencyStamp already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- t_agent_commission_detail ----------
CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` (
`Id` INT NOT NULL AUTO_INCREMENT,
`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,
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
PRIMARY KEY (`Id`),
KEY `ix_agent_comm_agent` (`AgentId`),
KEY `ix_agent_comm_order` (`OrderId`),
KEY `ix_agent_comm_status` (`Status`)
) COMMENT='代理返佣明细(首版无余额入账)';
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent_commission_detail' AND COLUMN_NAME = 'ConcurrencyStamp'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_agent_commission_detail` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`',
'SELECT ''t_agent_commission_detail.ConcurrencyStamp already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- t_customer.AgentId ----------
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
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;

View File

@ -0,0 +1,362 @@
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;
/// <summary>基数类型展示名(运费/应收)。</summary>
public string? BaseTypeName { get; set; }
public decimal CommissionRate { get; set; }
public string? SalesProductIds { get; set; }
/// <summary>适用销售产品名称;空表示不限产品。</summary>
public List<string> SalesProductNames { 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; }
/// <summary>最近下单时间(含名下子客户)。</summary>
public DateTime? LastOrderTime { get; set; }
/// <summary>近 30 天运单数(含名下子客户,不含已删除)。</summary>
public int OrderCount30d { get; set; }
/// <summary>待付款运单数(含名下子客户)。</summary>
public int PendingPaymentCount { get; set; }
}
/// <summary>代理门户返佣明细分页筛选。</summary>
public class AgentPortalCommissionListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
/// <summary>pending / paid;空=全部。</summary>
public string? Status { get; set; }
/// <summary>运单号关键字。</summary>
public string? Keyword { get; set; }
public DateTime? CreateTimeStart { get; set; }
public DateTime? CreateTimeEnd { 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; }
}
/// <summary>匿名校验代理编号(客户注册归因)。</summary>
public class AgentValidateCodeOutput
{
public bool Valid { get; set; }
public string? AgentCode { get; set; }
/// <summary>脱敏代理名称,供注册页展示。</summary>
public string? AgentNameMasked { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// 代理工作台统计概览。
/// </summary>
public class AgentWorkplaceStatsOutput
{
/// <summary>名下主客户数。</summary>
public int CustomerCount { get; set; }
/// <summary>今日代客下单数。</summary>
public int TodayCreated { get; set; }
/// <summary>昨日代客下单数。</summary>
public int YesterdayCreated { get; set; }
/// <summary>较昨日新增百分比。</summary>
public decimal? DayOverDayGrowthPercent { get; set; }
/// <summary>待付款运单数。</summary>
public int PendingPayment { get; set; }
/// <summary>运输中运单数。</summary>
public int Shipping { get; set; }
/// <summary>问题件运单数。</summary>
public int Problematic { get; set; }
/// <summary>派送成功运单数(全量状态桶)。</summary>
public int DeliverySuccess { get; set; }
/// <summary>预计返佣笔数(未锁定)。</summary>
public int EstimatedCommissionCount { get; set; }
/// <summary>预计返佣金额(未锁定,不计入应打款)。</summary>
public decimal EstimatedCommissionAmount { get; set; }
/// <summary>待结算返佣笔数。</summary>
public int PendingCommissionCount { get; set; }
/// <summary>待结算返佣金额。</summary>
public decimal PendingCommissionAmount { get; set; }
/// <summary>已打款返佣金额(累计)。</summary>
public decimal PaidCommissionAmount { get; set; }
/// <summary>本周新增绑定主客户数(按 AgentBindTime)。</summary>
public int WeekNewCustomers { get; set; }
/// <summary>派送失败运单数。</summary>
public int DeliveryFailed { get; set; }
/// <summary>按 OrderStatus 分组数量。</summary>
public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// 代理工作台趋势点。
/// </summary>
public class AgentWorkplaceTrendPoint
{
public string Label { get; set; } = string.Empty;
public DateTime Start { get; set; }
public DateTime End { get; set; }
/// <summary>区间内代客下单数。</summary>
public int OrderCount { get; set; }
/// <summary>区间内计提返佣金额。</summary>
public decimal CommissionAmount { get; set; }
}
/// <summary>
/// 代理工作台趋势。
/// </summary>
public class AgentWorkplaceTrendOutput
{
public string Dimension { get; set; } = "day";
public List<AgentWorkplaceTrendPoint> Points { get; set; } = [];
}

View File

@ -19,4 +19,49 @@ public class KpiAreaInfoListInput
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// 国家/地区(匹配国家名称或国家代码,模糊)。
/// </summary>
public string? Country { get; set; }
/// <summary>
/// 匹配维度(如 zip/state/city/country,模糊)。
/// </summary>
public string? MatchDim { get; set; }
/// <summary>
/// 匹配方式(如 prefix/exact/range/all,模糊)。
/// </summary>
public string? MatchMode { get; set; }
/// <summary>
/// 州/省(模糊)。
/// </summary>
public string? Province { get; set; }
/// <summary>
/// 城市(模糊)。
/// </summary>
public string? City { get; set; }
/// <summary>
/// 起始邮编(模糊)。
/// </summary>
public string? LeftPostcode { get; set; }
/// <summary>
/// 截止邮编(模糊)。
/// </summary>
public string? RightPostcode { get; set; }
/// <summary>
/// 排序字段:leftPostcode / rightPostcode;空则按 Id 降序。
/// </summary>
public string? SortField { get; set; }
/// <summary>
/// 排序方向:asc / desc。
/// </summary>
public string? SortOrder { get; set; }
}

View File

@ -124,4 +124,10 @@ public class CreateCustomerInput
[StringLength(64)]
public string? AdvisorCode { get; set; }
/// <summary>
/// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
/// </summary>
[StringLength(32)]
public string? AgentCode { get; set; }
}

View File

@ -78,7 +78,7 @@ public class CustomerOutput
public string? Remark { get; set; }
/// <summary>
/// 销售人员。
/// 销售人员(遗留字段,管理端已改用 AgentId)。
/// </summary>
public string? Salesman { get; set; }
@ -87,6 +87,15 @@ public class CustomerOutput
/// </summary>
public string? CustomerService { get; set; }
/// <summary>绑定渠道代理 Id。</summary>
public int? AgentId { get; set; }
/// <summary>绑定代理编号。</summary>
public string? AgentCode { get; set; }
/// <summary>绑定代理名称。</summary>
public string? AgentName { get; set; }
/// <summary>
/// 客户分组。
/// </summary>

View File

@ -33,7 +33,7 @@ public class CustomerPaginatedInput
public CustomerGroup? CustomerGroup { get; set; }
/// <summary>
/// 销售人员。
/// 销售人员(遗留筛选)。
/// </summary>
public string? Salesman { get; set; }
@ -42,6 +42,9 @@ public class CustomerPaginatedInput
/// </summary>
public string? CustomerService { get; set; }
/// <summary>按绑定代理 Id 筛选。</summary>
public int? AgentId { get; set; }
/// <summary>
/// 排序字段:createTime / updateTime / customerCode / customerName / balance。
/// </summary>

View File

@ -78,7 +78,7 @@ public class CustomerPaginatedOutput
public string? Remark { get; set; }
/// <summary>
/// 销售人员。
/// 销售人员(遗留字段,管理端已改用 AgentId)。
/// </summary>
public string? Salesman { get; set; }
@ -87,6 +87,15 @@ public class CustomerPaginatedOutput
/// </summary>
public string? CustomerService { get; set; }
/// <summary>绑定渠道代理 Id。</summary>
public int? AgentId { get; set; }
/// <summary>绑定代理编号。</summary>
public string? AgentCode { get; set; }
/// <summary>绑定代理名称。</summary>
public string? AgentName { get; set; }
/// <summary>
/// 客户分组。
/// </summary>

View File

@ -81,7 +81,7 @@ public class UpdateCustomerInput
public string? Remark { get; set; }
/// <summary>
/// 销售人员。
/// 销售人员(遗留;新流程请用 AgentCode)。
/// </summary>
[StringLength(32)]
public string? Salesman { get; set; }
@ -92,6 +92,12 @@ public class UpdateCustomerInput
[StringLength(32)]
public string? CustomerService { get; set; }
/// <summary>
/// 渠道代理编号;传空串解绑;不传则保留原绑定。仅主客户可改。
/// </summary>
[StringLength(32)]
public string? AgentCode { get; set; }
/// <summary>
/// 客户分组(遗留字段,计价优先 MemberLevelId)。
/// </summary>

View File

@ -0,0 +1,14 @@
namespace Oms.Application.Messaging.Agents;
/// <summary>代理返佣异步任务动作。</summary>
public static class AgentCommissionJobActionCodes
{
/// <summary>下单/计费后写或刷新预计返佣。</summary>
public const string Estimate = "estimate";
/// <summary>派送终态锁定为待结算。</summary>
public const string Finalize = "finalize";
/// <summary>删单/全额退作废。</summary>
public const string Void = "void";
}

View File

@ -0,0 +1,75 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Shared;
namespace Oms.Application.Messaging.Agents;
/// <summary>消费代理返佣异步任务。</summary>
public sealed class AgentCommissionJobEventHandler(
IServiceScopeFactory scopeFactory,
IUnitOfWorkManager unitOfWorkManager,
ILogger<AgentCommissionJobEventHandler> logger)
: IEventHandler<AgentCommissionJobIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
AgentCommissionJobIntegrationEvent @event,
CancellationToken cancellationToken)
{
if (@event.OrderId <= 0 || string.IsNullOrWhiteSpace(@event.Action))
{
logger.LogWarning(
"AgentCommission job skipped invalid payload OrderId={OrderId} Action={Action}",
@event.OrderId,
@event.Action);
return;
}
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
using var scope = scopeFactory.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<AgentCommissionManager>();
try
{
var action = @event.Action.Trim();
if (string.Equals(action, AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
{
await manager.TryEstimateOnOrderCreatedAsync(@event.OrderId, cancellationToken);
}
else if (string.Equals(action, AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
{
await manager.TryAccrueOnDeliveryTerminalAsync(
@event.OrderId,
@event.TriggerOrderStatus ?? string.Empty,
cancellationToken);
}
else if (string.Equals(action, AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
{
await manager.TryVoidOnOrderCancelledAsync(
@event.OrderId,
@event.Reason ?? "异步作废返佣",
cancellationToken);
}
else
{
logger.LogWarning(
"AgentCommission job unknown Action={Action} OrderId={OrderId}",
@event.Action,
@event.OrderId);
}
}
catch (Exception ex)
{
// 失败 Ack,避免打满队列;明细可后续补偿
logger.LogWarning(
ex,
"AgentCommission job failed OrderId={OrderId} Action={Action}",
@event.OrderId,
@event.Action);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,21 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.Agents;
/// <summary>
/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。
/// </summary>
[Serializable]
public class AgentCommissionJobIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
/// <summary><see cref="AgentCommissionJobActionCodes"/>。</summary>
public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate;
/// <summary>finalize 时的触发运单状态。</summary>
public string? TriggerOrderStatus { get; set; }
/// <summary>void 时的原因备注。</summary>
public string? Reason { get; set; }
}

View File

@ -1,6 +1,8 @@
using Fake.DependencyInjection;
using Fake.EventBus.Distributed;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.Agents;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
@ -16,6 +18,7 @@ public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager,
IDistributedEventBus eventBus,
MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager,
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
@ -132,6 +135,21 @@ public sealed class TrackChangeOrderStatusApplier(
// 重新加载,避免营销侧依赖最新字段
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
{
OrderId = order.Id,
Action = AgentCommissionJobActionCodes.Finalize,
TriggerOrderStatus = OrderStatusCodes.DeliverySuccess
});
}
else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
{
await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
{
OrderId = order.Id,
Action = AgentCommissionJobActionCodes.Finalize,
TriggerOrderStatus = OrderStatusCodes.DeliveryFailed
});
}
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;

View File

@ -0,0 +1,463 @@
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.Authorization;
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>
[AllowAnonymous]
[HttpGet("/agent/public/validate-code")]
public async Task<AgentValidateCodeOutput> ValidateCodeAsync([FromQuery] string code)
{
var trimmed = code?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return new AgentValidateCodeOutput
{
Valid = false,
Message = "代理编号不能为空"
};
}
var agent = await AgentRepository.GetByAgentCodeAsync(trimmed);
if (agent == null || agent.Status != EnableStatus.Enabled)
{
return new AgentValidateCodeOutput
{
Valid = false,
Message = "代理编号无效或已停用"
};
}
return new AgentValidateCodeOutput
{
Valid = true,
AgentCode = agent.AgentCode,
AgentNameMasked = MaskAgentName(agent.AgentName),
Message = null
};
}
/// <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.Pending, 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;
}
private static string MaskAgentName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return "渠道代理";
}
var text = name.Trim();
return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1));
}
}

View File

@ -0,0 +1,581 @@
using System.Linq.Expressions;
using Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Mvc;
using MiniExcelLibs;
using Oms.Application.Dtos.Agents;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.AgentsAggregate.Consts;
using Oms.Domain.AgentsAggregate.Entities;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Services.Agents;
/// <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>();
private ISalesProductRepository SalesProductRepository =>
LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
/// <summary>当前代理资料(含返佣规则展示字段)。</summary>
[HttpGet("/agent/portal/profile")]
public async Task<AgentOutput> GetProfileAsync()
{
var agent = await GetCurrentAgentOrThrowAsync();
var productNames = await ResolveSalesProductNamesAsync(agent.SalesProductIds);
return new AgentOutput
{
Id = agent.Id,
AgentCode = agent.AgentCode,
AgentName = agent.AgentName,
Phone = agent.Phone,
Email = agent.Email,
Status = agent.Status,
BaseType = agent.BaseType,
BaseTypeName = ResolveBaseTypeName(agent.BaseType),
CommissionRate = agent.CommissionRate,
SalesProductIds = agent.SalesProductIds,
SalesProductNames = productNames,
Remark = agent.Remark,
CreateTime = agent.CreateTime
};
}
/// <summary>名下客户分页(含活跃度:最近下单 / 近30天票数 / 待付款数)。</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);
var activityMap = await BuildCustomerActivityMapAsync(agent.Id, entities.Select(x => x.Id).ToList());
return new OmsPageResult<AgentPortalCustomerOutput>
{
Items = entities.Select(x =>
{
activityMap.TryGetValue(x.Id, out var activity);
return new AgentPortalCustomerOutput
{
Id = x.Id,
CustomerCode = x.CustomerCode,
CustomerName = MaskName(x.CustomerName) ?? string.Empty,
Phone = MaskPhone(x.Phone),
AgentBindTime = x.AgentBindTime,
CreateTime = x.CreateTime,
LastOrderTime = activity.LastOrderTime,
OrderCount30d = activity.OrderCount30d,
PendingPaymentCount = activity.PendingPaymentCount
};
}).ToList(),
Total = total
};
}
/// <summary>名下订单分页(按当前绑定客户可见;不含应付)。</summary>
[HttpPost("/agent/portal/orders/list")]
public async Task<OmsPageResult<AgentPortalOrderOutput>> GetOrdersAsync(AgentPortalPageInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
var keyword = input.Keyword?.Trim();
var filter = new OrderListFilter
{
OwnedByAgentId = agent.Id,
Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword,
Status = "all"
};
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var (entities, total) = await OrderRepository.GetFilteredPagedListAsync(page, pageSize, filter);
var customerIds = entities.Where(x => x.CustomerId.HasValue).Select(x => x.CustomerId!.Value).Distinct().ToList();
var customers = customerIds.Count == 0
? []
: 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,
PayableAmount = null,
CreateTime = x.CreateTime
};
}).ToList(),
Total = total
};
}
/// <summary>
/// 代理工作台统计:名下客户、运单状态桶、待结算/已打款返佣、本周新增绑定。
/// </summary>
[HttpGet("/agent/portal/workplace/stats")]
public async Task<AgentWorkplaceStatsOutput> GetWorkplaceStatsAsync()
{
var agent = await GetCurrentAgentOrThrowAsync();
var filter = new OrderListFilter { OwnedByAgentId = agent.Id };
var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter);
var counts = rows
.Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
.ToDictionary(x => x.Status, x => x.Count, StringComparer.OrdinalIgnoreCase);
int GetCount(string status) =>
counts.TryGetValue(status, out var value) ? value : 0;
var today = DateTime.Today;
var tomorrow = today.AddDays(1);
var yesterday = today.AddDays(-1);
var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow);
var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today);
decimal? growth = null;
if (yesterdayCreated > 0)
{
growth = Math.Round((todayCreated - yesterdayCreated) * 100m / yesterdayCreated, 1);
}
else if (todayCreated > 0)
{
growth = 100m;
}
var customerCount = await CustomerRepository.CountAsync(x =>
x.AgentId == agent.Id && x.ParentCustomerId == null);
var weekStart = today.AddDays(-(((int)today.DayOfWeek + 6) % 7));
var weekNewCustomers = await CustomerRepository.CountAsync(x =>
x.AgentId == agent.Id
&& x.ParentCustomerId == null
&& x.AgentBindTime != null
&& x.AgentBindTime >= weekStart
&& x.AgentBindTime < tomorrow);
var estimatedDetails = await DetailRepository.GetListAsync(x =>
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Estimated);
var pendingDetails = await DetailRepository.GetListAsync(x =>
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending);
var paidDetails = await DetailRepository.GetListAsync(x =>
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Paid);
return new AgentWorkplaceStatsOutput
{
CustomerCount = customerCount,
TodayCreated = todayCreated,
YesterdayCreated = yesterdayCreated,
DayOverDayGrowthPercent = growth,
PendingPayment = GetCount(OrderStatusCodes.PendingPayment),
Shipping = GetCount(OrderStatusCodes.Shipping) + GetCount(OrderStatusCodes.PendingOnline),
Problematic = GetCount(OrderStatusCodes.Problematic),
DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess),
DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed),
EstimatedCommissionCount = estimatedDetails.Count,
EstimatedCommissionAmount = estimatedDetails.Sum(x => x.CommissionAmount),
PendingCommissionCount = pendingDetails.Count,
PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount),
PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount),
WeekNewCustomers = weekNewCustomers,
Counts = counts,
};
}
/// <summary>
/// 代理工作台趋势:按日统计代客下单数与计提返佣金额。
/// </summary>
/// <param name="days">天数,默认 7,最大 31。</param>
[HttpGet("/agent/portal/workplace/trend")]
public async Task<AgentWorkplaceTrendOutput> GetWorkplaceTrendAsync([FromQuery] int days = 7)
{
var agent = await GetCurrentAgentOrThrowAsync();
days = Math.Clamp(days, 1, 31);
var filter = new OrderListFilter { OwnedByAgentId = agent.Id };
var today = DateTime.Today;
var rangeStart = today.AddDays(-(days - 1));
var rangeEnd = today.AddDays(1);
var commissions = await DetailRepository.GetListAsync(x =>
x.AgentId == agent.Id
&& x.CreateTime >= rangeStart
&& x.CreateTime < rangeEnd);
var points = new List<AgentWorkplaceTrendPoint>(days);
for (var i = 0; i < days; i++)
{
var start = rangeStart.AddDays(i);
var end = start.AddDays(1);
var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, start, end);
var commissionAmount = commissions
.Where(x => x.CreateTime >= start && x.CreateTime < end)
.Sum(x => x.CommissionAmount);
points.Add(new AgentWorkplaceTrendPoint
{
Label = start.ToString("MM-dd"),
Start = start,
End = end,
OrderCount = orderCount,
CommissionAmount = commissionAmount,
});
}
return new AgentWorkplaceTrendOutput
{
Dimension = "day",
Points = points,
};
}
/// <summary>名下返佣明细(支持状态 / 运单号 / 计提时间筛选)。</summary>
[HttpPost("/agent/portal/commission/list")]
public async Task<OmsPageResult<AgentCommissionDetailOutput>> GetCommissionListAsync(
AgentPortalCommissionListInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
var details = await QueryCommissionDetailsAsync(agent, input);
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var total = details.Count;
var pageItems = details
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return new OmsPageResult<AgentCommissionDetailOutput>
{
Items = await MapCommissionOutputsAsync(agent, pageItems),
Total = total
};
}
/// <summary>导出名下返佣明细(Excel,最多 5000 条)。</summary>
[HttpPost("/agent/portal/commission/export")]
public async Task<FileContentResult> ExportCommissionAsync(AgentPortalCommissionListInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
var details = await QueryCommissionDetailsAsync(agent, input);
const int maxRows = 5000;
if (details.Count > maxRows)
{
throw new BusinessException($"导出结果超过 {maxRows} 条(当前 {details.Count}),请缩小筛选条件后再导出");
}
var outputs = await MapCommissionOutputsAsync(agent, details);
var rows = outputs.Select(x => new
{
运单号 = x.ServiceNo,
客户编号 = x.CustomerCode,
客户名称 = x.CustomerName,
基数类型 = ResolveBaseTypeName(x.BaseType),
基数金额 = x.BaseAmount,
返佣比例 = x.CommissionRate,
返佣金额 = x.CommissionAmount,
状态 = AgentCommissionStatusCodes.DisplayName(x.Status),
触发状态 = OmsDocHelper.GetOrderStatusName(x.TriggerOrderStatus) ?? x.TriggerOrderStatus,
计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"),
打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "",
}).ToList();
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, rows);
return new FileContentResult(
stream.ToArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = $"agent-commission-{DateTime.Now:yyyyMMddHHmmss}.xlsx"
};
}
private async Task<List<AgentCommissionDetail>> QueryCommissionDetailsAsync(
Agent agent,
AgentPortalCommissionListInput input)
{
var status = input.Status?.Trim();
var keyword = input.Keyword?.Trim();
var start = input.CreateTimeStart;
var end = input.CreateTimeEnd;
Expression<Func<AgentCommissionDetail, bool>> predicate = x => x.AgentId == agent.Id;
if (!string.IsNullOrWhiteSpace(status))
{
predicate = x => x.AgentId == agent.Id && x.Status == status;
}
var list = await DetailRepository.GetListAsync(predicate);
if (start.HasValue)
{
list = list.Where(x => x.CreateTime >= start.Value).ToList();
}
if (end.HasValue)
{
var endExclusive = end.Value.Date.AddDays(1);
list = list.Where(x => x.CreateTime < endExclusive).ToList();
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var orderIds = list.Select(x => x.OrderId).Distinct().ToList();
var orders = orderIds.Count == 0
? []
: await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
var matchedOrderIds = orders
.Where(o => o.ServiceNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|| (o.TrackNo != null && o.TrackNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
.Select(o => o.Id)
.ToHashSet();
list = list.Where(x => matchedOrderIds.Contains(x.OrderId)).ToList();
}
return list
.OrderByDescending(x => x.CreateTime)
.ThenByDescending(x => x.Id)
.ToList();
}
private async Task<List<AgentCommissionDetailOutput>> MapCommissionOutputsAsync(
Agent agent,
IReadOnlyCollection<AgentCommissionDetail> entities)
{
if (entities.Count == 0)
{
return [];
}
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id);
var orderMap = orders.ToDictionary(x => x.Id);
return entities.Select(x =>
{
customerMap.TryGetValue(x.CustomerId, out var customer);
orderMap.TryGetValue(x.OrderId, out var order);
return new AgentCommissionDetailOutput
{
Id = x.Id,
AgentId = x.AgentId,
AgentCode = agent.AgentCode,
AgentName = agent.AgentName,
OrderId = x.OrderId,
ServiceNo = order?.ServiceNo,
CustomerId = x.CustomerId,
CustomerCode = customer?.CustomerCode,
CustomerName = MaskName(customer?.CustomerName),
SalesProductId = x.SalesProductId,
BaseType = x.BaseType,
BaseAmount = x.BaseAmount,
CommissionRate = x.CommissionRate,
CommissionAmount = x.CommissionAmount,
Status = x.Status,
TriggerOrderStatus = x.TriggerOrderStatus,
PaidTime = x.PaidTime,
Remark = x.Remark,
CreateTime = x.CreateTime
};
}).ToList();
}
private async Task<Dictionary<int, (DateTime? LastOrderTime, int OrderCount30d, int PendingPaymentCount)>>
BuildCustomerActivityMapAsync(int agentId, List<int> mainCustomerIds)
{
var result = mainCustomerIds.ToDictionary(
id => id,
_ => ((DateTime?)null, 0, 0));
if (mainCustomerIds.Count == 0)
{
return result;
}
var children = await CustomerRepository.GetListAsync(x =>
x.ParentCustomerId != null && mainCustomerIds.Contains(x.ParentCustomerId.Value));
var mainByCustomerId = new Dictionary<int, int>();
foreach (var id in mainCustomerIds)
{
mainByCustomerId[id] = id;
}
foreach (var child in children)
{
if (child.ParentCustomerId is > 0)
{
mainByCustomerId[child.Id] = child.ParentCustomerId.Value;
}
}
var allIds = mainByCustomerId.Keys.ToList();
var since30 = DateTime.Today.AddDays(-30);
var orders = await OrderRepository.GetListAsync(x =>
x.AgentId == agentId
&& x.CustomerId != null
&& allIds.Contains(x.CustomerId.Value)
&& x.OrderStatus != OrderStatusCodes.Deleted);
foreach (var group in orders.GroupBy(o => mainByCustomerId[o.CustomerId!.Value]))
{
var list = group.ToList();
result[group.Key] = (
list.Max(o => (DateTime?)o.CreateTime),
list.Count(o => o.CreateTime >= since30),
list.Count(o => string.Equals(
o.OrderStatus,
OrderStatusCodes.PendingPayment,
StringComparison.OrdinalIgnoreCase)));
}
return result;
}
private async Task<List<string>> ResolveSalesProductNamesAsync(string? salesProductIds)
{
var ids = ParseSalesProductIds(salesProductIds);
if (ids.Count == 0)
{
return [];
}
var products = await SalesProductRepository.GetListAsync(x => ids.Contains(x.Id));
var map = products.ToDictionary(x => x.Id);
return ids
.Select(id => map.TryGetValue(id, out var p) ? $"{p.ProductName}({p.ProductCode})" : $"#{id}")
.ToList();
}
private static List<int> ParseSalesProductIds(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return [];
}
var text = raw.Trim();
if (text.StartsWith('['))
{
try
{
var arr = global::System.Text.Json.JsonSerializer.Deserialize<List<int>>(text);
return arr?.Where(x => x > 0).Distinct().ToList() ?? [];
}
catch
{
// fall through
}
}
return text
.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => int.TryParse(x, out var id) ? id : 0)
.Where(x => x > 0)
.Distinct()
.ToList();
}
private static string ResolveBaseTypeName(string? baseType)
{
if (string.Equals(baseType, "receivable", StringComparison.OrdinalIgnoreCase))
{
return "应收";
}
if (string.Equals(baseType, "freight", StringComparison.OrdinalIgnoreCase))
{
return "运费";
}
return string.IsNullOrWhiteSpace(baseType) ? "运费" : baseType;
}
private async Task<Agent> GetCurrentAgentOrThrowAsync()
{
var mapId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
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));
}
}

View File

@ -106,8 +106,9 @@ public class KpiAreaService : ApplicationService
await GetAreaOrThrowAsync(input.AreaId);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize);
var total = await KpiAreaRepository.CountInfosAsync(input.AreaId);
var filter = BuildInfoQueryFilter(input);
var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize, filter);
var total = await KpiAreaRepository.CountInfosAsync(input.AreaId, filter);
return new OmsPageResult<KpiAreaInfoOutput>
{
Items = entities.Select(MapInfoOutput).ToList(),
@ -115,6 +116,53 @@ public class KpiAreaService : ApplicationService
};
}
private static readonly HashSet<string> InfoSortFieldWhitelist = new(StringComparer.OrdinalIgnoreCase)
{
"leftPostcode",
"rightPostcode",
};
private static KpiAreaInfoQueryFilter BuildInfoQueryFilter(KpiAreaInfoListInput input) =>
new()
{
Country = input.Country,
MatchDim = input.MatchDim,
MatchMode = input.MatchMode,
Province = input.Province,
City = input.City,
LeftPostcode = input.LeftPostcode,
RightPostcode = input.RightPostcode,
SortField = NormalizeInfoSortField(input.SortField),
SortOrder = NormalizeInfoSortOrder(input.SortOrder),
};
private static string? NormalizeInfoSortField(string? sortField)
{
var field = sortField?.Trim();
if (string.IsNullOrWhiteSpace(field) || !InfoSortFieldWhitelist.Contains(field))
{
return null;
}
return field;
}
private static string? NormalizeInfoSortOrder(string? sortOrder)
{
var order = sortOrder?.Trim();
if (string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
{
return "asc";
}
if (string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase))
{
return "desc";
}
return null;
}
/// <summary>
/// 新增KPI分区明细。
/// </summary>

View File

@ -6,6 +6,7 @@ using Fake.Rbac.Application.Services;
using Fake.Rbac.Infrastructure;
using Fake.Security.Claims;
using Fake.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Dtos.Customers;
@ -15,6 +16,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 +38,9 @@ public class CustomerService : ApplicationService
private ICustomerReferralCodeRepository ReferralCodeRepository =>
LazyServiceProvider.GetRequiredService<ICustomerReferralCodeRepository>();
private IAgentRepository AgentRepository =>
LazyServiceProvider.GetRequiredService<IAgentRepository>();
private MarketingCouponManager CouponManager =>
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
@ -74,6 +79,8 @@ public class CustomerService : ApplicationService
entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
var memberLevelNameMap = await ResolveMemberLevelNameMapAsync(
entities.Where(x => x.MemberLevelId.HasValue).Select(x => x.MemberLevelId!.Value));
var agentMap = await ResolveAgentDisplayMapAsync(
entities.Where(x => x.AgentId.HasValue).Select(x => x.AgentId!.Value));
var items = entities.Select(entity =>
{
var output = ObjectMapper.Map<Customer, CustomerPaginatedOutput>(entity);
@ -86,6 +93,7 @@ public class CustomerService : ApplicationService
output.MemberLevelName = levelName;
}
FillAgentDisplay(output, entity.AgentId, agentMap);
return output;
}).ToList();
@ -117,6 +125,12 @@ public class CustomerService : ApplicationService
output.MemberLevelName = level?.LevelName;
}
if (entity.AgentId.HasValue)
{
var agentMap = await ResolveAgentDisplayMapAsync([entity.AgentId.Value]);
FillAgentDisplay(output, entity.AgentId, agentMap);
}
return output;
}
@ -215,10 +229,11 @@ public class CustomerService : ApplicationService
}
/// <summary>
/// 新增客户。
/// 新增客户(公开注册页可匿名调用;管理端也可带 token 代开户)。
/// </summary>
/// <param name="input">入参:客户创建参数。</param>
/// <returns>无返回体,仅完成新增客户。</returns>
[AllowAnonymous]
[HttpPost("/customer/create")]
public async Task CreateAsync(CreateCustomerInput input)
{
@ -240,13 +255,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<IReferralCommissionRuleRepository>());
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
// 有代理编号时不走 C2C 邀请,避免双发佣金
if (!entity.AgentId.HasValue)
{
await MarketingReferralBinder.BindReferrerOnCreateAsync(
entity,
input.InviteCode,
ReferralCodeRepository,
CustomerRepository,
MemberLevelRepository,
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
}
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,
input.AdvisorCode,
@ -392,6 +412,11 @@ public class CustomerService : ApplicationService
entity.CustomerService = NormalizeOptionalText(input.CustomerService);
}
if (input.AgentCode != null)
{
await AgentBinder.ApplyAgentBindingAsync(entity, input.AgentCode, AgentRepository);
}
if (input.CustomerGroup.HasValue)
{
entity.CustomerGroup = input.CustomerGroup;
@ -491,6 +516,8 @@ public class CustomerService : ApplicationService
var statusValue = input.Status.GetValueOrDefault();
var hasCustomerGroupFilter = input.CustomerGroup.HasValue;
var customerGroupValue = input.CustomerGroup.GetValueOrDefault();
var hasAgentFilter = input.AgentId is > 0;
var agentId = input.AgentId.GetValueOrDefault();
return customer =>
(string.IsNullOrWhiteSpace(keyword) ||
@ -501,7 +528,47 @@ public class CustomerService : ApplicationService
(!hasStatusFilter || customer.Status == statusValue) &&
(!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) &&
(string.IsNullOrWhiteSpace(salesman) || customer.Salesman == salesman) &&
(string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService);
(string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService) &&
(!hasAgentFilter || customer.AgentId == agentId);
}
private async Task<Dictionary<int, (string Code, string Name)>> ResolveAgentDisplayMapAsync(
IEnumerable<int> agentIds)
{
var ids = agentIds.Distinct().ToList();
if (ids.Count == 0)
{
return new Dictionary<int, (string Code, string Name)>();
}
var agents = await AgentRepository.GetListAsync(x => ids.Contains(x.Id));
return agents.ToDictionary(x => x.Id, x => (x.AgentCode, x.AgentName));
}
private static void FillAgentDisplay(
CustomerPaginatedOutput output,
int? agentId,
IReadOnlyDictionary<int, (string Code, string Name)> agentMap)
{
output.AgentId = agentId;
if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
{
output.AgentCode = agent.Code;
output.AgentName = agent.Name;
}
}
private static void FillAgentDisplay(
CustomerOutput output,
int? agentId,
IReadOnlyDictionary<int, (string Code, string Name)> agentMap)
{
output.AgentId = agentId;
if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
{
output.AgentCode = agent.Code;
output.AgentName = agent.Name;
}
}
private static Dictionary<string, bool> BuildCustomerSortDictionary(CustomerPaginatedInput input)

View File

@ -1208,6 +1208,12 @@ public partial class OrderService
return;
}
if (OmsSessionHelper.IsAgentLogin(CurrentUser))
{
await ApplyAgentOrderScopeAsync(filter);
return;
}
var userGuidRaw = CurrentUser.FindClaimValueOrNull(global::System.Security.Claims.ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userGuidRaw) || !Guid.TryParse(userGuidRaw, out var userGuid))
{
@ -1217,4 +1223,43 @@ public partial class OrderService
var context = await DataScopeBuilder.BuildAsync(userGuid, DataScopeApplier.OrderResourceCode);
DataScopeApplier.ApplyToOrderListFilter(filter, context);
}
/// <summary>
/// 渠道代理门户:列表按名下客户过滤(客户/主客户当前 AgentId);返佣仍认订单快照 AgentId。
/// </summary>
private async Task ApplyAgentOrderScopeAsync(OrderListFilter filter)
{
var agentRepository =
LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>();
var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (userId <= 0)
{
throw new BusinessException("代理未登录");
}
var agent = await agentRepository.GetByUserIdAsync(userId);
if (agent == null)
{
throw new BusinessException("代理不存在");
}
filter.OwnedByAgentId = agent.Id;
// 避免与快照字段叠滤,导致换绑前历史单被挡掉
filter.AgentId = null;
if (filter.CustomerId is > 0)
{
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == filter.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
CustomerRepository,
agentRepository,
CurrentUser,
customer);
}
}
}

View File

@ -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,7 +184,11 @@ public partial class OrderService
snapshotBinding,
serviceNo,
createContext);
order.AgentId = orderAgentId;
order = await OrderRepository.InsertAsync(order);
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Estimate);
var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights, country);
@ -2950,6 +2958,9 @@ public partial class OrderService
order.PredeclareFailReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Estimate);
// 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。
// 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
@ -3503,13 +3514,68 @@ public partial class OrderService
}
/// <summary>
/// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
/// 从会话解析有效客户(客户登录自动补全;管理员/代理须传 customerId;代理仅限名下客户)。
/// </summary>
private Task<Customer> ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) =>
OmsSessionHelper.ResolveSessionCustomerAsync(
private Task<Customer> ResolveEffectiveCustomerAsync(int? requestedCustomerId = null)
{
var agentRepository = OmsSessionHelper.IsAgentLogin(CurrentUser)
? LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>()
: null;
return OmsSessionHelper.ResolveSessionCustomerAsync(
CustomerRepository,
CurrentUser,
requestedCustomerId);
requestedCustomerId,
agentRepository);
}
/// <summary>
/// 代理返佣异步投递:有事务 Outbox 则发事件;否则同步兜底(无 UoW 场景)。
/// </summary>
private async Task EnqueueAgentCommissionJobAsync(
int orderId,
string action,
string? triggerOrderStatus = null,
string? reason = null)
{
if (orderId <= 0 || string.IsNullOrWhiteSpace(action))
{
return;
}
var uowManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager))
{
await EventBus.PublishAsync(new Messaging.Agents.AgentCommissionJobIntegrationEvent
{
OrderId = orderId,
Action = action.Trim(),
TriggerOrderStatus = triggerOrderStatus,
Reason = reason
});
return;
}
try
{
var mgr = LazyServiceProvider.GetRequiredService<AgentCommissionManager>();
if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
{
await mgr.TryEstimateOnOrderCreatedAsync(orderId);
}
else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
{
await mgr.TryAccrueOnDeliveryTerminalAsync(orderId, triggerOrderStatus ?? string.Empty);
}
else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
{
await mgr.TryVoidOnOrderCancelledAsync(orderId, reason ?? "同步作废返佣");
}
}
catch
{
// 返佣失败不阻断主业务
}
}
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
{

View File

@ -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,8 +345,16 @@ public partial class OrderService : ApplicationService
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
await couponManager.OnFirstDeliverySuccessAsync(order);
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess);
}
private Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) =>
EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Finalize,
triggerOrderStatus: triggerStatus);
/// <summary>
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
/// </summary>
@ -363,19 +376,27 @@ public partial class OrderService : ApplicationService
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
await commissionManager.ClawbackOnOrderRefundAsync(order);
if (input.IsFullRefund && order.CustomerId.HasValue)
if (input.IsFullRefund)
{
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer != null)
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Void,
reason: "订单全额退款作废返佣");
if (order.CustomerId.HasValue)
{
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
await pointsManager.ClawbackOnOrderAsync(
mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单退款扣回积分");
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer != null)
{
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
await pointsManager.ClawbackOnOrderAsync(
mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单退款扣回积分");
}
}
}
}
@ -464,6 +485,10 @@ public partial class OrderService : ApplicationService
}
await OrderRepository.UpdateAsync(order);
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Void,
reason: "订单删除作废返佣");
}
/// <summary>
@ -691,6 +716,25 @@ public partial class OrderService : ApplicationService
{
input.CustomerId = await ResolvePriceCheckCustomerIdAsync();
}
else if (OmsSessionHelper.IsAgentLogin(CurrentUser))
{
// 代理代客查价:仅允许名下绑定客户
var agentRepository =
LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>();
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
CustomerRepository,
agentRepository,
CurrentUser,
customer);
input.CustomerId =
await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(CustomerRepository, customer);
}
return await BuildPriceCheckResultAsync(input);
}
@ -1322,6 +1366,37 @@ public partial class OrderService : ApplicationService
private async Task EnsureCustomerCanAccessOrderAsync(Order order)
{
if (OmsSessionHelper.IsAgentLogin(CurrentUser))
{
var agentRepository =
LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>();
if (order.CustomerId is not > 0)
{
throw new BusinessException("订单不存在");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("订单不存在");
}
try
{
await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
CustomerRepository,
agentRepository,
CurrentUser,
customer);
}
catch (BusinessException)
{
throw new BusinessException("订单不存在");
}
return;
}
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,

View File

@ -0,0 +1,72 @@
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;
}
/// <summary>
/// 运营改绑:按代理编号覆盖;空串解绑。子客户不可改(跟主客户)。
/// </summary>
public static async Task ApplyAgentBindingAsync(
Customer entity,
string? agentCode,
IAgentRepository agentRepository,
CancellationToken cancellationToken = default)
{
if (entity.ParentCustomerId.HasValue)
{
throw new BusinessException("请改绑主客户");
}
var code = agentCode?.Trim();
if (string.IsNullOrEmpty(code))
{
entity.AgentId = null;
entity.AgentBindTime = null;
return;
}
var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
if (agent == null || agent.Status != EnableStatus.Enabled)
{
throw new BusinessException("代理编号无效或已停用");
}
entity.AgentId = agent.Id;
entity.AgentBindTime = DateTime.Now;
}
}

View File

@ -0,0 +1,424 @@
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.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
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;
}
/// <summary>下单后写入/刷新预计返佣(仅 estimated;已锁定/已打款/已作废不改)。</summary>
public async Task TryEstimateOnOrderCreatedAsync(
int orderId,
CancellationToken cancellationToken = default)
{
var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
if (order == null)
{
return;
}
await UpsertEstimatedAsync(order, cancellationToken);
}
/// <summary>预报计费完成后刷新预计金额(仍为 estimated)。</summary>
public Task TryRefreshEstimateAfterBillingAsync(
int orderId,
CancellationToken cancellationToken = default) =>
TryEstimateOnOrderCreatedAsync(orderId, cancellationToken);
/// <summary>派送成功/失败:重算锁定为 pending。</summary>
public async Task TryAccrueOnDeliveryTerminalAsync(
int orderId,
string triggerOrderStatus,
CancellationToken cancellationToken = default)
{
if (!IsTerminalTrigger(triggerOrderStatus))
{
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 existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing != null
&& (string.Equals(existing.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)
|| string.Equals(existing.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase)))
{
// pending 已锁定;paid 不回写
return;
}
if (existing != null
&& string.Equals(existing.Status, AgentCommissionStatusCodes.Void, StringComparison.OrdinalIgnoreCase))
{
return;
}
var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken);
if (agent == null || agent.Status != EnableStatus.Enabled)
{
return;
}
var built = await TryBuildAmountsAsync(agent, order, cancellationToken);
if (built == null)
{
// 终态仍算不出金额:若已有预计行则作废,避免脏数据进应结
if (existing != null
&& string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
{
existing.Status = AgentCommissionStatusCodes.Void;
existing.TriggerOrderStatus = triggerOrderStatus.Trim();
existing.Remark = AppendRemark(existing.Remark, "终态基数为0,自动作废");
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
}
return;
}
if (existing != null)
{
ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount);
existing.Status = AgentCommissionStatusCodes.Pending;
existing.TriggerOrderStatus = triggerOrderStatus.Trim();
existing.Remark = AppendRemark(existing.Remark, "派送终态锁定");
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
return;
}
// 并发双写时靠二次查重;无库级唯一索引
existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing != null)
{
if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
{
ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount);
existing.Status = AgentCommissionStatusCodes.Pending;
existing.TriggerOrderStatus = triggerOrderStatus.Trim();
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
}
return;
}
await _detailRepository.InsertAsync(new AgentCommissionDetail
{
AgentId = agent.Id,
OrderId = order.Id,
CustomerId = order.CustomerId.Value,
SalesProductId = built.Value.SalesProductId,
BaseType = agent.BaseType.Trim().ToLowerInvariant(),
BaseAmount = built.Value.BaseAmount,
CommissionRate = agent.CommissionRate,
CommissionAmount = built.Value.Amount,
Status = AgentCommissionStatusCodes.Pending,
TriggerOrderStatus = triggerOrderStatus.Trim()
}, cancellationToken);
}
/// <summary>订单删除 / 全额退款:预计或待结算作废;已打款不动。</summary>
public async Task TryVoidOnOrderCancelledAsync(
int orderId,
string reason,
CancellationToken cancellationToken = default)
{
var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing == null || !AgentCommissionStatusCodes.IsOpen(existing.Status))
{
return;
}
existing.Status = AgentCommissionStatusCodes.Void;
existing.Remark = AppendRemark(existing.Remark, reason);
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
}
private async Task UpsertEstimatedAsync(Order order, CancellationToken cancellationToken)
{
if (!order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue)
{
return;
}
if (order.IsRefunded > 0
|| string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
{
return;
}
var existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken);
if (existing != null
&& !string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
{
return;
}
var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken);
if (agent == null || agent.Status != EnableStatus.Enabled)
{
return;
}
var built = await TryBuildAmountsAsync(agent, order, cancellationToken);
int? salesProductId;
decimal baseAmount;
decimal amount;
if (built == null)
{
// 计费未出前也落一条预计(金额 0),方便代理看到「已关联」
if (agent.CommissionRate <= 0 || !AgentCommissionBaseTypes.IsSupported(agent.BaseType))
{
return;
}
int? productId = null;
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
productId = product?.Id;
}
if (!IsProductAllowed(agent.SalesProductIds, productId))
{
return;
}
salesProductId = productId;
baseAmount = 0M;
amount = 0M;
}
else
{
salesProductId = built.Value.SalesProductId;
baseAmount = built.Value.BaseAmount;
amount = built.Value.Amount;
}
if (existing != null)
{
ApplyAmounts(existing, agent, salesProductId, baseAmount, amount);
existing.Status = AgentCommissionStatusCodes.Estimated;
if (string.IsNullOrWhiteSpace(existing.TriggerOrderStatus))
{
existing.TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty;
}
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
return;
}
existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken);
if (existing != null)
{
if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
{
ApplyAmounts(existing, agent, salesProductId, baseAmount, amount);
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
}
return;
}
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.Estimated,
TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty
}, cancellationToken);
}
private async Task<(int? SalesProductId, decimal BaseAmount, decimal Amount)?> TryBuildAmountsAsync(
Agent agent,
Order order,
CancellationToken cancellationToken)
{
if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType) || agent.CommissionRate <= 0)
{
return null;
}
int? salesProductId = null;
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
salesProductId = product?.Id;
}
if (!IsProductAllowed(agent.SalesProductIds, salesProductId))
{
return null;
}
var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]);
var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order);
if (baseAmount <= 0)
{
return null;
}
var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero);
if (amount <= 0)
{
return null;
}
return (salesProductId, baseAmount, amount);
}
private static void ApplyAmounts(
AgentCommissionDetail detail,
Agent agent,
int? salesProductId,
decimal baseAmount,
decimal amount)
{
detail.SalesProductId = salesProductId;
detail.BaseType = agent.BaseType.Trim().ToLowerInvariant();
detail.BaseAmount = baseAmount;
detail.CommissionRate = agent.CommissionRate;
detail.CommissionAmount = amount;
}
private static string? AppendRemark(string? existing, string note)
{
var n = note?.Trim();
if (string.IsNullOrWhiteSpace(n))
{
return existing;
}
if (string.IsNullOrWhiteSpace(existing))
{
return n;
}
if (existing.Contains(n, StringComparison.Ordinal))
{
return existing;
}
return $"{existing}; {n}";
}
private static bool IsTerminalTrigger(string? status) =>
string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase);
private static decimal ResolveBaseAmount(
string baseType,
List<OrderFee> fees,
Order order)
{
if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase))
{
return MarketingFeeHelper.ResolvePaidBaseFreight(
MarketingFeeHelper.SumReceivableBaseFreight(fees),
order.LevelDiscountAmount,
order.CouponDiscountAmount);
}
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;
}
}

View File

@ -1,13 +1,14 @@
using System.Security.Claims;
using Fake.Application;
using Fake.Users;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 从 JWT Claims 解析当前会话类型(管理员 / 客户)。
/// 从 JWT Claims 解析当前会话类型(管理员 / 客户 / 渠道代理)。
/// </summary>
public static class OmsSessionHelper
{
@ -22,7 +23,9 @@ public static class OmsSessionHelper
public const string MapIdClaim = "mapId";
private const string CustomerLoginType = "1";
private const string AgentLoginType = "2";
private const string CustomerRoleName = "Customer";
private const string AgentRoleName = "Agent";
/// <summary>
/// 当前会话是否为客户门户登录。
@ -43,6 +46,24 @@ public static class OmsSessionHelper
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 当前会话是否为渠道代理门户登录。
/// </summary>
public static bool IsAgentLogin(ICurrentUser currentUser)
{
if (string.Equals(
currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
AgentLoginType,
StringComparison.Ordinal))
{
return true;
}
var role = currentUser.FindClaimOrNull(ClaimTypes.Role)?.Value
?? currentUser.FindClaimOrNull("role")?.Value;
return string.Equals(role, AgentRoleName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 解析当前会话的 RBAC mapId(user.userid)。
/// JWT nameidentifier 多为 Guid,GetId&lt;int&gt; 常为 0,需回退读 mapId claim。
@ -85,12 +106,14 @@ public static class OmsSessionHelper
TryGetCustomerMapId(currentUser);
/// <summary>
/// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员可按 CustomerId 代提交)。
/// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员/代理可按 CustomerId 代提交)。
/// 代理须传入 <paramref name="agentRepository"/>,且仅允许操作名下绑定客户。
/// </summary>
public static async Task<Customer> ResolveSessionCustomerAsync(
ICustomerRepository customerRepository,
ICurrentUser currentUser,
int? requestedCustomerId = null)
int? requestedCustomerId = null,
IAgentRepository? agentRepository = null)
{
var mapId = TryGetCustomerMapId(currentUser);
if (mapId.HasValue)
@ -121,9 +144,53 @@ public static class OmsSessionHelper
throw new BusinessException("客户不存在");
}
if (IsAgentLogin(currentUser))
{
await EnsureAgentOwnsCustomerAsync(customerRepository, agentRepository, currentUser, customer);
}
return customer;
}
/// <summary>
/// 渠道代理仅可操作名下绑定客户(主客户 AgentId=本代理;子客户跟主客户)。
/// </summary>
public static async Task EnsureAgentOwnsCustomerAsync(
ICustomerRepository customerRepository,
IAgentRepository? agentRepository,
ICurrentUser currentUser,
Customer customer)
{
if (agentRepository == null)
{
throw new BusinessException("代理会话缺少代理仓储");
}
var userId = ResolveMapUserId(currentUser);
if (userId <= 0)
{
throw new BusinessException("代理未登录");
}
var agent = await agentRepository.GetByUserIdAsync(userId);
if (agent == null)
{
throw new BusinessException("代理不存在");
}
int? bindAgentId = customer.AgentId;
if (customer.ParentCustomerId is > 0)
{
var main = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value);
bindAgentId = main?.AgentId;
}
if (bindAgentId != agent.Id)
{
throw new BusinessException("只能为名下绑定客户操作");
}
}
/// <summary>
/// 客户登录时强制返回 t_customer.Id;管理员返回入参 customerId。
/// </summary>

View File

@ -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);
}

View File

@ -0,0 +1,31 @@
namespace Oms.Domain.AgentsAggregate.Consts;
/// <summary>代理返佣明细结算状态。</summary>
public static class AgentCommissionStatusCodes
{
/// <summary>下单后预计返佣(金额可随计费刷新,不计入应打款)。</summary>
public const string Estimated = "estimated";
/// <summary>派送终态锁定,待运营线下打款。</summary>
public const string Pending = "pending";
/// <summary>已打款。</summary>
public const string Paid = "paid";
/// <summary>订单取消/全额退等作废。</summary>
public const string Void = "void";
public static bool IsOpen(string? status) =>
string.Equals(status, Estimated, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, Pending, StringComparison.OrdinalIgnoreCase);
public static string DisplayName(string? status) =>
status?.Trim().ToLowerInvariant() switch
{
Estimated => "预计返佣",
Pending => "待结算",
Paid => "已打款",
Void => "已作废",
_ => string.IsNullOrWhiteSpace(status) ? "--" : status
};
}

View 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; }
}

View File

@ -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>estimated / pending / paid / void。</summary>
[SugarColumn(Length = 16)]
public string Status { get; set; } = "estimated";
[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; }
}

View File

@ -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);
}

View File

@ -3,15 +3,43 @@ using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Domain.ChannelsAggregate.Repositories;
/// <summary>
/// KPI 分区明细列表筛选条件(字段均为可选,模糊匹配)。
/// </summary>
public class KpiAreaInfoQueryFilter
{
public string? Country { get; set; }
public string? MatchDim { get; set; }
public string? MatchMode { get; set; }
public string? Province { get; set; }
public string? City { get; set; }
public string? LeftPostcode { get; set; }
public string? RightPostcode { get; set; }
/// <summary>排序字段:leftPostcode / rightPostcode。</summary>
public string? SortField { get; set; }
/// <summary>排序方向:asc / desc。</summary>
public string? SortOrder { get; set; }
}
public interface IKpiAreaRepository : IRepository<KpiArea>, IKpiAreaPublishRepository
{
Task<KpiArea?> GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
Task<bool> ExistsNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
Task<List<KpiAreaInfo>> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default);
Task<List<KpiAreaInfo>> GetInfosAsync(
int areaId,
int pageIndex,
int pageSize,
KpiAreaInfoQueryFilter? filter = null,
CancellationToken cancellationToken = default);
Task<int> CountInfosAsync(int areaId, CancellationToken cancellationToken = default);
Task<int> CountInfosAsync(
int areaId,
KpiAreaInfoQueryFilter? filter = null,
CancellationToken cancellationToken = default);
Task<KpiAreaInfo> InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default);

View File

@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot
public decimal CommissionBalance { get; set; }
/// <summary>所属渠道代理(建议写在主客户上)。</summary>
public int? AgentId { get; set; }
public DateTime? AgentBindTime { get; set; }
public string? LastLoginIp { get; set; }
public DateTime? LastLoginTime { get; set; }

View File

@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot
public int? CustomerId { get; set; }
/// <summary>下单时代理快照(跟主客户 AgentId)。</summary>
public int? AgentId { get; set; }
public int? MemberLevelId { get; set; }
public string? PriceTierKey { get; set; }

View File

@ -34,6 +34,16 @@ public class OrderListFilter
public int? CustomerId { get; set; }
/// <summary>
/// 按下单快照 Order.AgentId 过滤(返佣归属;历史单换绑后不变)。
/// </summary>
public int? AgentId { get; set; }
/// <summary>
/// 按名下客户可见:订单客户(或主客户)当前 AgentId = 此值。代理门户运单列表用此字段。
/// </summary>
public int? OwnedByAgentId { get; set; }
/// <summary>
/// 发货仓 ID(订单或绑定行所属仓)。
/// </summary>

View 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);
}

View File

@ -18,20 +18,110 @@ public class KpiAreaRepository : OmsSqlSugarRepository<KpiArea>, IKpiAreaReposit
return AnyAsync(x => x.AppKey == appKey && x.AreaType == areaType && x.Name == name, cancellationToken);
}
public async Task<List<KpiAreaInfo>> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default)
public async Task<List<KpiAreaInfo>> GetInfosAsync(
int areaId,
int pageIndex,
int pageSize,
KpiAreaInfoQueryFilter? filter = null,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
// 依赖 idx_kpi_info_area_id (AreaId, Id),避免大分区 filesort
return await db.Queryable<KpiAreaInfo>()
.Where(x => x.AreaId == areaId)
.OrderBy(x => x.Id, OrderByType.Desc)
// 默认按 Id 降序,可走 idx_kpi_info_area_id;邮编排序可能 filesort,分区过大时注意性能
return await ApplyInfoOrder(BuildInfoQueryable(db, areaId, filter), filter)
.ToPageListAsync(pageIndex, pageSize, cancellationToken);
}
public async Task<int> CountInfosAsync(int areaId, CancellationToken cancellationToken = default)
public async Task<int> CountInfosAsync(
int areaId,
KpiAreaInfoQueryFilter? filter = null,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<KpiAreaInfo>().Where(x => x.AreaId == areaId).CountAsync(cancellationToken);
return await BuildInfoQueryable(db, areaId, filter).CountAsync(cancellationToken);
}
private static ISugarQueryable<KpiAreaInfo> BuildInfoQueryable(
ISqlSugarClient db,
int areaId,
KpiAreaInfoQueryFilter? filter)
{
var query = db.Queryable<KpiAreaInfo>().Where(x => x.AreaId == areaId);
if (filter == null)
{
return query;
}
var country = TrimOrNull(filter.Country);
if (country != null)
{
query = query.Where(x =>
(x.CountryName != null && x.CountryName.Contains(country))
|| (x.CountryCode != null && x.CountryCode.Contains(country)));
}
var matchDim = TrimOrNull(filter.MatchDim);
if (matchDim != null)
{
query = query.Where(x => x.MatchDim != null && x.MatchDim.Contains(matchDim));
}
var matchMode = TrimOrNull(filter.MatchMode);
if (matchMode != null)
{
query = query.Where(x => x.MatchMode != null && x.MatchMode.Contains(matchMode));
}
var province = TrimOrNull(filter.Province);
if (province != null)
{
query = query.Where(x => x.Province != null && x.Province.Contains(province));
}
var city = TrimOrNull(filter.City);
if (city != null)
{
query = query.Where(x => x.City != null && x.City.Contains(city));
}
var leftPostcode = TrimOrNull(filter.LeftPostcode);
if (leftPostcode != null)
{
query = query.Where(x => x.LeftPostcode != null && x.LeftPostcode.Contains(leftPostcode));
}
var rightPostcode = TrimOrNull(filter.RightPostcode);
if (rightPostcode != null)
{
query = query.Where(x => x.RightPostcode != null && x.RightPostcode.Contains(rightPostcode));
}
return query;
}
private static ISugarQueryable<KpiAreaInfo> ApplyInfoOrder(
ISugarQueryable<KpiAreaInfo> query,
KpiAreaInfoQueryFilter? filter)
{
var field = filter?.SortField?.Trim().ToLowerInvariant();
var asc = string.Equals(filter?.SortOrder?.Trim(), "asc", StringComparison.OrdinalIgnoreCase);
var order = asc ? OrderByType.Asc : OrderByType.Desc;
return field switch
{
"leftpostcode" => query
.OrderBy(x => x.LeftPostcode, order)
.OrderBy(x => x.Id, OrderByType.Desc),
"rightpostcode" => query
.OrderBy(x => x.RightPostcode, order)
.OrderBy(x => x.Id, OrderByType.Desc),
_ => query.OrderBy(x => x.Id, OrderByType.Desc),
};
}
private static string? TrimOrNull(string? value)
{
var trimmed = value?.Trim();
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}
public async Task<KpiAreaInfo> InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default)

View File

@ -47,6 +47,29 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
query = query.Where(x => x.CustomerId == customerId);
}
if (filter.AgentId.HasValue)
{
var agentId = filter.AgentId.Value;
query = query.Where(x => x.AgentId == agentId);
}
// 名下客户可见:本客户 AgentId,或子客户的主客户 AgentId
if (filter.OwnedByAgentId.HasValue)
{
var ownedByAgentId = filter.OwnedByAgentId.Value;
query = query.Where(o =>
SqlFunc.Subqueryable<Customer>()
.Where(c => c.Id == o.CustomerId && c.AgentId == ownedByAgentId)
.Any()
|| SqlFunc.Subqueryable<Customer>()
.Where(c => c.Id == o.CustomerId
&& c.ParentCustomerId != null
&& SqlFunc.Subqueryable<Customer>()
.Where(m => m.Id == c.ParentCustomerId && m.AgentId == ownedByAgentId)
.Any())
.Any());
}
var status = filter.Status?.Trim();
var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase);
if (isDeletedTab)
@ -1503,6 +1526,8 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
CreateTimeStart = filter.CreateTimeStart,
CreateTimeEnd = filter.CreateTimeEnd,
CustomerId = filter.CustomerId,
AgentId = filter.AgentId,
OwnedByAgentId = filter.OwnedByAgentId,
FieldFilters = filter.FieldFilters,
SortField = filter.SortField,
SortOrder = filter.SortOrder,
@ -1549,6 +1574,8 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
CreateTimeStart = filter.CreateTimeStart,
CreateTimeEnd = filter.CreateTimeEnd,
CustomerId = filter.CustomerId,
AgentId = filter.AgentId,
OwnedByAgentId = filter.OwnedByAgentId,
WarehouseId = filter.WarehouseId,
FulfillmentType = filter.FulfillmentType,
StockBindStatus = filter.StockBindStatus,

View 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"
]
});
}
}

View File

@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
public enum OmsLoginType
{
User = 0,
Customer = 1
Customer = 1,
Agent = 2
}
public class OmsLoginInput

View File

@ -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<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)
{
var normalizedAccount = account.Trim();

View File

@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput
/// </summary>
public string? AdvisorCode { get; set; }
/// <summary>
/// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。
/// </summary>
public string? AgentCode { get; set; }
[Required]
public WeChatProfileInput Wechat { get; set; } = new();
}

View File

@ -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<IReferralCommissionRuleRepository>(),
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<IReferralCommissionRuleRepository>(),
cancellationToken);
if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
{
entity.ReferralChannel = "wechat";
}
}
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,