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.
This commit is contained in:
zzh 2026-08-21 18:21:33 +08:00
parent 2018e45a7d
commit ce1349572d
8 changed files with 422 additions and 35 deletions

View File

@ -32,12 +32,32 @@ public class ReceivablePayablePaginatedInput
public int? CustomerId { get; set; }
/// <summary>
/// 订单创建时间起。
/// 客户编码关键词(模糊匹配)。
/// </summary>
public string? CustomerCode { get; set; }
/// <summary>
/// 仅保留各运单费用项的当前生效阶段。
/// </summary>
public bool EffectiveStageOnly { get; set; }
/// <summary>
/// 订单创建时间起(timeField=createTime 时生效,兼容旧参数)。
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// 订单创建时间止。
/// 订单创建时间止(timeField=createTime 时生效,兼容旧参数)。
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// 时间筛选字段:createTime / receiveTime / shipTime / payTime。
/// </summary>
public string? TimeField { get; set; }
/// <summary>
/// 支付状态:0 未支付,1 已支付。
/// </summary>
public int? IsPaid { get; set; }
}

View File

@ -5,6 +5,8 @@ namespace Oms.Application.Dtos.Finance;
/// </summary>
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;

View File

@ -0,0 +1,30 @@
namespace Oms.Application.Dtos.Finance;
/// <summary>
/// 应收应付汇总出参。
/// </summary>
public class ReceivablePayableSummaryOutput
{
/// <summary>
/// 匹配筛选条件的费用明细总行数。
/// </summary>
public int TotalLines { get; set; }
/// <summary>
/// 按费用类型 + 币种汇总。
/// </summary>
public List<ReceivablePayableSummaryItemOutput> 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; }
}

View File

@ -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;
/// <summary>
/// 财务相关 API<EFBFBD>。
/// </summary>
/// 财务相关 API。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Finance")]
public class FinanceService : ApplicationService
{
private const string FeeItemDictType = "fee_item";
private const int ExportMaxRows = 50000;
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService<IOrderRepository>();
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IDictRepository DictRepository =>
LazyServiceProvider.GetRequiredService<IDictRepository>();
private EffectiveScopeBuilder DataScopeBuilder =>
LazyServiceProvider.GetRequiredService<EffectiveScopeBuilder>();
/// <summary>
/// 分页查询应收应付(订单费用联查订单)<EFBFBD>。
/// 分页查询应收应付(订单费用联查订单)。
/// </summary>
[HttpGet("/finance/receivable-payable/list")]
public async Task<OmsPageResult<ReceivablePayablePaginatedOutput>> 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<ReceivablePayablePaginatedOutput>
{
Items = items,
Total = total
};
}
/// <summary>
/// 应收应付汇总(当前筛选条件下全量费用明细)。
/// </summary>
[HttpGet("/finance/receivable-payable/summary")]
public async Task<ReceivablePayableSummaryOutput> 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
};
}
/// <summary>
/// 导出应收应付 Excel。
/// </summary>
[HttpGet("/finance/receivable-payable/export")]
public async Task<FileContentResult> 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<OrderReceivablePayableFilter> 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<List<ReceivablePayablePaginatedOutput>> MapReceivablePayableRowsAsync(
List<OrderReceivablePayableRow> 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<ReceivablePayablePaginatedOutput>
private async Task<Dictionary<string, string>> BuildFeeItemNameMapAsync()
{
Items = items,
Total = total
};
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)
{

View File

@ -19,4 +19,34 @@ public class OrderReceivablePayableFilter : OrderListFilter
/// 计费阶段:forecast / inbound / outbound。
/// </summary>
public string? ChargingStage { get; set; }
/// <summary>
/// 客户编码关键词(模糊匹配)。
/// </summary>
public string? CustomerCode { get; set; }
/// <summary>
/// 仅保留各运单费用项的当前生效阶段(出库 &gt; 入库 &gt; 预报)。
/// </summary>
public bool EffectiveStageOnly { get; set; }
/// <summary>
/// 时间筛选字段:createTime / receiveTime / shipTime / payTime。
/// </summary>
public string? TimeField { get; set; }
/// <summary>
/// 时间区间起(与 TimeField 对应字段)。
/// </summary>
public DateTime? TimeStart { get; set; }
/// <summary>
/// 时间区间止(与 TimeField 对应字段)。
/// </summary>
public DateTime? TimeEnd { get; set; }
/// <summary>
/// 支付状态:0 未支付,1 已支付;null 表示全部。
/// </summary>
public int? IsPaid { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace Oms.Domain.OrdersAggregate;
/// <summary>
/// 应收应付汇总行(按费用类型 + 币种)。
/// </summary>
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; }
}

View File

@ -76,6 +76,15 @@ public interface IOrderRepository : IRepository<Order>
OrderReceivablePayableFilter filter,
CancellationToken cancellationToken = default);
Task<List<OrderReceivablePayableSummaryRow>> GetReceivablePayableSummaryAsync(
OrderReceivablePayableFilter filter,
CancellationToken cancellationToken = default);
Task<List<OrderReceivablePayableRow>> GetReceivablePayableExportListAsync(
OrderReceivablePayableFilter filter,
int maxRows,
CancellationToken cancellationToken = default);
Task<List<OrderStatusCountRow>> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default);
Task<List<OrderStatusCountRow>> GetStatusCountsByFilterAsync(

View File

@ -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<Order>, IOrderRepository, I
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var orderIdsQuery = ApplyListFilter(db.Queryable<Order>(), 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<List<OrderReceivablePayableSummaryRow>> 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<List<OrderReceivablePayableRow>> 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<ISugarQueryable<OrderFee>> BuildReceivablePayableFeeQueryAsync(
ISqlSugarClient db,
OrderReceivablePayableFilter filter,
CancellationToken cancellationToken = default)
{
var orderQuery = ApplyListFilter(db.Queryable<Order>(), filter);
var customerCode = filter.CustomerCode?.Trim();
if (!string.IsNullOrWhiteSpace(customerCode))
{
orderQuery = orderQuery.Where(o =>
SqlFunc.Subqueryable<Customer>()
.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<OrderFee>()
.Where(fee => !fee.IsDeleted)
.In(fee => fee.OrderId, orderIdsQuery);
@ -1104,7 +1178,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, 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<Order>, 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<Order> ApplyReceivablePayableTimeFilter(
ISugarQueryable<Order> 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<List<OrderReceivablePayableRow>> MapReceivablePayableRowsAsync(
ISqlSugarClient db,
List<OrderFee> 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<Order>, 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<Order>, IOrderRepository, I
Order = orderMap[fee.OrderId]
})
.ToList();
return (items, total);
}
public async Task<List<OrderStatusCountRow>> GetStatusCountsAsync(int? customerId, CancellationToken cancellationToken = default)