From b6831bad1d73268d9456e9268c34ab77bf8f3650 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 30 Oct 2026 10:18:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=A3=E7=90=86=E9=97=A8=E6=88=B7?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=94=B9=E7=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐工作台/名下客户订单返佣、注册校验与运营端客户 AgentId 改绑,订单列表按代理快照过滤。 Co-authored-by: Cursor --- src/Oms.Application/Dtos/Agents/AgentDtos.cs | 122 +++++ .../Dtos/Customers/CustomerOutput.cs | 11 +- .../Dtos/Customers/CustomerPaginatedInput.cs | 5 +- .../Dtos/Customers/CustomerPaginatedOutput.cs | 11 +- .../Dtos/Customers/UpdateCustomerInput.cs | 8 +- .../Services/Agents/AgentAdminService.cs | 48 ++ .../Services/Agents/AgentPortalService.cs | 460 +++++++++++++++--- .../Services/Customers/CustomerService.cs | 58 ++- .../Services/Orders/OrderService.Helpers.cs | 43 ++ .../Orders/OrderService.ParcelBilling.cs | 14 +- .../Services/Orders/OrderService.cs | 33 ++ src/Oms.Application/Shared/AgentBinder.cs | 32 ++ .../Shared/OmsSessionHelper.cs | 73 ++- .../OrdersAggregate/OrderListFilter.cs | 5 + .../Orders/OrderRepository.cs | 8 + 15 files changed, 854 insertions(+), 77 deletions(-) diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs index 8e849e3..8781ac0 100644 --- a/src/Oms.Application/Dtos/Agents/AgentDtos.cs +++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs @@ -30,10 +30,16 @@ public class AgentOutput public string BaseType { get; set; } = string.Empty; + /// 基数类型展示名(运费/应收)。 + public string? BaseTypeName { get; set; } + public decimal CommissionRate { get; set; } public string? SalesProductIds { get; set; } + /// 适用销售产品名称;空表示不限产品。 + public List SalesProductNames { get; set; } = []; + public string? Remark { get; set; } public DateTime CreateTime { get; set; } @@ -195,6 +201,33 @@ public class AgentPortalCustomerOutput public DateTime? AgentBindTime { get; set; } public DateTime CreateTime { get; set; } + + /// 最近下单时间(含名下子客户)。 + public DateTime? LastOrderTime { get; set; } + + /// 近 30 天运单数(含名下子客户,不含已删除)。 + public int OrderCount30d { get; set; } + + /// 待付款运单数(含名下子客户)。 + public int PendingPaymentCount { get; set; } +} + +/// 代理门户返佣明细分页筛选。 +public class AgentPortalCommissionListInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 50; + + /// pending / paid;空=全部。 + public string? Status { get; set; } + + /// 运单号关键字。 + public string? Keyword { get; set; } + + public DateTime? CreateTimeStart { get; set; } + + public DateTime? CreateTimeEnd { get; set; } } public class AgentPortalOrderOutput @@ -232,3 +265,92 @@ public class AgentPortalPageInput public string? Keyword { get; set; } } + +/// 匿名校验代理编号(客户注册归因)。 +public class AgentValidateCodeOutput +{ + public bool Valid { get; set; } + + public string? AgentCode { get; set; } + + /// 脱敏代理名称,供注册页展示。 + public string? AgentNameMasked { get; set; } + + public string? Message { get; set; } +} + +/// +/// 代理工作台统计概览。 +/// +public class AgentWorkplaceStatsOutput +{ + /// 名下主客户数。 + public int CustomerCount { get; set; } + + /// 今日代客下单数。 + public int TodayCreated { get; set; } + + /// 昨日代客下单数。 + public int YesterdayCreated { get; set; } + + /// 较昨日新增百分比。 + public decimal? DayOverDayGrowthPercent { get; set; } + + /// 待付款运单数。 + public int PendingPayment { get; set; } + + /// 运输中运单数。 + public int Shipping { get; set; } + + /// 问题件运单数。 + public int Problematic { get; set; } + + /// 派送成功运单数(全量状态桶)。 + public int DeliverySuccess { get; set; } + + /// 待结算返佣笔数。 + public int PendingCommissionCount { get; set; } + + /// 待结算返佣金额。 + public decimal PendingCommissionAmount { get; set; } + + /// 已打款返佣金额(累计)。 + public decimal PaidCommissionAmount { get; set; } + + /// 本周新增绑定主客户数(按 AgentBindTime)。 + public int WeekNewCustomers { get; set; } + + /// 派送失败运单数。 + public int DeliveryFailed { get; set; } + + /// 按 OrderStatus 分组数量。 + public Dictionary Counts { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +/// +/// 代理工作台趋势点。 +/// +public class AgentWorkplaceTrendPoint +{ + public string Label { get; set; } = string.Empty; + + public DateTime Start { get; set; } + + public DateTime End { get; set; } + + /// 区间内代客下单数。 + public int OrderCount { get; set; } + + /// 区间内计提返佣金额。 + public decimal CommissionAmount { get; set; } +} + +/// +/// 代理工作台趋势。 +/// +public class AgentWorkplaceTrendOutput +{ + public string Dimension { get; set; } = "day"; + + public List Points { get; set; } = []; +} diff --git a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs index 014262e..71dc952 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerOutput.cs @@ -78,7 +78,7 @@ public class CustomerOutput public string? Remark { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留字段,管理端已改用 AgentId)。 /// public string? Salesman { get; set; } @@ -87,6 +87,15 @@ public class CustomerOutput /// public string? CustomerService { get; set; } + /// 绑定渠道代理 Id。 + public int? AgentId { get; set; } + + /// 绑定代理编号。 + public string? AgentCode { get; set; } + + /// 绑定代理名称。 + public string? AgentName { get; set; } + /// /// 客户分组。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs index a765c86..301170e 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedInput.cs @@ -33,7 +33,7 @@ public class CustomerPaginatedInput public CustomerGroup? CustomerGroup { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留筛选)。 /// public string? Salesman { get; set; } @@ -42,6 +42,9 @@ public class CustomerPaginatedInput /// public string? CustomerService { get; set; } + /// 按绑定代理 Id 筛选。 + public int? AgentId { get; set; } + /// /// 排序字段:createTime / updateTime / customerCode / customerName / balance。 /// diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs index 63fb35e..c20b23d 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs @@ -78,7 +78,7 @@ public class CustomerPaginatedOutput public string? Remark { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留字段,管理端已改用 AgentId)。 /// public string? Salesman { get; set; } @@ -87,6 +87,15 @@ public class CustomerPaginatedOutput /// public string? CustomerService { get; set; } + /// 绑定渠道代理 Id。 + public int? AgentId { get; set; } + + /// 绑定代理编号。 + public string? AgentCode { get; set; } + + /// 绑定代理名称。 + public string? AgentName { get; set; } + /// /// 客户分组。 /// diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs index 2bd683d..7e9fc36 100644 --- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs @@ -81,7 +81,7 @@ public class UpdateCustomerInput public string? Remark { get; set; } /// - /// 销售人员。 + /// 销售人员(遗留;新流程请用 AgentCode)。 /// [StringLength(32)] public string? Salesman { get; set; } @@ -92,6 +92,12 @@ public class UpdateCustomerInput [StringLength(32)] public string? CustomerService { get; set; } + /// + /// 渠道代理编号;传空串解绑;不传则保留原绑定。仅主客户可改。 + /// + [StringLength(32)] + public string? AgentCode { get; set; } + /// /// 客户分组(遗留字段,计价优先 MemberLevelId)。 /// diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs index a92323f..4b1b670 100644 --- a/src/Oms.Application/Services/Agents/AgentAdminService.cs +++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs @@ -6,6 +6,7 @@ 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; @@ -43,6 +44,42 @@ public class AgentAdminService : ApplicationService private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService(); + /// + /// 匿名校验代理编号(客户注册页归因 / 手填兜底)。 + /// + [AllowAnonymous] + [HttpGet("/agent/public/validate-code")] + public async Task ValidateCodeAsync([FromQuery] string code) + { + var trimmed = code?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return new AgentValidateCodeOutput + { + Valid = false, + Message = "代理编号不能为空" + }; + } + + var agent = await AgentRepository.GetByAgentCodeAsync(trimmed); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + return new AgentValidateCodeOutput + { + Valid = false, + Message = "代理编号无效或已停用" + }; + } + + return new AgentValidateCodeOutput + { + Valid = true, + AgentCode = agent.AgentCode, + AgentNameMasked = MaskAgentName(agent.AgentName), + Message = null + }; + } + /// 分页查询代理。 [HttpPost("/agent/admin/list")] public async Task> GetListAsync(AgentListInput input) @@ -411,4 +448,15 @@ public class AgentAdminService : ApplicationService return mapId.Value; } + + private static string MaskAgentName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return "渠道代理"; + } + + var text = name.Trim(); + return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1)); + } } diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs index ba05bf9..20c43ba 100644 --- a/src/Oms.Application/Services/Agents/AgentPortalService.cs +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -1,22 +1,26 @@ using System.Linq.Expressions; using Fake.Application; using Fake.Domain.Exceptions; -using Fake.Security.Claims; 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(名下客户 / 订单 / 返佣)。 +/// 渠道代理门户 API(名下客户 / 订单 / 返佣 / 工作台)。 /// [ApiExplorerSettings(GroupName = "OMS-Agent")] public class AgentPortalService : ApplicationService @@ -30,11 +34,15 @@ public class AgentPortalService : ApplicationService 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, @@ -44,14 +52,16 @@ public class AgentPortalService : ApplicationService 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 }; } - /// 名下客户分页(含主客户 AgentId=我)。 + /// 名下客户分页(含活跃度:最近下单 / 近30天票数 / 待付款数)。 [HttpPost("/agent/portal/customers/list")] public async Task> GetCustomersAsync(AgentPortalPageInput input) { @@ -77,16 +87,25 @@ public class AgentPortalService : ApplicationService [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 => new AgentPortalCustomerOutput + Items = entities.Select(x => { - Id = x.Id, - CustomerCode = x.CustomerCode, - CustomerName = MaskName(x.CustomerName) ?? string.Empty, - Phone = MaskPhone(x.Phone), - AgentBindTime = x.AgentBindTime, - CreateTime = x.CreateTime + 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 }; @@ -141,7 +160,6 @@ public class AgentPortalService : ApplicationService OrderStatus = x.OrderStatus, ConsigneeCountryName = x.ConsigneeCountryName, ReceivableSubtotal = x.ReceivableSubtotal, - // 门户不返回应付:固定 null PayableAmount = null, CreateTime = x.CreateTime }; @@ -150,72 +168,384 @@ public class AgentPortalService : ApplicationService }; } - /// 名下返佣明细。 - [HttpPost("/agent/portal/commission/list")] - public async Task> GetCommissionListAsync(AgentPortalPageInput input) + /// + /// 代理工作台统计:名下客户、运单状态桶、待结算/已打款返佣、本周新增绑定。 + /// + [HttpGet("/agent/portal/workplace/stats")] + public async Task GetWorkplaceStatsAsync() { var agent = await GetCurrentAgentOrThrowAsync(); - Expression> predicate = x => x.AgentId == agent.Id; + 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 entities = await DetailRepository.GetPagedListAsync( - predicate, - page, - pageSize, - new Dictionary - { - [nameof(AgentCommissionDetail.CreateTime)] = false, - [nameof(AgentCommissionDetail.Id)] = false - }); - var total = await DetailRepository.CountAsync(predicate); - - var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList(); - var orderIds = entities.Select(x => x.OrderId).Distinct().ToList(); - var customers = customerIds.Count == 0 - ? [] - : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); - var orders = orderIds.Count == 0 - ? [] - : await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); - var customerMap = customers.ToDictionary(x => x.Id); - var orderMap = orders.ToDictionary(x => x.Id); + var total = details.Count; + var pageItems = details + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); return new OmsPageResult { - Items = 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(), + 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 = CurrentUser.GetId(); + var mapId = OmsSessionHelper.ResolveMapUserId(CurrentUser); if (mapId <= 0) { throw new BusinessException("代理未登录"); diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index fe27eed..19ab5ac 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -78,6 +78,8 @@ public class CustomerService : ApplicationService entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId })); var memberLevelNameMap = await ResolveMemberLevelNameMapAsync( entities.Where(x => x.MemberLevelId.HasValue).Select(x => x.MemberLevelId!.Value)); + var agentMap = await ResolveAgentDisplayMapAsync( + entities.Where(x => x.AgentId.HasValue).Select(x => x.AgentId!.Value)); var items = entities.Select(entity => { var output = ObjectMapper.Map(entity); @@ -90,6 +92,7 @@ public class CustomerService : ApplicationService output.MemberLevelName = levelName; } + FillAgentDisplay(output, entity.AgentId, agentMap); return output; }).ToList(); @@ -121,6 +124,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; } @@ -401,6 +410,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; @@ -500,6 +514,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) || @@ -510,7 +526,47 @@ public class CustomerService : ApplicationService (!hasStatusFilter || customer.Status == statusValue) && (!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) && (string.IsNullOrWhiteSpace(salesman) || customer.Salesman == salesman) && - (string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService); + (string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService) && + (!hasAgentFilter || customer.AgentId == agentId); + } + + private async Task> ResolveAgentDisplayMapAsync( + IEnumerable agentIds) + { + var ids = agentIds.Distinct().ToList(); + if (ids.Count == 0) + { + return new Dictionary(); + } + + var agents = await AgentRepository.GetListAsync(x => ids.Contains(x.Id)); + return agents.ToDictionary(x => x.Id, x => (x.AgentCode, x.AgentName)); + } + + private static void FillAgentDisplay( + CustomerPaginatedOutput output, + int? agentId, + IReadOnlyDictionary agentMap) + { + output.AgentId = agentId; + if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent)) + { + output.AgentCode = agent.Code; + output.AgentName = agent.Name; + } + } + + private static void FillAgentDisplay( + CustomerOutput output, + int? agentId, + IReadOnlyDictionary agentMap) + { + output.AgentId = agentId; + if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent)) + { + output.AgentCode = agent.Code; + output.AgentName = agent.Name; + } } private static Dictionary BuildCustomerSortDictionary(CustomerPaginatedInput input) diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index c64ecff..b1ed712 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -1208,6 +1208,12 @@ public partial class OrderService return; } + if (OmsSessionHelper.IsAgentLogin(CurrentUser)) + { + await ApplyAgentOrderScopeAsync(filter); + return; + } + var userGuidRaw = CurrentUser.FindClaimValueOrNull(global::System.Security.Claims.ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userGuidRaw) || !Guid.TryParse(userGuidRaw, out var userGuid)) { @@ -1217,4 +1223,41 @@ public partial class OrderService var context = await DataScopeBuilder.BuildAsync(userGuid, DataScopeApplier.OrderResourceCode); DataScopeApplier.ApplyToOrderListFilter(filter, context); } + + /// + /// 渠道代理门户:列表仅可见下单快照 AgentId=本代理的运单。 + /// + private async Task ApplyAgentOrderScopeAsync(OrderListFilter filter) + { + var agentRepository = + LazyServiceProvider.GetRequiredService(); + var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser); + if (userId <= 0) + { + throw new BusinessException("代理未登录"); + } + + var agent = await agentRepository.GetByUserIdAsync(userId); + if (agent == null) + { + throw new BusinessException("代理不存在"); + } + + filter.AgentId = agent.Id; + + if (filter.CustomerId is > 0) + { + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == filter.CustomerId.Value); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + await OmsSessionHelper.EnsureAgentOwnsCustomerAsync( + CustomerRepository, + agentRepository, + CurrentUser, + customer); + } + } } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index a2237c3..e7bfc51 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3508,13 +3508,19 @@ public partial class OrderService } /// - /// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。 + /// 从会话解析有效客户(客户登录自动补全;管理员/代理须传 customerId;代理仅限名下客户)。 /// - private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) => - OmsSessionHelper.ResolveSessionCustomerAsync( + private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) + { + var agentRepository = OmsSessionHelper.IsAgentLogin(CurrentUser) + ? LazyServiceProvider.GetRequiredService() + : null; + return OmsSessionHelper.ResolveSessionCustomerAsync( CustomerRepository, CurrentUser, - requestedCustomerId); + requestedCustomerId, + agentRepository); + } private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId) { diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 1938010..154f1b7 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -704,6 +704,25 @@ public partial class OrderService : ApplicationService { input.CustomerId = await ResolvePriceCheckCustomerIdAsync(); } + else if (OmsSessionHelper.IsAgentLogin(CurrentUser)) + { + // 代理代客查价:仅允许名下绑定客户 + var agentRepository = + LazyServiceProvider.GetRequiredService(); + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + await OmsSessionHelper.EnsureAgentOwnsCustomerAsync( + CustomerRepository, + agentRepository, + CurrentUser, + customer); + input.CustomerId = + await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(CustomerRepository, customer); + } return await BuildPriceCheckResultAsync(input); } @@ -1335,6 +1354,20 @@ public partial class OrderService : ApplicationService private async Task EnsureCustomerCanAccessOrderAsync(Order order) { + if (OmsSessionHelper.IsAgentLogin(CurrentUser)) + { + var agentRepository = + LazyServiceProvider.GetRequiredService(); + var userId = OmsSessionHelper.ResolveMapUserId(CurrentUser); + var agent = userId > 0 ? await agentRepository.GetByUserIdAsync(userId) : null; + if (agent == null || order.AgentId != agent.Id) + { + throw new BusinessException("订单不存在"); + } + + return; + } + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( CustomerRepository, CurrentUser, diff --git a/src/Oms.Application/Shared/AgentBinder.cs b/src/Oms.Application/Shared/AgentBinder.cs index d4abe01..0613642 100644 --- a/src/Oms.Application/Shared/AgentBinder.cs +++ b/src/Oms.Application/Shared/AgentBinder.cs @@ -37,4 +37,36 @@ public static class AgentBinder entity.AgentId = agent.Id; entity.AgentBindTime = DateTime.Now; } + + /// + /// 运营改绑:按代理编号覆盖;空串解绑。子客户不可改(跟主客户)。 + /// + public static async Task ApplyAgentBindingAsync( + Customer entity, + string? agentCode, + IAgentRepository agentRepository, + CancellationToken cancellationToken = default) + { + if (entity.ParentCustomerId.HasValue) + { + throw new BusinessException("请改绑主客户"); + } + + var code = agentCode?.Trim(); + if (string.IsNullOrEmpty(code)) + { + entity.AgentId = null; + entity.AgentBindTime = null; + return; + } + + var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + throw new BusinessException("代理编号无效或已停用"); + } + + entity.AgentId = agent.Id; + entity.AgentBindTime = DateTime.Now; + } } diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs index c5ff2b4..0351e04 100644 --- a/src/Oms.Application/Shared/OmsSessionHelper.cs +++ b/src/Oms.Application/Shared/OmsSessionHelper.cs @@ -1,13 +1,14 @@ using System.Security.Claims; using Fake.Application; using Fake.Users; +using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; namespace Oms.Application.Shared; /// -/// 从 JWT Claims 解析当前会话类型(管理员 / 客户)。 +/// 从 JWT Claims 解析当前会话类型(管理员 / 客户 / 渠道代理)。 /// public static class OmsSessionHelper { @@ -22,7 +23,9 @@ public static class OmsSessionHelper public const string MapIdClaim = "mapId"; private const string CustomerLoginType = "1"; + private const string AgentLoginType = "2"; private const string CustomerRoleName = "Customer"; + private const string AgentRoleName = "Agent"; /// /// 当前会话是否为客户门户登录。 @@ -43,6 +46,24 @@ public static class OmsSessionHelper return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase); } + /// + /// 当前会话是否为渠道代理门户登录。 + /// + public static bool IsAgentLogin(ICurrentUser currentUser) + { + if (string.Equals( + currentUser.FindClaimOrNull(LoginTypeClaim)?.Value, + AgentLoginType, + StringComparison.Ordinal)) + { + return true; + } + + var role = currentUser.FindClaimOrNull(ClaimTypes.Role)?.Value + ?? currentUser.FindClaimOrNull("role")?.Value; + return string.Equals(role, AgentRoleName, StringComparison.OrdinalIgnoreCase); + } + /// /// 解析当前会话的 RBAC mapId(user.userid)。 /// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。 @@ -85,12 +106,14 @@ public static class OmsSessionHelper TryGetCustomerMapId(currentUser); /// - /// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员可按 CustomerId 代提交)。 + /// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员/代理可按 CustomerId 代提交)。 + /// 代理须传入 ,且仅允许操作名下绑定客户。 /// public static async Task ResolveSessionCustomerAsync( ICustomerRepository customerRepository, ICurrentUser currentUser, - int? requestedCustomerId = null) + int? requestedCustomerId = null, + IAgentRepository? agentRepository = null) { var mapId = TryGetCustomerMapId(currentUser); if (mapId.HasValue) @@ -121,9 +144,53 @@ public static class OmsSessionHelper throw new BusinessException("客户不存在"); } + if (IsAgentLogin(currentUser)) + { + await EnsureAgentOwnsCustomerAsync(customerRepository, agentRepository, currentUser, customer); + } + return customer; } + /// + /// 渠道代理仅可操作名下绑定客户(主客户 AgentId=本代理;子客户跟主客户)。 + /// + public static async Task EnsureAgentOwnsCustomerAsync( + ICustomerRepository customerRepository, + IAgentRepository? agentRepository, + ICurrentUser currentUser, + Customer customer) + { + if (agentRepository == null) + { + throw new BusinessException("代理会话缺少代理仓储"); + } + + var userId = ResolveMapUserId(currentUser); + if (userId <= 0) + { + throw new BusinessException("代理未登录"); + } + + var agent = await agentRepository.GetByUserIdAsync(userId); + if (agent == null) + { + throw new BusinessException("代理不存在"); + } + + int? bindAgentId = customer.AgentId; + if (customer.ParentCustomerId is > 0) + { + var main = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value); + bindAgentId = main?.AgentId; + } + + if (bindAgentId != agent.Id) + { + throw new BusinessException("只能为名下绑定客户操作"); + } + } + /// /// 客户登录时强制返回 t_customer.Id;管理员返回入参 customerId。 /// diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs index 0cbfc0e..419d536 100644 --- a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs +++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs @@ -34,6 +34,11 @@ public class OrderListFilter public int? CustomerId { get; set; } + /// + /// 渠道代理 Id(门户列表强制按下单快照 AgentId 过滤)。 + /// + public int? AgentId { get; set; } + /// /// 发货仓 ID(订单或绑定行所属仓)。 /// diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index df1e482..9ef3900 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -47,6 +47,12 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I query = query.Where(x => x.CustomerId == customerId); } + if (filter.AgentId.HasValue) + { + var agentId = filter.AgentId.Value; + query = query.Where(x => x.AgentId == agentId); + } + var status = filter.Status?.Trim(); var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase); if (isDeletedTab) @@ -1503,6 +1509,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CreateTimeStart = filter.CreateTimeStart, CreateTimeEnd = filter.CreateTimeEnd, CustomerId = filter.CustomerId, + AgentId = filter.AgentId, FieldFilters = filter.FieldFilters, SortField = filter.SortField, SortOrder = filter.SortOrder, @@ -1549,6 +1556,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CreateTimeStart = filter.CreateTimeStart, CreateTimeEnd = filter.CreateTimeEnd, CustomerId = filter.CustomerId, + AgentId = filter.AgentId, WarehouseId = filter.WarehouseId, FulfillmentType = filter.FulfillmentType, StockBindStatus = filter.StockBindStatus,