From 91ba6d5d59b32ed48b2d6b48c9bff39d7f8bce24 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 26 Jun 2026 15:29:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A1=E9=87=8D=E8=BF=9B=E4=BD=8D?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=85=BC=E5=AE=B9=E4=B8=8E=20KPI=20=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E6=98=8E=E7=BB=86=20Excel=20=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布规则快照反序列化合并 StartValue/EndValue 至 Start/End - 小包创建进位时快照区间无效则降级读 t_weight_rounding_info - 新增 KPI 分区明细导入模板下载与 Excel 批量导入接口 - 补充快照进位与导入相关单元测试 Co-authored-by: Cursor --- .../Dtos/Channels/KpiAreaInfoImportInput.cs | 19 ++ .../Dtos/Channels/KpiAreaInfoImportOutput.cs | 23 ++ .../Dtos/Orders/PublishSnapshotRuleDtos.cs | 8 + .../Services/Channels/KpiAreaService.cs | 67 +++++ .../Orders/OrderService.ParcelBilling.cs | 22 +- .../Shared/KpiAreaInfoImportHelper.cs | 230 ++++++++++++++++++ .../Shared/PublishSnapshotNormalizer.cs | 31 +++ src/Oms.Tests/KpiAreaInfoImportHelperTests.cs | 43 ++++ .../RulesSnapshotVerificationTests.cs | 30 +++ 9 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs create mode 100644 src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs create mode 100644 src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs create mode 100644 src/Oms.Tests/KpiAreaInfoImportHelperTests.cs diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs new file mode 100644 index 0000000..a2e5388 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportInput.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; + +namespace Oms.Application.Dtos.Channels; + +/// +/// KPI 分区明细 Excel 导入入参。 +/// +public class KpiAreaInfoImportInput +{ + /// + /// 分区主键。 + /// + public int AreaId { get; set; } + + /// + /// Excel 文件。 + /// + public IFormFile File { get; set; } = null!; +} diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs new file mode 100644 index 0000000..5927568 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs @@ -0,0 +1,23 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// KPI 分区明细 Excel 导入结果。 +/// +public class KpiAreaInfoImportOutput +{ + public int SuccessCount { get; set; } + + public int FailCount { get; set; } + + public List Failures { get; set; } = []; +} + +/// +/// 导入失败行。 +/// +public class KpiAreaInfoImportFailRow +{ + public int RowNumber { get; set; } + + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs index 9321cfe..c28a384 100644 --- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs +++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs @@ -55,6 +55,14 @@ public class WeightRoundingSnapshotRangeDto public decimal End { get; set; } public string? Public { get; set; } + + /// 旧快照/明细 API 兼容(t_weight_rounding_info.StartValue);读取后由 合并至 。 + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public decimal StartValue { get; set; } + + /// 旧快照/明细 API 兼容(t_weight_rounding_info.EndValue);读取后由 合并至 。 + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public decimal EndValue { get; set; } } /// diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 5138bc8..f391563 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -1,11 +1,13 @@ using System.Linq.Expressions; using Fake.Application; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Channels; @@ -17,6 +19,8 @@ public class KpiAreaService : ApplicationService { private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); + private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); + /// /// 查询KPI分区列表。 /// @@ -210,6 +214,69 @@ public class KpiAreaService : ApplicationService await KpiAreaRepository.DeleteInfoAsync(input.Id); } + /// + /// 下载 KPI 分区明细导入模板。 + /// + [HttpGet("/channel/kpi-area/info/import-template")] + public async Task DownloadInfoImportTemplateAsync([FromQuery] string? areaType) + { + var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync(); + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = KpiAreaInfoImportHelper.ResolveTemplateFileName(areaType) + }; + } + + /// + /// 导入 KPI 分区明细(Excel)。 + /// + [HttpPost("/channel/kpi-area/info/import")] + public async Task ImportInfoAsync([FromForm] KpiAreaInfoImportInput input) + { + if (input.File == null || input.File.Length == 0) + { + throw new BusinessException("请上传 Excel 文件"); + } + + await GetAreaOrThrowAsync(input.AreaId); + + await using var stream = input.File.OpenReadStream(); + var parsedRows = await KpiAreaInfoImportHelper.ParseImportRowsAsync(stream); + if (parsedRows.Count == 0) + { + throw new BusinessException("Excel 中无有效数据行"); + } + + var countries = await CountryRepository.GetListAsync(); + var countriesByCode = countries + .Where(x => !string.IsNullOrWhiteSpace(x.CountryCode)) + .GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var output = new KpiAreaInfoImportOutput(); + foreach (var (rowNumber, entity) in parsedRows) + { + try + { + KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode); + entity.AreaId = input.AreaId; + await KpiAreaRepository.InsertInfoAsync(entity); + output.SuccessCount++; + } + catch (Exception ex) + { + output.FailCount++; + output.Failures.Add(new KpiAreaInfoImportFailRow + { + RowNumber = rowNumber, + Reason = ex.Message + }); + } + } + + return output; + } + private static Expression> BuildAreaPredicate(KpiAreaListInput input) { var appKey = input.AppKey?.Trim(); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 7b21faa..4ee7d7e 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -597,10 +597,13 @@ public partial class OrderService throw new BusinessException("未配置计重进位"); } + var snapshotRanges = rulesSnapshot?.WeightRoundingInfos; + var fromSnapshot = snapshotRanges is { Count: > 0 } + && snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public)); IReadOnlyList ranges; - if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }) + if (fromSnapshot) { - ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos); + ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!); } else { @@ -613,7 +616,20 @@ public partial class OrderService ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); } - forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + try + { + forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + } + catch (InvalidOperationException) when (fromSnapshot) + { + var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); + if (dbRanges.Count == 0) + { + throw; + } + + forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges); + } } catch (BusinessException) { diff --git a/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs new file mode 100644 index 0000000..7377412 --- /dev/null +++ b/src/Oms.Application/Shared/KpiAreaInfoImportHelper.cs @@ -0,0 +1,230 @@ +using System.Text.RegularExpressions; +using MiniExcelLibs; +using Oms.Application.Dtos.Channels; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// KPI 分区明细 Excel 模板与导入解析。 +/// +public static class KpiAreaInfoImportHelper +{ + private static readonly Regex ChinesePattern = new(@"[\u4E00-\u9FA5]", RegexOptions.Compiled); + + private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["国家代码"] = nameof(KpiAreaInfo.CountryCode), + ["CountryCode"] = nameof(KpiAreaInfo.CountryCode), + ["countryCode"] = nameof(KpiAreaInfo.CountryCode), + ["国家名称"] = nameof(KpiAreaInfo.CountryName), + ["CountryName"] = nameof(KpiAreaInfo.CountryName), + ["countryName"] = nameof(KpiAreaInfo.CountryName), + ["州/省"] = nameof(KpiAreaInfo.Province), + ["州省"] = nameof(KpiAreaInfo.Province), + ["Province"] = nameof(KpiAreaInfo.Province), + ["province"] = nameof(KpiAreaInfo.Province), + ["城市"] = nameof(KpiAreaInfo.City), + ["City"] = nameof(KpiAreaInfo.City), + ["city"] = nameof(KpiAreaInfo.City), + ["起始邮编"] = nameof(KpiAreaInfo.LeftPostcode), + ["LeftPostcode"] = nameof(KpiAreaInfo.LeftPostcode), + ["leftPostcode"] = nameof(KpiAreaInfo.LeftPostcode), + ["leftPostCode"] = nameof(KpiAreaInfo.LeftPostcode), + ["截止邮编"] = nameof(KpiAreaInfo.RightPostcode), + ["RightPostcode"] = nameof(KpiAreaInfo.RightPostcode), + ["rightPostcode"] = nameof(KpiAreaInfo.RightPostcode), + ["rightPostCode"] = nameof(KpiAreaInfo.RightPostcode), + }; + + public static async Task BuildTemplateBytesAsync() + { + var rows = new List> + { + new() + { + ["国家代码"] = "US", + ["国家名称"] = "美国", + ["州/省"] = "CA", + ["城市"] = "", + ["起始邮编"] = "90000", + ["截止邮编"] = "90199" + } + }; + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, rows); + return stream.ToArray(); + } + + public static string ResolveTemplateFileName(string? areaType) => + areaType?.Trim().ToLowerInvariant() switch + { + "blacklist" => "黑名单分区明细导入模板.xlsx", + "remote" => "偏远分区明细导入模板.xlsx", + "price" => "价格分区明细导入模板.xlsx", + _ => "分区明细导入模板.xlsx" + }; + + public static async Task> ParseImportRowsAsync(Stream fileStream) + { + var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true); + var result = new List<(int RowNumber, KpiAreaInfo Entity)>(); + var rowNumber = 1; + + foreach (var row in rows) + { + rowNumber++; + if (row is not IDictionary rowDict) + { + continue; + } + + if (IsEmptyRow(rowDict)) + { + continue; + } + + result.Add((rowNumber, ParseRow(rowDict))); + } + + return result; + } + + public static void PrepareEntityForImport( + KpiAreaInfo entity, + IReadOnlyDictionary countriesByCode) + { + ValidateEntity(entity); + + if (string.IsNullOrWhiteSpace(entity.CountryName) + && !string.IsNullOrWhiteSpace(entity.CountryCode)) + { + var code = entity.CountryCode.Trim(); + if (countriesByCode.TryGetValue(code, out var country)) + { + entity.CountryName = country.ChineseName; + } + } + + if (string.IsNullOrWhiteSpace(entity.CountryName)) + { + throw new InvalidOperationException("国家名称不能为空,请填写国家名称或有效的国家代码"); + } + } + + private static KpiAreaInfo ParseRow(IDictionary rowDict) + { + var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (header, value) in rowDict) + { + if (!HeaderAliases.TryGetValue(header.Trim(), out var field)) + { + continue; + } + + mapped[field] = NormalizeCell(value); + } + + return new KpiAreaInfo + { + CountryCode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.CountryCode)), + CountryName = mapped.GetValueOrDefault(nameof(KpiAreaInfo.CountryName)), + Province = mapped.GetValueOrDefault(nameof(KpiAreaInfo.Province)), + City = mapped.GetValueOrDefault(nameof(KpiAreaInfo.City)), + LeftPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.LeftPostcode)), + RightPostcode = mapped.GetValueOrDefault(nameof(KpiAreaInfo.RightPostcode)) + }; + } + + private static void ValidateEntity(KpiAreaInfo entity) + { + if (string.IsNullOrWhiteSpace(entity.CountryCode)) + { + throw new InvalidOperationException("国家代码不能为空"); + } + + entity.CountryCode = entity.CountryCode.Trim(); + if (entity.CountryCode.Length > 16) + { + throw new InvalidOperationException("国家代码不可超过16个字符"); + } + + if (!string.IsNullOrWhiteSpace(entity.CountryName)) + { + entity.CountryName = entity.CountryName.Trim(); + if (entity.CountryName.Length > 64) + { + throw new InvalidOperationException("国家名称不可超过64个字符"); + } + } + + entity.Province = TrimOrNull(entity.Province); + entity.City = TrimOrNull(entity.City); + entity.LeftPostcode = TrimOrNull(entity.LeftPostcode); + entity.RightPostcode = TrimOrNull(entity.RightPostcode); + + if (!string.IsNullOrWhiteSpace(entity.Province) && entity.Province.Length > 128) + { + throw new InvalidOperationException("州/省不可超过128个字符"); + } + + if (!string.IsNullOrWhiteSpace(entity.City) && entity.City.Length > 128) + { + throw new InvalidOperationException("城市不可超过128个字符"); + } + + ValidatePostcode(entity.LeftPostcode, "起始邮编"); + ValidatePostcode(entity.RightPostcode, "截止邮编"); + + var hasLeft = !string.IsNullOrWhiteSpace(entity.LeftPostcode); + var hasRight = !string.IsNullOrWhiteSpace(entity.RightPostcode); + if (hasLeft != hasRight) + { + throw new InvalidOperationException("起始邮编与截止邮编需同时填写或同时留空"); + } + } + + private static void ValidatePostcode(string? value, string label) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (ChinesePattern.IsMatch(value)) + { + throw new InvalidOperationException($"{label}不能包含中文"); + } + + if (value.Length > 30) + { + throw new InvalidOperationException($"{label}不可超过30个字符"); + } + } + + private static string? TrimOrNull(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim(); + } + + private static string? NormalizeCell(object? value) + { + if (value is null) + { + return null; + } + + var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool IsEmptyRow(IDictionary rowDict) => + rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value))); +} diff --git a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs index ac3ff63..0ca3f0b 100644 --- a/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotNormalizer.cs @@ -16,6 +16,7 @@ public static class PublishSnapshotNormalizer NormalizeKpiAreas(snapshot.BlacklistAreaInfos); NormalizeKpiAreas(snapshot.RemoteAreaInfos); + NormalizeWeightRoundingRanges(snapshot.WeightRoundingInfos); } public static void NormalizePriceSnapshot(SalesProductPricePublishSnapshotDto? snapshot) @@ -47,4 +48,34 @@ public static class PublishSnapshotNormalizer area.RightPostcode = null; } } + + public static void NormalizeWeightRoundingRanges(List? ranges) + { + if (ranges is null) + { + return; + } + + foreach (var range in ranges) + { + if (range.StartValue != 0) + { + range.Start = range.StartValue; + } + + if (range.EndValue != 0) + { + range.End = range.EndValue; + } + + range.StartValue = 0; + range.EndValue = 0; + + if (range.Start == 0 && range.End == 0 && !string.IsNullOrWhiteSpace(range.Public)) + { + // 无效区间占位,避免误用导致「计重进位区间未匹配」;后续会降级读 DB。 + range.Public = null; + } + } + } } diff --git a/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs new file mode 100644 index 0000000..1adc5ed --- /dev/null +++ b/src/Oms.Tests/KpiAreaInfoImportHelperTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Tests; + +public class KpiAreaInfoImportHelperTests +{ + [Fact] + public void PrepareEntityForImport_ValidRow_ShouldFillCountryName() + { + var entity = new KpiAreaInfo + { + CountryCode = "US", + LeftPostcode = "90000", + RightPostcode = "90199" + }; + var countries = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["US"] = new Country { CountryCode = "US", ChineseName = "美国" } + }; + + KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countries); + + entity.CountryName.Should().Be("美国"); + } + + [Fact] + public void PrepareEntityForImport_MissingPostcodePair_ShouldThrow() + { + var entity = new KpiAreaInfo + { + CountryCode = "US", + CountryName = "美国", + LeftPostcode = "90000" + }; + + var act = () => KpiAreaInfoImportHelper.PrepareEntityForImport(entity, new Dictionary()); + + act.Should().Throw().WithMessage("*截止邮编*"); + } +} diff --git a/src/Oms.Tests/RulesSnapshotVerificationTests.cs b/src/Oms.Tests/RulesSnapshotVerificationTests.cs index 8ec6e40..78e4f5b 100644 --- a/src/Oms.Tests/RulesSnapshotVerificationTests.cs +++ b/src/Oms.Tests/RulesSnapshotVerificationTests.cs @@ -121,4 +121,34 @@ public class RulesSnapshotVerificationTests rawWeight.Should().Be(0.5M); chargeable.Should().Be(0.5M); } + + [Fact] + public void DeserializeRulesSnapshot_StartValueEndValue_ShouldApplyRoundingFor12kg() + { + const string json = """ + { + "WeightRoundingId": 1, + "WeightRoundingInfos": [ + { "Id": 1, "StartValue": 0, "EndValue": 99, "Public": "0.5" } + ], + "WeightRuleInfos": [ + { "Id": 1, "VolumetricFactor": "8000", "Condition": "gw>vw", "BillableWeight": "gw" } + ] + } + """; + + var snapshot = OmsDocHelper.DeserializeRulesSnapshot(json); + snapshot.Should().NotBeNull(); + snapshot!.WeightRoundingInfos[0].Start.Should().Be(0); + snapshot.WeightRoundingInfos[0].End.Should().Be(99); + + var forecast = new OrderWeight { Length = 10, Width = 10, Height = 10, ActualWeight = 1.2M }; + var ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(snapshot.WeightRuleInfos); + var (_, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); + var ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshot.WeightRoundingInfos); + var chargeable = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); + + rawWeight.Should().Be(1.2M); + chargeable.Should().Be(1.5M); + } }