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);
+ }
}