admin/src/Oms.Application/Services/Agents/AgentPortalService.cs
zzh b6831bad1d
All checks were successful
Build and Deploy / deploy (push) Successful in 1m14s
feat: 代理门户能力与客户绑定代理改绑
补齐工作台/名下客户订单返佣、注册校验与运营端客户 AgentId 改绑,订单列表按代理快照过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 10:18:32 +08:00

589 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>名下订单分页(强制 Order.AgentId;不含应付)。</summary>
[HttpPost("/agent/portal/orders/list")]
public async Task<OmsPageResult<AgentPortalOrderOutput>> GetOrdersAsync(AgentPortalPageInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
var keyword = input.Keyword?.Trim();
Expression<Func<Order, bool>> predicate = string.IsNullOrWhiteSpace(keyword)
? x => x.AgentId == agent.Id
: x => x.AgentId == agent.Id
&& (x.ServiceNo.Contains(keyword!)
|| (x.TrackNo != null && x.TrackNo.Contains(keyword!))
|| (x.ProductCode != null && x.ProductCode.Contains(keyword!)));
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await OrderRepository.GetPagedListAsync(
predicate,
page,
pageSize,
new Dictionary<string, bool>
{
[nameof(Order.CreateTime)] = false,
[nameof(Order.Id)] = false
});
var total = await OrderRepository.CountAsync(predicate);
var customerIds = entities.Where(x => x.CustomerId.HasValue).Select(x => x.CustomerId!.Value).Distinct().ToList();
var customers = customerIds.Count == 0
? []
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id);
return new OmsPageResult<AgentPortalOrderOutput>
{
Items = entities.Select(x =>
{
customerMap.TryGetValue(x.CustomerId ?? 0, out var customer);
return new AgentPortalOrderOutput
{
Id = x.Id,
ServiceNo = x.ServiceNo,
TrackNo = x.TrackNo,
CustomerId = x.CustomerId,
CustomerCode = customer?.CustomerCode,
CustomerName = MaskName(customer?.CustomerName),
ProductCode = x.ProductCode,
OrderStatus = x.OrderStatus,
ConsigneeCountryName = x.ConsigneeCountryName,
ReceivableSubtotal = x.ReceivableSubtotal,
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 { AgentId = 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 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),
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 { AgentId = 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,
状态 = string.Equals(x.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)
? "已打款"
: "待结算",
触发状态 = 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));
}
}