Merge pull request '20261030' (#18) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 2m0s
All checks were successful
Build and Deploy / deploy (push) Successful in 2m0s
Reviewed-on: #18
This commit is contained in:
commit
e1c6e1b166
105
sql/migrate_agent_module_20261029.sql
Normal file
105
sql/migrate_agent_module_20261029.sql
Normal file
@ -0,0 +1,105 @@
|
||||
-- 渠道代理与返佣(首版轻量)。可重复执行。
|
||||
-- 含:t_agent、t_agent_commission_detail;t_customer / t_order 扩展 AgentId。
|
||||
-- AggregateRoot 需要 ConcurrencyStamp。
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- ---------- t_agent ----------
|
||||
CREATE TABLE IF NOT EXISTS `t_agent` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT,
|
||||
`UserId` INT NOT NULL COMMENT 'RBAC user.userid / JWT mapId',
|
||||
`AgentCode` VARCHAR(32) NOT NULL COMMENT '登录账号+注册链接编码',
|
||||
`AgentName` VARCHAR(64) NOT NULL COMMENT '显示名',
|
||||
`Phone` VARCHAR(32) NULL,
|
||||
`Email` VARCHAR(128) NULL,
|
||||
`Password` VARCHAR(64) NOT NULL COMMENT 'MD5 密码',
|
||||
`Status` TINYINT NOT NULL DEFAULT 2 COMMENT '1停用 2启用',
|
||||
`BaseType` VARCHAR(32) NOT NULL DEFAULT 'freight' COMMENT 'freight/receivable',
|
||||
`CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返佣比例',
|
||||
`SalesProductIds` VARCHAR(512) NULL COMMENT '可选销售产品Id列表 JSON/[1,2] 或逗号;空=不限',
|
||||
`Remark` VARCHAR(512) NULL,
|
||||
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0,
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`UpdateUserId` INT NOT NULL DEFAULT 0,
|
||||
`UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
-- 不用 DEFAULT(UUID()):statement 复制下会报 1674;运行时由 ORM 写入
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `ix_agent_code` (`AgentCode`),
|
||||
KEY `ix_agent_userid` (`UserId`)
|
||||
) COMMENT='渠道代理主数据';
|
||||
|
||||
-- 已建表补 ConcurrencyStamp(可重复)
|
||||
SET @exists := (
|
||||
SELECT COUNT(1) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent' AND COLUMN_NAME = 'ConcurrencyStamp'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_agent` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`',
|
||||
'SELECT ''t_agent.ConcurrencyStamp already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------- t_agent_commission_detail ----------
|
||||
CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT,
|
||||
`AgentId` INT NOT NULL,
|
||||
`OrderId` INT NOT NULL,
|
||||
`CustomerId` INT NOT NULL,
|
||||
`SalesProductId` INT NULL,
|
||||
`BaseType` VARCHAR(32) NOT NULL,
|
||||
`BaseAmount` DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
`CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||||
`CommissionAmount` DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
`Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid',
|
||||
`TriggerOrderStatus` VARCHAR(32) NOT NULL,
|
||||
`PaidTime` DATETIME NULL,
|
||||
`Remark` VARCHAR(512) NULL,
|
||||
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0,
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`UpdateUserId` INT NOT NULL DEFAULT 0,
|
||||
`UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '' COMMENT '并发戳',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `ix_agent_comm_agent` (`AgentId`),
|
||||
KEY `ix_agent_comm_order` (`OrderId`),
|
||||
KEY `ix_agent_comm_status` (`Status`)
|
||||
) COMMENT='代理返佣明细(首版无余额入账)';
|
||||
|
||||
SET @exists := (
|
||||
SELECT COUNT(1) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_agent_commission_detail' AND COLUMN_NAME = 'ConcurrencyStamp'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_agent_commission_detail` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT '''' COMMENT ''并发戳'' AFTER `UpdateTime`',
|
||||
'SELECT ''t_agent_commission_detail.ConcurrencyStamp already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------- t_customer.AgentId ----------
|
||||
SET @exists := (
|
||||
SELECT COUNT(1) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_customer' AND COLUMN_NAME = 'AgentId'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_customer` ADD COLUMN `AgentId` INT NULL COMMENT ''所属渠道代理'' AFTER `CommissionBalance`, ADD COLUMN `AgentBindTime` DATETIME NULL COMMENT ''代理绑定时间'' AFTER `AgentId`, ADD KEY `ix_customer_agent` (`AgentId`)',
|
||||
'SELECT ''t_customer.AgentId already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------- t_order.AgentId ----------
|
||||
SET @exists := (
|
||||
SELECT COUNT(1) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'AgentId'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_order` ADD COLUMN `AgentId` INT NULL COMMENT ''下单时代理快照'' AFTER `CustomerId`, ADD KEY `ix_order_agent` (`AgentId`)',
|
||||
'SELECT ''t_order.AgentId already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
362
src/Oms.Application/Dtos/Agents/AgentDtos.cs
Normal file
362
src/Oms.Application/Dtos/Agents/AgentDtos.cs
Normal file
@ -0,0 +1,362 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
|
||||
namespace Oms.Application.Dtos.Agents;
|
||||
|
||||
public class AgentListInput
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
public int PageSize { get; set; } = 50;
|
||||
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
public EnableStatus? Status { get; set; }
|
||||
}
|
||||
|
||||
public class AgentOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string AgentCode { get; set; } = string.Empty;
|
||||
|
||||
public string AgentName { get; set; } = string.Empty;
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
public EnableStatus Status { get; set; }
|
||||
|
||||
public string BaseType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>基数类型展示名(运费/应收)。</summary>
|
||||
public string? BaseTypeName { get; set; }
|
||||
|
||||
public decimal CommissionRate { get; set; }
|
||||
|
||||
public string? SalesProductIds { get; set; }
|
||||
|
||||
/// <summary>适用销售产品名称;空表示不限产品。</summary>
|
||||
public List<string> SalesProductNames { get; set; } = [];
|
||||
|
||||
public string? Remark { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
|
||||
public class AgentCreateInput
|
||||
{
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string AgentCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string AgentName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(128)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(6)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>freight / receivable。</summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string BaseType { get; set; } = "freight";
|
||||
|
||||
[Range(0, 1)]
|
||||
public decimal CommissionRate { get; set; }
|
||||
|
||||
/// <summary>可选销售产品 Id,JSON 或逗号分隔;空=不限。</summary>
|
||||
[StringLength(512)]
|
||||
public string? SalesProductIds { get; set; }
|
||||
|
||||
[StringLength(512)]
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
public class AgentUpdateInput
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string AgentName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(128)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>不传则不改密码。</summary>
|
||||
[MinLength(6)]
|
||||
public string? Password { get; set; }
|
||||
|
||||
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
|
||||
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string BaseType { get; set; } = "freight";
|
||||
|
||||
[Range(0, 1)]
|
||||
public decimal CommissionRate { get; set; }
|
||||
|
||||
[StringLength(512)]
|
||||
public string? SalesProductIds { get; set; }
|
||||
|
||||
[StringLength(512)]
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
public class AgentRebindCustomerInput
|
||||
{
|
||||
[Required]
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
/// <summary>空则解绑。</summary>
|
||||
public int? AgentId { get; set; }
|
||||
}
|
||||
|
||||
public class AgentCommissionListInput
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
public int PageSize { get; set; } = 50;
|
||||
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
public int? OrderId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class AgentCommissionDetailOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int AgentId { get; set; }
|
||||
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
public string? AgentName { get; set; }
|
||||
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public string? ServiceNo { get; set; }
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
public int? SalesProductId { get; set; }
|
||||
|
||||
public string BaseType { get; set; } = string.Empty;
|
||||
|
||||
public decimal BaseAmount { get; set; }
|
||||
|
||||
public decimal CommissionRate { get; set; }
|
||||
|
||||
public decimal CommissionAmount { get; set; }
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string TriggerOrderStatus { get; set; } = string.Empty;
|
||||
|
||||
public DateTime? PaidTime { get; set; }
|
||||
|
||||
public string? Remark { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
|
||||
public class AgentCommissionMarkPaidInput
|
||||
{
|
||||
[Required]
|
||||
public List<int> Ids { get; set; } = [];
|
||||
|
||||
[StringLength(512)]
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
public class AgentPortalCustomerOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string CustomerCode { get; set; } = string.Empty;
|
||||
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public DateTime? AgentBindTime { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>最近下单时间(含名下子客户)。</summary>
|
||||
public DateTime? LastOrderTime { get; set; }
|
||||
|
||||
/// <summary>近 30 天运单数(含名下子客户,不含已删除)。</summary>
|
||||
public int OrderCount30d { get; set; }
|
||||
|
||||
/// <summary>待付款运单数(含名下子客户)。</summary>
|
||||
public int PendingPaymentCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>代理门户返佣明细分页筛选。</summary>
|
||||
public class AgentPortalCommissionListInput
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
public int PageSize { get; set; } = 50;
|
||||
|
||||
/// <summary>pending / paid;空=全部。</summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>运单号关键字。</summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
public DateTime? CreateTimeStart { get; set; }
|
||||
|
||||
public DateTime? CreateTimeEnd { get; set; }
|
||||
}
|
||||
|
||||
public class AgentPortalOrderOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string ServiceNo { get; set; } = string.Empty;
|
||||
|
||||
public string? TrackNo { get; set; }
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public string? CustomerCode { get; set; }
|
||||
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
public string? ProductCode { get; set; }
|
||||
|
||||
public string? OrderStatus { get; set; }
|
||||
|
||||
public string? ConsigneeCountryName { get; set; }
|
||||
|
||||
public decimal? ReceivableSubtotal { get; set; }
|
||||
|
||||
public decimal? PayableAmount { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
|
||||
public class AgentPortalPageInput
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
public int PageSize { get; set; } = 50;
|
||||
|
||||
public string? Keyword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>匿名校验代理编号(客户注册归因)。</summary>
|
||||
public class AgentValidateCodeOutput
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
/// <summary>脱敏代理名称,供注册页展示。</summary>
|
||||
public string? AgentNameMasked { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代理工作台统计概览。
|
||||
/// </summary>
|
||||
public class AgentWorkplaceStatsOutput
|
||||
{
|
||||
/// <summary>名下主客户数。</summary>
|
||||
public int CustomerCount { get; set; }
|
||||
|
||||
/// <summary>今日代客下单数。</summary>
|
||||
public int TodayCreated { get; set; }
|
||||
|
||||
/// <summary>昨日代客下单数。</summary>
|
||||
public int YesterdayCreated { get; set; }
|
||||
|
||||
/// <summary>较昨日新增百分比。</summary>
|
||||
public decimal? DayOverDayGrowthPercent { get; set; }
|
||||
|
||||
/// <summary>待付款运单数。</summary>
|
||||
public int PendingPayment { get; set; }
|
||||
|
||||
/// <summary>运输中运单数。</summary>
|
||||
public int Shipping { get; set; }
|
||||
|
||||
/// <summary>问题件运单数。</summary>
|
||||
public int Problematic { get; set; }
|
||||
|
||||
/// <summary>派送成功运单数(全量状态桶)。</summary>
|
||||
public int DeliverySuccess { get; set; }
|
||||
|
||||
/// <summary>预计返佣笔数(未锁定)。</summary>
|
||||
public int EstimatedCommissionCount { get; set; }
|
||||
|
||||
/// <summary>预计返佣金额(未锁定,不计入应打款)。</summary>
|
||||
public decimal EstimatedCommissionAmount { get; set; }
|
||||
|
||||
/// <summary>待结算返佣笔数。</summary>
|
||||
public int PendingCommissionCount { get; set; }
|
||||
|
||||
/// <summary>待结算返佣金额。</summary>
|
||||
public decimal PendingCommissionAmount { get; set; }
|
||||
|
||||
/// <summary>已打款返佣金额(累计)。</summary>
|
||||
public decimal PaidCommissionAmount { get; set; }
|
||||
|
||||
/// <summary>本周新增绑定主客户数(按 AgentBindTime)。</summary>
|
||||
public int WeekNewCustomers { get; set; }
|
||||
|
||||
/// <summary>派送失败运单数。</summary>
|
||||
public int DeliveryFailed { get; set; }
|
||||
|
||||
/// <summary>按 OrderStatus 分组数量。</summary>
|
||||
public Dictionary<string, int> Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代理工作台趋势点。
|
||||
/// </summary>
|
||||
public class AgentWorkplaceTrendPoint
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public DateTime Start { get; set; }
|
||||
|
||||
public DateTime End { get; set; }
|
||||
|
||||
/// <summary>区间内代客下单数。</summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>区间内计提返佣金额。</summary>
|
||||
public decimal CommissionAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代理工作台趋势。
|
||||
/// </summary>
|
||||
public class AgentWorkplaceTrendOutput
|
||||
{
|
||||
public string Dimension { get; set; } = "day";
|
||||
|
||||
public List<AgentWorkplaceTrendPoint> Points { get; set; } = [];
|
||||
}
|
||||
@ -19,4 +19,49 @@ public class KpiAreaInfoListInput
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 国家/地区(匹配国家名称或国家代码,模糊)。
|
||||
/// </summary>
|
||||
public string? Country { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配维度(如 zip/state/city/country,模糊)。
|
||||
/// </summary>
|
||||
public string? MatchDim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 匹配方式(如 prefix/exact/range/all,模糊)。
|
||||
/// </summary>
|
||||
public string? MatchMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 州/省(模糊)。
|
||||
/// </summary>
|
||||
public string? Province { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市(模糊)。
|
||||
/// </summary>
|
||||
public string? City { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 起始邮编(模糊)。
|
||||
/// </summary>
|
||||
public string? LeftPostcode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 截止邮编(模糊)。
|
||||
/// </summary>
|
||||
public string? RightPostcode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段:leftPostcode / rightPostcode;空则按 Id 降序。
|
||||
/// </summary>
|
||||
public string? SortField { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序方向:asc / desc。
|
||||
/// </summary>
|
||||
public string? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
@ -124,4 +124,10 @@ public class CreateCustomerInput
|
||||
[StringLength(64)]
|
||||
public string? AdvisorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
}
|
||||
|
||||
@ -78,7 +78,7 @@ public class CustomerOutput
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 销售人员。
|
||||
/// 销售人员(遗留字段,管理端已改用 AgentId)。
|
||||
/// </summary>
|
||||
public string? Salesman { get; set; }
|
||||
|
||||
@ -87,6 +87,15 @@ public class CustomerOutput
|
||||
/// </summary>
|
||||
public string? CustomerService { get; set; }
|
||||
|
||||
/// <summary>绑定渠道代理 Id。</summary>
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
/// <summary>绑定代理编号。</summary>
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
/// <summary>绑定代理名称。</summary>
|
||||
public string? AgentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户分组。
|
||||
/// </summary>
|
||||
|
||||
@ -33,7 +33,7 @@ public class CustomerPaginatedInput
|
||||
public CustomerGroup? CustomerGroup { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 销售人员。
|
||||
/// 销售人员(遗留筛选)。
|
||||
/// </summary>
|
||||
public string? Salesman { get; set; }
|
||||
|
||||
@ -42,6 +42,9 @@ public class CustomerPaginatedInput
|
||||
/// </summary>
|
||||
public string? CustomerService { get; set; }
|
||||
|
||||
/// <summary>按绑定代理 Id 筛选。</summary>
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段:createTime / updateTime / customerCode / customerName / balance。
|
||||
/// </summary>
|
||||
|
||||
@ -78,7 +78,7 @@ public class CustomerPaginatedOutput
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 销售人员。
|
||||
/// 销售人员(遗留字段,管理端已改用 AgentId)。
|
||||
/// </summary>
|
||||
public string? Salesman { get; set; }
|
||||
|
||||
@ -87,6 +87,15 @@ public class CustomerPaginatedOutput
|
||||
/// </summary>
|
||||
public string? CustomerService { get; set; }
|
||||
|
||||
/// <summary>绑定渠道代理 Id。</summary>
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
/// <summary>绑定代理编号。</summary>
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
/// <summary>绑定代理名称。</summary>
|
||||
public string? AgentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户分组。
|
||||
/// </summary>
|
||||
|
||||
@ -81,7 +81,7 @@ public class UpdateCustomerInput
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 销售人员。
|
||||
/// 销售人员(遗留;新流程请用 AgentCode)。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Salesman { get; set; }
|
||||
@ -92,6 +92,12 @@ public class UpdateCustomerInput
|
||||
[StringLength(32)]
|
||||
public string? CustomerService { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理编号;传空串解绑;不传则保留原绑定。仅主客户可改。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户分组(遗留字段,计价优先 MemberLevelId)。
|
||||
/// </summary>
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
namespace Oms.Application.Messaging.Agents;
|
||||
|
||||
/// <summary>代理返佣异步任务动作。</summary>
|
||||
public static class AgentCommissionJobActionCodes
|
||||
{
|
||||
/// <summary>下单/计费后写或刷新预计返佣。</summary>
|
||||
public const string Estimate = "estimate";
|
||||
|
||||
/// <summary>派送终态锁定为待结算。</summary>
|
||||
public const string Finalize = "finalize";
|
||||
|
||||
/// <summary>删单/全额退作废。</summary>
|
||||
public const string Void = "void";
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Shared;
|
||||
|
||||
namespace Oms.Application.Messaging.Agents;
|
||||
|
||||
/// <summary>消费代理返佣异步任务。</summary>
|
||||
public sealed class AgentCommissionJobEventHandler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<AgentCommissionJobEventHandler> logger)
|
||||
: IEventHandler<AgentCommissionJobIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
AgentCommissionJobIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (@event.OrderId <= 0 || string.IsNullOrWhiteSpace(@event.Action))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"AgentCommission job skipped invalid payload OrderId={OrderId} Action={Action}",
|
||||
@event.OrderId,
|
||||
@event.Action);
|
||||
return;
|
||||
}
|
||||
|
||||
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var manager = scope.ServiceProvider.GetRequiredService<AgentCommissionManager>();
|
||||
|
||||
try
|
||||
{
|
||||
var action = @event.Action.Trim();
|
||||
if (string.Equals(action, AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await manager.TryEstimateOnOrderCreatedAsync(@event.OrderId, cancellationToken);
|
||||
}
|
||||
else if (string.Equals(action, AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await manager.TryAccrueOnDeliveryTerminalAsync(
|
||||
@event.OrderId,
|
||||
@event.TriggerOrderStatus ?? string.Empty,
|
||||
cancellationToken);
|
||||
}
|
||||
else if (string.Equals(action, AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await manager.TryVoidOnOrderCancelledAsync(
|
||||
@event.OrderId,
|
||||
@event.Reason ?? "异步作废返佣",
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"AgentCommission job unknown Action={Action} OrderId={OrderId}",
|
||||
@event.Action,
|
||||
@event.OrderId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 失败 Ack,避免打满队列;明细可后续补偿
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"AgentCommission job failed OrderId={OrderId} Action={Action}",
|
||||
@event.OrderId,
|
||||
@event.Action);
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AgentCommissionJobIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
/// <summary><see cref="AgentCommissionJobActionCodes"/>。</summary>
|
||||
public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate;
|
||||
|
||||
/// <summary>finalize 时的触发运单状态。</summary>
|
||||
public string? TriggerOrderStatus { get; set; }
|
||||
|
||||
/// <summary>void 时的原因备注。</summary>
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus.Distributed;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Messaging.Agents;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
@ -16,6 +18,7 @@ public sealed class TrackChangeOrderStatusApplier(
|
||||
IOrderRepository orderRepository,
|
||||
OrderOperationLogWriter operationLogWriter,
|
||||
ReferralCommissionManager referralCommissionManager,
|
||||
IDistributedEventBus eventBus,
|
||||
MarketingCouponManager marketingCouponManager,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
|
||||
@ -132,6 +135,21 @@ public sealed class TrackChangeOrderStatusApplier(
|
||||
// 重新加载,避免营销侧依赖最新字段
|
||||
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
|
||||
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
|
||||
await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
Action = AgentCommissionJobActionCodes.Finalize,
|
||||
TriggerOrderStatus = OrderStatusCodes.DeliverySuccess
|
||||
});
|
||||
}
|
||||
else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
Action = AgentCommissionJobActionCodes.Finalize,
|
||||
TriggerOrderStatus = OrderStatusCodes.DeliveryFailed
|
||||
});
|
||||
}
|
||||
|
||||
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
|
||||
|
||||
463
src/Oms.Application/Services/Agents/AgentAdminService.cs
Normal file
463
src/Oms.Application/Services/Agents/AgentAdminService.cs
Normal file
@ -0,0 +1,463 @@
|
||||
using System.Linq.Expressions;
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Fake.Rbac.Application.Dtos.Role;
|
||||
using Fake.Rbac.Application.Dtos.User;
|
||||
using Fake.Rbac.Application.Services;
|
||||
using Fake.Rbac.Infrastructure;
|
||||
using Fake.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Oms.Application.Dtos.Agents;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.AgentsAggregate.Consts;
|
||||
using Oms.Domain.AgentsAggregate.Entities;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
|
||||
namespace Oms.Application.Services.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理管理端 API(开户、规则、改绑、返佣明细)。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "OMS-Agent")]
|
||||
public class AgentAdminService : ApplicationService
|
||||
{
|
||||
public const string AgentRoleCode = "Agent";
|
||||
|
||||
private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||
|
||||
private IAgentCommissionDetailRepository DetailRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IAgentCommissionDetailRepository>();
|
||||
|
||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||
|
||||
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||
|
||||
private UserService UserService => LazyServiceProvider.GetRequiredService<UserService>();
|
||||
|
||||
private RoleService RoleService => LazyServiceProvider.GetRequiredService<RoleService>();
|
||||
|
||||
private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService<FakeRbacDbContext>();
|
||||
|
||||
/// <summary>
|
||||
/// 匿名校验代理编号(客户注册页归因 / 手填兜底)。
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/agent/public/validate-code")]
|
||||
public async Task<AgentValidateCodeOutput> ValidateCodeAsync([FromQuery] string code)
|
||||
{
|
||||
var trimmed = code?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return new AgentValidateCodeOutput
|
||||
{
|
||||
Valid = false,
|
||||
Message = "代理编号不能为空"
|
||||
};
|
||||
}
|
||||
|
||||
var agent = await AgentRepository.GetByAgentCodeAsync(trimmed);
|
||||
if (agent == null || agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
return new AgentValidateCodeOutput
|
||||
{
|
||||
Valid = false,
|
||||
Message = "代理编号无效或已停用"
|
||||
};
|
||||
}
|
||||
|
||||
return new AgentValidateCodeOutput
|
||||
{
|
||||
Valid = true,
|
||||
AgentCode = agent.AgentCode,
|
||||
AgentNameMasked = MaskAgentName(agent.AgentName),
|
||||
Message = null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>分页查询代理。</summary>
|
||||
[HttpPost("/agent/admin/list")]
|
||||
public async Task<OmsPageResult<AgentOutput>> GetListAsync(AgentListInput input)
|
||||
{
|
||||
var predicate = BuildAgentListPredicate(input);
|
||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var entities = await AgentRepository.GetPagedListAsync(
|
||||
predicate,
|
||||
page,
|
||||
pageSize,
|
||||
new Dictionary<string, bool> { [nameof(Agent.CreateTime)] = false, [nameof(Agent.Id)] = false });
|
||||
var total = await AgentRepository.CountAsync(predicate);
|
||||
return new OmsPageResult<AgentOutput>
|
||||
{
|
||||
Items = entities.Select(MapAgent).ToList(),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>创建代理(同步 RBAC 用户 + Agent 角色)。</summary>
|
||||
[HttpPost("/agent/admin/create")]
|
||||
public async Task CreateAsync(AgentCreateInput input)
|
||||
{
|
||||
var code = input.AgentCode.Trim();
|
||||
if (await AgentRepository.ExistsAgentCodeAsync(code))
|
||||
{
|
||||
throw new BusinessException("代理编号已存在");
|
||||
}
|
||||
|
||||
EnsureBaseType(input.BaseType);
|
||||
|
||||
var roleId = await GetAgentRoleIdOrThrowAsync();
|
||||
var user = await UserService.CreateAsync(new UserCreateDto
|
||||
{
|
||||
Name = input.AgentName.Trim(),
|
||||
Account = code,
|
||||
Password = input.Password,
|
||||
Email = string.IsNullOrWhiteSpace(input.Email)
|
||||
? $"{code}@agent.local"
|
||||
: input.Email.Trim(),
|
||||
RoleIds = []
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await UserService.AssignRolesAsync(user.Id, [roleId]);
|
||||
var userId = await GetRbacUserMapIdOrThrowAsync(user.Id);
|
||||
await AgentRepository.InsertAsync(new Agent
|
||||
{
|
||||
UserId = userId,
|
||||
AgentCode = code,
|
||||
AgentName = input.AgentName.Trim(),
|
||||
Phone = NormalizeOptional(input.Phone),
|
||||
Email = NormalizeOptional(input.Email),
|
||||
Password = OmsPasswordHelper.ToMd5(input.Password),
|
||||
Status = EnableStatus.Enabled,
|
||||
BaseType = input.BaseType.Trim().ToLowerInvariant(),
|
||||
CommissionRate = input.CommissionRate,
|
||||
SalesProductIds = NormalizeOptional(input.SalesProductIds),
|
||||
Remark = NormalizeOptional(input.Remark)
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
await UserService.DeleteAsync(user.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore rollback failure
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>更新代理资料与返佣规则。</summary>
|
||||
[HttpPost("/agent/admin/update")]
|
||||
public async Task UpdateAsync(AgentUpdateInput input)
|
||||
{
|
||||
var entity = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.Id)
|
||||
?? throw new BusinessException("代理不存在");
|
||||
EnsureBaseType(input.BaseType);
|
||||
|
||||
entity.AgentName = input.AgentName.Trim();
|
||||
entity.Phone = NormalizeOptional(input.Phone);
|
||||
entity.Email = NormalizeOptional(input.Email);
|
||||
entity.Status = input.Status == EnableStatus.None ? EnableStatus.Enabled : input.Status;
|
||||
entity.BaseType = input.BaseType.Trim().ToLowerInvariant();
|
||||
entity.CommissionRate = input.CommissionRate;
|
||||
entity.SalesProductIds = NormalizeOptional(input.SalesProductIds);
|
||||
entity.Remark = NormalizeOptional(input.Remark);
|
||||
if (!string.IsNullOrWhiteSpace(input.Password))
|
||||
{
|
||||
entity.Password = OmsPasswordHelper.ToMd5(input.Password);
|
||||
}
|
||||
|
||||
await AgentRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>改绑主客户所属代理(仅影响之后新单)。</summary>
|
||||
[HttpPost("/agent/admin/rebind")]
|
||||
public async Task RebindCustomerAsync(AgentRebindCustomerInput input)
|
||||
{
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId)
|
||||
?? throw new BusinessException("客户不存在");
|
||||
if (customer.ParentCustomerId.HasValue)
|
||||
{
|
||||
throw new BusinessException("请改绑主客户");
|
||||
}
|
||||
|
||||
if (input.AgentId.HasValue && input.AgentId.Value > 0)
|
||||
{
|
||||
var agent = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.AgentId.Value)
|
||||
?? throw new BusinessException("代理不存在");
|
||||
if (agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("代理已停用");
|
||||
}
|
||||
|
||||
customer.AgentId = agent.Id;
|
||||
customer.AgentBindTime = DateTime.Now;
|
||||
}
|
||||
else
|
||||
{
|
||||
customer.AgentId = null;
|
||||
customer.AgentBindTime = null;
|
||||
}
|
||||
|
||||
await CustomerRepository.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
/// <summary>分页查询返佣明细。</summary>
|
||||
[HttpPost("/agent/admin/commission/list")]
|
||||
public async Task<OmsPageResult<AgentCommissionDetailOutput>> GetCommissionListAsync(AgentCommissionListInput input)
|
||||
{
|
||||
var predicate = BuildCommissionPredicate(input);
|
||||
|
||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var entities = await DetailRepository.GetPagedListAsync(
|
||||
predicate,
|
||||
page,
|
||||
pageSize,
|
||||
new Dictionary<string, bool>
|
||||
{
|
||||
[nameof(AgentCommissionDetail.CreateTime)] = false,
|
||||
[nameof(AgentCommissionDetail.Id)] = false
|
||||
});
|
||||
var total = await DetailRepository.CountAsync(predicate);
|
||||
return new OmsPageResult<AgentCommissionDetailOutput>
|
||||
{
|
||||
Items = await MapCommissionOutputsAsync(entities),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>标记返佣明细为已打款。</summary>
|
||||
[HttpPost("/agent/admin/commission/mark-paid")]
|
||||
public async Task MarkCommissionPaidAsync(AgentCommissionMarkPaidInput input)
|
||||
{
|
||||
if (input.Ids == null || input.Ids.Count == 0)
|
||||
{
|
||||
throw new BusinessException("请选择明细");
|
||||
}
|
||||
|
||||
var ids = input.Ids.Distinct().ToList();
|
||||
var list = await DetailRepository.GetListAsync(x => ids.Contains(x.Id));
|
||||
var now = DateTime.Now;
|
||||
var remark = NormalizeOptional(input.Remark);
|
||||
foreach (var item in list)
|
||||
{
|
||||
// 仅「待结算」可标记打款;预计/作废/已打款跳过
|
||||
if (!string.Equals(item.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
item.Status = AgentCommissionStatusCodes.Paid;
|
||||
item.PaidTime = now;
|
||||
if (!string.IsNullOrWhiteSpace(remark))
|
||||
{
|
||||
item.Remark = remark;
|
||||
}
|
||||
|
||||
await DetailRepository.UpdateAsync(item);
|
||||
}
|
||||
}
|
||||
|
||||
private static Expression<Func<Agent, bool>> BuildAgentListPredicate(AgentListInput input)
|
||||
{
|
||||
var keyword = input.Keyword?.Trim();
|
||||
var hasStatus = input.Status.HasValue && input.Status.Value != EnableStatus.None;
|
||||
if (hasStatus && !string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var status = input.Status!.Value;
|
||||
return x => x.Status == status
|
||||
&& (x.AgentCode.Contains(keyword!)
|
||||
|| x.AgentName.Contains(keyword!)
|
||||
|| (x.Phone != null && x.Phone.Contains(keyword!)));
|
||||
}
|
||||
|
||||
if (hasStatus)
|
||||
{
|
||||
var status = input.Status!.Value;
|
||||
return x => x.Status == status;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return x => x.AgentCode.Contains(keyword!)
|
||||
|| x.AgentName.Contains(keyword!)
|
||||
|| (x.Phone != null && x.Phone.Contains(keyword!));
|
||||
}
|
||||
|
||||
return x => true;
|
||||
}
|
||||
|
||||
private static Expression<Func<AgentCommissionDetail, bool>> BuildCommissionPredicate(AgentCommissionListInput input)
|
||||
{
|
||||
var status = input.Status?.Trim();
|
||||
if (input.AgentId is > 0 && input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var agentId = input.AgentId.Value;
|
||||
var orderId = input.OrderId.Value;
|
||||
return x => x.AgentId == agentId && x.OrderId == orderId && x.Status == status;
|
||||
}
|
||||
|
||||
if (input.AgentId is > 0 && input.OrderId is > 0)
|
||||
{
|
||||
var agentId = input.AgentId.Value;
|
||||
var orderId = input.OrderId.Value;
|
||||
return x => x.AgentId == agentId && x.OrderId == orderId;
|
||||
}
|
||||
|
||||
if (input.AgentId is > 0 && !string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var agentId = input.AgentId.Value;
|
||||
return x => x.AgentId == agentId && x.Status == status;
|
||||
}
|
||||
|
||||
if (input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var orderId = input.OrderId.Value;
|
||||
return x => x.OrderId == orderId && x.Status == status;
|
||||
}
|
||||
|
||||
if (input.AgentId is > 0)
|
||||
{
|
||||
var agentId = input.AgentId.Value;
|
||||
return x => x.AgentId == agentId;
|
||||
}
|
||||
|
||||
if (input.OrderId is > 0)
|
||||
{
|
||||
var orderId = input.OrderId.Value;
|
||||
return x => x.OrderId == orderId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return x => x.Status == status;
|
||||
}
|
||||
|
||||
return x => true;
|
||||
}
|
||||
|
||||
private async Task<List<AgentCommissionDetailOutput>> MapCommissionOutputsAsync(
|
||||
List<AgentCommissionDetail> entities)
|
||||
{
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var agentIds = entities.Select(x => x.AgentId).Distinct().ToList();
|
||||
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
|
||||
var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
|
||||
|
||||
var agents = await AgentRepository.GetListAsync(x => agentIds.Contains(x.Id));
|
||||
var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
|
||||
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
|
||||
|
||||
var agentMap = agents.ToDictionary(x => x.Id);
|
||||
var customerMap = customers.ToDictionary(x => x.Id);
|
||||
var orderMap = orders.ToDictionary(x => x.Id);
|
||||
|
||||
return entities.Select(x =>
|
||||
{
|
||||
agentMap.TryGetValue(x.AgentId, out var agent);
|
||||
customerMap.TryGetValue(x.CustomerId, out var customer);
|
||||
orderMap.TryGetValue(x.OrderId, out var order);
|
||||
return new AgentCommissionDetailOutput
|
||||
{
|
||||
Id = x.Id,
|
||||
AgentId = x.AgentId,
|
||||
AgentCode = agent?.AgentCode,
|
||||
AgentName = agent?.AgentName,
|
||||
OrderId = x.OrderId,
|
||||
ServiceNo = order?.ServiceNo,
|
||||
CustomerId = x.CustomerId,
|
||||
CustomerCode = customer?.CustomerCode,
|
||||
CustomerName = customer?.CustomerName,
|
||||
SalesProductId = x.SalesProductId,
|
||||
BaseType = x.BaseType,
|
||||
BaseAmount = x.BaseAmount,
|
||||
CommissionRate = x.CommissionRate,
|
||||
CommissionAmount = x.CommissionAmount,
|
||||
Status = x.Status,
|
||||
TriggerOrderStatus = x.TriggerOrderStatus,
|
||||
PaidTime = x.PaidTime,
|
||||
Remark = x.Remark,
|
||||
CreateTime = x.CreateTime
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static AgentOutput MapAgent(Agent x) => new()
|
||||
{
|
||||
Id = x.Id,
|
||||
AgentCode = x.AgentCode,
|
||||
AgentName = x.AgentName,
|
||||
Phone = x.Phone,
|
||||
Email = x.Email,
|
||||
Status = x.Status,
|
||||
BaseType = x.BaseType,
|
||||
CommissionRate = x.CommissionRate,
|
||||
SalesProductIds = x.SalesProductIds,
|
||||
Remark = x.Remark,
|
||||
CreateTime = x.CreateTime
|
||||
};
|
||||
|
||||
private static void EnsureBaseType(string? baseType)
|
||||
{
|
||||
if (!AgentCommissionBaseTypes.IsSupported(baseType))
|
||||
{
|
||||
throw new BusinessException("基数类型仅支持 freight(运费)或 receivable(应收)");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private async Task<Guid> GetAgentRoleIdOrThrowAsync()
|
||||
{
|
||||
var roles = await RoleService.GetAllRolesAsync();
|
||||
var role = roles.FirstOrDefault(x =>
|
||||
string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (role == null)
|
||||
{
|
||||
throw new BusinessException("未找到 Agent 角色,请先执行种子或手动创建");
|
||||
}
|
||||
|
||||
return role.Id;
|
||||
}
|
||||
|
||||
private async Task<int> GetRbacUserMapIdOrThrowAsync(Guid userId)
|
||||
{
|
||||
var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
|
||||
var mapId = await RbacDbContext.Database.SqlQueryRaw<int?>(sql, userId).FirstOrDefaultAsync();
|
||||
if (!mapId.HasValue || mapId.Value <= 0)
|
||||
{
|
||||
throw new BusinessException("RBAC 用户映射失败");
|
||||
}
|
||||
|
||||
return mapId.Value;
|
||||
}
|
||||
|
||||
private static string MaskAgentName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return "渠道代理";
|
||||
}
|
||||
|
||||
var text = name.Trim();
|
||||
return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1));
|
||||
}
|
||||
}
|
||||
581
src/Oms.Application/Services/Agents/AgentPortalService.cs
Normal file
581
src/Oms.Application/Services/Agents/AgentPortalService.cs
Normal file
@ -0,0 +1,581 @@
|
||||
using System.Linq.Expressions;
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiniExcelLibs;
|
||||
using Oms.Application.Dtos.Agents;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.AgentsAggregate.Consts;
|
||||
using Oms.Domain.AgentsAggregate.Entities;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Services.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理门户 API(名下客户 / 订单 / 返佣 / 工作台)。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "OMS-Agent")]
|
||||
public class AgentPortalService : ApplicationService
|
||||
{
|
||||
private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||
|
||||
private IAgentCommissionDetailRepository DetailRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IAgentCommissionDetailRepository>();
|
||||
|
||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||
|
||||
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
|
||||
|
||||
private ISalesProductRepository SalesProductRepository =>
|
||||
LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||
|
||||
/// <summary>当前代理资料(含返佣规则展示字段)。</summary>
|
||||
[HttpGet("/agent/portal/profile")]
|
||||
public async Task<AgentOutput> GetProfileAsync()
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
var productNames = await ResolveSalesProductNamesAsync(agent.SalesProductIds);
|
||||
return new AgentOutput
|
||||
{
|
||||
Id = agent.Id,
|
||||
AgentCode = agent.AgentCode,
|
||||
AgentName = agent.AgentName,
|
||||
Phone = agent.Phone,
|
||||
Email = agent.Email,
|
||||
Status = agent.Status,
|
||||
BaseType = agent.BaseType,
|
||||
BaseTypeName = ResolveBaseTypeName(agent.BaseType),
|
||||
CommissionRate = agent.CommissionRate,
|
||||
SalesProductIds = agent.SalesProductIds,
|
||||
SalesProductNames = productNames,
|
||||
Remark = agent.Remark,
|
||||
CreateTime = agent.CreateTime
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>名下客户分页(含活跃度:最近下单 / 近30天票数 / 待付款数)。</summary>
|
||||
[HttpPost("/agent/portal/customers/list")]
|
||||
public async Task<OmsPageResult<AgentPortalCustomerOutput>> GetCustomersAsync(AgentPortalPageInput input)
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
var keyword = input.Keyword?.Trim();
|
||||
Expression<Func<Customer, bool>> predicate = string.IsNullOrWhiteSpace(keyword)
|
||||
? x => x.AgentId == agent.Id && x.ParentCustomerId == null
|
||||
: x => x.AgentId == agent.Id
|
||||
&& x.ParentCustomerId == null
|
||||
&& (x.CustomerCode.Contains(keyword!)
|
||||
|| x.CustomerName.Contains(keyword!)
|
||||
|| (x.Phone != null && x.Phone.Contains(keyword!)));
|
||||
|
||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var entities = await CustomerRepository.GetPagedListAsync(
|
||||
predicate,
|
||||
page,
|
||||
pageSize,
|
||||
new Dictionary<string, bool>
|
||||
{
|
||||
[nameof(Customer.CreateTime)] = false,
|
||||
[nameof(Customer.Id)] = false
|
||||
});
|
||||
var total = await CustomerRepository.CountAsync(predicate);
|
||||
var activityMap = await BuildCustomerActivityMapAsync(agent.Id, entities.Select(x => x.Id).ToList());
|
||||
|
||||
return new OmsPageResult<AgentPortalCustomerOutput>
|
||||
{
|
||||
Items = entities.Select(x =>
|
||||
{
|
||||
activityMap.TryGetValue(x.Id, out var activity);
|
||||
return new AgentPortalCustomerOutput
|
||||
{
|
||||
Id = x.Id,
|
||||
CustomerCode = x.CustomerCode,
|
||||
CustomerName = MaskName(x.CustomerName) ?? string.Empty,
|
||||
Phone = MaskPhone(x.Phone),
|
||||
AgentBindTime = x.AgentBindTime,
|
||||
CreateTime = x.CreateTime,
|
||||
LastOrderTime = activity.LastOrderTime,
|
||||
OrderCount30d = activity.OrderCount30d,
|
||||
PendingPaymentCount = activity.PendingPaymentCount
|
||||
};
|
||||
}).ToList(),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>名下订单分页(按当前绑定客户可见;不含应付)。</summary>
|
||||
[HttpPost("/agent/portal/orders/list")]
|
||||
public async Task<OmsPageResult<AgentPortalOrderOutput>> GetOrdersAsync(AgentPortalPageInput input)
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
var keyword = input.Keyword?.Trim();
|
||||
var filter = new OrderListFilter
|
||||
{
|
||||
OwnedByAgentId = agent.Id,
|
||||
Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword,
|
||||
Status = "all"
|
||||
};
|
||||
|
||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var (entities, total) = await OrderRepository.GetFilteredPagedListAsync(page, pageSize, filter);
|
||||
|
||||
var customerIds = entities.Where(x => x.CustomerId.HasValue).Select(x => x.CustomerId!.Value).Distinct().ToList();
|
||||
var customers = customerIds.Count == 0
|
||||
? []
|
||||
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
|
||||
var customerMap = customers.ToDictionary(x => x.Id);
|
||||
|
||||
return new OmsPageResult<AgentPortalOrderOutput>
|
||||
{
|
||||
Items = entities.Select(x =>
|
||||
{
|
||||
customerMap.TryGetValue(x.CustomerId ?? 0, out var customer);
|
||||
return new AgentPortalOrderOutput
|
||||
{
|
||||
Id = x.Id,
|
||||
ServiceNo = x.ServiceNo,
|
||||
TrackNo = x.TrackNo,
|
||||
CustomerId = x.CustomerId,
|
||||
CustomerCode = customer?.CustomerCode,
|
||||
CustomerName = MaskName(customer?.CustomerName),
|
||||
ProductCode = x.ProductCode,
|
||||
OrderStatus = x.OrderStatus,
|
||||
ConsigneeCountryName = x.ConsigneeCountryName,
|
||||
ReceivableSubtotal = x.ReceivableSubtotal,
|
||||
PayableAmount = null,
|
||||
CreateTime = x.CreateTime
|
||||
};
|
||||
}).ToList(),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代理工作台统计:名下客户、运单状态桶、待结算/已打款返佣、本周新增绑定。
|
||||
/// </summary>
|
||||
[HttpGet("/agent/portal/workplace/stats")]
|
||||
public async Task<AgentWorkplaceStatsOutput> GetWorkplaceStatsAsync()
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
var filter = new OrderListFilter { OwnedByAgentId = agent.Id };
|
||||
var rows = await OrderRepository.GetStatusCountsByFilterAsync(filter);
|
||||
var counts = rows
|
||||
.Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(x => x.Status, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int GetCount(string status) =>
|
||||
counts.TryGetValue(status, out var value) ? value : 0;
|
||||
|
||||
var today = DateTime.Today;
|
||||
var tomorrow = today.AddDays(1);
|
||||
var yesterday = today.AddDays(-1);
|
||||
var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow);
|
||||
var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today);
|
||||
|
||||
decimal? growth = null;
|
||||
if (yesterdayCreated > 0)
|
||||
{
|
||||
growth = Math.Round((todayCreated - yesterdayCreated) * 100m / yesterdayCreated, 1);
|
||||
}
|
||||
else if (todayCreated > 0)
|
||||
{
|
||||
growth = 100m;
|
||||
}
|
||||
|
||||
var customerCount = await CustomerRepository.CountAsync(x =>
|
||||
x.AgentId == agent.Id && x.ParentCustomerId == null);
|
||||
|
||||
var weekStart = today.AddDays(-(((int)today.DayOfWeek + 6) % 7));
|
||||
var weekNewCustomers = await CustomerRepository.CountAsync(x =>
|
||||
x.AgentId == agent.Id
|
||||
&& x.ParentCustomerId == null
|
||||
&& x.AgentBindTime != null
|
||||
&& x.AgentBindTime >= weekStart
|
||||
&& x.AgentBindTime < tomorrow);
|
||||
|
||||
var estimatedDetails = await DetailRepository.GetListAsync(x =>
|
||||
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Estimated);
|
||||
var pendingDetails = await DetailRepository.GetListAsync(x =>
|
||||
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending);
|
||||
var paidDetails = await DetailRepository.GetListAsync(x =>
|
||||
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Paid);
|
||||
|
||||
return new AgentWorkplaceStatsOutput
|
||||
{
|
||||
CustomerCount = customerCount,
|
||||
TodayCreated = todayCreated,
|
||||
YesterdayCreated = yesterdayCreated,
|
||||
DayOverDayGrowthPercent = growth,
|
||||
PendingPayment = GetCount(OrderStatusCodes.PendingPayment),
|
||||
Shipping = GetCount(OrderStatusCodes.Shipping) + GetCount(OrderStatusCodes.PendingOnline),
|
||||
Problematic = GetCount(OrderStatusCodes.Problematic),
|
||||
DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess),
|
||||
DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed),
|
||||
EstimatedCommissionCount = estimatedDetails.Count,
|
||||
EstimatedCommissionAmount = estimatedDetails.Sum(x => x.CommissionAmount),
|
||||
PendingCommissionCount = pendingDetails.Count,
|
||||
PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount),
|
||||
PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount),
|
||||
WeekNewCustomers = weekNewCustomers,
|
||||
Counts = counts,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代理工作台趋势:按日统计代客下单数与计提返佣金额。
|
||||
/// </summary>
|
||||
/// <param name="days">天数,默认 7,最大 31。</param>
|
||||
[HttpGet("/agent/portal/workplace/trend")]
|
||||
public async Task<AgentWorkplaceTrendOutput> GetWorkplaceTrendAsync([FromQuery] int days = 7)
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
days = Math.Clamp(days, 1, 31);
|
||||
var filter = new OrderListFilter { OwnedByAgentId = agent.Id };
|
||||
var today = DateTime.Today;
|
||||
var rangeStart = today.AddDays(-(days - 1));
|
||||
var rangeEnd = today.AddDays(1);
|
||||
|
||||
var commissions = await DetailRepository.GetListAsync(x =>
|
||||
x.AgentId == agent.Id
|
||||
&& x.CreateTime >= rangeStart
|
||||
&& x.CreateTime < rangeEnd);
|
||||
|
||||
var points = new List<AgentWorkplaceTrendPoint>(days);
|
||||
for (var i = 0; i < days; i++)
|
||||
{
|
||||
var start = rangeStart.AddDays(i);
|
||||
var end = start.AddDays(1);
|
||||
var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, start, end);
|
||||
var commissionAmount = commissions
|
||||
.Where(x => x.CreateTime >= start && x.CreateTime < end)
|
||||
.Sum(x => x.CommissionAmount);
|
||||
points.Add(new AgentWorkplaceTrendPoint
|
||||
{
|
||||
Label = start.ToString("MM-dd"),
|
||||
Start = start,
|
||||
End = end,
|
||||
OrderCount = orderCount,
|
||||
CommissionAmount = commissionAmount,
|
||||
});
|
||||
}
|
||||
|
||||
return new AgentWorkplaceTrendOutput
|
||||
{
|
||||
Dimension = "day",
|
||||
Points = points,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>名下返佣明细(支持状态 / 运单号 / 计提时间筛选)。</summary>
|
||||
[HttpPost("/agent/portal/commission/list")]
|
||||
public async Task<OmsPageResult<AgentCommissionDetailOutput>> GetCommissionListAsync(
|
||||
AgentPortalCommissionListInput input)
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
var details = await QueryCommissionDetailsAsync(agent, input);
|
||||
var page = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var total = details.Count;
|
||||
var pageItems = details
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return new OmsPageResult<AgentCommissionDetailOutput>
|
||||
{
|
||||
Items = await MapCommissionOutputsAsync(agent, pageItems),
|
||||
Total = total
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>导出名下返佣明细(Excel,最多 5000 条)。</summary>
|
||||
[HttpPost("/agent/portal/commission/export")]
|
||||
public async Task<FileContentResult> ExportCommissionAsync(AgentPortalCommissionListInput input)
|
||||
{
|
||||
var agent = await GetCurrentAgentOrThrowAsync();
|
||||
var details = await QueryCommissionDetailsAsync(agent, input);
|
||||
const int maxRows = 5000;
|
||||
if (details.Count > maxRows)
|
||||
{
|
||||
throw new BusinessException($"导出结果超过 {maxRows} 条(当前 {details.Count}),请缩小筛选条件后再导出");
|
||||
}
|
||||
|
||||
var outputs = await MapCommissionOutputsAsync(agent, details);
|
||||
var rows = outputs.Select(x => new
|
||||
{
|
||||
运单号 = x.ServiceNo,
|
||||
客户编号 = x.CustomerCode,
|
||||
客户名称 = x.CustomerName,
|
||||
基数类型 = ResolveBaseTypeName(x.BaseType),
|
||||
基数金额 = x.BaseAmount,
|
||||
返佣比例 = x.CommissionRate,
|
||||
返佣金额 = x.CommissionAmount,
|
||||
状态 = AgentCommissionStatusCodes.DisplayName(x.Status),
|
||||
触发状态 = OmsDocHelper.GetOrderStatusName(x.TriggerOrderStatus) ?? x.TriggerOrderStatus,
|
||||
计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "",
|
||||
}).ToList();
|
||||
|
||||
await using var stream = new MemoryStream();
|
||||
await MiniExcel.SaveAsAsync(stream, rows);
|
||||
return new FileContentResult(
|
||||
stream.ToArray(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
{
|
||||
FileDownloadName = $"agent-commission-{DateTime.Now:yyyyMMddHHmmss}.xlsx"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<AgentCommissionDetail>> QueryCommissionDetailsAsync(
|
||||
Agent agent,
|
||||
AgentPortalCommissionListInput input)
|
||||
{
|
||||
var status = input.Status?.Trim();
|
||||
var keyword = input.Keyword?.Trim();
|
||||
var start = input.CreateTimeStart;
|
||||
var end = input.CreateTimeEnd;
|
||||
|
||||
Expression<Func<AgentCommissionDetail, bool>> predicate = x => x.AgentId == agent.Id;
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
predicate = x => x.AgentId == agent.Id && x.Status == status;
|
||||
}
|
||||
|
||||
var list = await DetailRepository.GetListAsync(predicate);
|
||||
if (start.HasValue)
|
||||
{
|
||||
list = list.Where(x => x.CreateTime >= start.Value).ToList();
|
||||
}
|
||||
|
||||
if (end.HasValue)
|
||||
{
|
||||
var endExclusive = end.Value.Date.AddDays(1);
|
||||
list = list.Where(x => x.CreateTime < endExclusive).ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var orderIds = list.Select(x => x.OrderId).Distinct().ToList();
|
||||
var orders = orderIds.Count == 0
|
||||
? []
|
||||
: await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
|
||||
var matchedOrderIds = orders
|
||||
.Where(o => o.ServiceNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|
||||
|| (o.TrackNo != null && o.TrackNo.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(o => o.Id)
|
||||
.ToHashSet();
|
||||
list = list.Where(x => matchedOrderIds.Contains(x.OrderId)).ToList();
|
||||
}
|
||||
|
||||
return list
|
||||
.OrderByDescending(x => x.CreateTime)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<AgentCommissionDetailOutput>> MapCommissionOutputsAsync(
|
||||
Agent agent,
|
||||
IReadOnlyCollection<AgentCommissionDetail> entities)
|
||||
{
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
|
||||
var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
|
||||
var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
|
||||
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
|
||||
var customerMap = customers.ToDictionary(x => x.Id);
|
||||
var orderMap = orders.ToDictionary(x => x.Id);
|
||||
|
||||
return entities.Select(x =>
|
||||
{
|
||||
customerMap.TryGetValue(x.CustomerId, out var customer);
|
||||
orderMap.TryGetValue(x.OrderId, out var order);
|
||||
return new AgentCommissionDetailOutput
|
||||
{
|
||||
Id = x.Id,
|
||||
AgentId = x.AgentId,
|
||||
AgentCode = agent.AgentCode,
|
||||
AgentName = agent.AgentName,
|
||||
OrderId = x.OrderId,
|
||||
ServiceNo = order?.ServiceNo,
|
||||
CustomerId = x.CustomerId,
|
||||
CustomerCode = customer?.CustomerCode,
|
||||
CustomerName = MaskName(customer?.CustomerName),
|
||||
SalesProductId = x.SalesProductId,
|
||||
BaseType = x.BaseType,
|
||||
BaseAmount = x.BaseAmount,
|
||||
CommissionRate = x.CommissionRate,
|
||||
CommissionAmount = x.CommissionAmount,
|
||||
Status = x.Status,
|
||||
TriggerOrderStatus = x.TriggerOrderStatus,
|
||||
PaidTime = x.PaidTime,
|
||||
Remark = x.Remark,
|
||||
CreateTime = x.CreateTime
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, (DateTime? LastOrderTime, int OrderCount30d, int PendingPaymentCount)>>
|
||||
BuildCustomerActivityMapAsync(int agentId, List<int> mainCustomerIds)
|
||||
{
|
||||
var result = mainCustomerIds.ToDictionary(
|
||||
id => id,
|
||||
_ => ((DateTime?)null, 0, 0));
|
||||
if (mainCustomerIds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var children = await CustomerRepository.GetListAsync(x =>
|
||||
x.ParentCustomerId != null && mainCustomerIds.Contains(x.ParentCustomerId.Value));
|
||||
var mainByCustomerId = new Dictionary<int, int>();
|
||||
foreach (var id in mainCustomerIds)
|
||||
{
|
||||
mainByCustomerId[id] = id;
|
||||
}
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (child.ParentCustomerId is > 0)
|
||||
{
|
||||
mainByCustomerId[child.Id] = child.ParentCustomerId.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var allIds = mainByCustomerId.Keys.ToList();
|
||||
var since30 = DateTime.Today.AddDays(-30);
|
||||
var orders = await OrderRepository.GetListAsync(x =>
|
||||
x.AgentId == agentId
|
||||
&& x.CustomerId != null
|
||||
&& allIds.Contains(x.CustomerId.Value)
|
||||
&& x.OrderStatus != OrderStatusCodes.Deleted);
|
||||
|
||||
foreach (var group in orders.GroupBy(o => mainByCustomerId[o.CustomerId!.Value]))
|
||||
{
|
||||
var list = group.ToList();
|
||||
result[group.Key] = (
|
||||
list.Max(o => (DateTime?)o.CreateTime),
|
||||
list.Count(o => o.CreateTime >= since30),
|
||||
list.Count(o => string.Equals(
|
||||
o.OrderStatus,
|
||||
OrderStatusCodes.PendingPayment,
|
||||
StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ResolveSalesProductNamesAsync(string? salesProductIds)
|
||||
{
|
||||
var ids = ParseSalesProductIds(salesProductIds);
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var products = await SalesProductRepository.GetListAsync(x => ids.Contains(x.Id));
|
||||
var map = products.ToDictionary(x => x.Id);
|
||||
return ids
|
||||
.Select(id => map.TryGetValue(id, out var p) ? $"{p.ProductName}({p.ProductCode})" : $"#{id}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<int> ParseSalesProductIds(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var text = raw.Trim();
|
||||
if (text.StartsWith('['))
|
||||
{
|
||||
try
|
||||
{
|
||||
var arr = global::System.Text.Json.JsonSerializer.Deserialize<List<int>>(text);
|
||||
return arr?.Where(x => x > 0).Distinct().ToList() ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(x => int.TryParse(x, out var id) ? id : 0)
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string ResolveBaseTypeName(string? baseType)
|
||||
{
|
||||
if (string.Equals(baseType, "receivable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "应收";
|
||||
}
|
||||
|
||||
if (string.Equals(baseType, "freight", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "运费";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(baseType) ? "运费" : baseType;
|
||||
}
|
||||
|
||||
private async Task<Agent> GetCurrentAgentOrThrowAsync()
|
||||
{
|
||||
var mapId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
|
||||
if (mapId <= 0)
|
||||
{
|
||||
throw new BusinessException("代理未登录");
|
||||
}
|
||||
|
||||
var agent = await AgentRepository.GetByUserIdAsync(mapId);
|
||||
if (agent == null)
|
||||
{
|
||||
throw new BusinessException("代理不存在");
|
||||
}
|
||||
|
||||
if (agent.Status != Domain.Shared.Enums.EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("代理已停用");
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static string? MaskPhone(string? phone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(phone) || phone.Length < 7)
|
||||
{
|
||||
return phone;
|
||||
}
|
||||
|
||||
return string.Concat(phone.AsSpan(0, 3), "****", phone.AsSpan(phone.Length - 4));
|
||||
}
|
||||
|
||||
private static string? MaskName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
var text = name.Trim();
|
||||
return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1));
|
||||
}
|
||||
}
|
||||
@ -106,8 +106,9 @@ public class KpiAreaService : ApplicationService
|
||||
await GetAreaOrThrowAsync(input.AreaId);
|
||||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize);
|
||||
var total = await KpiAreaRepository.CountInfosAsync(input.AreaId);
|
||||
var filter = BuildInfoQueryFilter(input);
|
||||
var entities = await KpiAreaRepository.GetInfosAsync(input.AreaId, pageIndex, pageSize, filter);
|
||||
var total = await KpiAreaRepository.CountInfosAsync(input.AreaId, filter);
|
||||
return new OmsPageResult<KpiAreaInfoOutput>
|
||||
{
|
||||
Items = entities.Select(MapInfoOutput).ToList(),
|
||||
@ -115,6 +116,53 @@ public class KpiAreaService : ApplicationService
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> InfoSortFieldWhitelist = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"leftPostcode",
|
||||
"rightPostcode",
|
||||
};
|
||||
|
||||
private static KpiAreaInfoQueryFilter BuildInfoQueryFilter(KpiAreaInfoListInput input) =>
|
||||
new()
|
||||
{
|
||||
Country = input.Country,
|
||||
MatchDim = input.MatchDim,
|
||||
MatchMode = input.MatchMode,
|
||||
Province = input.Province,
|
||||
City = input.City,
|
||||
LeftPostcode = input.LeftPostcode,
|
||||
RightPostcode = input.RightPostcode,
|
||||
SortField = NormalizeInfoSortField(input.SortField),
|
||||
SortOrder = NormalizeInfoSortOrder(input.SortOrder),
|
||||
};
|
||||
|
||||
private static string? NormalizeInfoSortField(string? sortField)
|
||||
{
|
||||
var field = sortField?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(field) || !InfoSortFieldWhitelist.Contains(field))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
private static string? NormalizeInfoSortOrder(string? sortOrder)
|
||||
{
|
||||
var order = sortOrder?.Trim();
|
||||
if (string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "asc";
|
||||
}
|
||||
|
||||
if (string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "desc";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增KPI分区明细。
|
||||
/// </summary>
|
||||
|
||||
@ -6,6 +6,7 @@ using Fake.Rbac.Application.Services;
|
||||
using Fake.Rbac.Infrastructure;
|
||||
using Fake.Security.Claims;
|
||||
using Fake.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Oms.Application.Dtos.Customers;
|
||||
@ -15,6 +16,7 @@ using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.MarketingAggregate.Repositories;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
|
||||
@ -36,6 +38,9 @@ public class CustomerService : ApplicationService
|
||||
private ICustomerReferralCodeRepository ReferralCodeRepository =>
|
||||
LazyServiceProvider.GetRequiredService<ICustomerReferralCodeRepository>();
|
||||
|
||||
private IAgentRepository AgentRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||
|
||||
private MarketingCouponManager CouponManager =>
|
||||
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
|
||||
@ -74,6 +79,8 @@ public class CustomerService : ApplicationService
|
||||
entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
|
||||
var memberLevelNameMap = await ResolveMemberLevelNameMapAsync(
|
||||
entities.Where(x => x.MemberLevelId.HasValue).Select(x => x.MemberLevelId!.Value));
|
||||
var agentMap = await ResolveAgentDisplayMapAsync(
|
||||
entities.Where(x => x.AgentId.HasValue).Select(x => x.AgentId!.Value));
|
||||
var items = entities.Select(entity =>
|
||||
{
|
||||
var output = ObjectMapper.Map<Customer, CustomerPaginatedOutput>(entity);
|
||||
@ -86,6 +93,7 @@ public class CustomerService : ApplicationService
|
||||
output.MemberLevelName = levelName;
|
||||
}
|
||||
|
||||
FillAgentDisplay(output, entity.AgentId, agentMap);
|
||||
return output;
|
||||
}).ToList();
|
||||
|
||||
@ -117,6 +125,12 @@ public class CustomerService : ApplicationService
|
||||
output.MemberLevelName = level?.LevelName;
|
||||
}
|
||||
|
||||
if (entity.AgentId.HasValue)
|
||||
{
|
||||
var agentMap = await ResolveAgentDisplayMapAsync([entity.AgentId.Value]);
|
||||
FillAgentDisplay(output, entity.AgentId, agentMap);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@ -215,10 +229,11 @@ public class CustomerService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增客户。
|
||||
/// 新增客户(公开注册页可匿名调用;管理端也可带 token 代开户)。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:客户创建参数。</param>
|
||||
/// <returns>无返回体,仅完成新增客户。</returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("/customer/create")]
|
||||
public async Task CreateAsync(CreateCustomerInput input)
|
||||
{
|
||||
@ -240,13 +255,18 @@ public class CustomerService : ApplicationService
|
||||
var entity = BuildCustomerEntity(input, normalized, null, userId);
|
||||
entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId);
|
||||
entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey);
|
||||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||||
entity,
|
||||
input.InviteCode,
|
||||
ReferralCodeRepository,
|
||||
CustomerRepository,
|
||||
MemberLevelRepository,
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
|
||||
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
|
||||
// 有代理编号时不走 C2C 邀请,避免双发佣金
|
||||
if (!entity.AgentId.HasValue)
|
||||
{
|
||||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||||
entity,
|
||||
input.InviteCode,
|
||||
ReferralCodeRepository,
|
||||
CustomerRepository,
|
||||
MemberLevelRepository,
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
|
||||
}
|
||||
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
||||
entity,
|
||||
input.AdvisorCode,
|
||||
@ -392,6 +412,11 @@ public class CustomerService : ApplicationService
|
||||
entity.CustomerService = NormalizeOptionalText(input.CustomerService);
|
||||
}
|
||||
|
||||
if (input.AgentCode != null)
|
||||
{
|
||||
await AgentBinder.ApplyAgentBindingAsync(entity, input.AgentCode, AgentRepository);
|
||||
}
|
||||
|
||||
if (input.CustomerGroup.HasValue)
|
||||
{
|
||||
entity.CustomerGroup = input.CustomerGroup;
|
||||
@ -491,6 +516,8 @@ public class CustomerService : ApplicationService
|
||||
var statusValue = input.Status.GetValueOrDefault();
|
||||
var hasCustomerGroupFilter = input.CustomerGroup.HasValue;
|
||||
var customerGroupValue = input.CustomerGroup.GetValueOrDefault();
|
||||
var hasAgentFilter = input.AgentId is > 0;
|
||||
var agentId = input.AgentId.GetValueOrDefault();
|
||||
|
||||
return customer =>
|
||||
(string.IsNullOrWhiteSpace(keyword) ||
|
||||
@ -501,7 +528,47 @@ public class CustomerService : ApplicationService
|
||||
(!hasStatusFilter || customer.Status == statusValue) &&
|
||||
(!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) &&
|
||||
(string.IsNullOrWhiteSpace(salesman) || customer.Salesman == salesman) &&
|
||||
(string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService);
|
||||
(string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService) &&
|
||||
(!hasAgentFilter || customer.AgentId == agentId);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, (string Code, string Name)>> ResolveAgentDisplayMapAsync(
|
||||
IEnumerable<int> agentIds)
|
||||
{
|
||||
var ids = agentIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return new Dictionary<int, (string Code, string Name)>();
|
||||
}
|
||||
|
||||
var agents = await AgentRepository.GetListAsync(x => ids.Contains(x.Id));
|
||||
return agents.ToDictionary(x => x.Id, x => (x.AgentCode, x.AgentName));
|
||||
}
|
||||
|
||||
private static void FillAgentDisplay(
|
||||
CustomerPaginatedOutput output,
|
||||
int? agentId,
|
||||
IReadOnlyDictionary<int, (string Code, string Name)> agentMap)
|
||||
{
|
||||
output.AgentId = agentId;
|
||||
if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
|
||||
{
|
||||
output.AgentCode = agent.Code;
|
||||
output.AgentName = agent.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FillAgentDisplay(
|
||||
CustomerOutput output,
|
||||
int? agentId,
|
||||
IReadOnlyDictionary<int, (string Code, string Name)> agentMap)
|
||||
{
|
||||
output.AgentId = agentId;
|
||||
if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
|
||||
{
|
||||
output.AgentCode = agent.Code;
|
||||
output.AgentName = agent.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, bool> BuildCustomerSortDictionary(CustomerPaginatedInput input)
|
||||
|
||||
@ -1208,6 +1208,12 @@ public partial class OrderService
|
||||
return;
|
||||
}
|
||||
|
||||
if (OmsSessionHelper.IsAgentLogin(CurrentUser))
|
||||
{
|
||||
await ApplyAgentOrderScopeAsync(filter);
|
||||
return;
|
||||
}
|
||||
|
||||
var userGuidRaw = CurrentUser.FindClaimValueOrNull(global::System.Security.Claims.ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userGuidRaw) || !Guid.TryParse(userGuidRaw, out var userGuid))
|
||||
{
|
||||
@ -1217,4 +1223,43 @@ public partial class OrderService
|
||||
var context = await DataScopeBuilder.BuildAsync(userGuid, DataScopeApplier.OrderResourceCode);
|
||||
DataScopeApplier.ApplyToOrderListFilter(filter, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理门户:列表按名下客户过滤(客户/主客户当前 AgentId);返佣仍认订单快照 AgentId。
|
||||
/// </summary>
|
||||
private async Task ApplyAgentOrderScopeAsync(OrderListFilter filter)
|
||||
{
|
||||
var agentRepository =
|
||||
LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>();
|
||||
var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
|
||||
if (userId <= 0)
|
||||
{
|
||||
throw new BusinessException("代理未登录");
|
||||
}
|
||||
|
||||
var agent = await agentRepository.GetByUserIdAsync(userId);
|
||||
if (agent == null)
|
||||
{
|
||||
throw new BusinessException("代理不存在");
|
||||
}
|
||||
|
||||
filter.OwnedByAgentId = agent.Id;
|
||||
// 避免与快照字段叠滤,导致换绑前历史单被挡掉
|
||||
filter.AgentId = null;
|
||||
|
||||
if (filter.CustomerId is > 0)
|
||||
{
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == filter.CustomerId.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new BusinessException("客户不存在");
|
||||
}
|
||||
|
||||
await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
|
||||
CustomerRepository,
|
||||
agentRepository,
|
||||
CurrentUser,
|
||||
customer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,6 +138,10 @@ public partial class OrderService
|
||||
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
|
||||
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
|
||||
input.CustomerId = sessionCustomer.Id;
|
||||
var mainCustomerForAgent = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
CustomerRepository,
|
||||
sessionCustomer);
|
||||
var orderAgentId = mainCustomerForAgent.AgentId;
|
||||
|
||||
await ValidateParcelCreateInputAsync(input);
|
||||
|
||||
@ -180,7 +184,11 @@ public partial class OrderService
|
||||
snapshotBinding,
|
||||
serviceNo,
|
||||
createContext);
|
||||
order.AgentId = orderAgentId;
|
||||
order = await OrderRepository.InsertAsync(order);
|
||||
await EnqueueAgentCommissionJobAsync(
|
||||
order.Id,
|
||||
Messaging.Agents.AgentCommissionJobActionCodes.Estimate);
|
||||
|
||||
var weights = BuildInitialWeightRows(order.Id, input);
|
||||
await SaveParcelChildrenAsync(order, input, weights, country);
|
||||
@ -2950,6 +2958,9 @@ public partial class OrderService
|
||||
order.PredeclareFailReason = string.Empty;
|
||||
order.UpdateTime = DateTime.Now;
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
await EnqueueAgentCommissionJobAsync(
|
||||
order.Id,
|
||||
Messaging.Agents.AgentCommissionJobActionCodes.Estimate);
|
||||
|
||||
// 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。
|
||||
// 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
|
||||
@ -3503,13 +3514,68 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
|
||||
/// 从会话解析有效客户(客户登录自动补全;管理员/代理须传 customerId;代理仅限名下客户)。
|
||||
/// </summary>
|
||||
private Task<Customer> ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) =>
|
||||
OmsSessionHelper.ResolveSessionCustomerAsync(
|
||||
private Task<Customer> ResolveEffectiveCustomerAsync(int? requestedCustomerId = null)
|
||||
{
|
||||
var agentRepository = OmsSessionHelper.IsAgentLogin(CurrentUser)
|
||||
? LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>()
|
||||
: null;
|
||||
return OmsSessionHelper.ResolveSessionCustomerAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
requestedCustomerId);
|
||||
requestedCustomerId,
|
||||
agentRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代理返佣异步投递:有事务 Outbox 则发事件;否则同步兜底(无 UoW 场景)。
|
||||
/// </summary>
|
||||
private async Task EnqueueAgentCommissionJobAsync(
|
||||
int orderId,
|
||||
string action,
|
||||
string? triggerOrderStatus = null,
|
||||
string? reason = null)
|
||||
{
|
||||
if (orderId <= 0 || string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var uowManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager))
|
||||
{
|
||||
await EventBus.PublishAsync(new Messaging.Agents.AgentCommissionJobIntegrationEvent
|
||||
{
|
||||
OrderId = orderId,
|
||||
Action = action.Trim(),
|
||||
TriggerOrderStatus = triggerOrderStatus,
|
||||
Reason = reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mgr = LazyServiceProvider.GetRequiredService<AgentCommissionManager>();
|
||||
if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await mgr.TryEstimateOnOrderCreatedAsync(orderId);
|
||||
}
|
||||
else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await mgr.TryAccrueOnDeliveryTerminalAsync(orderId, triggerOrderStatus ?? string.Empty);
|
||||
}
|
||||
else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await mgr.TryVoidOnOrderCancelledAsync(orderId, reason ?? "同步作废返佣");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 返佣失败不阻断主业务
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
|
||||
{
|
||||
|
||||
@ -297,6 +297,11 @@ public partial class OrderService : ApplicationService
|
||||
order = await GetOrderOrThrowAsync(order.Id);
|
||||
await ApplyDeliverySuccessMarketingAsync(order);
|
||||
}
|
||||
else if (string.Equals(target, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
order = await GetOrderOrThrowAsync(order.Id);
|
||||
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliveryFailed);
|
||||
}
|
||||
|
||||
order = await GetOrderOrThrowAsync(order.Id);
|
||||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
|
||||
@ -340,8 +345,16 @@ public partial class OrderService : ApplicationService
|
||||
|
||||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||||
await couponManager.OnFirstDeliverySuccessAsync(order);
|
||||
|
||||
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess);
|
||||
}
|
||||
|
||||
private Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) =>
|
||||
EnqueueAgentCommissionJobAsync(
|
||||
order.Id,
|
||||
Messaging.Agents.AgentCommissionJobActionCodes.Finalize,
|
||||
triggerOrderStatus: triggerStatus);
|
||||
|
||||
/// <summary>
|
||||
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
|
||||
/// </summary>
|
||||
@ -363,19 +376,27 @@ public partial class OrderService : ApplicationService
|
||||
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
|
||||
await commissionManager.ClawbackOnOrderRefundAsync(order);
|
||||
|
||||
if (input.IsFullRefund && order.CustomerId.HasValue)
|
||||
if (input.IsFullRefund)
|
||||
{
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
|
||||
if (customer != null)
|
||||
await EnqueueAgentCommissionJobAsync(
|
||||
order.Id,
|
||||
Messaging.Agents.AgentCommissionJobActionCodes.Void,
|
||||
reason: "订单全额退款作废返佣");
|
||||
|
||||
if (order.CustomerId.HasValue)
|
||||
{
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
CustomerRepository, customer);
|
||||
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
|
||||
await pointsManager.ClawbackOnOrderAsync(
|
||||
mainCustomer.Id,
|
||||
order.Id,
|
||||
order.ServiceNo,
|
||||
"订单退款扣回积分");
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
|
||||
if (customer != null)
|
||||
{
|
||||
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
|
||||
CustomerRepository, customer);
|
||||
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
|
||||
await pointsManager.ClawbackOnOrderAsync(
|
||||
mainCustomer.Id,
|
||||
order.Id,
|
||||
order.ServiceNo,
|
||||
"订单退款扣回积分");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -464,6 +485,10 @@ public partial class OrderService : ApplicationService
|
||||
}
|
||||
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
await EnqueueAgentCommissionJobAsync(
|
||||
order.Id,
|
||||
Messaging.Agents.AgentCommissionJobActionCodes.Void,
|
||||
reason: "订单删除作废返佣");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -691,6 +716,25 @@ public partial class OrderService : ApplicationService
|
||||
{
|
||||
input.CustomerId = await ResolvePriceCheckCustomerIdAsync();
|
||||
}
|
||||
else if (OmsSessionHelper.IsAgentLogin(CurrentUser))
|
||||
{
|
||||
// 代理代客查价:仅允许名下绑定客户
|
||||
var agentRepository =
|
||||
LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>();
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new BusinessException("客户不存在");
|
||||
}
|
||||
|
||||
await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
|
||||
CustomerRepository,
|
||||
agentRepository,
|
||||
CurrentUser,
|
||||
customer);
|
||||
input.CustomerId =
|
||||
await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(CustomerRepository, customer);
|
||||
}
|
||||
|
||||
return await BuildPriceCheckResultAsync(input);
|
||||
}
|
||||
@ -1322,6 +1366,37 @@ public partial class OrderService : ApplicationService
|
||||
|
||||
private async Task EnsureCustomerCanAccessOrderAsync(Order order)
|
||||
{
|
||||
if (OmsSessionHelper.IsAgentLogin(CurrentUser))
|
||||
{
|
||||
var agentRepository =
|
||||
LazyServiceProvider.GetRequiredService<Domain.AgentsAggregate.Repositories.IAgentRepository>();
|
||||
if (order.CustomerId is not > 0)
|
||||
{
|
||||
throw new BusinessException("订单不存在");
|
||||
}
|
||||
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new BusinessException("订单不存在");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await OmsSessionHelper.EnsureAgentOwnsCustomerAsync(
|
||||
CustomerRepository,
|
||||
agentRepository,
|
||||
CurrentUser,
|
||||
customer);
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
throw new BusinessException("订单不存在");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
|
||||
72
src/Oms.Application/Shared/AgentBinder.cs
Normal file
72
src/Oms.Application/Shared/AgentBinder.cs
Normal file
@ -0,0 +1,72 @@
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 注册时绑定渠道代理。与 C2C 邀请互斥:有 agent 时由调用方跳过邀请绑定。
|
||||
/// </summary>
|
||||
public static class AgentBinder
|
||||
{
|
||||
public static async Task BindAgentOnCreateAsync(
|
||||
Customer entity,
|
||||
string? agentCode,
|
||||
IAgentRepository agentRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (entity.AgentId.HasValue && entity.AgentId.Value > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var code = agentCode?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
|
||||
if (agent == null || agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("代理编号无效或已停用");
|
||||
}
|
||||
|
||||
entity.AgentId = agent.Id;
|
||||
entity.AgentBindTime = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 运营改绑:按代理编号覆盖;空串解绑。子客户不可改(跟主客户)。
|
||||
/// </summary>
|
||||
public static async Task ApplyAgentBindingAsync(
|
||||
Customer entity,
|
||||
string? agentCode,
|
||||
IAgentRepository agentRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (entity.ParentCustomerId.HasValue)
|
||||
{
|
||||
throw new BusinessException("请改绑主客户");
|
||||
}
|
||||
|
||||
var code = agentCode?.Trim();
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
entity.AgentId = null;
|
||||
entity.AgentBindTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
|
||||
if (agent == null || agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("代理编号无效或已停用");
|
||||
}
|
||||
|
||||
entity.AgentId = agent.Id;
|
||||
entity.AgentBindTime = DateTime.Now;
|
||||
}
|
||||
}
|
||||
424
src/Oms.Application/Shared/AgentCommissionManager.cs
Normal file
424
src/Oms.Application/Shared/AgentCommissionManager.cs
Normal file
@ -0,0 +1,424 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Oms.Domain.AgentsAggregate.Consts;
|
||||
using Oms.Domain.AgentsAggregate.Entities;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理返佣:下单写预计 → 派送终态锁定待结算 → 取消/退款作废;不入余额。
|
||||
/// </summary>
|
||||
public class AgentCommissionManager : IScopedDependency
|
||||
{
|
||||
private readonly IAgentRepository _agentRepository;
|
||||
private readonly IAgentCommissionDetailRepository _detailRepository;
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly ISalesProductRepository _salesProductRepository;
|
||||
|
||||
public AgentCommissionManager(
|
||||
IAgentRepository agentRepository,
|
||||
IAgentCommissionDetailRepository detailRepository,
|
||||
IOrderRepository orderRepository,
|
||||
ISalesProductRepository salesProductRepository)
|
||||
{
|
||||
_agentRepository = agentRepository;
|
||||
_detailRepository = detailRepository;
|
||||
_orderRepository = orderRepository;
|
||||
_salesProductRepository = salesProductRepository;
|
||||
}
|
||||
|
||||
/// <summary>下单后写入/刷新预计返佣(仅 estimated;已锁定/已打款/已作废不改)。</summary>
|
||||
public async Task TryEstimateOnOrderCreatedAsync(
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
|
||||
if (order == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await UpsertEstimatedAsync(order, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>预报计费完成后刷新预计金额(仍为 estimated)。</summary>
|
||||
public Task TryRefreshEstimateAfterBillingAsync(
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
TryEstimateOnOrderCreatedAsync(orderId, cancellationToken);
|
||||
|
||||
/// <summary>派送成功/失败:重算锁定为 pending。</summary>
|
||||
public async Task TryAccrueOnDeliveryTerminalAsync(
|
||||
int orderId,
|
||||
string triggerOrderStatus,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsTerminalTrigger(triggerOrderStatus))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
|
||||
if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.IsRefunded > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
|
||||
if (existing != null
|
||||
&& (string.Equals(existing.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(existing.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// pending 已锁定;paid 不回写
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing != null
|
||||
&& string.Equals(existing.Status, AgentCommissionStatusCodes.Void, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken);
|
||||
if (agent == null || agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var built = await TryBuildAmountsAsync(agent, order, cancellationToken);
|
||||
if (built == null)
|
||||
{
|
||||
// 终态仍算不出金额:若已有预计行则作废,避免脏数据进应结
|
||||
if (existing != null
|
||||
&& string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
existing.Status = AgentCommissionStatusCodes.Void;
|
||||
existing.TriggerOrderStatus = triggerOrderStatus.Trim();
|
||||
existing.Remark = AppendRemark(existing.Remark, "终态基数为0,自动作废");
|
||||
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount);
|
||||
existing.Status = AgentCommissionStatusCodes.Pending;
|
||||
existing.TriggerOrderStatus = triggerOrderStatus.Trim();
|
||||
existing.Remark = AppendRemark(existing.Remark, "派送终态锁定");
|
||||
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// 并发双写时靠二次查重;无库级唯一索引
|
||||
existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount);
|
||||
existing.Status = AgentCommissionStatusCodes.Pending;
|
||||
existing.TriggerOrderStatus = triggerOrderStatus.Trim();
|
||||
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await _detailRepository.InsertAsync(new AgentCommissionDetail
|
||||
{
|
||||
AgentId = agent.Id,
|
||||
OrderId = order.Id,
|
||||
CustomerId = order.CustomerId.Value,
|
||||
SalesProductId = built.Value.SalesProductId,
|
||||
BaseType = agent.BaseType.Trim().ToLowerInvariant(),
|
||||
BaseAmount = built.Value.BaseAmount,
|
||||
CommissionRate = agent.CommissionRate,
|
||||
CommissionAmount = built.Value.Amount,
|
||||
Status = AgentCommissionStatusCodes.Pending,
|
||||
TriggerOrderStatus = triggerOrderStatus.Trim()
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>订单删除 / 全额退款:预计或待结算作废;已打款不动。</summary>
|
||||
public async Task TryVoidOnOrderCancelledAsync(
|
||||
int orderId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
|
||||
if (existing == null || !AgentCommissionStatusCodes.IsOpen(existing.Status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
existing.Status = AgentCommissionStatusCodes.Void;
|
||||
existing.Remark = AppendRemark(existing.Remark, reason);
|
||||
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task UpsertEstimatedAsync(Order order, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.IsRefunded > 0
|
||||
|| string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken);
|
||||
if (existing != null
|
||||
&& !string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken);
|
||||
if (agent == null || agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var built = await TryBuildAmountsAsync(agent, order, cancellationToken);
|
||||
int? salesProductId;
|
||||
decimal baseAmount;
|
||||
decimal amount;
|
||||
if (built == null)
|
||||
{
|
||||
// 计费未出前也落一条预计(金额 0),方便代理看到「已关联」
|
||||
if (agent.CommissionRate <= 0 || !AgentCommissionBaseTypes.IsSupported(agent.BaseType))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int? productId = null;
|
||||
if (!string.IsNullOrWhiteSpace(order.ProductCode))
|
||||
{
|
||||
var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||||
productId = product?.Id;
|
||||
}
|
||||
|
||||
if (!IsProductAllowed(agent.SalesProductIds, productId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
salesProductId = productId;
|
||||
baseAmount = 0M;
|
||||
amount = 0M;
|
||||
}
|
||||
else
|
||||
{
|
||||
salesProductId = built.Value.SalesProductId;
|
||||
baseAmount = built.Value.BaseAmount;
|
||||
amount = built.Value.Amount;
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
ApplyAmounts(existing, agent, salesProductId, baseAmount, amount);
|
||||
existing.Status = AgentCommissionStatusCodes.Estimated;
|
||||
if (string.IsNullOrWhiteSpace(existing.TriggerOrderStatus))
|
||||
{
|
||||
existing.TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ApplyAmounts(existing, agent, salesProductId, baseAmount, amount);
|
||||
await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await _detailRepository.InsertAsync(new AgentCommissionDetail
|
||||
{
|
||||
AgentId = agent.Id,
|
||||
OrderId = order.Id,
|
||||
CustomerId = order.CustomerId.Value,
|
||||
SalesProductId = salesProductId,
|
||||
BaseType = agent.BaseType.Trim().ToLowerInvariant(),
|
||||
BaseAmount = baseAmount,
|
||||
CommissionRate = agent.CommissionRate,
|
||||
CommissionAmount = amount,
|
||||
Status = AgentCommissionStatusCodes.Estimated,
|
||||
TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<(int? SalesProductId, decimal BaseAmount, decimal Amount)?> TryBuildAmountsAsync(
|
||||
Agent agent,
|
||||
Order order,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType) || agent.CommissionRate <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int? salesProductId = null;
|
||||
if (!string.IsNullOrWhiteSpace(order.ProductCode))
|
||||
{
|
||||
var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||||
salesProductId = product?.Id;
|
||||
}
|
||||
|
||||
if (!IsProductAllowed(agent.SalesProductIds, salesProductId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||||
var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order);
|
||||
if (baseAmount <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero);
|
||||
if (amount <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (salesProductId, baseAmount, amount);
|
||||
}
|
||||
|
||||
private static void ApplyAmounts(
|
||||
AgentCommissionDetail detail,
|
||||
Agent agent,
|
||||
int? salesProductId,
|
||||
decimal baseAmount,
|
||||
decimal amount)
|
||||
{
|
||||
detail.SalesProductId = salesProductId;
|
||||
detail.BaseType = agent.BaseType.Trim().ToLowerInvariant();
|
||||
detail.BaseAmount = baseAmount;
|
||||
detail.CommissionRate = agent.CommissionRate;
|
||||
detail.CommissionAmount = amount;
|
||||
}
|
||||
|
||||
private static string? AppendRemark(string? existing, string note)
|
||||
{
|
||||
var n = note?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(n))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
|
||||
if (existing.Contains(n, StringComparison.Ordinal))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
return $"{existing}; {n}";
|
||||
}
|
||||
|
||||
private static bool IsTerminalTrigger(string? status) =>
|
||||
string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static decimal ResolveBaseAmount(
|
||||
string baseType,
|
||||
List<OrderFee> fees,
|
||||
Order order)
|
||||
{
|
||||
if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MarketingFeeHelper.ResolvePaidBaseFreight(
|
||||
MarketingFeeHelper.SumReceivableBaseFreight(fees),
|
||||
order.LevelDiscountAmount,
|
||||
order.CouponDiscountAmount);
|
||||
}
|
||||
|
||||
var sum = MarketingFeeHelper.SumReceivable(fees);
|
||||
if (sum > 0)
|
||||
{
|
||||
return sum;
|
||||
}
|
||||
|
||||
return Math.Max(0M, order.ReceivableSubtotal ?? 0M);
|
||||
}
|
||||
|
||||
internal static bool IsProductAllowed(string? salesProductIdsRaw, int? salesProductId)
|
||||
{
|
||||
var allowed = ParseProductIds(salesProductIdsRaw);
|
||||
if (allowed.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return salesProductId.HasValue && allowed.Contains(salesProductId.Value);
|
||||
}
|
||||
|
||||
internal static HashSet<int> ParseProductIds(string? raw)
|
||||
{
|
||||
var set = new HashSet<int>();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return set;
|
||||
}
|
||||
|
||||
var text = raw.Trim();
|
||||
try
|
||||
{
|
||||
if (text.StartsWith('['))
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<int>>(text);
|
||||
if (list != null)
|
||||
{
|
||||
foreach (var id in list.Where(x => x > 0))
|
||||
{
|
||||
set.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// fall through to comma split
|
||||
}
|
||||
|
||||
foreach (var part in text.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) && id > 0)
|
||||
{
|
||||
set.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using Fake.Application;
|
||||
using Fake.Users;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 从 JWT Claims 解析当前会话类型(管理员 / 客户)。
|
||||
/// 从 JWT Claims 解析当前会话类型(管理员 / 客户 / 渠道代理)。
|
||||
/// </summary>
|
||||
public static class OmsSessionHelper
|
||||
{
|
||||
@ -22,7 +23,9 @@ public static class OmsSessionHelper
|
||||
public const string MapIdClaim = "mapId";
|
||||
|
||||
private const string CustomerLoginType = "1";
|
||||
private const string AgentLoginType = "2";
|
||||
private const string CustomerRoleName = "Customer";
|
||||
private const string AgentRoleName = "Agent";
|
||||
|
||||
/// <summary>
|
||||
/// 当前会话是否为客户门户登录。
|
||||
@ -43,6 +46,24 @@ public static class OmsSessionHelper
|
||||
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前会话是否为渠道代理门户登录。
|
||||
/// </summary>
|
||||
public static bool IsAgentLogin(ICurrentUser currentUser)
|
||||
{
|
||||
if (string.Equals(
|
||||
currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
|
||||
AgentLoginType,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var role = currentUser.FindClaimOrNull(ClaimTypes.Role)?.Value
|
||||
?? currentUser.FindClaimOrNull("role")?.Value;
|
||||
return string.Equals(role, AgentRoleName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析当前会话的 RBAC mapId(user.userid)。
|
||||
/// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。
|
||||
@ -85,12 +106,14 @@ public static class OmsSessionHelper
|
||||
TryGetCustomerMapId(currentUser);
|
||||
|
||||
/// <summary>
|
||||
/// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员可按 CustomerId 代提交)。
|
||||
/// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员/代理可按 CustomerId 代提交)。
|
||||
/// 代理须传入 <paramref name="agentRepository"/>,且仅允许操作名下绑定客户。
|
||||
/// </summary>
|
||||
public static async Task<Customer> ResolveSessionCustomerAsync(
|
||||
ICustomerRepository customerRepository,
|
||||
ICurrentUser currentUser,
|
||||
int? requestedCustomerId = null)
|
||||
int? requestedCustomerId = null,
|
||||
IAgentRepository? agentRepository = null)
|
||||
{
|
||||
var mapId = TryGetCustomerMapId(currentUser);
|
||||
if (mapId.HasValue)
|
||||
@ -121,9 +144,53 @@ public static class OmsSessionHelper
|
||||
throw new BusinessException("客户不存在");
|
||||
}
|
||||
|
||||
if (IsAgentLogin(currentUser))
|
||||
{
|
||||
await EnsureAgentOwnsCustomerAsync(customerRepository, agentRepository, currentUser, customer);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理仅可操作名下绑定客户(主客户 AgentId=本代理;子客户跟主客户)。
|
||||
/// </summary>
|
||||
public static async Task EnsureAgentOwnsCustomerAsync(
|
||||
ICustomerRepository customerRepository,
|
||||
IAgentRepository? agentRepository,
|
||||
ICurrentUser currentUser,
|
||||
Customer customer)
|
||||
{
|
||||
if (agentRepository == null)
|
||||
{
|
||||
throw new BusinessException("代理会话缺少代理仓储");
|
||||
}
|
||||
|
||||
var userId = ResolveMapUserId(currentUser);
|
||||
if (userId <= 0)
|
||||
{
|
||||
throw new BusinessException("代理未登录");
|
||||
}
|
||||
|
||||
var agent = await agentRepository.GetByUserIdAsync(userId);
|
||||
if (agent == null)
|
||||
{
|
||||
throw new BusinessException("代理不存在");
|
||||
}
|
||||
|
||||
int? bindAgentId = customer.AgentId;
|
||||
if (customer.ParentCustomerId is > 0)
|
||||
{
|
||||
var main = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value);
|
||||
bindAgentId = main?.AgentId;
|
||||
}
|
||||
|
||||
if (bindAgentId != agent.Id)
|
||||
{
|
||||
throw new BusinessException("只能为名下绑定客户操作");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 客户登录时强制返回 t_customer.Id;管理员返回入参 customerId。
|
||||
/// </summary>
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
namespace Oms.Domain.AgentsAggregate.Consts;
|
||||
|
||||
/// <summary>代理返佣基数类型(首版仅运费/应收)。</summary>
|
||||
public static class AgentCommissionBaseTypes
|
||||
{
|
||||
public const string Freight = "freight";
|
||||
public const string Receivable = "receivable";
|
||||
|
||||
public static bool IsSupported(string? baseType) =>
|
||||
string.Equals(baseType, Freight, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(baseType, Receivable, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
namespace Oms.Domain.AgentsAggregate.Consts;
|
||||
|
||||
/// <summary>代理返佣明细结算状态。</summary>
|
||||
public static class AgentCommissionStatusCodes
|
||||
{
|
||||
/// <summary>下单后预计返佣(金额可随计费刷新,不计入应打款)。</summary>
|
||||
public const string Estimated = "estimated";
|
||||
|
||||
/// <summary>派送终态锁定,待运营线下打款。</summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>已打款。</summary>
|
||||
public const string Paid = "paid";
|
||||
|
||||
/// <summary>订单取消/全额退等作废。</summary>
|
||||
public const string Void = "void";
|
||||
|
||||
public static bool IsOpen(string? status) =>
|
||||
string.Equals(status, Estimated, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status, Pending, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static string DisplayName(string? status) =>
|
||||
status?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
Estimated => "预计返佣",
|
||||
Pending => "待结算",
|
||||
Paid => "已打款",
|
||||
Void => "已作废",
|
||||
_ => string.IsNullOrWhiteSpace(status) ? "--" : status
|
||||
};
|
||||
}
|
||||
45
src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
Normal file
45
src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using Oms.Domain.Shared;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Oms.Domain.AgentsAggregate.Entities;
|
||||
|
||||
/// <summary>渠道代理主数据。</summary>
|
||||
[SugarTable("t_agent")]
|
||||
public class Agent : OmsAuditedAggregateRoot
|
||||
{
|
||||
/// <summary>RBAC user.userid / JWT mapId。</summary>
|
||||
public int UserId { get; set; }
|
||||
|
||||
/// <summary>登录账号 + 注册链接编码。</summary>
|
||||
[SugarColumn(Length = 32)]
|
||||
public string AgentCode { get; set; } = string.Empty;
|
||||
|
||||
[SugarColumn(Length = 64)]
|
||||
public string AgentName { get; set; } = string.Empty;
|
||||
|
||||
[SugarColumn(Length = 32, IsNullable = true)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[SugarColumn(Length = 128, IsNullable = true)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>MD5 密码(对齐客户)。</summary>
|
||||
[SugarColumn(Length = 64)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
|
||||
|
||||
/// <summary>freight / receivable。</summary>
|
||||
[SugarColumn(Length = 32)]
|
||||
public string BaseType { get; set; } = "freight";
|
||||
|
||||
public decimal CommissionRate { get; set; }
|
||||
|
||||
/// <summary>可选销售产品 Id 列表(JSON 或逗号);空=不限产品。</summary>
|
||||
[SugarColumn(Length = 512, IsNullable = true)]
|
||||
public string? SalesProductIds { get; set; }
|
||||
|
||||
[SugarColumn(Length = 512, IsNullable = true)]
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
using Oms.Domain.Shared;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Oms.Domain.AgentsAggregate.Entities;
|
||||
|
||||
/// <summary>代理返佣明细(首版无余额入账)。</summary>
|
||||
[SugarTable("t_agent_commission_detail")]
|
||||
public class AgentCommissionDetail : OmsAuditedAggregateRoot
|
||||
{
|
||||
public int AgentId { get; set; }
|
||||
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public int? SalesProductId { get; set; }
|
||||
|
||||
[SugarColumn(Length = 32)]
|
||||
public string BaseType { get; set; } = string.Empty;
|
||||
|
||||
public decimal BaseAmount { get; set; }
|
||||
|
||||
public decimal CommissionRate { get; set; }
|
||||
|
||||
public decimal CommissionAmount { get; set; }
|
||||
|
||||
/// <summary>estimated / pending / paid / void。</summary>
|
||||
[SugarColumn(Length = 16)]
|
||||
public string Status { get; set; } = "estimated";
|
||||
|
||||
[SugarColumn(Length = 32)]
|
||||
public string TriggerOrderStatus { get; set; } = string.Empty;
|
||||
|
||||
public DateTime? PaidTime { get; set; }
|
||||
|
||||
[SugarColumn(Length = 512, IsNullable = true)]
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.AgentsAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.AgentsAggregate.Repositories;
|
||||
|
||||
public interface IAgentRepository : IRepository<Agent>
|
||||
{
|
||||
Task<Agent?> GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Agent?> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsAgentCodeAsync(string agentCode, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IAgentCommissionDetailRepository : IRepository<AgentCommissionDetail>
|
||||
{
|
||||
Task<AgentCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -3,15 +3,43 @@ using Oms.Domain.ChannelsAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.ChannelsAggregate.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// KPI 分区明细列表筛选条件(字段均为可选,模糊匹配)。
|
||||
/// </summary>
|
||||
public class KpiAreaInfoQueryFilter
|
||||
{
|
||||
public string? Country { get; set; }
|
||||
public string? MatchDim { get; set; }
|
||||
public string? MatchMode { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? LeftPostcode { get; set; }
|
||||
public string? RightPostcode { get; set; }
|
||||
|
||||
/// <summary>排序字段:leftPostcode / rightPostcode。</summary>
|
||||
public string? SortField { get; set; }
|
||||
|
||||
/// <summary>排序方向:asc / desc。</summary>
|
||||
public string? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public interface IKpiAreaRepository : IRepository<KpiArea>, IKpiAreaPublishRepository
|
||||
{
|
||||
Task<KpiArea?> GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<KpiAreaInfo>> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default);
|
||||
Task<List<KpiAreaInfo>> GetInfosAsync(
|
||||
int areaId,
|
||||
int pageIndex,
|
||||
int pageSize,
|
||||
KpiAreaInfoQueryFilter? filter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountInfosAsync(int areaId, CancellationToken cancellationToken = default);
|
||||
Task<int> CountInfosAsync(
|
||||
int areaId,
|
||||
KpiAreaInfoQueryFilter? filter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<KpiAreaInfo> InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot
|
||||
|
||||
public decimal CommissionBalance { get; set; }
|
||||
|
||||
/// <summary>所属渠道代理(建议写在主客户上)。</summary>
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
public DateTime? AgentBindTime { get; set; }
|
||||
|
||||
public string? LastLoginIp { get; set; }
|
||||
|
||||
public DateTime? LastLoginTime { get; set; }
|
||||
|
||||
@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
/// <summary>下单时代理快照(跟主客户 AgentId)。</summary>
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
public int? MemberLevelId { get; set; }
|
||||
|
||||
public string? PriceTierKey { get; set; }
|
||||
|
||||
@ -34,6 +34,16 @@ public class OrderListFilter
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 按下单快照 Order.AgentId 过滤(返佣归属;历史单换绑后不变)。
|
||||
/// </summary>
|
||||
public int? AgentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 按名下客户可见:订单客户(或主客户)当前 AgentId = 此值。代理门户运单列表用此字段。
|
||||
/// </summary>
|
||||
public int? OwnedByAgentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发货仓 ID(订单或绑定行所属仓)。
|
||||
/// </summary>
|
||||
|
||||
37
src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
Normal file
37
src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Oms.Domain.AgentsAggregate.Entities;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.SqlSugarCore.Repositories;
|
||||
|
||||
namespace Oms.SqlSugarCore.Agents;
|
||||
|
||||
public class AgentRepository : OmsSqlSugarRepository<Agent>, IAgentRepository, IScopedDependency
|
||||
{
|
||||
public Task<Agent?> GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default)
|
||||
=> FirstOrDefaultAsync(x => x.AgentCode == agentCode, cancellationToken);
|
||||
|
||||
public Task<Agent?> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default)
|
||||
=> FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||
|
||||
public async Task<bool> ExistsAgentCodeAsync(
|
||||
string agentCode,
|
||||
int? excludeId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (excludeId.HasValue)
|
||||
{
|
||||
return await AnyAsync(x => x.AgentCode == agentCode && x.Id != excludeId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
return await AnyAsync(x => x.AgentCode == agentCode, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class AgentCommissionDetailRepository
|
||||
: OmsSqlSugarRepository<AgentCommissionDetail>,
|
||||
IAgentCommissionDetailRepository,
|
||||
IScopedDependency
|
||||
{
|
||||
public Task<AgentCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
|
||||
=> FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
|
||||
}
|
||||
@ -18,20 +18,110 @@ public class KpiAreaRepository : OmsSqlSugarRepository<KpiArea>, IKpiAreaReposit
|
||||
return AnyAsync(x => x.AppKey == appKey && x.AreaType == areaType && x.Name == name, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<KpiAreaInfo>> GetInfosAsync(int areaId, int pageIndex, int pageSize, CancellationToken cancellationToken = default)
|
||||
public async Task<List<KpiAreaInfo>> GetInfosAsync(
|
||||
int areaId,
|
||||
int pageIndex,
|
||||
int pageSize,
|
||||
KpiAreaInfoQueryFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
// 依赖 idx_kpi_info_area_id (AreaId, Id),避免大分区 filesort
|
||||
return await db.Queryable<KpiAreaInfo>()
|
||||
.Where(x => x.AreaId == areaId)
|
||||
.OrderBy(x => x.Id, OrderByType.Desc)
|
||||
// 默认按 Id 降序,可走 idx_kpi_info_area_id;邮编排序可能 filesort,分区过大时注意性能
|
||||
return await ApplyInfoOrder(BuildInfoQueryable(db, areaId, filter), filter)
|
||||
.ToPageListAsync(pageIndex, pageSize, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> CountInfosAsync(int areaId, CancellationToken cancellationToken = default)
|
||||
public async Task<int> CountInfosAsync(
|
||||
int areaId,
|
||||
KpiAreaInfoQueryFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
return await db.Queryable<KpiAreaInfo>().Where(x => x.AreaId == areaId).CountAsync(cancellationToken);
|
||||
return await BuildInfoQueryable(db, areaId, filter).CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static ISugarQueryable<KpiAreaInfo> BuildInfoQueryable(
|
||||
ISqlSugarClient db,
|
||||
int areaId,
|
||||
KpiAreaInfoQueryFilter? filter)
|
||||
{
|
||||
var query = db.Queryable<KpiAreaInfo>().Where(x => x.AreaId == areaId);
|
||||
if (filter == null)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var country = TrimOrNull(filter.Country);
|
||||
if (country != null)
|
||||
{
|
||||
query = query.Where(x =>
|
||||
(x.CountryName != null && x.CountryName.Contains(country))
|
||||
|| (x.CountryCode != null && x.CountryCode.Contains(country)));
|
||||
}
|
||||
|
||||
var matchDim = TrimOrNull(filter.MatchDim);
|
||||
if (matchDim != null)
|
||||
{
|
||||
query = query.Where(x => x.MatchDim != null && x.MatchDim.Contains(matchDim));
|
||||
}
|
||||
|
||||
var matchMode = TrimOrNull(filter.MatchMode);
|
||||
if (matchMode != null)
|
||||
{
|
||||
query = query.Where(x => x.MatchMode != null && x.MatchMode.Contains(matchMode));
|
||||
}
|
||||
|
||||
var province = TrimOrNull(filter.Province);
|
||||
if (province != null)
|
||||
{
|
||||
query = query.Where(x => x.Province != null && x.Province.Contains(province));
|
||||
}
|
||||
|
||||
var city = TrimOrNull(filter.City);
|
||||
if (city != null)
|
||||
{
|
||||
query = query.Where(x => x.City != null && x.City.Contains(city));
|
||||
}
|
||||
|
||||
var leftPostcode = TrimOrNull(filter.LeftPostcode);
|
||||
if (leftPostcode != null)
|
||||
{
|
||||
query = query.Where(x => x.LeftPostcode != null && x.LeftPostcode.Contains(leftPostcode));
|
||||
}
|
||||
|
||||
var rightPostcode = TrimOrNull(filter.RightPostcode);
|
||||
if (rightPostcode != null)
|
||||
{
|
||||
query = query.Where(x => x.RightPostcode != null && x.RightPostcode.Contains(rightPostcode));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static ISugarQueryable<KpiAreaInfo> ApplyInfoOrder(
|
||||
ISugarQueryable<KpiAreaInfo> query,
|
||||
KpiAreaInfoQueryFilter? filter)
|
||||
{
|
||||
var field = filter?.SortField?.Trim().ToLowerInvariant();
|
||||
var asc = string.Equals(filter?.SortOrder?.Trim(), "asc", StringComparison.OrdinalIgnoreCase);
|
||||
var order = asc ? OrderByType.Asc : OrderByType.Desc;
|
||||
|
||||
return field switch
|
||||
{
|
||||
"leftpostcode" => query
|
||||
.OrderBy(x => x.LeftPostcode, order)
|
||||
.OrderBy(x => x.Id, OrderByType.Desc),
|
||||
"rightpostcode" => query
|
||||
.OrderBy(x => x.RightPostcode, order)
|
||||
.OrderBy(x => x.Id, OrderByType.Desc),
|
||||
_ => query.OrderBy(x => x.Id, OrderByType.Desc),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
public async Task<KpiAreaInfo> InsertInfoAsync(KpiAreaInfo info, CancellationToken cancellationToken = default)
|
||||
|
||||
@ -47,6 +47,29 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
||||
query = query.Where(x => x.CustomerId == customerId);
|
||||
}
|
||||
|
||||
if (filter.AgentId.HasValue)
|
||||
{
|
||||
var agentId = filter.AgentId.Value;
|
||||
query = query.Where(x => x.AgentId == agentId);
|
||||
}
|
||||
|
||||
// 名下客户可见:本客户 AgentId,或子客户的主客户 AgentId
|
||||
if (filter.OwnedByAgentId.HasValue)
|
||||
{
|
||||
var ownedByAgentId = filter.OwnedByAgentId.Value;
|
||||
query = query.Where(o =>
|
||||
SqlFunc.Subqueryable<Customer>()
|
||||
.Where(c => c.Id == o.CustomerId && c.AgentId == ownedByAgentId)
|
||||
.Any()
|
||||
|| SqlFunc.Subqueryable<Customer>()
|
||||
.Where(c => c.Id == o.CustomerId
|
||||
&& c.ParentCustomerId != null
|
||||
&& SqlFunc.Subqueryable<Customer>()
|
||||
.Where(m => m.Id == c.ParentCustomerId && m.AgentId == ownedByAgentId)
|
||||
.Any())
|
||||
.Any());
|
||||
}
|
||||
|
||||
var status = filter.Status?.Trim();
|
||||
var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase);
|
||||
if (isDeletedTab)
|
||||
@ -1503,6 +1526,8 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
||||
CreateTimeStart = filter.CreateTimeStart,
|
||||
CreateTimeEnd = filter.CreateTimeEnd,
|
||||
CustomerId = filter.CustomerId,
|
||||
AgentId = filter.AgentId,
|
||||
OwnedByAgentId = filter.OwnedByAgentId,
|
||||
FieldFilters = filter.FieldFilters,
|
||||
SortField = filter.SortField,
|
||||
SortOrder = filter.SortOrder,
|
||||
@ -1549,6 +1574,8 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
||||
CreateTimeStart = filter.CreateTimeStart,
|
||||
CreateTimeEnd = filter.CreateTimeEnd,
|
||||
CustomerId = filter.CustomerId,
|
||||
AgentId = filter.AgentId,
|
||||
OwnedByAgentId = filter.OwnedByAgentId,
|
||||
WarehouseId = filter.WarehouseId,
|
||||
FulfillmentType = filter.FulfillmentType,
|
||||
StockBindStatus = filter.StockBindStatus,
|
||||
|
||||
32
src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
Normal file
32
src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Fake.Data.Seeding;
|
||||
using Fake.Rbac.Application.Dtos.Role;
|
||||
using Fake.Rbac.Application.Services;
|
||||
|
||||
namespace SimpleAdmin.Api.Auth;
|
||||
|
||||
public class AgentRoleSeedContributor(RoleService roleService) : IDataSeedContributor
|
||||
{
|
||||
public const string AgentRoleCode = "Agent";
|
||||
public const string AgentRoleName = "渠道代理";
|
||||
|
||||
public async Task SeedAsync()
|
||||
{
|
||||
var roles = await roleService.GetAllRolesAsync();
|
||||
if (roles.Any(x => string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await roleService.CreateAsync(new RoleCreateDto
|
||||
{
|
||||
Code = AgentRoleCode,
|
||||
Name = AgentRoleName,
|
||||
Permissions =
|
||||
[
|
||||
"Agent.Portal",
|
||||
"Agent.Customers.Query",
|
||||
"Agent.Orders.Query"
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
|
||||
public enum OmsLoginType
|
||||
{
|
||||
User = 0,
|
||||
Customer = 1
|
||||
Customer = 1,
|
||||
Agent = 2
|
||||
}
|
||||
|
||||
public class OmsLoginInput
|
||||
|
||||
@ -21,9 +21,11 @@ using Fake.ObjectStorage;
|
||||
using Oms.Application;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Application.Storage;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.Shared.Enums;
|
||||
using SimpleAdmin.Api.WeChat.Dtos;
|
||||
using SimpleAdmin.Api.WeChat.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
@ -68,6 +70,7 @@ public partial class AuthService(
|
||||
{
|
||||
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
|
||||
OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken),
|
||||
OmsLoginType.Agent => await LoginAgentAsync(input.Account, input.Password, cancellationToken),
|
||||
_ => throw new BusinessException("不支持的登录类型")
|
||||
};
|
||||
}
|
||||
@ -284,6 +287,11 @@ public partial class AuthService(
|
||||
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
|
||||
}
|
||||
|
||||
if (loginType == OmsLoginType.Agent)
|
||||
{
|
||||
return await RefreshAgentTokenAsync(token.Claims, cancellationToken);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(userIdText, out var userId))
|
||||
{
|
||||
throw new BusinessException("无效的刷新令牌");
|
||||
@ -438,6 +446,60 @@ public partial class AuthService(
|
||||
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<OmsLoginResultDto> LoginAgentAsync(string account, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var agentRepository = LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||
var code = account.Trim();
|
||||
var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
|
||||
if (agent == null)
|
||||
{
|
||||
throw new BusinessException("代理账号或密码错误");
|
||||
}
|
||||
|
||||
if (agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("代理已停用");
|
||||
}
|
||||
|
||||
if (!string.Equals(agent.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BusinessException("代理账号或密码错误");
|
||||
}
|
||||
|
||||
var userGuid = await GetRbacUserGuidByMapIdAsync(agent.UserId, cancellationToken);
|
||||
if (!userGuid.HasValue)
|
||||
{
|
||||
throw new BusinessException("代理未关联用户");
|
||||
}
|
||||
|
||||
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Agent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<OmsLoginResultDto> RefreshAgentTokenAsync(IEnumerable<Claim> claims, CancellationToken cancellationToken)
|
||||
{
|
||||
var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId);
|
||||
if (mapId <= 0)
|
||||
{
|
||||
throw new BusinessException("无效的刷新令牌");
|
||||
}
|
||||
|
||||
var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken);
|
||||
if (!userGuid.HasValue)
|
||||
{
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
|
||||
var agentRepository = LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||||
var agent = await agentRepository.GetByUserIdAsync(mapId, cancellationToken);
|
||||
if (agent == null || agent.Status != EnableStatus.Enabled)
|
||||
{
|
||||
throw new BusinessException("代理不存在或已停用");
|
||||
}
|
||||
|
||||
var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Agent);
|
||||
return BuildTokenOnlyResult(userGuid.Value, refreshedClaims);
|
||||
}
|
||||
|
||||
private async Task<OmsLoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedAccount = account.Trim();
|
||||
|
||||
@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput
|
||||
/// </summary>
|
||||
public string? AdvisorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。
|
||||
/// </summary>
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
[Required]
|
||||
public WeChatProfileInput Wechat { get; set; } = new();
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
using Oms.Domain.CustomersAggregate.Repositories;
|
||||
using Oms.Domain.AgentsAggregate.Repositories;
|
||||
using Oms.Domain.MarketingAggregate.Repositories;
|
||||
using SimpleAdmin.Api.WeChat.Dtos;
|
||||
using SimpleAdmin.Api.WeChat.Services;
|
||||
@ -30,6 +31,7 @@ public class WeChatCustomerService(
|
||||
ICustomerRepository customerRepository,
|
||||
ICustomerReferralCodeRepository referralCodeRepository,
|
||||
IMemberLevelRepository memberLevelRepository,
|
||||
IAgentRepository agentRepository,
|
||||
MarketingCouponManager couponManager,
|
||||
UserService userService,
|
||||
RoleService roleService,
|
||||
@ -156,17 +158,21 @@ public class WeChatCustomerService(
|
||||
cancellationToken),
|
||||
PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey
|
||||
};
|
||||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||||
entity,
|
||||
input.InviteCode,
|
||||
referralCodeRepository,
|
||||
customerRepository,
|
||||
memberLevelRepository,
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>(),
|
||||
cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
|
||||
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, agentRepository, cancellationToken);
|
||||
if (!entity.AgentId.HasValue)
|
||||
{
|
||||
entity.ReferralChannel = "wechat";
|
||||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||||
entity,
|
||||
input.InviteCode,
|
||||
referralCodeRepository,
|
||||
customerRepository,
|
||||
memberLevelRepository,
|
||||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>(),
|
||||
cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
|
||||
{
|
||||
entity.ReferralChannel = "wechat";
|
||||
}
|
||||
}
|
||||
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
||||
entity,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user