From ce1349572ddc6109e9b24540c6965b46d327771f Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 21 Aug 2026 18:21:33 +0800 Subject: [PATCH] feat: extend receivable-payable APIs with summary, export, and filters Add summary/export endpoints plus customer code, effective stage, time field, and payment status filtering for finance reconciliation. --- .../ReceivablePayablePaginatedInput.cs | 24 ++- .../ReceivablePayablePaginatedOutput.cs | 4 + .../Finance/ReceivablePayableSummaryOutput.cs | 30 +++ .../Services/Finance/FinanceService.cs | 181 +++++++++++++++--- .../OrderReceivablePayableFilter.cs | 30 +++ .../OrderReceivablePayableSummaryRow.cs | 15 ++ .../Repositories/IOrderRepository.cs | 9 + .../Orders/OrderRepository.cs | 164 ++++++++++++++-- 8 files changed, 422 insertions(+), 35 deletions(-) create mode 100644 src/Oms.Application/Dtos/Finance/ReceivablePayableSummaryOutput.cs create mode 100644 src/Oms.Domain/OrdersAggregate/OrderReceivablePayableSummaryRow.cs diff --git a/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedInput.cs b/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedInput.cs index bb51370..ce68e6b 100644 --- a/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedInput.cs +++ b/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedInput.cs @@ -32,12 +32,32 @@ public class ReceivablePayablePaginatedInput public int? CustomerId { get; set; } /// - /// 订单创建时间起。 + /// 客户编码关键词(模糊匹配)。 + /// + public string? CustomerCode { get; set; } + + /// + /// 仅保留各运单费用项的当前生效阶段。 + /// + public bool EffectiveStageOnly { get; set; } + + /// + /// 订单创建时间起(timeField=createTime 时生效,兼容旧参数)。 /// public DateTime? StartTime { get; set; } /// - /// 订单创建时间止。 + /// 订单创建时间止(timeField=createTime 时生效,兼容旧参数)。 /// public DateTime? EndTime { get; set; } + + /// + /// 时间筛选字段:createTime / receiveTime / shipTime / payTime。 + /// + public string? TimeField { get; set; } + + /// + /// 支付状态:0 未支付,1 已支付。 + /// + public int? IsPaid { get; set; } } diff --git a/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedOutput.cs b/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedOutput.cs index fa832c3..b39a291 100644 --- a/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedOutput.cs +++ b/src/Oms.Application/Dtos/Finance/ReceivablePayablePaginatedOutput.cs @@ -5,6 +5,8 @@ namespace Oms.Application.Dtos.Finance; /// public class ReceivablePayablePaginatedOutput { + public int OrderId { get; set; } + public string ServiceNo { get; set; } = string.Empty; public string? ClientNo { get; set; } @@ -37,6 +39,8 @@ public class ReceivablePayablePaginatedOutput public string CostItem { get; set; } = string.Empty; + public string? CostItemName { get; set; } + public decimal Amount { get; set; } public string Currency { get; set; } = string.Empty; diff --git a/src/Oms.Application/Dtos/Finance/ReceivablePayableSummaryOutput.cs b/src/Oms.Application/Dtos/Finance/ReceivablePayableSummaryOutput.cs new file mode 100644 index 0000000..4578aeb --- /dev/null +++ b/src/Oms.Application/Dtos/Finance/ReceivablePayableSummaryOutput.cs @@ -0,0 +1,30 @@ +namespace Oms.Application.Dtos.Finance; + +/// +/// 应收应付汇总出参。 +/// +public class ReceivablePayableSummaryOutput +{ + /// + /// 匹配筛选条件的费用明细总行数。 + /// + public int TotalLines { get; set; } + + /// + /// 按费用类型 + 币种汇总。 + /// + public List Items { get; set; } = []; +} + +public class ReceivablePayableSummaryItemOutput +{ + public string FeeType { get; set; } = string.Empty; + + public string? FeeTypeName { get; set; } + + public string Currency { get; set; } = string.Empty; + + public decimal TotalAmount { get; set; } + + public int LineCount { get; set; } +} diff --git a/src/Oms.Application/Services/Finance/FinanceService.cs b/src/Oms.Application/Services/Finance/FinanceService.cs index 5cce6f0..6a3021d 100644 --- a/src/Oms.Application/Services/Finance/FinanceService.cs +++ b/src/Oms.Application/Services/Finance/FinanceService.cs @@ -1,46 +1,49 @@ using Fake.Application; using Fake.Users; using Microsoft.AspNetCore.Mvc; +using MiniExcelLibs; using Oms.Application.DataPerm; using Oms.Application.Dtos.Finance; using Oms.Application.Dtos.Shared; +using Oms.Application.Services.Orders; using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Finance; /// -/// 财务相关 API�。 - /// +/// 财务相关 API。 +/// [ApiExplorerSettings(GroupName = "OMS-Finance")] public class FinanceService : ApplicationService { + private const string FeeItemDictType = "fee_item"; + private const int ExportMaxRows = 50000; + private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private IDictRepository DictRepository => + LazyServiceProvider.GetRequiredService(); + private EffectiveScopeBuilder DataScopeBuilder => LazyServiceProvider.GetRequiredService(); /// - /// 分页查询应收应付(订单费用联查订单)�。 + /// 分页查询应收应付(订单费用联查订单)。 /// [HttpGet("/finance/receivable-payable/list")] public async Task> GetReceivablePayableListAsync( [FromQuery] ReceivablePayablePaginatedInput input) { - var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( - CustomerRepository, - CurrentUser, - input.CustomerId); - var filter = BuildFilter(input, customerId); - await ApplyInternalOrderDataScopeAsync(filter); - + var filter = await BuildScopedFilterAsync(input); var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var (rows, total) = await OrderRepository.GetReceivablePayablePagedListAsync( @@ -48,6 +51,102 @@ public class FinanceService : ApplicationService pageSize, filter); + var items = await MapReceivablePayableRowsAsync(rows); + return new OmsPageResult + { + Items = items, + Total = total + }; + } + + /// + /// 应收应付汇总(当前筛选条件下全量费用明细)。 + /// + [HttpGet("/finance/receivable-payable/summary")] + public async Task GetReceivablePayableSummaryAsync( + [FromQuery] ReceivablePayablePaginatedInput input) + { + var filter = await BuildScopedFilterAsync(input); + var rows = await OrderRepository.GetReceivablePayableSummaryAsync(filter); + var items = rows + .Select(row => new ReceivablePayableSummaryItemOutput + { + FeeType = row.FeeType, + FeeTypeName = GetFeeTypeName(row.FeeType), + Currency = row.Currency, + TotalAmount = row.TotalAmount, + LineCount = row.LineCount + }) + .OrderBy(x => x.FeeType) + .ThenBy(x => x.Currency) + .ToList(); + + return new ReceivablePayableSummaryOutput + { + TotalLines = items.Sum(x => x.LineCount), + Items = items + }; + } + + /// + /// 导出应收应付 Excel。 + /// + [HttpGet("/finance/receivable-payable/export")] + public async Task ExportReceivablePayableAsync( + [FromQuery] ReceivablePayablePaginatedInput input) + { + var filter = await BuildScopedFilterAsync(input); + var rows = await OrderRepository.GetReceivablePayableExportListAsync(filter, ExportMaxRows); + var items = await MapReceivablePayableRowsAsync(rows); + var exportRows = items.Select(item => new + { + 运单号 = item.ServiceNo, + 客户单号 = item.ClientNo, + 跟踪号 = item.TrackNo, + 客户编码 = item.CustomerCode, + 费用类型 = item.FeeTypeName, + 费用项 = item.CostItemName ?? item.CostItem, + 金额 = item.Amount, + 币种 = item.Currency, + 计费阶段 = item.ChargingStageName, + 订单状态 = item.OrderStatusName, + 支付状态 = item.IsPaid == 1 ? "已支付" : "未支付", + 产品编码 = item.ProductCode, + 渠道编码 = item.ChannelCode, + 目的国 = item.ConsigneeCountryName, + 下游服务商 = item.DownstreamProvider, + 价格编号 = item.PriceNo, + 价格生效时间 = item.PriceEffectiveTime, + 支付时间 = item.PayTime, + 收货时间 = item.ReceiveTime, + 发货时间 = item.ShipTime + }).ToList(); + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, exportRows); + stream.Position = 0; + return new FileContentResult(stream.ToArray(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = $"receivable-payable-{DateTime.Now:yyyyMMddHHmmss}.xlsx" + }; + } + + private async Task BuildScopedFilterAsync( + ReceivablePayablePaginatedInput input) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var filter = BuildFilter(input, customerId); + await ApplyInternalOrderDataScopeAsync(filter); + return filter; + } + + private async Task> MapReceivablePayableRowsAsync( + List rows) + { var customerIds = rows .Select(x => x.Order.CustomerId) .Where(x => x.HasValue) @@ -58,8 +157,9 @@ public class FinanceService : ApplicationService ? [] : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); var customerMap = customers.ToDictionary(x => x.Id); + var feeItemNameMap = await BuildFeeItemNameMapAsync(); - var items = rows.Select(row => + return rows.Select(row => { string? customerCode = null; if (row.Order.CustomerId.HasValue @@ -68,8 +168,12 @@ public class FinanceService : ApplicationService customerCode = customer.CustomerCode; } + var costItem = row.Fee.CostItem; + feeItemNameMap.TryGetValue(costItem, out var costItemName); + return new ReceivablePayablePaginatedOutput { + OrderId = row.Order.Id, ServiceNo = row.Order.ServiceNo, ClientNo = row.Order.ClientNo, TrackNo = row.Order.TrackNo, @@ -85,7 +189,8 @@ public class FinanceService : ApplicationService ConsigneeCountryName = row.Order.ConsigneeCountryName, FeeType = row.Fee.Type, FeeTypeName = GetFeeTypeName(row.Fee.Type), - CostItem = row.Fee.CostItem, + CostItem = costItem, + CostItemName = costItemName ?? ParcelBillingCalculator.ResolveFeeDisplayName(costItem, null), Amount = row.Fee.Amount, Currency = row.Fee.Currency, DownstreamProvider = row.Fee.DownstreamProvider, @@ -95,27 +200,59 @@ public class FinanceService : ApplicationService ChargingStageName = GetChargingStageName(row.Fee.ChargingStage) }; }).ToList(); + } - return new OmsPageResult - { - Items = items, - Total = total - }; + private async Task> BuildFeeItemNameMapAsync() + { + var dictRows = await DictRepository.GetListAsync(x => x.DictType == FeeItemDictType); + return dictRows + .Where(x => !string.IsNullOrWhiteSpace(x.DictCode)) + .GroupBy(x => x.DictCode.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.First().DictName?.Trim() ?? g.Key, + StringComparer.OrdinalIgnoreCase); } private static OrderReceivablePayableFilter BuildFilter( ReceivablePayablePaginatedInput input, - int? customerId) => - new() + int? customerId) + { + var timeField = NormalizeTimeField(input.TimeField); + var useCreateTime = timeField == "createTime"; + return new() { Keyword = input.Keyword, CustomerId = customerId, - CreateTimeStart = input.StartTime, - CreateTimeEnd = input.EndTime, + CustomerCode = input.CustomerCode, + CreateTimeStart = useCreateTime ? input.StartTime : null, + CreateTimeEnd = useCreateTime ? input.EndTime : null, + TimeField = timeField, + TimeStart = input.StartTime, + TimeEnd = input.EndTime, FeeType = input.FeeType, CostItem = input.CostItem, - ChargingStage = input.ChargingStage + ChargingStage = input.ChargingStage, + EffectiveStageOnly = input.EffectiveStageOnly, + IsPaid = NormalizeIsPaid(input.IsPaid) }; + } + + private static int? NormalizeIsPaid(int? isPaid) + { + if (!isPaid.HasValue) return null; + return isPaid.Value == 1 ? 1 : isPaid.Value == 0 ? 0 : null; + } + + private static string NormalizeTimeField(string? timeField) + { + var raw = timeField?.Trim(); + return raw switch + { + "receiveTime" or "shipTime" or "payTime" => raw, + _ => "createTime" + }; + } private async Task ApplyInternalOrderDataScopeAsync(OrderReceivablePayableFilter filter) { diff --git a/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableFilter.cs index 9d72a6f..41c494e 100644 --- a/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableFilter.cs +++ b/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableFilter.cs @@ -19,4 +19,34 @@ public class OrderReceivablePayableFilter : OrderListFilter /// 计费阶段:forecast / inbound / outbound。 /// public string? ChargingStage { get; set; } + + /// + /// 客户编码关键词(模糊匹配)。 + /// + public string? CustomerCode { get; set; } + + /// + /// 仅保留各运单费用项的当前生效阶段(出库 > 入库 > 预报)。 + /// + public bool EffectiveStageOnly { get; set; } + + /// + /// 时间筛选字段:createTime / receiveTime / shipTime / payTime。 + /// + public string? TimeField { get; set; } + + /// + /// 时间区间起(与 TimeField 对应字段)。 + /// + public DateTime? TimeStart { get; set; } + + /// + /// 时间区间止(与 TimeField 对应字段)。 + /// + public DateTime? TimeEnd { get; set; } + + /// + /// 支付状态:0 未支付,1 已支付;null 表示全部。 + /// + public int? IsPaid { get; set; } } diff --git a/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableSummaryRow.cs b/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableSummaryRow.cs new file mode 100644 index 0000000..64afd60 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/OrderReceivablePayableSummaryRow.cs @@ -0,0 +1,15 @@ +namespace Oms.Domain.OrdersAggregate; + +/// +/// 应收应付汇总行(按费用类型 + 币种)。 +/// +public class OrderReceivablePayableSummaryRow +{ + public string FeeType { get; set; } = string.Empty; + + public string Currency { get; set; } = string.Empty; + + public decimal TotalAmount { get; set; } + + public int LineCount { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index e06bec6..1cd7722 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -76,6 +76,15 @@ public interface IOrderRepository : IRepository OrderReceivablePayableFilter filter, CancellationToken cancellationToken = default); + Task> GetReceivablePayableSummaryAsync( + OrderReceivablePayableFilter filter, + CancellationToken cancellationToken = default); + + Task> GetReceivablePayableExportListAsync( + OrderReceivablePayableFilter filter, + int maxRows, + CancellationToken cancellationToken = default); + Task> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default); Task> GetStatusCountsByFilterAsync( diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index b957f31..c50a52f 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1,4 +1,5 @@ using Fake.DependencyInjection; +using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.OrdersAggregate; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -1088,8 +1089,81 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - var orderIdsQuery = ApplyListFilter(db.Queryable(), filter).Select(x => x.Id); + var feeQuery = await BuildReceivablePayableFeeQueryAsync(db, filter, cancellationToken); + var total = await feeQuery.CountAsync(cancellationToken); + var fees = await feeQuery + .OrderBy(fee => fee.CreateTime, OrderByType.Desc) + .OrderBy(fee => fee.Id, OrderByType.Desc) + .ToPageListAsync(pageIndex, pageSize, cancellationToken); + + return (await MapReceivablePayableRowsAsync(db, fees, cancellationToken), total); + } + + public async Task> GetReceivablePayableSummaryAsync( + OrderReceivablePayableFilter filter, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var feeQuery = await BuildReceivablePayableFeeQueryAsync(db, filter, cancellationToken); + + return await feeQuery + .GroupBy(fee => new { fee.Type, fee.Currency }) + .Select(g => new OrderReceivablePayableSummaryRow + { + FeeType = g.Type, + Currency = g.Currency, + TotalAmount = SqlFunc.AggregateSum(g.Amount), + LineCount = SqlFunc.AggregateCount(g.Id) + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetReceivablePayableExportListAsync( + OrderReceivablePayableFilter filter, + int maxRows, + CancellationToken cancellationToken = default) + { + if (maxRows <= 0) + { + return []; + } + + var db = await GetDbContextAsync(cancellationToken); + var feeQuery = await BuildReceivablePayableFeeQueryAsync(db, filter, cancellationToken); + var fees = await feeQuery + .OrderBy(fee => fee.CreateTime, OrderByType.Desc) + .OrderBy(fee => fee.Id, OrderByType.Desc) + .Take(maxRows) + .ToListAsync(cancellationToken); + + return await MapReceivablePayableRowsAsync(db, fees, cancellationToken); + } + + private static async Task> BuildReceivablePayableFeeQueryAsync( + ISqlSugarClient db, + OrderReceivablePayableFilter filter, + CancellationToken cancellationToken = default) + { + var orderQuery = ApplyListFilter(db.Queryable(), filter); + var customerCode = filter.CustomerCode?.Trim(); + if (!string.IsNullOrWhiteSpace(customerCode)) + { + orderQuery = orderQuery.Where(o => + SqlFunc.Subqueryable() + .Where(c => c.Id == o.CustomerId && c.CustomerCode.Contains(customerCode)) + .Any()); + } + + orderQuery = ApplyReceivablePayableTimeFilter(orderQuery, filter); + + if (filter.IsPaid is 0 or 1) + { + var paidFlag = (byte)filter.IsPaid.Value; + orderQuery = orderQuery.Where(o => o.IsPaid == paidFlag); + } + + var orderIdsQuery = orderQuery.Select(x => x.Id); var feeQuery = db.Queryable() .Where(fee => !fee.IsDeleted) .In(fee => fee.OrderId, orderIdsQuery); @@ -1104,7 +1178,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I var costItem = filter.CostItem?.Trim(); if (!string.IsNullOrWhiteSpace(costItem)) { - feeQuery = feeQuery.Where(fee => fee.CostItem.Contains(costItem)); + feeQuery = feeQuery.Where(fee => fee.CostItem == costItem || fee.CostItem.Contains(costItem)); } var chargingStage = filter.ChargingStage?.Trim(); @@ -1114,15 +1188,85 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I feeQuery = feeQuery.Where(fee => fee.ChargingStage == chargingStage); } - var total = await feeQuery.CountAsync(cancellationToken); - var fees = await feeQuery - .OrderBy(fee => fee.CreateTime, OrderByType.Desc) - .OrderBy(fee => fee.Id, OrderByType.Desc) - .ToPageListAsync(pageIndex, pageSize, cancellationToken); + if (!filter.EffectiveStageOnly) + { + return feeQuery; + } + var candidates = await feeQuery + .Select(f => new { f.Id, f.OrderId, f.Type, f.CostItem, f.ChargingStage }) + .ToListAsync(cancellationToken); + if (candidates.Count == 0) + { + return feeQuery.Where(_ => false); + } + + var winningIds = candidates + .GroupBy(f => (f.OrderId, f.Type, f.CostItem)) + .Select(g => g + .OrderByDescending(f => GetChargingStageRank(f.ChargingStage)) + .ThenByDescending(f => f.Id) + .First() + .Id) + .ToArray(); + + return feeQuery.Where(f => winningIds.Contains(f.Id)); + } + + private static int GetChargingStageRank(string? stage) => + stage switch + { + OrderFeeChargingStageCodes.Outbound => 3, + OrderFeeChargingStageCodes.Inbound => 2, + OrderFeeChargingStageCodes.Forecast => 1, + _ => 0 + }; + + private static ISugarQueryable ApplyReceivablePayableTimeFilter( + ISugarQueryable query, + OrderReceivablePayableFilter filter) + { + var timeField = filter.TimeField?.Trim(); + if (string.IsNullOrWhiteSpace(timeField) || string.Equals(timeField, "createTime", StringComparison.OrdinalIgnoreCase)) + { + return query; + } + + if (filter.TimeStart.HasValue) + { + var start = filter.TimeStart.Value; + query = timeField switch + { + "receiveTime" => query.Where(o => o.ReceiveTime != null && o.ReceiveTime >= start), + "shipTime" => query.Where(o => o.ShipTime != null && o.ShipTime >= start), + "payTime" => query.Where(o => o.PayTime != null && o.PayTime >= start), + _ => query + }; + } + + if (filter.TimeEnd.HasValue) + { + var end = filter.TimeEnd.Value; + query = timeField switch + { + "receiveTime" => query.Where(o => o.ReceiveTime != null && o.ReceiveTime <= end), + "shipTime" => query.Where(o => o.ShipTime != null && o.ShipTime <= end), + "payTime" => query.Where(o => o.PayTime != null && o.PayTime <= end), + _ => query + }; + } + + return query; + } + + private static async Task> MapReceivablePayableRowsAsync( + ISqlSugarClient db, + List fees, + CancellationToken cancellationToken) + { if (fees.Count == 0) { - return ([], total); + return []; } var orderIds = fees.Select(x => x.OrderId).Distinct().ToArray(); @@ -1131,7 +1275,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .ToListAsync(cancellationToken); var orderMap = orders.ToDictionary(x => x.Id); - var items = fees + return fees .Where(fee => orderMap.ContainsKey(fee.OrderId)) .Select(fee => new OrderReceivablePayableRow { @@ -1139,8 +1283,6 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I Order = orderMap[fee.OrderId] }) .ToList(); - - return (items, total); } public async Task> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default)