diff --git a/src/Oms.Application/Dtos/Orders/OrderDownstreamWeightImportInput.cs b/src/Oms.Application/Dtos/Orders/OrderDownstreamWeightImportInput.cs new file mode 100644 index 0000000..d767903 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderDownstreamWeightImportInput.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 对账件重尺(downstream)Excel 导入入参。 +/// +public class OrderDownstreamWeightImportInput +{ + /// + /// Excel 文件。 + /// + public IFormFile File { get; set; } = null!; +} diff --git a/src/Oms.Application/Dtos/Orders/OrderDownstreamWeightImportOutput.cs b/src/Oms.Application/Dtos/Orders/OrderDownstreamWeightImportOutput.cs new file mode 100644 index 0000000..b5eca33 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderDownstreamWeightImportOutput.cs @@ -0,0 +1,25 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 对账件重尺导入结果。 +/// +public class OrderDownstreamWeightImportOutput +{ + public int SuccessCount { get; set; } + + public int FailCount { get; set; } + + public List Failures { get; set; } = []; +} + +/// +/// 导入失败行。 +/// +public class OrderDownstreamWeightImportFailRow +{ + public int RowNumber { get; set; } + + public string? WaybillNo { get; set; } + + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 4f0296f..7e2245d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -256,11 +256,85 @@ public partial class OrderService public async Task 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(); } + /// + /// 下载对账件重尺(downstream)导入模板。导入后只重算应付,不改应收。 + /// + [HttpGet("/order/weight/downstream-import-template")] + public async Task DownloadDownstreamWeightImportTemplateAsync() + { + var bytes = await OrderDownstreamWeightImportHelper.BuildTemplateBytesAsync(); + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = OrderDownstreamWeightImportHelper.TemplateFileName + }; + } + + /// + /// 导入对账件重尺:写入 Type=downstream,锁定当前渠道重算应付;不改应收。 + /// + [HttpPost("/order/weight/downstream-import")] + public async Task 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; + } + /// /// 查询排货过程:优先返回快照;无快照或 live=true 时按当前规则现算(不落库)。 /// @@ -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); + } + + /// + /// 写入对账件重尺并锁定当前渠道重算应付(不改应收)。 + /// + 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 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; + } + } } /// @@ -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}"); } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 974c13e..10f9c5d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -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); diff --git a/src/Oms.Application/Shared/OrderDownstreamWeightImportHelper.cs b/src/Oms.Application/Shared/OrderDownstreamWeightImportHelper.cs new file mode 100644 index 0000000..c16a2f7 --- /dev/null +++ b/src/Oms.Application/Shared/OrderDownstreamWeightImportHelper.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using MiniExcelLibs; +using Oms.Application.Dtos.Orders; + +namespace Oms.Application.Shared; + +/// +/// 对账件重尺(downstream)Excel 模板与解析。 +/// 仅写入下游对账重量,触发应付重算;不改应收。 +/// +public static class OrderDownstreamWeightImportHelper +{ + public const int MaxRows = 2000; + + private static readonly Dictionary 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 BuildTemplateBytesAsync() + { + var rows = new List> + { + 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> 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 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 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 rowDict) + { + var mapped = new Dictionary(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 rowDict) => + rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value))); +} + +/// +/// 对账件重尺导入行。 +/// +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; } + + /// + /// Excel 单元格解析错误(校验阶段直接抛出)。 + /// + public string? ParseError { get; set; } +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index c50a52f..b7d0884 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -996,6 +996,7 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I fee.OrderId = orderId; var existingList = await db.Queryable() .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, 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, 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, IOrderRepository, I public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - await db.Deleteable() - .Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Receivable) + await db.Updateable() + .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() + await db.Updateable() + .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() - .Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Payable) + await db.Updateable() + .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() + await db.Updateable() + .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, IOrderRepository, I var db = await GetDbContextAsync(cancellationToken); return await db.Queryable() - .Where(x => ids.Contains(x.OrderId)) + .Where(x => ids.Contains(x.OrderId) && !x.IsDeleted) .ToListAsync(cancellationToken); }