feat: 对账件重尺导入与应收只留一套
All checks were successful
Build and Deploy / deploy (push) Successful in 1m45s

新增 downstream 重量导入并锁定渠道重算应付;重算应收清空全部阶段;费用软删与查询过滤,避免预报入库叠算。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-12 16:57:28 +08:00
parent 43fcb4136a
commit 87abcecb65
6 changed files with 533 additions and 16 deletions

View File

@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 对账件重尺(downstream)Excel 导入入参。
/// </summary>
public class OrderDownstreamWeightImportInput
{
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
}

View File

@ -0,0 +1,25 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 对账件重尺导入结果。
/// </summary>
public class OrderDownstreamWeightImportOutput
{
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public List<OrderDownstreamWeightImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class OrderDownstreamWeightImportFailRow
{
public int RowNumber { get; set; }
public string? WaybillNo { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -256,11 +256,85 @@ public partial class OrderService
public async Task<OrderOutput> RerunInboundBillingAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
EnsureReceivableMutable(order);
await ProcessInboundBillingForBatchAsync(order.Id);
order = await GetOrderOrThrowAsync(input.Id);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 下载对账件重尺(downstream)导入模板。导入后只重算应付,不改应收。
/// </summary>
[HttpGet("/order/weight/downstream-import-template")]
public async Task<FileContentResult> DownloadDownstreamWeightImportTemplateAsync()
{
var bytes = await OrderDownstreamWeightImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = OrderDownstreamWeightImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入对账件重尺:写入 Type=downstream,锁定当前渠道重算应付;不改应收。
/// </summary>
[HttpPost("/order/weight/downstream-import")]
public async Task<OrderDownstreamWeightImportOutput> ImportDownstreamWeightsAsync(
[FromForm] OrderDownstreamWeightImportInput input)
{
if (input.File == null || input.File.Length == 0)
{
throw new BusinessException("请上传 Excel 文件");
}
await using var stream = input.File.OpenReadStream();
List<(int RowNumber, OrderDownstreamWeightImportRow Row)> parsedRows;
try
{
parsedRows = await OrderDownstreamWeightImportHelper.ParseImportRowsAsync(stream);
}
catch (Exception ex)
{
throw new BusinessException($"Excel 解析失败:{ex.Message}");
}
if (parsedRows.Count == 0)
{
throw new BusinessException("Excel 中无有效数据行");
}
if (parsedRows.Count > OrderDownstreamWeightImportHelper.MaxRows)
{
throw new BusinessException(
$"单次最多导入 {OrderDownstreamWeightImportHelper.MaxRows} 行,当前 {parsedRows.Count} 行");
}
var output = new OrderDownstreamWeightImportOutput();
foreach (var (rowNumber, row) in parsedRows)
{
try
{
OrderDownstreamWeightImportHelper.ValidateRow(row);
await ApplyDownstreamWeightAndRecalculatePayableAsync(row);
output.SuccessCount++;
}
catch (Exception ex)
{
output.FailCount++;
output.Failures.Add(new OrderDownstreamWeightImportFailRow
{
RowNumber = rowNumber,
WaybillNo = row.WaybillNo,
Reason = ex is BusinessException or InvalidOperationException
? ex.Message
: $"导入失败:{ex.Message}"
});
}
}
return output;
}
/// <summary>
/// 查询排货过程:优先返回快照;无快照或 live=true 时按当前规则现算(不落库)。
/// </summary>
@ -701,7 +775,121 @@ public partial class OrderService
private async Task RecalculatePayableCoreAsync(Order order)
{
await ReallocateCoreAsync(order, scheme: null, serviceChannelId: null, enforceReallocateGate: false);
// 已有渠道时锁定渠道只重算金额,避免「重算应付」误换渠道清下游单号。
await ReallocateCoreAsync(
order,
scheme: null,
serviceChannelId: null,
enforceReallocateGate: false,
lockCurrentChannel: true);
}
/// <summary>
/// 写入对账件重尺并锁定当前渠道重算应付(不改应收)。
/// </summary>
private async Task ApplyDownstreamWeightAndRecalculatePayableAsync(OrderDownstreamWeightImportRow row)
{
var order = await OrderRepository.GetByWaybillNoAsync(row.WaybillNo!.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ChannelCode))
{
throw new BusinessException("订单未确认服务渠道,无法按对账重量重算应付");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法重算应付");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("销售产品不存在");
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var downstream = weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Downstream, StringComparison.OrdinalIgnoreCase));
if (downstream == null)
{
throw new BusinessException("订单缺少下游件重尺记录");
}
// 尺寸可选:未填则沿用下游已有,仍缺则回落收货→发货→预报。
var length = row.Length ?? downstream.Length;
var width = row.Width ?? downstream.Width;
var height = row.Height ?? downstream.Height;
if (length is not > 0 || width is not > 0 || height is not > 0)
{
FillDimensionsFromFallback(weights, ref length, ref width, ref height);
}
if (length is not > 0 || width is not > 0 || height is not > 0)
{
throw new BusinessException("件重尺尺寸不完整,请在 Excel 填写长/宽/高,或先完善订单收货/发货尺寸");
}
ApplyMeasuredDimensions(downstream, length.Value, width.Value, height.Value, row.ActualWeight!.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, downstream);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
await ReallocateCoreAsync(
order,
scheme: null,
serviceChannelId: null,
enforceReallocateGate: false,
lockCurrentChannel: true,
preferDownstreamWeight: true);
}
private static void FillDimensionsFromFallback(
IReadOnlyList<OrderWeight> weights,
ref decimal? length,
ref decimal? width,
ref decimal? height)
{
foreach (var type in new[]
{
OrderWeightTypeCodes.Receive,
OrderWeightTypeCodes.Ship,
OrderWeightTypeCodes.Forecast
})
{
var src = weights.FirstOrDefault(x =>
string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase));
if (src == null)
{
continue;
}
if (length is not > 0)
{
length = src.Length;
}
if (width is not > 0)
{
width = src.Width;
}
if (height is not > 0)
{
height = src.Height;
}
if (length is > 0 && width is > 0 && height is > 0)
{
return;
}
}
}
/// <summary>
@ -711,7 +899,9 @@ public partial class OrderService
Order order,
string? scheme,
int? serviceChannelId,
bool enforceReallocateGate = true)
bool enforceReallocateGate = true,
bool lockCurrentChannel = false,
bool preferDownstreamWeight = false)
{
string? schemeOverride = null;
int? forceChannelId = null;
@ -723,6 +913,18 @@ public partial class OrderService
? AllocationSchemeCodes.Designated
: normalizedScheme;
}
else if (lockCurrentChannel && !string.IsNullOrWhiteSpace(order.ChannelCode))
{
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
x.ChannelCode == order.ChannelCode.Trim());
if (channel == null)
{
throw new BusinessException("订单服务渠道不存在,无法重算应付");
}
forceChannelId = channel.Id;
schemeOverride = AllocationSchemeCodes.Designated;
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
@ -736,13 +938,18 @@ public partial class OrderService
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var weightRow = ResolvePayableWeightRow(order, weights);
var weightRow = preferDownstreamWeight
? weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Downstream, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException("缺少件重尺类型 downstream")
: ResolvePayableWeightRow(order, weights);
if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0)
{
throw new BusinessException("计费重无效,请先完善件重尺");
}
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
@ -753,6 +960,11 @@ public partial class OrderService
snapshotBinding);
var (_, chargingStage) = ResolveReceivableBillingStage(order);
// 对账件重尺导入:费用阶段记 outbound(终稿成本),与入库应收阶段解耦。
if (preferDownstreamWeight)
{
chargingStage = OrderFeeChargingStageCodes.Outbound;
}
await ConfirmServiceChannelAndPayableAsync(
order,
@ -823,11 +1035,12 @@ public partial class OrderService
var weightType = chargingStage switch
{
OrderFeeChargingStageCodes.Inbound => OrderWeightTypeCodes.Receive,
_ => OrderWeightTypeCodes.Downstream
OrderFeeChargingStageCodes.Outbound => OrderWeightTypeCodes.Ship,
_ => OrderWeightTypeCodes.Forecast
};
return weights.FirstOrDefault(x => x.Type == weightType)
?? weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream)
return weights.FirstOrDefault(x =>
string.Equals(x.Type, weightType, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException($"缺少件重尺类型 {weightType}");
}

View File

@ -774,7 +774,8 @@ public partial class OrderService
{
if (replaceExistingReceivable)
{
await OrderRepository.DeleteReceivableFeesByStageAsync(order.Id, chargingStage);
// 有效应收只保留一套:清空全部阶段后再写入当前阶段,避免预报+入库叠算。
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
}
await OrderRepository.UpsertFeesAsync(order.Id, fees);

View File

@ -0,0 +1,251 @@
using System.Globalization;
using MiniExcelLibs;
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Shared;
/// <summary>
/// 对账件重尺(downstream)Excel 模板与解析。
/// 仅写入下游对账重量,触发应付重算;不改应收。
/// </summary>
public static class OrderDownstreamWeightImportHelper
{
public const int MaxRows = 2000;
private static readonly Dictionary<string, string> HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
{
["运单号"] = "WaybillNo",
["单号"] = "WaybillNo",
["服务商单号"] = "WaybillNo",
["WaybillNo"] = "WaybillNo",
["waybillNo"] = "WaybillNo",
["ServiceNo"] = "WaybillNo",
["serviceNo"] = "WaybillNo",
["长"] = "Length",
["长度"] = "Length",
["Length"] = "Length",
["length"] = "Length",
["宽"] = "Width",
["宽度"] = "Width",
["Width"] = "Width",
["width"] = "Width",
["高"] = "Height",
["高度"] = "Height",
["Height"] = "Height",
["height"] = "Height",
["实重"] = "ActualWeight",
["重量"] = "ActualWeight",
["ActualWeight"] = "ActualWeight",
["actualWeight"] = "ActualWeight",
["Weight"] = "ActualWeight",
["weight"] = "ActualWeight",
};
public static async Task<byte[]> BuildTemplateBytesAsync()
{
var rows = new List<Dictionary<string, object?>>
{
new()
{
["运单号"] = "OMS202601010001",
["长"] = 30,
["宽"] = 20,
["高"] = 10,
["实重"] = 1.25
}
};
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, rows);
return stream.ToArray();
}
public static string TemplateFileName => "对账件重尺导入模板.xlsx";
public static async Task<List<(int RowNumber, OrderDownstreamWeightImportRow Row)>> ParseImportRowsAsync(
Stream fileStream)
{
var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true);
var result = new List<(int RowNumber, OrderDownstreamWeightImportRow Row)>();
var rowNumber = 1;
foreach (var row in rows)
{
rowNumber++;
if (row is not IDictionary<string, object> rowDict)
{
continue;
}
if (IsEmptyRow(rowDict))
{
continue;
}
try
{
result.Add((rowNumber, ParseRow(rowDict)));
}
catch (InvalidOperationException ex)
{
result.Add((rowNumber, new OrderDownstreamWeightImportRow
{
WaybillNo = TryPeekWaybillNo(rowDict),
ParseError = ex.Message
}));
}
}
return result;
}
public static void ValidateRow(OrderDownstreamWeightImportRow row)
{
if (!string.IsNullOrWhiteSpace(row.ParseError))
{
throw new InvalidOperationException(row.ParseError);
}
if (string.IsNullOrWhiteSpace(row.WaybillNo))
{
throw new InvalidOperationException("运单号不能为空");
}
row.WaybillNo = row.WaybillNo.Trim();
if (row.WaybillNo.Length > 64)
{
throw new InvalidOperationException("运单号不可超过64个字符");
}
if (!row.ActualWeight.HasValue || row.ActualWeight.Value <= 0)
{
throw new InvalidOperationException("实重必须大于0");
}
ValidateOptionalDimension(row.Length, "长");
ValidateOptionalDimension(row.Width, "宽");
ValidateOptionalDimension(row.Height, "高");
var hasAnyDim = row.Length.HasValue || row.Width.HasValue || row.Height.HasValue;
var hasAllDim = row.Length.HasValue && row.Width.HasValue && row.Height.HasValue;
if (hasAnyDim && !hasAllDim)
{
throw new InvalidOperationException("长/宽/高需同时填写或全部留空(留空则沿用订单已有尺寸)");
}
}
private static string? TryPeekWaybillNo(IDictionary<string, object> rowDict)
{
foreach (var (header, value) in rowDict)
{
if (string.IsNullOrWhiteSpace(header))
{
continue;
}
if (HeaderAliases.TryGetValue(header.Trim(), out var field)
&& string.Equals(field, "WaybillNo", StringComparison.OrdinalIgnoreCase))
{
return NormalizeCell(value);
}
}
return null;
}
private static void ValidateOptionalDimension(decimal? value, string label)
{
if (!value.HasValue)
{
return;
}
if (value.Value <= 0)
{
throw new InvalidOperationException($"{label}必须大于0");
}
}
private static OrderDownstreamWeightImportRow ParseRow(IDictionary<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var (header, value) in rowDict)
{
if (string.IsNullOrWhiteSpace(header))
{
continue;
}
if (!HeaderAliases.TryGetValue(header.Trim(), out var field))
{
continue;
}
mapped[field] = NormalizeCell(value);
}
return new OrderDownstreamWeightImportRow
{
WaybillNo = mapped.GetValueOrDefault("WaybillNo"),
Length = ParseDecimal(mapped.GetValueOrDefault("Length")),
Width = ParseDecimal(mapped.GetValueOrDefault("Width")),
Height = ParseDecimal(mapped.GetValueOrDefault("Height")),
ActualWeight = ParseDecimal(mapped.GetValueOrDefault("ActualWeight"))
};
}
private static decimal? ParseDecimal(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
{
return value;
}
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("zh-CN"), out value))
{
return value;
}
throw new InvalidOperationException($"数值格式无效:{text}");
}
private static string? NormalizeCell(object? value)
{
if (value is null)
{
return null;
}
var text = Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
private static bool IsEmptyRow(IDictionary<string, object> rowDict) =>
rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
}
/// <summary>
/// 对账件重尺导入行。
/// </summary>
public sealed class OrderDownstreamWeightImportRow
{
public string? WaybillNo { get; set; }
public decimal? Length { get; set; }
public decimal? Width { get; set; }
public decimal? Height { get; set; }
public decimal? ActualWeight { get; set; }
/// <summary>
/// Excel 单元格解析错误(校验阶段直接抛出)。
/// </summary>
public string? ParseError { get; set; }
}

View File

@ -996,6 +996,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
fee.OrderId = orderId;
var existingList = await db.Queryable<OrderFee>()
.Where(x => x.OrderId == orderId
&& !x.IsDeleted
&& x.Type == fee.Type
&& x.CostItem == fee.CostItem
&& x.ChargingStage == fee.ChargingStage)
@ -1004,6 +1005,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
var existing = existingList.FirstOrDefault();
if (existing == null)
{
fee.IsDeleted = false;
await db.Insertable(fee).ExecuteCommandAsync(cancellationToken);
continue;
}
@ -1013,6 +1015,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
existing.PriceNo = fee.PriceNo;
existing.PriceEffectiveTime = fee.PriceEffectiveTime;
existing.DownstreamProvider = fee.DownstreamProvider;
existing.IsDeleted = false;
existing.UpdateTime = DateTime.Now;
await db.Updateable(existing).ExecuteCommandAsync(cancellationToken);
}
@ -1021,36 +1024,46 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Receivable)
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Receivable
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeleteReceivableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Receivable
&& x.ChargingStage == chargingStage)
&& x.ChargingStage == chargingStage
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Payable)
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Payable
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
await db.Updateable<OrderFee>()
.SetColumns(x => new OrderFee { IsDeleted = true, UpdateTime = DateTime.Now })
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Payable
&& x.ChargingStage == chargingStage)
&& x.ChargingStage == chargingStage
&& !x.IsDeleted)
.ExecuteCommandAsync(cancellationToken);
}
@ -1078,7 +1091,7 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<OrderFee>()
.Where(x => ids.Contains(x.OrderId))
.Where(x => ids.Contains(x.OrderId) && !x.IsDeleted)
.ToListAsync(cancellationToken);
}