using System.Linq.Expressions; using Fake.Application; using Fake.Domain.Exceptions; using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; using Oms.Application.Dtos.Agents; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; using Oms.Domain.AgentsAggregate.Consts; using Oms.Domain.AgentsAggregate.Entities; using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; namespace Oms.Application.Services.Agents; /// /// 渠道代理门户 API(名下客户 / 订单 / 返佣 / 工作台)。 /// [ApiExplorerSettings(GroupName = "OMS-Agent")] public class AgentPortalService : ApplicationService { private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService(); private IAgentCommissionDetailRepository DetailRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); /// 当前代理资料(含返佣规则展示字段)。 [HttpGet("/agent/portal/profile")] public async Task GetProfileAsync() { var agent = await GetCurrentAgentOrThrowAsync(); var productNames = await ResolveSalesProductNamesAsync(agent.SalesProductIds); return new AgentOutput { Id = agent.Id, AgentCode = agent.AgentCode, AgentName = agent.AgentName, Phone = agent.Phone, Email = agent.Email, Status = agent.Status, BaseType = agent.BaseType, BaseTypeName = ResolveBaseTypeName(agent.BaseType), CommissionRate = agent.CommissionRate, SalesProductIds = agent.SalesProductIds, SalesProductNames = productNames, Remark = agent.Remark, CreateTime = agent.CreateTime }; } /// 名下客户分页(含活跃度:最近下单 / 近30天票数 / 待付款数)。 [HttpPost("/agent/portal/customers/list")] public async Task> GetCustomersAsync(AgentPortalPageInput input) { var agent = await GetCurrentAgentOrThrowAsync(); var keyword = input.Keyword?.Trim(); Expression> predicate = string.IsNullOrWhiteSpace(keyword) ? x => x.AgentId == agent.Id && x.ParentCustomerId == null : x => x.AgentId == agent.Id && x.ParentCustomerId == null && (x.CustomerCode.Contains(keyword!) || x.CustomerName.Contains(keyword!) || (x.Phone != null && x.Phone.Contains(keyword!))); var page = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var entities = await CustomerRepository.GetPagedListAsync( predicate, page, pageSize, new Dictionary { [nameof(Customer.CreateTime)] = false, [nameof(Customer.Id)] = false }); var total = await CustomerRepository.CountAsync(predicate); var activityMap = await BuildCustomerActivityMapAsync(agent.Id, entities.Select(x => x.Id).ToList()); return new OmsPageResult { Items = entities.Select(x => { activityMap.TryGetValue(x.Id, out var activity); return new AgentPortalCustomerOutput { Id = x.Id, CustomerCode = x.CustomerCode, CustomerName = MaskName(x.CustomerName) ?? string.Empty, Phone = MaskPhone(x.Phone), AgentBindTime = x.AgentBindTime, CreateTime = x.CreateTime, LastOrderTime = activity.LastOrderTime, OrderCount30d = activity.OrderCount30d, PendingPaymentCount = activity.PendingPaymentCount }; }).ToList(), Total = total }; } /// 名下订单分页(强制 Order.AgentId;不含应付)。 [HttpPost("/agent/portal/orders/list")] public async Task> GetOrdersAsync(AgentPortalPageInput input) { var agent = await GetCurrentAgentOrThrowAsync(); var keyword = input.Keyword?.Trim(); Expression> 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 { [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 { Items = entities.Select(x => { customerMap.TryGetValue(x.CustomerId ?? 0, out var customer); return new AgentPortalOrderOutput { Id = x.Id, ServiceNo = x.ServiceNo, TrackNo = x.TrackNo, CustomerId = x.CustomerId, CustomerCode = customer?.CustomerCode, CustomerName = MaskName(customer?.CustomerName), ProductCode = x.ProductCode, OrderStatus = x.OrderStatus, ConsigneeCountryName = x.ConsigneeCountryName, ReceivableSubtotal = x.ReceivableSubtotal, PayableAmount = null, CreateTime = x.CreateTime }; }).ToList(), Total = total }; } /// /// 代理工作台统计:名下客户、运单状态桶、待结算/已打款返佣、本周新增绑定。 /// [HttpGet("/agent/portal/workplace/stats")] public async Task GetWorkplaceStatsAsync() { var agent = await GetCurrentAgentOrThrowAsync(); var filter = new OrderListFilter { 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, }; } /// /// 代理工作台趋势:按日统计代客下单数与计提返佣金额。 /// /// 天数,默认 7,最大 31。 [HttpGet("/agent/portal/workplace/trend")] public async Task GetWorkplaceTrendAsync([FromQuery] int days = 7) { var agent = await GetCurrentAgentOrThrowAsync(); days = Math.Clamp(days, 1, 31); var filter = new OrderListFilter { 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(days); for (var i = 0; i < days; i++) { var start = rangeStart.AddDays(i); var end = start.AddDays(1); var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, start, end); var commissionAmount = commissions .Where(x => x.CreateTime >= start && x.CreateTime < end) .Sum(x => x.CommissionAmount); points.Add(new AgentWorkplaceTrendPoint { Label = start.ToString("MM-dd"), Start = start, End = end, OrderCount = orderCount, CommissionAmount = commissionAmount, }); } return new AgentWorkplaceTrendOutput { Dimension = "day", Points = points, }; } /// 名下返佣明细(支持状态 / 运单号 / 计提时间筛选)。 [HttpPost("/agent/portal/commission/list")] public async Task> GetCommissionListAsync( AgentPortalCommissionListInput input) { var agent = await GetCurrentAgentOrThrowAsync(); var details = await QueryCommissionDetailsAsync(agent, input); var page = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var total = details.Count; var pageItems = details .Skip((page - 1) * pageSize) .Take(pageSize) .ToList(); return new OmsPageResult { Items = await MapCommissionOutputsAsync(agent, pageItems), Total = total }; } /// 导出名下返佣明细(Excel,最多 5000 条)。 [HttpPost("/agent/portal/commission/export")] public async Task ExportCommissionAsync(AgentPortalCommissionListInput input) { var agent = await GetCurrentAgentOrThrowAsync(); var details = await QueryCommissionDetailsAsync(agent, input); const int maxRows = 5000; if (details.Count > maxRows) { throw new BusinessException($"导出结果超过 {maxRows} 条(当前 {details.Count}),请缩小筛选条件后再导出"); } var outputs = await MapCommissionOutputsAsync(agent, details); var rows = outputs.Select(x => new { 运单号 = x.ServiceNo, 客户编号 = x.CustomerCode, 客户名称 = x.CustomerName, 基数类型 = ResolveBaseTypeName(x.BaseType), 基数金额 = x.BaseAmount, 返佣比例 = x.CommissionRate, 返佣金额 = x.CommissionAmount, 状态 = 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> QueryCommissionDetailsAsync( Agent agent, AgentPortalCommissionListInput input) { var status = input.Status?.Trim(); var keyword = input.Keyword?.Trim(); var start = input.CreateTimeStart; var end = input.CreateTimeEnd; Expression> predicate = x => x.AgentId == agent.Id; if (!string.IsNullOrWhiteSpace(status)) { predicate = x => x.AgentId == agent.Id && x.Status == status; } var list = await DetailRepository.GetListAsync(predicate); if (start.HasValue) { list = list.Where(x => x.CreateTime >= start.Value).ToList(); } if (end.HasValue) { var endExclusive = end.Value.Date.AddDays(1); list = list.Where(x => x.CreateTime < endExclusive).ToList(); } if (!string.IsNullOrWhiteSpace(keyword)) { var orderIds = list.Select(x => x.OrderId).Distinct().ToList(); var orders = orderIds.Count == 0 ? [] : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); var matchedOrderIds = orders .Where(o => o.ServiceNo.Contains(keyword, StringComparison.OrdinalIgnoreCase) || (o.TrackNo != null && o.TrackNo.Contains(keyword, StringComparison.OrdinalIgnoreCase))) .Select(o => o.Id) .ToHashSet(); list = list.Where(x => matchedOrderIds.Contains(x.OrderId)).ToList(); } return list .OrderByDescending(x => x.CreateTime) .ThenByDescending(x => x.Id) .ToList(); } private async Task> MapCommissionOutputsAsync( Agent agent, IReadOnlyCollection entities) { if (entities.Count == 0) { return []; } var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList(); var orderIds = entities.Select(x => x.OrderId).Distinct().ToList(); var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); var customerMap = customers.ToDictionary(x => x.Id); var orderMap = orders.ToDictionary(x => x.Id); return entities.Select(x => { customerMap.TryGetValue(x.CustomerId, out var customer); orderMap.TryGetValue(x.OrderId, out var order); return new AgentCommissionDetailOutput { Id = x.Id, AgentId = x.AgentId, AgentCode = agent.AgentCode, AgentName = agent.AgentName, OrderId = x.OrderId, ServiceNo = order?.ServiceNo, CustomerId = x.CustomerId, CustomerCode = customer?.CustomerCode, CustomerName = MaskName(customer?.CustomerName), SalesProductId = x.SalesProductId, BaseType = x.BaseType, BaseAmount = x.BaseAmount, CommissionRate = x.CommissionRate, CommissionAmount = x.CommissionAmount, Status = x.Status, TriggerOrderStatus = x.TriggerOrderStatus, PaidTime = x.PaidTime, Remark = x.Remark, CreateTime = x.CreateTime }; }).ToList(); } private async Task> BuildCustomerActivityMapAsync(int agentId, List mainCustomerIds) { var result = mainCustomerIds.ToDictionary( id => id, _ => ((DateTime?)null, 0, 0)); if (mainCustomerIds.Count == 0) { return result; } var children = await CustomerRepository.GetListAsync(x => x.ParentCustomerId != null && mainCustomerIds.Contains(x.ParentCustomerId.Value)); var mainByCustomerId = new Dictionary(); foreach (var id in mainCustomerIds) { mainByCustomerId[id] = id; } foreach (var child in children) { if (child.ParentCustomerId is > 0) { mainByCustomerId[child.Id] = child.ParentCustomerId.Value; } } var allIds = mainByCustomerId.Keys.ToList(); var since30 = DateTime.Today.AddDays(-30); var orders = await OrderRepository.GetListAsync(x => x.AgentId == agentId && x.CustomerId != null && allIds.Contains(x.CustomerId.Value) && x.OrderStatus != OrderStatusCodes.Deleted); foreach (var group in orders.GroupBy(o => mainByCustomerId[o.CustomerId!.Value])) { var list = group.ToList(); result[group.Key] = ( list.Max(o => (DateTime?)o.CreateTime), list.Count(o => o.CreateTime >= since30), list.Count(o => string.Equals( o.OrderStatus, OrderStatusCodes.PendingPayment, StringComparison.OrdinalIgnoreCase))); } return result; } private async Task> ResolveSalesProductNamesAsync(string? salesProductIds) { var ids = ParseSalesProductIds(salesProductIds); if (ids.Count == 0) { return []; } var products = await SalesProductRepository.GetListAsync(x => ids.Contains(x.Id)); var map = products.ToDictionary(x => x.Id); return ids .Select(id => map.TryGetValue(id, out var p) ? $"{p.ProductName}({p.ProductCode})" : $"#{id}") .ToList(); } private static List ParseSalesProductIds(string? raw) { if (string.IsNullOrWhiteSpace(raw)) { return []; } var text = raw.Trim(); if (text.StartsWith('[')) { try { var arr = global::System.Text.Json.JsonSerializer.Deserialize>(text); return arr?.Where(x => x > 0).Distinct().ToList() ?? []; } catch { // fall through } } return text .Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(x => int.TryParse(x, out var id) ? id : 0) .Where(x => x > 0) .Distinct() .ToList(); } private static string ResolveBaseTypeName(string? baseType) { if (string.Equals(baseType, "receivable", StringComparison.OrdinalIgnoreCase)) { return "应收"; } if (string.Equals(baseType, "freight", StringComparison.OrdinalIgnoreCase)) { return "运费"; } return string.IsNullOrWhiteSpace(baseType) ? "运费" : baseType; } private async Task GetCurrentAgentOrThrowAsync() { var mapId = OmsSessionHelper.ResolveMapUserId(CurrentUser); if (mapId <= 0) { throw new BusinessException("代理未登录"); } var agent = await AgentRepository.GetByUserIdAsync(mapId); if (agent == null) { throw new BusinessException("代理不存在"); } if (agent.Status != Domain.Shared.Enums.EnableStatus.Enabled) { throw new BusinessException("代理已停用"); } return agent; } private static string? MaskPhone(string? phone) { if (string.IsNullOrWhiteSpace(phone) || phone.Length < 7) { return phone; } return string.Concat(phone.AsSpan(0, 3), "****", phone.AsSpan(phone.Length - 4)); } private static string? MaskName(string? name) { if (string.IsNullOrWhiteSpace(name)) { return name; } var text = name.Trim(); return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1)); } }