diff --git a/sql/migrate_agent_module_20261029.sql b/sql/migrate_agent_module_20261029.sql
new file mode 100644
index 0000000..ef74284
--- /dev/null
+++ b/sql/migrate_agent_module_20261029.sql
@@ -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;
diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs
new file mode 100644
index 0000000..23ad7d3
--- /dev/null
+++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs
@@ -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;
+
+ /// 基数类型展示名(运费/应收)。
+ public string? BaseTypeName { get; set; }
+
+ public decimal CommissionRate { get; set; }
+
+ public string? SalesProductIds { get; set; }
+
+ /// 适用销售产品名称;空表示不限产品。
+ public List SalesProductNames { get; set; } = [];
+
+ public string? Remark { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class AgentCreateInput
+{
+ [Required]
+ [StringLength(32)]
+ public string AgentCode { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(64)]
+ public string AgentName { get; set; } = string.Empty;
+
+ [StringLength(32)]
+ public string? Phone { get; set; }
+
+ [StringLength(128)]
+ public string? Email { get; set; }
+
+ [Required]
+ [MinLength(6)]
+ public string Password { get; set; } = string.Empty;
+
+ /// freight / receivable。
+ [Required]
+ [StringLength(32)]
+ public string BaseType { get; set; } = "freight";
+
+ [Range(0, 1)]
+ public decimal CommissionRate { get; set; }
+
+ /// 可选销售产品 Id,JSON 或逗号分隔;空=不限。
+ [StringLength(512)]
+ public string? SalesProductIds { get; set; }
+
+ [StringLength(512)]
+ public string? Remark { get; set; }
+}
+
+public class AgentUpdateInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ [Required]
+ [StringLength(64)]
+ public string AgentName { get; set; } = string.Empty;
+
+ [StringLength(32)]
+ public string? Phone { get; set; }
+
+ [StringLength(128)]
+ public string? Email { get; set; }
+
+ /// 不传则不改密码。
+ [MinLength(6)]
+ public string? Password { get; set; }
+
+ public EnableStatus Status { get; set; } = EnableStatus.Enabled;
+
+ [Required]
+ [StringLength(32)]
+ public string BaseType { get; set; } = "freight";
+
+ [Range(0, 1)]
+ public decimal CommissionRate { get; set; }
+
+ [StringLength(512)]
+ public string? SalesProductIds { get; set; }
+
+ [StringLength(512)]
+ public string? Remark { get; set; }
+}
+
+public class AgentRebindCustomerInput
+{
+ [Required]
+ public int CustomerId { get; set; }
+
+ /// 空则解绑。
+ public int? AgentId { get; set; }
+}
+
+public class AgentCommissionListInput
+{
+ public int Page { get; set; } = 1;
+
+ public int PageSize { get; set; } = 50;
+
+ public int? AgentId { get; set; }
+
+ public int? OrderId { get; set; }
+
+ public string? Status { get; set; }
+}
+
+public class AgentCommissionDetailOutput
+{
+ public int Id { get; set; }
+
+ public int AgentId { get; set; }
+
+ public string? AgentCode { get; set; }
+
+ public string? AgentName { get; set; }
+
+ public int OrderId { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public string? CustomerCode { get; set; }
+
+ public string? CustomerName { get; set; }
+
+ public int? SalesProductId { get; set; }
+
+ public string BaseType { get; set; } = string.Empty;
+
+ public decimal BaseAmount { get; set; }
+
+ public decimal CommissionRate { get; set; }
+
+ public decimal CommissionAmount { get; set; }
+
+ public string Status { get; set; } = string.Empty;
+
+ public string TriggerOrderStatus { get; set; } = string.Empty;
+
+ public DateTime? PaidTime { get; set; }
+
+ public string? Remark { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class AgentCommissionMarkPaidInput
+{
+ [Required]
+ public List Ids { get; set; } = [];
+
+ [StringLength(512)]
+ public string? Remark { get; set; }
+}
+
+public class AgentPortalCustomerOutput
+{
+ public int Id { get; set; }
+
+ public string CustomerCode { get; set; } = string.Empty;
+
+ public string CustomerName { get; set; } = string.Empty;
+
+ public string? Phone { get; set; }
+
+ public DateTime? AgentBindTime { get; set; }
+
+ public DateTime CreateTime { get; set; }
+
+ /// 最近下单时间(含名下子客户)。
+ public DateTime? LastOrderTime { get; set; }
+
+ /// 近 30 天运单数(含名下子客户,不含已删除)。
+ public int OrderCount30d { get; set; }
+
+ /// 待付款运单数(含名下子客户)。
+ public int PendingPaymentCount { get; set; }
+}
+
+/// 代理门户返佣明细分页筛选。
+public class AgentPortalCommissionListInput
+{
+ public int Page { get; set; } = 1;
+
+ public int PageSize { get; set; } = 50;
+
+ /// pending / paid;空=全部。
+ public string? Status { get; set; }
+
+ /// 运单号关键字。
+ public string? Keyword { get; set; }
+
+ public DateTime? CreateTimeStart { get; set; }
+
+ public DateTime? CreateTimeEnd { get; set; }
+}
+
+public class AgentPortalOrderOutput
+{
+ 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; }
+}
+
+/// 匿名校验代理编号(客户注册归因)。
+public class AgentValidateCodeOutput
+{
+ public bool Valid { get; set; }
+
+ public string? AgentCode { get; set; }
+
+ /// 脱敏代理名称,供注册页展示。
+ public string? AgentNameMasked { get; set; }
+
+ public string? Message { get; set; }
+}
+
+///
+/// 代理工作台统计概览。
+///
+public class AgentWorkplaceStatsOutput
+{
+ /// 名下主客户数。
+ public int CustomerCount { get; set; }
+
+ /// 今日代客下单数。
+ public int TodayCreated { get; set; }
+
+ /// 昨日代客下单数。
+ public int YesterdayCreated { get; set; }
+
+ /// 较昨日新增百分比。
+ public decimal? DayOverDayGrowthPercent { get; set; }
+
+ /// 待付款运单数。
+ public int PendingPayment { get; set; }
+
+ /// 运输中运单数。
+ public int Shipping { get; set; }
+
+ /// 问题件运单数。
+ public int Problematic { get; set; }
+
+ /// 派送成功运单数(全量状态桶)。
+ public int DeliverySuccess { get; set; }
+
+ /// 预计返佣笔数(未锁定)。
+ public int EstimatedCommissionCount { get; set; }
+
+ /// 预计返佣金额(未锁定,不计入应打款)。
+ public decimal EstimatedCommissionAmount { get; set; }
+
+ /// 待结算返佣笔数。
+ public int PendingCommissionCount { get; set; }
+
+ /// 待结算返佣金额。
+ public decimal PendingCommissionAmount { get; set; }
+
+ /// 已打款返佣金额(累计)。
+ public decimal PaidCommissionAmount { get; set; }
+
+ /// 本周新增绑定主客户数(按 AgentBindTime)。
+ public int WeekNewCustomers { get; set; }
+
+ /// 派送失败运单数。
+ public int DeliveryFailed { get; set; }
+
+ /// 按 OrderStatus 分组数量。
+ public Dictionary Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+///
+/// 代理工作台趋势点。
+///
+public class AgentWorkplaceTrendPoint
+{
+ public string Label { get; set; } = string.Empty;
+
+ public DateTime Start { get; set; }
+
+ public DateTime End { get; set; }
+
+ /// 区间内代客下单数。
+ public int OrderCount { get; set; }
+
+ /// 区间内计提返佣金额。
+ public decimal CommissionAmount { get; set; }
+}
+
+///
+/// 代理工作台趋势。
+///
+public class AgentWorkplaceTrendOutput
+{
+ public string Dimension { get; set; } = "day";
+
+ public List Points { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs
index 120c115..b073063 100644
--- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs
+++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoListInput.cs
@@ -19,4 +19,49 @@ public class KpiAreaInfoListInput
/// 每页条数。
///
public int PageSize { get; set; }
+
+ ///
+ /// 国家/地区(匹配国家名称或国家代码,模糊)。
+ ///
+ public string? Country { get; set; }
+
+ ///
+ /// 匹配维度(如 zip/state/city/country,模糊)。
+ ///
+ public string? MatchDim { get; set; }
+
+ ///
+ /// 匹配方式(如 prefix/exact/range/all,模糊)。
+ ///
+ public string? MatchMode { get; set; }
+
+ ///
+ /// 州/省(模糊)。
+ ///
+ public string? Province { get; set; }
+
+ ///
+ /// 城市(模糊)。
+ ///
+ public string? City { get; set; }
+
+ ///
+ /// 起始邮编(模糊)。
+ ///
+ public string? LeftPostcode { get; set; }
+
+ ///
+ /// 截止邮编(模糊)。
+ ///
+ public string? RightPostcode { get; set; }
+
+ ///
+ /// 排序字段:leftPostcode / rightPostcode;空则按 Id 降序。
+ ///
+ public string? SortField { get; set; }
+
+ ///
+ /// 排序方向:asc / desc。
+ ///
+ public string? SortOrder { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
index 1c1ac13..a1c3d94 100644
--- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
+++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
@@ -124,4 +124,10 @@ public class CreateCustomerInput
[StringLength(64)]
public string? AdvisorCode { get; set; }
+ ///
+ /// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
+ ///
+ [StringLength(32)]
+ public string? AgentCode { get; set; }
+
}
diff --git a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs
index 014262e..71dc952 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs
@@ -78,7 +78,7 @@ public class CustomerOutput
public string? Remark { get; set; }
///
- /// 销售人员。
+ /// 销售人员(遗留字段,管理端已改用 AgentId)。
///
public string? Salesman { get; set; }
@@ -87,6 +87,15 @@ public class CustomerOutput
///
public string? CustomerService { get; set; }
+ /// 绑定渠道代理 Id。
+ public int? AgentId { get; set; }
+
+ /// 绑定代理编号。
+ public string? AgentCode { get; set; }
+
+ /// 绑定代理名称。
+ public string? AgentName { get; set; }
+
///
/// 客户分组。
///
diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs
index a765c86..301170e 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs
@@ -33,7 +33,7 @@ public class CustomerPaginatedInput
public CustomerGroup? CustomerGroup { get; set; }
///
- /// 销售人员。
+ /// 销售人员(遗留筛选)。
///
public string? Salesman { get; set; }
@@ -42,6 +42,9 @@ public class CustomerPaginatedInput
///
public string? CustomerService { get; set; }
+ /// 按绑定代理 Id 筛选。
+ public int? AgentId { get; set; }
+
///
/// 排序字段:createTime / updateTime / customerCode / customerName / balance。
///
diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs
index 63fb35e..c20b23d 100644
--- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs
+++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs
@@ -78,7 +78,7 @@ public class CustomerPaginatedOutput
public string? Remark { get; set; }
///
- /// 销售人员。
+ /// 销售人员(遗留字段,管理端已改用 AgentId)。
///
public string? Salesman { get; set; }
@@ -87,6 +87,15 @@ public class CustomerPaginatedOutput
///
public string? CustomerService { get; set; }
+ /// 绑定渠道代理 Id。
+ public int? AgentId { get; set; }
+
+ /// 绑定代理编号。
+ public string? AgentCode { get; set; }
+
+ /// 绑定代理名称。
+ public string? AgentName { get; set; }
+
///
/// 客户分组。
///
diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs
index 2bd683d..7e9fc36 100644
--- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs
+++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs
@@ -81,7 +81,7 @@ public class UpdateCustomerInput
public string? Remark { get; set; }
///
- /// 销售人员。
+ /// 销售人员(遗留;新流程请用 AgentCode)。
///
[StringLength(32)]
public string? Salesman { get; set; }
@@ -92,6 +92,12 @@ public class UpdateCustomerInput
[StringLength(32)]
public string? CustomerService { get; set; }
+ ///
+ /// 渠道代理编号;传空串解绑;不传则保留原绑定。仅主客户可改。
+ ///
+ [StringLength(32)]
+ public string? AgentCode { get; set; }
+
///
/// 客户分组(遗留字段,计价优先 MemberLevelId)。
///
diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs
new file mode 100644
index 0000000..ca11e1d
--- /dev/null
+++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs
@@ -0,0 +1,14 @@
+namespace Oms.Application.Messaging.Agents;
+
+/// 代理返佣异步任务动作。
+public static class AgentCommissionJobActionCodes
+{
+ /// 下单/计费后写或刷新预计返佣。
+ public const string Estimate = "estimate";
+
+ /// 派送终态锁定为待结算。
+ public const string Finalize = "finalize";
+
+ /// 删单/全额退作废。
+ public const string Void = "void";
+}
diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs
new file mode 100644
index 0000000..8a5866c
--- /dev/null
+++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs
@@ -0,0 +1,75 @@
+using Fake.DependencyInjection;
+using Fake.EventBus;
+using Fake.UnitOfWork;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Oms.Application.Shared;
+
+namespace Oms.Application.Messaging.Agents;
+
+/// 消费代理返佣异步任务。
+public sealed class AgentCommissionJobEventHandler(
+ IServiceScopeFactory scopeFactory,
+ IUnitOfWorkManager unitOfWorkManager,
+ ILogger logger)
+ : IEventHandler, ITransientDependency
+{
+ public async Task HandleAsync(
+ AgentCommissionJobIntegrationEvent @event,
+ CancellationToken cancellationToken)
+ {
+ if (@event.OrderId <= 0 || string.IsNullOrWhiteSpace(@event.Action))
+ {
+ logger.LogWarning(
+ "AgentCommission job skipped invalid payload OrderId={OrderId} Action={Action}",
+ @event.OrderId,
+ @event.Action);
+ return;
+ }
+
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
+ using var scope = scopeFactory.CreateScope();
+ var manager = scope.ServiceProvider.GetRequiredService();
+
+ try
+ {
+ var action = @event.Action.Trim();
+ if (string.Equals(action, AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
+ {
+ await manager.TryEstimateOnOrderCreatedAsync(@event.OrderId, cancellationToken);
+ }
+ else if (string.Equals(action, AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
+ {
+ await manager.TryAccrueOnDeliveryTerminalAsync(
+ @event.OrderId,
+ @event.TriggerOrderStatus ?? string.Empty,
+ cancellationToken);
+ }
+ else if (string.Equals(action, AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
+ {
+ await manager.TryVoidOnOrderCancelledAsync(
+ @event.OrderId,
+ @event.Reason ?? "异步作废返佣",
+ cancellationToken);
+ }
+ else
+ {
+ logger.LogWarning(
+ "AgentCommission job unknown Action={Action} OrderId={OrderId}",
+ @event.Action,
+ @event.OrderId);
+ }
+ }
+ catch (Exception ex)
+ {
+ // 失败 Ack,避免打满队列;明细可后续补偿
+ logger.LogWarning(
+ ex,
+ "AgentCommission job failed OrderId={OrderId} Action={Action}",
+ @event.OrderId,
+ @event.Action);
+ }
+
+ await unitOfWork.CompleteAsync(cancellationToken);
+ }
+}
diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs
new file mode 100644
index 0000000..a5b168d
--- /dev/null
+++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs
@@ -0,0 +1,21 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.Agents;
+
+///
+/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。
+///
+[Serializable]
+public class AgentCommissionJobIntegrationEvent : IntegrationEvent
+{
+ public int OrderId { get; set; }
+
+ /// 。
+ public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate;
+
+ /// finalize 时的触发运单状态。
+ public string? TriggerOrderStatus { get; set; }
+
+ /// void 时的原因备注。
+ public string? Reason { get; set; }
+}
diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs
index e82a28c..9a973e6 100644
--- a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs
+++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs
@@ -1,6 +1,8 @@
using Fake.DependencyInjection;
+using Fake.EventBus.Distributed;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
+using Oms.Application.Messaging.Agents;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
@@ -16,6 +18,7 @@ public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager,
+ IDistributedEventBus eventBus,
MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager,
ILogger 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;
diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs
new file mode 100644
index 0000000..f354267
--- /dev/null
+++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs
@@ -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;
+
+///
+/// 渠道代理管理端 API(开户、规则、改绑、返佣明细)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Agent")]
+public class AgentAdminService : ApplicationService
+{
+ public const string AgentRoleCode = "Agent";
+
+ private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService();
+
+ private IAgentCommissionDetailRepository DetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+
+ private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
+
+ private UserService UserService => LazyServiceProvider.GetRequiredService();
+
+ private RoleService RoleService => LazyServiceProvider.GetRequiredService();
+
+ private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 匿名校验代理编号(客户注册页归因 / 手填兜底)。
+ ///
+ [AllowAnonymous]
+ [HttpGet("/agent/public/validate-code")]
+ public async Task ValidateCodeAsync([FromQuery] string code)
+ {
+ var trimmed = code?.Trim();
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ return new AgentValidateCodeOutput
+ {
+ Valid = false,
+ Message = "代理编号不能为空"
+ };
+ }
+
+ var agent = await AgentRepository.GetByAgentCodeAsync(trimmed);
+ if (agent == null || agent.Status != EnableStatus.Enabled)
+ {
+ return new AgentValidateCodeOutput
+ {
+ Valid = false,
+ Message = "代理编号无效或已停用"
+ };
+ }
+
+ return new AgentValidateCodeOutput
+ {
+ Valid = true,
+ AgentCode = agent.AgentCode,
+ AgentNameMasked = MaskAgentName(agent.AgentName),
+ Message = null
+ };
+ }
+
+ /// 分页查询代理。
+ [HttpPost("/agent/admin/list")]
+ public async Task> GetListAsync(AgentListInput input)
+ {
+ var predicate = BuildAgentListPredicate(input);
+ var page = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var entities = await AgentRepository.GetPagedListAsync(
+ predicate,
+ page,
+ pageSize,
+ new Dictionary { [nameof(Agent.CreateTime)] = false, [nameof(Agent.Id)] = false });
+ var total = await AgentRepository.CountAsync(predicate);
+ return new OmsPageResult
+ {
+ Items = entities.Select(MapAgent).ToList(),
+ Total = total
+ };
+ }
+
+ /// 创建代理(同步 RBAC 用户 + Agent 角色)。
+ [HttpPost("/agent/admin/create")]
+ public async Task CreateAsync(AgentCreateInput input)
+ {
+ var code = input.AgentCode.Trim();
+ if (await AgentRepository.ExistsAgentCodeAsync(code))
+ {
+ throw new BusinessException("代理编号已存在");
+ }
+
+ EnsureBaseType(input.BaseType);
+
+ var roleId = await GetAgentRoleIdOrThrowAsync();
+ var user = await UserService.CreateAsync(new UserCreateDto
+ {
+ Name = input.AgentName.Trim(),
+ Account = code,
+ Password = input.Password,
+ Email = string.IsNullOrWhiteSpace(input.Email)
+ ? $"{code}@agent.local"
+ : input.Email.Trim(),
+ RoleIds = []
+ });
+
+ try
+ {
+ await UserService.AssignRolesAsync(user.Id, [roleId]);
+ var userId = await GetRbacUserMapIdOrThrowAsync(user.Id);
+ await AgentRepository.InsertAsync(new Agent
+ {
+ UserId = userId,
+ AgentCode = code,
+ AgentName = input.AgentName.Trim(),
+ Phone = NormalizeOptional(input.Phone),
+ Email = NormalizeOptional(input.Email),
+ Password = OmsPasswordHelper.ToMd5(input.Password),
+ Status = EnableStatus.Enabled,
+ BaseType = input.BaseType.Trim().ToLowerInvariant(),
+ CommissionRate = input.CommissionRate,
+ SalesProductIds = NormalizeOptional(input.SalesProductIds),
+ Remark = NormalizeOptional(input.Remark)
+ });
+ }
+ catch
+ {
+ try
+ {
+ await UserService.DeleteAsync(user.Id);
+ }
+ catch
+ {
+ // ignore rollback failure
+ }
+
+ throw;
+ }
+ }
+
+ /// 更新代理资料与返佣规则。
+ [HttpPost("/agent/admin/update")]
+ public async Task UpdateAsync(AgentUpdateInput input)
+ {
+ var entity = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.Id)
+ ?? throw new BusinessException("代理不存在");
+ EnsureBaseType(input.BaseType);
+
+ entity.AgentName = input.AgentName.Trim();
+ entity.Phone = NormalizeOptional(input.Phone);
+ entity.Email = NormalizeOptional(input.Email);
+ entity.Status = input.Status == EnableStatus.None ? EnableStatus.Enabled : input.Status;
+ entity.BaseType = input.BaseType.Trim().ToLowerInvariant();
+ entity.CommissionRate = input.CommissionRate;
+ entity.SalesProductIds = NormalizeOptional(input.SalesProductIds);
+ entity.Remark = NormalizeOptional(input.Remark);
+ if (!string.IsNullOrWhiteSpace(input.Password))
+ {
+ entity.Password = OmsPasswordHelper.ToMd5(input.Password);
+ }
+
+ await AgentRepository.UpdateAsync(entity);
+ }
+
+ /// 改绑主客户所属代理(仅影响之后新单)。
+ [HttpPost("/agent/admin/rebind")]
+ public async Task RebindCustomerAsync(AgentRebindCustomerInput input)
+ {
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId)
+ ?? throw new BusinessException("客户不存在");
+ if (customer.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("请改绑主客户");
+ }
+
+ if (input.AgentId.HasValue && input.AgentId.Value > 0)
+ {
+ var agent = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.AgentId.Value)
+ ?? throw new BusinessException("代理不存在");
+ if (agent.Status != EnableStatus.Enabled)
+ {
+ throw new BusinessException("代理已停用");
+ }
+
+ customer.AgentId = agent.Id;
+ customer.AgentBindTime = DateTime.Now;
+ }
+ else
+ {
+ customer.AgentId = null;
+ customer.AgentBindTime = null;
+ }
+
+ await CustomerRepository.UpdateAsync(customer);
+ }
+
+ /// 分页查询返佣明细。
+ [HttpPost("/agent/admin/commission/list")]
+ public async Task> GetCommissionListAsync(AgentCommissionListInput input)
+ {
+ var predicate = BuildCommissionPredicate(input);
+
+ var page = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var entities = await DetailRepository.GetPagedListAsync(
+ predicate,
+ page,
+ pageSize,
+ new Dictionary
+ {
+ [nameof(AgentCommissionDetail.CreateTime)] = false,
+ [nameof(AgentCommissionDetail.Id)] = false
+ });
+ var total = await DetailRepository.CountAsync(predicate);
+ return new OmsPageResult
+ {
+ Items = await MapCommissionOutputsAsync(entities),
+ Total = total
+ };
+ }
+
+ /// 标记返佣明细为已打款。
+ [HttpPost("/agent/admin/commission/mark-paid")]
+ public async Task MarkCommissionPaidAsync(AgentCommissionMarkPaidInput input)
+ {
+ if (input.Ids == null || input.Ids.Count == 0)
+ {
+ throw new BusinessException("请选择明细");
+ }
+
+ var ids = input.Ids.Distinct().ToList();
+ var list = await DetailRepository.GetListAsync(x => ids.Contains(x.Id));
+ var now = DateTime.Now;
+ var remark = NormalizeOptional(input.Remark);
+ foreach (var item in list)
+ {
+ // 仅「待结算」可标记打款;预计/作废/已打款跳过
+ if (!string.Equals(item.Status, AgentCommissionStatusCodes.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> BuildAgentListPredicate(AgentListInput input)
+ {
+ var keyword = input.Keyword?.Trim();
+ var hasStatus = input.Status.HasValue && input.Status.Value != EnableStatus.None;
+ if (hasStatus && !string.IsNullOrWhiteSpace(keyword))
+ {
+ var status = input.Status!.Value;
+ return x => x.Status == status
+ && (x.AgentCode.Contains(keyword!)
+ || x.AgentName.Contains(keyword!)
+ || (x.Phone != null && x.Phone.Contains(keyword!)));
+ }
+
+ if (hasStatus)
+ {
+ var status = input.Status!.Value;
+ return x => x.Status == status;
+ }
+
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ return x => x.AgentCode.Contains(keyword!)
+ || x.AgentName.Contains(keyword!)
+ || (x.Phone != null && x.Phone.Contains(keyword!));
+ }
+
+ return x => true;
+ }
+
+ private static Expression> BuildCommissionPredicate(AgentCommissionListInput input)
+ {
+ var status = input.Status?.Trim();
+ if (input.AgentId is > 0 && input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status))
+ {
+ var agentId = input.AgentId.Value;
+ var orderId = input.OrderId.Value;
+ return x => x.AgentId == agentId && x.OrderId == orderId && x.Status == status;
+ }
+
+ if (input.AgentId is > 0 && input.OrderId is > 0)
+ {
+ var agentId = input.AgentId.Value;
+ var orderId = input.OrderId.Value;
+ return x => x.AgentId == agentId && x.OrderId == orderId;
+ }
+
+ if (input.AgentId is > 0 && !string.IsNullOrWhiteSpace(status))
+ {
+ var agentId = input.AgentId.Value;
+ return x => x.AgentId == agentId && x.Status == status;
+ }
+
+ if (input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status))
+ {
+ var orderId = input.OrderId.Value;
+ return x => x.OrderId == orderId && x.Status == status;
+ }
+
+ if (input.AgentId is > 0)
+ {
+ var agentId = input.AgentId.Value;
+ return x => x.AgentId == agentId;
+ }
+
+ if (input.OrderId is > 0)
+ {
+ var orderId = input.OrderId.Value;
+ return x => x.OrderId == orderId;
+ }
+
+ if (!string.IsNullOrWhiteSpace(status))
+ {
+ return x => x.Status == status;
+ }
+
+ return x => true;
+ }
+
+ private async Task> MapCommissionOutputsAsync(
+ List entities)
+ {
+ if (entities.Count == 0)
+ {
+ return [];
+ }
+
+ var agentIds = entities.Select(x => x.AgentId).Distinct().ToList();
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
+ var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
+
+ var agents = await AgentRepository.GetListAsync(x => agentIds.Contains(x.Id));
+ var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
+
+ var agentMap = agents.ToDictionary(x => x.Id);
+ var customerMap = customers.ToDictionary(x => x.Id);
+ var orderMap = orders.ToDictionary(x => x.Id);
+
+ return entities.Select(x =>
+ {
+ agentMap.TryGetValue(x.AgentId, out var agent);
+ customerMap.TryGetValue(x.CustomerId, out var customer);
+ orderMap.TryGetValue(x.OrderId, out var order);
+ return new AgentCommissionDetailOutput
+ {
+ Id = x.Id,
+ AgentId = x.AgentId,
+ AgentCode = agent?.AgentCode,
+ AgentName = agent?.AgentName,
+ OrderId = x.OrderId,
+ ServiceNo = order?.ServiceNo,
+ CustomerId = x.CustomerId,
+ CustomerCode = customer?.CustomerCode,
+ CustomerName = customer?.CustomerName,
+ SalesProductId = x.SalesProductId,
+ BaseType = x.BaseType,
+ BaseAmount = x.BaseAmount,
+ CommissionRate = x.CommissionRate,
+ CommissionAmount = x.CommissionAmount,
+ Status = x.Status,
+ TriggerOrderStatus = x.TriggerOrderStatus,
+ PaidTime = x.PaidTime,
+ Remark = x.Remark,
+ CreateTime = x.CreateTime
+ };
+ }).ToList();
+ }
+
+ private static AgentOutput MapAgent(Agent x) => new()
+ {
+ Id = x.Id,
+ AgentCode = x.AgentCode,
+ AgentName = x.AgentName,
+ Phone = x.Phone,
+ Email = x.Email,
+ Status = x.Status,
+ BaseType = x.BaseType,
+ CommissionRate = x.CommissionRate,
+ SalesProductIds = x.SalesProductIds,
+ Remark = x.Remark,
+ CreateTime = x.CreateTime
+ };
+
+ private static void EnsureBaseType(string? baseType)
+ {
+ if (!AgentCommissionBaseTypes.IsSupported(baseType))
+ {
+ throw new BusinessException("基数类型仅支持 freight(运费)或 receivable(应收)");
+ }
+ }
+
+ private static string? NormalizeOptional(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private async Task GetAgentRoleIdOrThrowAsync()
+ {
+ var roles = await RoleService.GetAllRolesAsync();
+ var role = roles.FirstOrDefault(x =>
+ string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase));
+ if (role == null)
+ {
+ throw new BusinessException("未找到 Agent 角色,请先执行种子或手动创建");
+ }
+
+ return role.Id;
+ }
+
+ private async Task GetRbacUserMapIdOrThrowAsync(Guid userId)
+ {
+ var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
+ var mapId = await RbacDbContext.Database.SqlQueryRaw(sql, userId).FirstOrDefaultAsync();
+ if (!mapId.HasValue || mapId.Value <= 0)
+ {
+ throw new BusinessException("RBAC 用户映射失败");
+ }
+
+ return mapId.Value;
+ }
+
+ private static string MaskAgentName(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return "渠道代理";
+ }
+
+ var text = name.Trim();
+ return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1));
+ }
+}
diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs
new file mode 100644
index 0000000..3b30f9a
--- /dev/null
+++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs
@@ -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;
+
+///
+/// 渠道代理门户 API(名下客户 / 订单 / 返佣 / 工作台)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Agent")]
+public class AgentPortalService : ApplicationService
+{
+ private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService();
+
+ private IAgentCommissionDetailRepository DetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+
+ private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
+
+ private ISalesProductRepository SalesProductRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ /// 当前代理资料(含返佣规则展示字段)。
+ [HttpGet("/agent/portal/profile")]
+ public async Task GetProfileAsync()
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ var productNames = await ResolveSalesProductNamesAsync(agent.SalesProductIds);
+ return new AgentOutput
+ {
+ Id = agent.Id,
+ 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
+ };
+ }
+
+ /// 名下客户分页(含活跃度:最近下单 / 近30天票数 / 待付款数)。
+ [HttpPost("/agent/portal/customers/list")]
+ public async Task> GetCustomersAsync(AgentPortalPageInput input)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ var keyword = input.Keyword?.Trim();
+ Expression> predicate = string.IsNullOrWhiteSpace(keyword)
+ ? x => x.AgentId == agent.Id && x.ParentCustomerId == null
+ : x => x.AgentId == agent.Id
+ && x.ParentCustomerId == null
+ && (x.CustomerCode.Contains(keyword!)
+ || x.CustomerName.Contains(keyword!)
+ || (x.Phone != null && x.Phone.Contains(keyword!)));
+
+ var page = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var entities = await CustomerRepository.GetPagedListAsync(
+ predicate,
+ page,
+ pageSize,
+ new Dictionary
+ {
+ [nameof(Customer.CreateTime)] = false,
+ [nameof(Customer.Id)] = false
+ });
+ var total = await CustomerRepository.CountAsync(predicate);
+ var activityMap = await BuildCustomerActivityMapAsync(agent.Id, entities.Select(x => x.Id).ToList());
+
+ return new OmsPageResult
+ {
+ 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
+ };
+ }
+
+ /// 名下订单分页(按当前绑定客户可见;不含应付)。
+ [HttpPost("/agent/portal/orders/list")]
+ public async Task> 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
+ {
+ 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
+ };
+ }
+
+ ///
+ /// 代理工作台统计:名下客户、运单状态桶、待结算/已打款返佣、本周新增绑定。
+ ///
+ [HttpGet("/agent/portal/workplace/stats")]
+ public async Task 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,
+ };
+ }
+
+ ///
+ /// 代理工作台趋势:按日统计代客下单数与计提返佣金额。
+ ///
+ /// 天数,默认 7,最大 31。
+ [HttpGet("/agent/portal/workplace/trend")]
+ public async Task GetWorkplaceTrendAsync([FromQuery] int days = 7)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ days = Math.Clamp(days, 1, 31);
+ var filter = new OrderListFilter { 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(days);
+ for (var i = 0; i < days; i++)
+ {
+ var start = rangeStart.AddDays(i);
+ var end = start.AddDays(1);
+ var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, start, end);
+ var commissionAmount = commissions
+ .Where(x => x.CreateTime >= start && x.CreateTime < end)
+ .Sum(x => x.CommissionAmount);
+ points.Add(new AgentWorkplaceTrendPoint
+ {
+ Label = start.ToString("MM-dd"),
+ Start = start,
+ End = end,
+ OrderCount = orderCount,
+ CommissionAmount = commissionAmount,
+ });
+ }
+
+ return new AgentWorkplaceTrendOutput
+ {
+ Dimension = "day",
+ Points = points,
+ };
+ }
+
+ /// 名下返佣明细(支持状态 / 运单号 / 计提时间筛选)。
+ [HttpPost("/agent/portal/commission/list")]
+ public async Task> GetCommissionListAsync(
+ AgentPortalCommissionListInput input)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ var details = await QueryCommissionDetailsAsync(agent, input);
+ var page = OmsDocHelper.NormalizePage(input.Page);
+ var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
+ var total = details.Count;
+ var pageItems = details
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+
+ return new OmsPageResult
+ {
+ Items = await MapCommissionOutputsAsync(agent, pageItems),
+ Total = total
+ };
+ }
+
+ /// 导出名下返佣明细(Excel,最多 5000 条)。
+ [HttpPost("/agent/portal/commission/export")]
+ public async Task ExportCommissionAsync(AgentPortalCommissionListInput input)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ var details = await QueryCommissionDetailsAsync(agent, input);
+ const int maxRows = 5000;
+ if (details.Count > maxRows)
+ {
+ throw new BusinessException($"导出结果超过 {maxRows} 条(当前 {details.Count}),请缩小筛选条件后再导出");
+ }
+
+ var outputs = await MapCommissionOutputsAsync(agent, details);
+ var rows = outputs.Select(x => new
+ {
+ 运单号 = x.ServiceNo,
+ 客户编号 = x.CustomerCode,
+ 客户名称 = x.CustomerName,
+ 基数类型 = ResolveBaseTypeName(x.BaseType),
+ 基数金额 = x.BaseAmount,
+ 返佣比例 = x.CommissionRate,
+ 返佣金额 = x.CommissionAmount,
+ 状态 = 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> QueryCommissionDetailsAsync(
+ Agent agent,
+ AgentPortalCommissionListInput input)
+ {
+ var status = input.Status?.Trim();
+ var keyword = input.Keyword?.Trim();
+ var start = input.CreateTimeStart;
+ var end = input.CreateTimeEnd;
+
+ Expression> predicate = x => x.AgentId == agent.Id;
+ if (!string.IsNullOrWhiteSpace(status))
+ {
+ predicate = x => x.AgentId == agent.Id && x.Status == status;
+ }
+
+ var list = await DetailRepository.GetListAsync(predicate);
+ if (start.HasValue)
+ {
+ list = list.Where(x => x.CreateTime >= start.Value).ToList();
+ }
+
+ if (end.HasValue)
+ {
+ var endExclusive = end.Value.Date.AddDays(1);
+ list = list.Where(x => x.CreateTime < endExclusive).ToList();
+ }
+
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ var orderIds = list.Select(x => x.OrderId).Distinct().ToList();
+ var orders = orderIds.Count == 0
+ ? []
+ : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
+ var matchedOrderIds = orders
+ .Where(o => o.ServiceNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)
+ || (o.TrackNo != null && o.TrackNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
+ .Select(o => o.Id)
+ .ToHashSet();
+ list = list.Where(x => matchedOrderIds.Contains(x.OrderId)).ToList();
+ }
+
+ return list
+ .OrderByDescending(x => x.CreateTime)
+ .ThenByDescending(x => x.Id)
+ .ToList();
+ }
+
+ private async Task> MapCommissionOutputsAsync(
+ Agent agent,
+ IReadOnlyCollection entities)
+ {
+ if (entities.Count == 0)
+ {
+ return [];
+ }
+
+ var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
+ var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
+ var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
+ var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
+ var customerMap = customers.ToDictionary(x => x.Id);
+ var orderMap = orders.ToDictionary(x => x.Id);
+
+ return entities.Select(x =>
+ {
+ customerMap.TryGetValue(x.CustomerId, out var customer);
+ orderMap.TryGetValue(x.OrderId, out var order);
+ return new AgentCommissionDetailOutput
+ {
+ Id = x.Id,
+ AgentId = x.AgentId,
+ AgentCode = agent.AgentCode,
+ AgentName = agent.AgentName,
+ OrderId = x.OrderId,
+ ServiceNo = order?.ServiceNo,
+ CustomerId = x.CustomerId,
+ CustomerCode = customer?.CustomerCode,
+ CustomerName = MaskName(customer?.CustomerName),
+ SalesProductId = x.SalesProductId,
+ BaseType = x.BaseType,
+ BaseAmount = x.BaseAmount,
+ CommissionRate = x.CommissionRate,
+ CommissionAmount = x.CommissionAmount,
+ Status = x.Status,
+ TriggerOrderStatus = x.TriggerOrderStatus,
+ PaidTime = x.PaidTime,
+ Remark = x.Remark,
+ CreateTime = x.CreateTime
+ };
+ }).ToList();
+ }
+
+ private async Task>
+ BuildCustomerActivityMapAsync(int agentId, List mainCustomerIds)
+ {
+ var result = mainCustomerIds.ToDictionary(
+ id => id,
+ _ => ((DateTime?)null, 0, 0));
+ if (mainCustomerIds.Count == 0)
+ {
+ return result;
+ }
+
+ var children = await CustomerRepository.GetListAsync(x =>
+ x.ParentCustomerId != null && mainCustomerIds.Contains(x.ParentCustomerId.Value));
+ var mainByCustomerId = new Dictionary();
+ foreach (var id in mainCustomerIds)
+ {
+ mainByCustomerId[id] = id;
+ }
+
+ foreach (var child in children)
+ {
+ if (child.ParentCustomerId is > 0)
+ {
+ mainByCustomerId[child.Id] = child.ParentCustomerId.Value;
+ }
+ }
+
+ var allIds = mainByCustomerId.Keys.ToList();
+ var since30 = DateTime.Today.AddDays(-30);
+ var orders = await OrderRepository.GetListAsync(x =>
+ x.AgentId == agentId
+ && x.CustomerId != null
+ && allIds.Contains(x.CustomerId.Value)
+ && x.OrderStatus != OrderStatusCodes.Deleted);
+
+ foreach (var group in orders.GroupBy(o => mainByCustomerId[o.CustomerId!.Value]))
+ {
+ var list = group.ToList();
+ result[group.Key] = (
+ list.Max(o => (DateTime?)o.CreateTime),
+ list.Count(o => o.CreateTime >= since30),
+ list.Count(o => string.Equals(
+ o.OrderStatus,
+ OrderStatusCodes.PendingPayment,
+ StringComparison.OrdinalIgnoreCase)));
+ }
+
+ return result;
+ }
+
+ private async Task> ResolveSalesProductNamesAsync(string? salesProductIds)
+ {
+ var ids = ParseSalesProductIds(salesProductIds);
+ if (ids.Count == 0)
+ {
+ return [];
+ }
+
+ var products = await SalesProductRepository.GetListAsync(x => ids.Contains(x.Id));
+ var map = products.ToDictionary(x => x.Id);
+ return ids
+ .Select(id => map.TryGetValue(id, out var p) ? $"{p.ProductName}({p.ProductCode})" : $"#{id}")
+ .ToList();
+ }
+
+ private static List ParseSalesProductIds(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return [];
+ }
+
+ var text = raw.Trim();
+ if (text.StartsWith('['))
+ {
+ try
+ {
+ var arr = global::System.Text.Json.JsonSerializer.Deserialize>(text);
+ return arr?.Where(x => x > 0).Distinct().ToList() ?? [];
+ }
+ catch
+ {
+ // fall through
+ }
+ }
+
+ return text
+ .Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(x => int.TryParse(x, out var id) ? id : 0)
+ .Where(x => x > 0)
+ .Distinct()
+ .ToList();
+ }
+
+ private static string ResolveBaseTypeName(string? baseType)
+ {
+ if (string.Equals(baseType, "receivable", StringComparison.OrdinalIgnoreCase))
+ {
+ return "应收";
+ }
+
+ if (string.Equals(baseType, "freight", StringComparison.OrdinalIgnoreCase))
+ {
+ return "运费";
+ }
+
+ return string.IsNullOrWhiteSpace(baseType) ? "运费" : baseType;
+ }
+
+ private async Task GetCurrentAgentOrThrowAsync()
+ {
+ var mapId = 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));
+ }
+}
diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs
index 95f6061..b7eb21b 100644
--- a/src/Oms.Application/Services/Channels/KpiAreaService.cs
+++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs
@@ -106,8 +106,9 @@ public class KpiAreaService : ApplicationService
await GetAreaOrThrowAsync(input.AreaId);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
- var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize);
- var total = await KpiAreaRepository.CountInfosAsync(input.AreaId);
+ var filter = BuildInfoQueryFilter(input);
+ var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize, filter);
+ var total = await KpiAreaRepository.CountInfosAsync(input.AreaId, filter);
return new OmsPageResult
{
Items = entities.Select(MapInfoOutput).ToList(),
@@ -115,6 +116,53 @@ public class KpiAreaService : ApplicationService
};
}
+ private static readonly HashSet 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;
+ }
+
///
/// 新增KPI分区明细。
///
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 02d8ab0..009bedc 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -6,6 +6,7 @@ using Fake.Rbac.Application.Services;
using Fake.Rbac.Infrastructure;
using Fake.Security.Claims;
using Fake.EntityFrameworkCore;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Dtos.Customers;
@@ -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();
+ private IAgentRepository AgentRepository =>
+ LazyServiceProvider.GetRequiredService();
+
private MarketingCouponManager CouponManager =>
LazyServiceProvider.GetRequiredService();
@@ -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(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
}
///
- /// 新增客户。
+ /// 新增客户(公开注册页可匿名调用;管理端也可带 token 代开户)。
///
/// 入参:客户创建参数。
/// 无返回体,仅完成新增客户。
+ [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());
+ await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
+ // 有代理编号时不走 C2C 邀请,避免双发佣金
+ if (!entity.AgentId.HasValue)
+ {
+ await MarketingReferralBinder.BindReferrerOnCreateAsync(
+ entity,
+ input.InviteCode,
+ ReferralCodeRepository,
+ CustomerRepository,
+ MemberLevelRepository,
+ LazyServiceProvider.GetRequiredService());
+ }
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,
input.AdvisorCode,
@@ -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> ResolveAgentDisplayMapAsync(
+ IEnumerable agentIds)
+ {
+ var ids = agentIds.Distinct().ToList();
+ if (ids.Count == 0)
+ {
+ return new Dictionary();
+ }
+
+ var agents = await AgentRepository.GetListAsync(x => ids.Contains(x.Id));
+ return agents.ToDictionary(x => x.Id, x => (x.AgentCode, x.AgentName));
+ }
+
+ private static void FillAgentDisplay(
+ CustomerPaginatedOutput output,
+ int? agentId,
+ IReadOnlyDictionary agentMap)
+ {
+ output.AgentId = agentId;
+ if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
+ {
+ output.AgentCode = agent.Code;
+ output.AgentName = agent.Name;
+ }
+ }
+
+ private static void FillAgentDisplay(
+ CustomerOutput output,
+ int? agentId,
+ IReadOnlyDictionary agentMap)
+ {
+ output.AgentId = agentId;
+ if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
+ {
+ output.AgentCode = agent.Code;
+ output.AgentName = agent.Name;
+ }
}
private static Dictionary BuildCustomerSortDictionary(CustomerPaginatedInput input)
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index c64ecff..a3d5ca0 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -1208,6 +1208,12 @@ public partial class OrderService
return;
}
+ if (OmsSessionHelper.IsAgentLogin(CurrentUser))
+ {
+ await ApplyAgentOrderScopeAsync(filter);
+ return;
+ }
+
var userGuidRaw = CurrentUser.FindClaimValueOrNull(global::System.Security.Claims.ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userGuidRaw) || !Guid.TryParse(userGuidRaw, out var userGuid))
{
@@ -1217,4 +1223,43 @@ public partial class OrderService
var context = await DataScopeBuilder.BuildAsync(userGuid, DataScopeApplier.OrderResourceCode);
DataScopeApplier.ApplyToOrderListFilter(filter, context);
}
+
+ ///
+ /// 渠道代理门户:列表按名下客户过滤(客户/主客户当前 AgentId);返佣仍认订单快照 AgentId。
+ ///
+ private async Task ApplyAgentOrderScopeAsync(OrderListFilter filter)
+ {
+ var agentRepository =
+ LazyServiceProvider.GetRequiredService();
+ var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
+ if (userId <= 0)
+ {
+ throw new BusinessException("代理未登录");
+ }
+
+ var agent = await agentRepository.GetByUserIdAsync(userId);
+ if (agent == null)
+ {
+ throw new BusinessException("代理不存在");
+ }
+
+ filter.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);
+ }
+ }
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 3237847..68bd43e 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -138,6 +138,10 @@ public partial class OrderService
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
input.CustomerId = sessionCustomer.Id;
+ var mainCustomerForAgent = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ CustomerRepository,
+ sessionCustomer);
+ var orderAgentId = mainCustomerForAgent.AgentId;
await ValidateParcelCreateInputAsync(input);
@@ -180,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
}
///
- /// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
+ /// 从会话解析有效客户(客户登录自动补全;管理员/代理须传 customerId;代理仅限名下客户)。
///
- private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) =>
- OmsSessionHelper.ResolveSessionCustomerAsync(
+ private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null)
+ {
+ var agentRepository = OmsSessionHelper.IsAgentLogin(CurrentUser)
+ ? LazyServiceProvider.GetRequiredService()
+ : null;
+ return OmsSessionHelper.ResolveSessionCustomerAsync(
CustomerRepository,
CurrentUser,
- requestedCustomerId);
+ requestedCustomerId,
+ agentRepository);
+ }
+
+ ///
+ /// 代理返佣异步投递:有事务 Outbox 则发事件;否则同步兜底(无 UoW 场景)。
+ ///
+ private async Task EnqueueAgentCommissionJobAsync(
+ int orderId,
+ string action,
+ string? triggerOrderStatus = null,
+ string? reason = null)
+ {
+ if (orderId <= 0 || string.IsNullOrWhiteSpace(action))
+ {
+ return;
+ }
+
+ var uowManager = LazyServiceProvider.GetRequiredService();
+ if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager))
+ {
+ await EventBus.PublishAsync(new Messaging.Agents.AgentCommissionJobIntegrationEvent
+ {
+ OrderId = orderId,
+ Action = action.Trim(),
+ TriggerOrderStatus = triggerOrderStatus,
+ Reason = reason
+ });
+ return;
+ }
+
+ try
+ {
+ var mgr = LazyServiceProvider.GetRequiredService();
+ if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
+ {
+ await mgr.TryEstimateOnOrderCreatedAsync(orderId);
+ }
+ else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
+ {
+ await mgr.TryAccrueOnDeliveryTerminalAsync(orderId, triggerOrderStatus ?? string.Empty);
+ }
+ else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
+ {
+ await mgr.TryVoidOnOrderCancelledAsync(orderId, reason ?? "同步作废返佣");
+ }
+ }
+ catch
+ {
+ // 返佣失败不阻断主业务
+ }
+ }
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
{
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index b8d113a..2a4e99c 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -297,6 +297,11 @@ public partial class OrderService : ApplicationService
order = await GetOrderOrThrowAsync(order.Id);
await ApplyDeliverySuccessMarketingAsync(order);
}
+ else if (string.Equals(target, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
+ {
+ order = await GetOrderOrThrowAsync(order.Id);
+ await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliveryFailed);
+ }
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
@@ -340,8 +345,16 @@ public partial class OrderService : ApplicationService
var couponManager = LazyServiceProvider.GetRequiredService();
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);
+
///
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
///
@@ -363,19 +376,27 @@ public partial class OrderService : ApplicationService
var commissionManager = LazyServiceProvider.GetRequiredService();
await commissionManager.ClawbackOnOrderRefundAsync(order);
- if (input.IsFullRefund && order.CustomerId.HasValue)
+ if (input.IsFullRefund)
{
- var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
- if (customer != null)
+ await EnqueueAgentCommissionJobAsync(
+ order.Id,
+ Messaging.Agents.AgentCommissionJobActionCodes.Void,
+ reason: "订单全额退款作废返佣");
+
+ if (order.CustomerId.HasValue)
{
- var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
- CustomerRepository, customer);
- var pointsManager = LazyServiceProvider.GetRequiredService();
- await pointsManager.ClawbackOnOrderAsync(
- mainCustomer.Id,
- order.Id,
- order.ServiceNo,
- "订单退款扣回积分");
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
+ if (customer != null)
+ {
+ var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
+ CustomerRepository, customer);
+ var pointsManager = LazyServiceProvider.GetRequiredService();
+ await pointsManager.ClawbackOnOrderAsync(
+ mainCustomer.Id,
+ order.Id,
+ order.ServiceNo,
+ "订单退款扣回积分");
+ }
}
}
}
@@ -464,6 +485,10 @@ public partial class OrderService : ApplicationService
}
await OrderRepository.UpdateAsync(order);
+ await EnqueueAgentCommissionJobAsync(
+ order.Id,
+ Messaging.Agents.AgentCommissionJobActionCodes.Void,
+ reason: "订单删除作废返佣");
}
///
@@ -691,6 +716,25 @@ public partial class OrderService : ApplicationService
{
input.CustomerId = await ResolvePriceCheckCustomerIdAsync();
}
+ else if (OmsSessionHelper.IsAgentLogin(CurrentUser))
+ {
+ // 代理代客查价:仅允许名下绑定客户
+ var agentRepository =
+ LazyServiceProvider.GetRequiredService();
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
+ if (customer == null)
+ {
+ throw new BusinessException("客户不存在");
+ }
+
+ await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
+ CustomerRepository,
+ agentRepository,
+ CurrentUser,
+ customer);
+ input.CustomerId =
+ await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(CustomerRepository, customer);
+ }
return await BuildPriceCheckResultAsync(input);
}
@@ -1322,6 +1366,37 @@ public partial class OrderService : ApplicationService
private async Task EnsureCustomerCanAccessOrderAsync(Order order)
{
+ if (OmsSessionHelper.IsAgentLogin(CurrentUser))
+ {
+ var agentRepository =
+ LazyServiceProvider.GetRequiredService();
+ 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,
diff --git a/src/Oms.Application/Shared/AgentBinder.cs b/src/Oms.Application/Shared/AgentBinder.cs
new file mode 100644
index 0000000..0613642
--- /dev/null
+++ b/src/Oms.Application/Shared/AgentBinder.cs
@@ -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;
+
+///
+/// 注册时绑定渠道代理。与 C2C 邀请互斥:有 agent 时由调用方跳过邀请绑定。
+///
+public static class AgentBinder
+{
+ public static async Task BindAgentOnCreateAsync(
+ Customer entity,
+ string? agentCode,
+ IAgentRepository agentRepository,
+ CancellationToken cancellationToken = default)
+ {
+ if (entity.AgentId.HasValue && entity.AgentId.Value > 0)
+ {
+ return;
+ }
+
+ var code = agentCode?.Trim();
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ return;
+ }
+
+ var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
+ if (agent == null || agent.Status != EnableStatus.Enabled)
+ {
+ throw new BusinessException("代理编号无效或已停用");
+ }
+
+ entity.AgentId = agent.Id;
+ entity.AgentBindTime = DateTime.Now;
+ }
+
+ ///
+ /// 运营改绑:按代理编号覆盖;空串解绑。子客户不可改(跟主客户)。
+ ///
+ public static async Task ApplyAgentBindingAsync(
+ Customer entity,
+ string? agentCode,
+ IAgentRepository agentRepository,
+ CancellationToken cancellationToken = default)
+ {
+ if (entity.ParentCustomerId.HasValue)
+ {
+ throw new BusinessException("请改绑主客户");
+ }
+
+ var code = agentCode?.Trim();
+ if (string.IsNullOrEmpty(code))
+ {
+ entity.AgentId = null;
+ entity.AgentBindTime = null;
+ return;
+ }
+
+ var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
+ if (agent == null || agent.Status != EnableStatus.Enabled)
+ {
+ throw new BusinessException("代理编号无效或已停用");
+ }
+
+ entity.AgentId = agent.Id;
+ entity.AgentBindTime = DateTime.Now;
+ }
+}
diff --git a/src/Oms.Application/Shared/AgentCommissionManager.cs b/src/Oms.Application/Shared/AgentCommissionManager.cs
new file mode 100644
index 0000000..d330e67
--- /dev/null
+++ b/src/Oms.Application/Shared/AgentCommissionManager.cs
@@ -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;
+
+///
+/// 渠道代理返佣:下单写预计 → 派送终态锁定待结算 → 取消/退款作废;不入余额。
+///
+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;
+ }
+
+ /// 下单后写入/刷新预计返佣(仅 estimated;已锁定/已打款/已作废不改)。
+ public async Task TryEstimateOnOrderCreatedAsync(
+ int orderId,
+ CancellationToken cancellationToken = default)
+ {
+ var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
+ if (order == null)
+ {
+ return;
+ }
+
+ await UpsertEstimatedAsync(order, cancellationToken);
+ }
+
+ /// 预报计费完成后刷新预计金额(仍为 estimated)。
+ public Task TryRefreshEstimateAfterBillingAsync(
+ int orderId,
+ CancellationToken cancellationToken = default) =>
+ TryEstimateOnOrderCreatedAsync(orderId, cancellationToken);
+
+ /// 派送成功/失败:重算锁定为 pending。
+ public async Task TryAccrueOnDeliveryTerminalAsync(
+ int orderId,
+ string triggerOrderStatus,
+ 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);
+ }
+
+ /// 订单删除 / 全额退款:预计或待结算作废;已打款不动。
+ 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 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 ParseProductIds(string? raw)
+ {
+ var set = new HashSet();
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return set;
+ }
+
+ var text = raw.Trim();
+ try
+ {
+ if (text.StartsWith('['))
+ {
+ var list = JsonSerializer.Deserialize>(text);
+ if (list != null)
+ {
+ foreach (var id in list.Where(x => x > 0))
+ {
+ set.Add(id);
+ }
+ }
+
+ return set;
+ }
+ }
+ catch (JsonException)
+ {
+ // fall through to comma split
+ }
+
+ foreach (var part in text.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) && id > 0)
+ {
+ set.Add(id);
+ }
+ }
+
+ return set;
+ }
+}
diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs
index c5ff2b4..0351e04 100644
--- a/src/Oms.Application/Shared/OmsSessionHelper.cs
+++ b/src/Oms.Application/Shared/OmsSessionHelper.cs
@@ -1,13 +1,14 @@
using System.Security.Claims;
using Fake.Application;
using Fake.Users;
+using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
///
-/// 从 JWT Claims 解析当前会话类型(管理员 / 客户)。
+/// 从 JWT Claims 解析当前会话类型(管理员 / 客户 / 渠道代理)。
///
public static class OmsSessionHelper
{
@@ -22,7 +23,9 @@ public static class OmsSessionHelper
public const string MapIdClaim = "mapId";
private const string CustomerLoginType = "1";
+ private const string AgentLoginType = "2";
private const string CustomerRoleName = "Customer";
+ private const string AgentRoleName = "Agent";
///
/// 当前会话是否为客户门户登录。
@@ -43,6 +46,24 @@ public static class OmsSessionHelper
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
}
+ ///
+ /// 当前会话是否为渠道代理门户登录。
+ ///
+ public static bool IsAgentLogin(ICurrentUser currentUser)
+ {
+ if (string.Equals(
+ currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
+ AgentLoginType,
+ StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var role = currentUser.FindClaimOrNull(ClaimTypes.Role)?.Value
+ ?? currentUser.FindClaimOrNull("role")?.Value;
+ return string.Equals(role, AgentRoleName, StringComparison.OrdinalIgnoreCase);
+ }
+
///
/// 解析当前会话的 RBAC mapId(user.userid)。
/// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。
@@ -85,12 +106,14 @@ public static class OmsSessionHelper
TryGetCustomerMapId(currentUser);
///
- /// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员可按 CustomerId 代提交)。
+ /// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员/代理可按 CustomerId 代提交)。
+ /// 代理须传入 ,且仅允许操作名下绑定客户。
///
public static async Task ResolveSessionCustomerAsync(
ICustomerRepository customerRepository,
ICurrentUser currentUser,
- int? requestedCustomerId = null)
+ int? requestedCustomerId = null,
+ IAgentRepository? agentRepository = null)
{
var mapId = TryGetCustomerMapId(currentUser);
if (mapId.HasValue)
@@ -121,9 +144,53 @@ public static class OmsSessionHelper
throw new BusinessException("客户不存在");
}
+ if (IsAgentLogin(currentUser))
+ {
+ await EnsureAgentOwnsCustomerAsync(customerRepository, agentRepository, currentUser, customer);
+ }
+
return customer;
}
+ ///
+ /// 渠道代理仅可操作名下绑定客户(主客户 AgentId=本代理;子客户跟主客户)。
+ ///
+ public static async Task EnsureAgentOwnsCustomerAsync(
+ ICustomerRepository customerRepository,
+ IAgentRepository? agentRepository,
+ ICurrentUser currentUser,
+ Customer customer)
+ {
+ if (agentRepository == null)
+ {
+ throw new BusinessException("代理会话缺少代理仓储");
+ }
+
+ var userId = ResolveMapUserId(currentUser);
+ if (userId <= 0)
+ {
+ throw new BusinessException("代理未登录");
+ }
+
+ var agent = await agentRepository.GetByUserIdAsync(userId);
+ if (agent == null)
+ {
+ throw new BusinessException("代理不存在");
+ }
+
+ int? bindAgentId = customer.AgentId;
+ if (customer.ParentCustomerId is > 0)
+ {
+ var main = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value);
+ bindAgentId = main?.AgentId;
+ }
+
+ if (bindAgentId != agent.Id)
+ {
+ throw new BusinessException("只能为名下绑定客户操作");
+ }
+ }
+
///
/// 客户登录时强制返回 t_customer.Id;管理员返回入参 customerId。
///
diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs
new file mode 100644
index 0000000..36b1a26
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs
@@ -0,0 +1,12 @@
+namespace Oms.Domain.AgentsAggregate.Consts;
+
+/// 代理返佣基数类型(首版仅运费/应收)。
+public static class AgentCommissionBaseTypes
+{
+ public const string Freight = "freight";
+ public const string Receivable = "receivable";
+
+ public static bool IsSupported(string? baseType) =>
+ string.Equals(baseType, Freight, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(baseType, Receivable, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs
new file mode 100644
index 0000000..407ed2c
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs
@@ -0,0 +1,31 @@
+namespace Oms.Domain.AgentsAggregate.Consts;
+
+/// 代理返佣明细结算状态。
+public static class AgentCommissionStatusCodes
+{
+ /// 下单后预计返佣(金额可随计费刷新,不计入应打款)。
+ public const string Estimated = "estimated";
+
+ /// 派送终态锁定,待运营线下打款。
+ public const string Pending = "pending";
+
+ /// 已打款。
+ public const string Paid = "paid";
+
+ /// 订单取消/全额退等作废。
+ public const string Void = "void";
+
+ public static bool IsOpen(string? status) =>
+ string.Equals(status, Estimated, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, Pending, StringComparison.OrdinalIgnoreCase);
+
+ public static string DisplayName(string? status) =>
+ status?.Trim().ToLowerInvariant() switch
+ {
+ Estimated => "预计返佣",
+ Pending => "待结算",
+ Paid => "已打款",
+ Void => "已作废",
+ _ => string.IsNullOrWhiteSpace(status) ? "--" : status
+ };
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs b/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
new file mode 100644
index 0000000..fb3ade8
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
@@ -0,0 +1,45 @@
+using Oms.Domain.Shared;
+using Oms.Domain.Shared.Enums;
+using SqlSugar;
+
+namespace Oms.Domain.AgentsAggregate.Entities;
+
+/// 渠道代理主数据。
+[SugarTable("t_agent")]
+public class Agent : OmsAuditedAggregateRoot
+{
+ /// RBAC user.userid / JWT mapId。
+ public int UserId { get; set; }
+
+ /// 登录账号 + 注册链接编码。
+ [SugarColumn(Length = 32)]
+ public string AgentCode { get; set; } = string.Empty;
+
+ [SugarColumn(Length = 64)]
+ public string AgentName { get; set; } = string.Empty;
+
+ [SugarColumn(Length = 32, IsNullable = true)]
+ public string? Phone { get; set; }
+
+ [SugarColumn(Length = 128, IsNullable = true)]
+ public string? Email { get; set; }
+
+ /// MD5 密码(对齐客户)。
+ [SugarColumn(Length = 64)]
+ public string Password { get; set; } = string.Empty;
+
+ public EnableStatus Status { get; set; } = EnableStatus.Enabled;
+
+ /// freight / receivable。
+ [SugarColumn(Length = 32)]
+ public string BaseType { get; set; } = "freight";
+
+ public decimal CommissionRate { get; set; }
+
+ /// 可选销售产品 Id 列表(JSON 或逗号);空=不限产品。
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? SalesProductIds { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs
new file mode 100644
index 0000000..1e8a65d
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs
@@ -0,0 +1,38 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.AgentsAggregate.Entities;
+
+/// 代理返佣明细(首版无余额入账)。
+[SugarTable("t_agent_commission_detail")]
+public class AgentCommissionDetail : OmsAuditedAggregateRoot
+{
+ public int AgentId { get; set; }
+
+ public int OrderId { get; set; }
+
+ public int CustomerId { get; set; }
+
+ public int? SalesProductId { get; set; }
+
+ [SugarColumn(Length = 32)]
+ public string BaseType { get; set; } = string.Empty;
+
+ public decimal BaseAmount { get; set; }
+
+ public decimal CommissionRate { get; set; }
+
+ public decimal CommissionAmount { get; set; }
+
+ /// estimated / pending / paid / void。
+ [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; }
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs b/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs
new file mode 100644
index 0000000..66e61f4
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs
@@ -0,0 +1,18 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.AgentsAggregate.Entities;
+
+namespace Oms.Domain.AgentsAggregate.Repositories;
+
+public interface IAgentRepository : IRepository
+{
+ Task GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default);
+
+ Task GetByUserIdAsync(int userId, CancellationToken cancellationToken = default);
+
+ Task ExistsAgentCodeAsync(string agentCode, int? excludeId = null, CancellationToken cancellationToken = default);
+}
+
+public interface IAgentCommissionDetailRepository : IRepository
+{
+ Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs
index 6dce96b..aa5de53 100644
--- a/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs
+++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IKpiAreaRepository.cs
@@ -3,15 +3,43 @@ using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Domain.ChannelsAggregate.Repositories;
+///
+/// KPI 分区明细列表筛选条件(字段均为可选,模糊匹配)。
+///
+public class KpiAreaInfoQueryFilter
+{
+ public string? Country { get; set; }
+ public string? MatchDim { get; set; }
+ public string? MatchMode { get; set; }
+ public string? Province { get; set; }
+ public string? City { get; set; }
+ public string? LeftPostcode { get; set; }
+ public string? RightPostcode { get; set; }
+
+ /// 排序字段:leftPostcode / rightPostcode。
+ public string? SortField { get; set; }
+
+ /// 排序方向:asc / desc。
+ public string? SortOrder { get; set; }
+}
+
public interface IKpiAreaRepository : IRepository, IKpiAreaPublishRepository
{
Task GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
Task ExistsNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
- Task> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default);
+ Task> GetInfosAsync(
+ int areaId,
+ int pageIndex,
+ int pageSize,
+ KpiAreaInfoQueryFilter? filter = null,
+ CancellationToken cancellationToken = default);
- Task CountInfosAsync(int areaId, CancellationToken cancellationToken = default);
+ Task CountInfosAsync(
+ int areaId,
+ KpiAreaInfoQueryFilter? filter = null,
+ CancellationToken cancellationToken = default);
Task InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default);
diff --git a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
index 0f73967..22f87fa 100644
--- a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
+++ b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
@@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot
public decimal CommissionBalance { get; set; }
+ /// 所属渠道代理(建议写在主客户上)。
+ public int? AgentId { get; set; }
+
+ public DateTime? AgentBindTime { get; set; }
+
public string? LastLoginIp { get; set; }
public DateTime? LastLoginTime { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 2f2b77e..d0334bf 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot
public int? CustomerId { get; set; }
+ /// 下单时代理快照(跟主客户 AgentId)。
+ public int? AgentId { get; set; }
+
public int? MemberLevelId { get; set; }
public string? PriceTierKey { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
index 0cbfc0e..429d54c 100644
--- a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
+++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
@@ -34,6 +34,16 @@ public class OrderListFilter
public int? CustomerId { get; set; }
+ ///
+ /// 按下单快照 Order.AgentId 过滤(返佣归属;历史单换绑后不变)。
+ ///
+ public int? AgentId { get; set; }
+
+ ///
+ /// 按名下客户可见:订单客户(或主客户)当前 AgentId = 此值。代理门户运单列表用此字段。
+ ///
+ public int? OwnedByAgentId { get; set; }
+
///
/// 发货仓 ID(订单或绑定行所属仓)。
///
diff --git a/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs b/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
new file mode 100644
index 0000000..fce388b
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
@@ -0,0 +1,37 @@
+using Fake.DependencyInjection;
+using Oms.Domain.AgentsAggregate.Entities;
+using Oms.Domain.AgentsAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.Agents;
+
+public class AgentRepository : OmsSqlSugarRepository, IAgentRepository, IScopedDependency
+{
+ public Task GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.AgentCode == agentCode, cancellationToken);
+
+ public Task GetByUserIdAsync(int userId, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
+
+ public async Task ExistsAgentCodeAsync(
+ string agentCode,
+ int? excludeId = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (excludeId.HasValue)
+ {
+ return await AnyAsync(x => x.AgentCode == agentCode && x.Id != excludeId.Value, cancellationToken);
+ }
+
+ return await AnyAsync(x => x.AgentCode == agentCode, cancellationToken);
+ }
+}
+
+public class AgentCommissionDetailRepository
+ : OmsSqlSugarRepository,
+ IAgentCommissionDetailRepository,
+ IScopedDependency
+{
+ public Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
+}
diff --git a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs
index e50b197..5466625 100644
--- a/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs
+++ b/src/Oms.SqlSugarCore/Channels/KpiAreaRepository.cs
@@ -18,20 +18,110 @@ public class KpiAreaRepository : OmsSqlSugarRepository, IKpiAreaReposit
return AnyAsync(x => x.AppKey == appKey && x.AreaType == areaType && x.Name == name, cancellationToken);
}
- public async Task> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default)
+ public async Task> GetInfosAsync(
+ int areaId,
+ int pageIndex,
+ int pageSize,
+ KpiAreaInfoQueryFilter? filter = null,
+ CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
- // 依赖 idx_kpi_info_area_id (AreaId, Id),避免大分区 filesort
- return await db.Queryable()
- .Where(x => x.AreaId == areaId)
- .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 CountInfosAsync(int areaId, CancellationToken cancellationToken = default)
+ public async Task CountInfosAsync(
+ int areaId,
+ KpiAreaInfoQueryFilter? filter = null,
+ CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
- return await db.Queryable().Where(x => x.AreaId == areaId).CountAsync(cancellationToken);
+ return await BuildInfoQueryable(db, areaId, filter).CountAsync(cancellationToken);
+ }
+
+ private static ISugarQueryable BuildInfoQueryable(
+ ISqlSugarClient db,
+ int areaId,
+ KpiAreaInfoQueryFilter? filter)
+ {
+ var query = db.Queryable().Where(x => x.AreaId == areaId);
+ if (filter == null)
+ {
+ return query;
+ }
+
+ var country = TrimOrNull(filter.Country);
+ if (country != null)
+ {
+ query = query.Where(x =>
+ (x.CountryName != null && x.CountryName.Contains(country))
+ || (x.CountryCode != null && x.CountryCode.Contains(country)));
+ }
+
+ var matchDim = TrimOrNull(filter.MatchDim);
+ if (matchDim != null)
+ {
+ query = query.Where(x => x.MatchDim != null && x.MatchDim.Contains(matchDim));
+ }
+
+ var matchMode = TrimOrNull(filter.MatchMode);
+ if (matchMode != null)
+ {
+ query = query.Where(x => x.MatchMode != null && x.MatchMode.Contains(matchMode));
+ }
+
+ var province = TrimOrNull(filter.Province);
+ if (province != null)
+ {
+ query = query.Where(x => x.Province != null && x.Province.Contains(province));
+ }
+
+ var city = TrimOrNull(filter.City);
+ if (city != null)
+ {
+ query = query.Where(x => x.City != null && x.City.Contains(city));
+ }
+
+ var leftPostcode = TrimOrNull(filter.LeftPostcode);
+ if (leftPostcode != null)
+ {
+ query = query.Where(x => x.LeftPostcode != null && x.LeftPostcode.Contains(leftPostcode));
+ }
+
+ var rightPostcode = TrimOrNull(filter.RightPostcode);
+ if (rightPostcode != null)
+ {
+ query = query.Where(x => x.RightPostcode != null && x.RightPostcode.Contains(rightPostcode));
+ }
+
+ return query;
+ }
+
+ private static ISugarQueryable ApplyInfoOrder(
+ ISugarQueryable query,
+ KpiAreaInfoQueryFilter? filter)
+ {
+ var field = filter?.SortField?.Trim().ToLowerInvariant();
+ var asc = string.Equals(filter?.SortOrder?.Trim(), "asc", StringComparison.OrdinalIgnoreCase);
+ var order = asc ? OrderByType.Asc : OrderByType.Desc;
+
+ return field switch
+ {
+ "leftpostcode" => query
+ .OrderBy(x => x.LeftPostcode, order)
+ .OrderBy(x => x.Id, OrderByType.Desc),
+ "rightpostcode" => query
+ .OrderBy(x => x.RightPostcode, order)
+ .OrderBy(x => x.Id, OrderByType.Desc),
+ _ => query.OrderBy(x => x.Id, OrderByType.Desc),
+ };
+ }
+
+ private static string? TrimOrNull(string? value)
+ {
+ var trimmed = value?.Trim();
+ return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}
public async Task InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default)
diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
index df1e482..8a46cbf 100644
--- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
+++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
@@ -47,6 +47,29 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I
query = query.Where(x => x.CustomerId == customerId);
}
+ if (filter.AgentId.HasValue)
+ {
+ var agentId = filter.AgentId.Value;
+ query = query.Where(x => x.AgentId == agentId);
+ }
+
+ // 名下客户可见:本客户 AgentId,或子客户的主客户 AgentId
+ if (filter.OwnedByAgentId.HasValue)
+ {
+ var ownedByAgentId = filter.OwnedByAgentId.Value;
+ query = query.Where(o =>
+ SqlFunc.Subqueryable()
+ .Where(c => c.Id == o.CustomerId && c.AgentId == ownedByAgentId)
+ .Any()
+ || SqlFunc.Subqueryable()
+ .Where(c => c.Id == o.CustomerId
+ && c.ParentCustomerId != null
+ && SqlFunc.Subqueryable()
+ .Where(m => m.Id == c.ParentCustomerId && m.AgentId == ownedByAgentId)
+ .Any())
+ .Any());
+ }
+
var status = filter.Status?.Trim();
var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase);
if (isDeletedTab)
@@ -1503,6 +1526,8 @@ public class OrderRepository : OmsSqlSugarRepository, 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, 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,
diff --git a/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs b/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
new file mode 100644
index 0000000..d40a217
--- /dev/null
+++ b/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
@@ -0,0 +1,32 @@
+using Fake.Data.Seeding;
+using Fake.Rbac.Application.Dtos.Role;
+using Fake.Rbac.Application.Services;
+
+namespace SimpleAdmin.Api.Auth;
+
+public class AgentRoleSeedContributor(RoleService roleService) : IDataSeedContributor
+{
+ public const string AgentRoleCode = "Agent";
+ public const string AgentRoleName = "渠道代理";
+
+ public async Task SeedAsync()
+ {
+ var roles = await roleService.GetAllRolesAsync();
+ if (roles.Any(x => string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ await roleService.CreateAsync(new RoleCreateDto
+ {
+ Code = AgentRoleCode,
+ Name = AgentRoleName,
+ Permissions =
+ [
+ "Agent.Portal",
+ "Agent.Customers.Query",
+ "Agent.Orders.Query"
+ ]
+ });
+ }
+}
diff --git a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs
index c1c40fb..54ed3b0 100644
--- a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs
+++ b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs
@@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
public enum OmsLoginType
{
User = 0,
- Customer = 1
+ Customer = 1,
+ Agent = 2
}
public class OmsLoginInput
diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs
index 5bef285..6809f4a 100644
--- a/src/SimpleAdmin.Api/Auth/AuthService.cs
+++ b/src/SimpleAdmin.Api/Auth/AuthService.cs
@@ -21,9 +21,11 @@ using Fake.ObjectStorage;
using Oms.Application;
using Oms.Application.Shared;
using Oms.Application.Storage;
+using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.Domain.Shared.Enums;
using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Services;
using SixLabors.ImageSharp;
@@ -68,6 +70,7 @@ public partial class AuthService(
{
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken),
+ OmsLoginType.Agent => await LoginAgentAsync(input.Account, input.Password, cancellationToken),
_ => throw new BusinessException("不支持的登录类型")
};
}
@@ -284,6 +287,11 @@ public partial class AuthService(
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
}
+ if (loginType == OmsLoginType.Agent)
+ {
+ return await RefreshAgentTokenAsync(token.Claims, cancellationToken);
+ }
+
if (!Guid.TryParse(userIdText, out var userId))
{
throw new BusinessException("无效的刷新令牌");
@@ -438,6 +446,60 @@ public partial class AuthService(
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
}
+ private async Task LoginAgentAsync(string account, string password, CancellationToken cancellationToken)
+ {
+ var agentRepository = LazyServiceProvider.GetRequiredService();
+ var code = account.Trim();
+ var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
+ if (agent == null)
+ {
+ throw new BusinessException("代理账号或密码错误");
+ }
+
+ if (agent.Status != EnableStatus.Enabled)
+ {
+ throw new BusinessException("代理已停用");
+ }
+
+ if (!string.Equals(agent.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException("代理账号或密码错误");
+ }
+
+ var userGuid = await GetRbacUserGuidByMapIdAsync(agent.UserId, cancellationToken);
+ if (!userGuid.HasValue)
+ {
+ throw new BusinessException("代理未关联用户");
+ }
+
+ return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Agent, cancellationToken);
+ }
+
+ private async Task RefreshAgentTokenAsync(IEnumerable claims, CancellationToken cancellationToken)
+ {
+ var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId);
+ if (mapId <= 0)
+ {
+ throw new BusinessException("无效的刷新令牌");
+ }
+
+ var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken);
+ if (!userGuid.HasValue)
+ {
+ throw new BusinessException("用户不存在");
+ }
+
+ var agentRepository = LazyServiceProvider.GetRequiredService();
+ var agent = await agentRepository.GetByUserIdAsync(mapId, cancellationToken);
+ if (agent == null || agent.Status != EnableStatus.Enabled)
+ {
+ throw new BusinessException("代理不存在或已停用");
+ }
+
+ var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Agent);
+ return BuildTokenOnlyResult(userGuid.Value, refreshedClaims);
+ }
+
private async Task LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
{
var normalizedAccount = account.Trim();
diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
index d254466..246efac 100644
--- a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
+++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
@@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput
///
public string? AdvisorCode { get; set; }
+ ///
+ /// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。
+ ///
+ public string? AgentCode { get; set; }
+
[Required]
public WeChatProfileInput Wechat { get; set; } = new();
}
diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
index e5c6ec2..5771985 100644
--- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
+++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
@@ -14,6 +14,7 @@ using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Repositories;
using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Services;
@@ -30,6 +31,7 @@ public class WeChatCustomerService(
ICustomerRepository customerRepository,
ICustomerReferralCodeRepository referralCodeRepository,
IMemberLevelRepository memberLevelRepository,
+ IAgentRepository agentRepository,
MarketingCouponManager couponManager,
UserService userService,
RoleService roleService,
@@ -156,17 +158,21 @@ public class WeChatCustomerService(
cancellationToken),
PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey
};
- await MarketingReferralBinder.BindReferrerOnCreateAsync(
- entity,
- input.InviteCode,
- referralCodeRepository,
- customerRepository,
- memberLevelRepository,
- LazyServiceProvider.GetRequiredService(),
- cancellationToken);
- if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
+ await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, agentRepository, cancellationToken);
+ if (!entity.AgentId.HasValue)
{
- entity.ReferralChannel = "wechat";
+ await MarketingReferralBinder.BindReferrerOnCreateAsync(
+ entity,
+ input.InviteCode,
+ referralCodeRepository,
+ customerRepository,
+ memberLevelRepository,
+ LazyServiceProvider.GetRequiredService(),
+ cancellationToken);
+ if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
+ {
+ entity.ReferralChannel = "wechat";
+ }
}
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,