fix: 计重进位快照兼容与 KPI 分区明细 Excel 导入

- 发布规则快照反序列化合并 StartValue/EndValue 至 Start/End
- 小包创建进位时快照区间无效则降级读 t_weight_rounding_info
- 新增 KPI 分区明细导入模板下载与 Excel 批量导入接口
- 补充快照进位与导入相关单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 15:29:17 +08:00
parent 7827b4803e
commit 91ba6d5d59
9 changed files with 470 additions and 3 deletions

View File

@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// KPI 分区明细 Excel 导入入参。
/// </summary>
public class KpiAreaInfoImportInput
{
/// <summary>
/// 分区主键。
/// </summary>
public int AreaId { get; set; }
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
}

View File

@ -0,0 +1,23 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// KPI 分区明细 Excel 导入结果。
/// </summary>
public class KpiAreaInfoImportOutput
{
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public List<KpiAreaInfoImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class KpiAreaInfoImportFailRow
{
public int RowNumber { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -55,6 +55,14 @@ public class WeightRoundingSnapshotRangeDto
public decimal End { get; set; }
public string? Public { get; set; }
/// <summary>旧快照/明细 API 兼容(t_weight_rounding_info.StartValue);读取后由 <see cref="PublishSnapshotNormalizer"/> 合并至 <see cref="Start"/>。</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public decimal StartValue { get; set; }
/// <summary>旧快照/明细 API 兼容(t_weight_rounding_info.EndValue);读取后由 <see cref="PublishSnapshotNormalizer"/> 合并至 <see cref="End"/>。</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public decimal EndValue { get; set; }
}
/// <summary>

View File

@ -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<IKpiAreaRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
/// <summary>
/// 查询KPI分区列表。
/// </summary>
@ -210,6 +214,69 @@ public class KpiAreaService : ApplicationService
await KpiAreaRepository.DeleteInfoAsync(input.Id);
}
/// <summary>
/// 下载 KPI 分区明细导入模板。
/// </summary>
[HttpGet("/channel/kpi-area/info/import-template")]
public async Task<FileContentResult> DownloadInfoImportTemplateAsync([FromQuery] string? areaType)
{
var bytes = await KpiAreaInfoImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = KpiAreaInfoImportHelper.ResolveTemplateFileName(areaType)
};
}
/// <summary>
/// 导入 KPI 分区明细(Excel)。
/// </summary>
[HttpPost("/channel/kpi-area/info/import")]
public async Task<KpiAreaInfoImportOutput> 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<Func<KpiArea, bool>> BuildAreaPredicate(KpiAreaListInput input)
{
var appKey = input.AppKey?.Trim();

View File

@ -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<WeightRoundingInfo> ranges;
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 })
if (fromSnapshot)
{
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(rulesSnapshot.WeightRoundingInfos);
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
}
else
{
@ -613,8 +616,21 @@ public partial class OrderService
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
}
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)
{
throw;

View File

@ -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;
/// <summary>
/// KPI 分区明细 Excel 模板与导入解析。
/// </summary>
public static class KpiAreaInfoImportHelper
{
private static readonly Regex ChinesePattern = new(@"[\u4E00-\u9FA5]", RegexOptions.Compiled);
private static readonly Dictionary<string, string> 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<byte[]> BuildTemplateBytesAsync()
{
var rows = new List<Dictionary<string, object?>>
{
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<List<(int RowNumber, KpiAreaInfo Entity)>> 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<string, object> rowDict)
{
continue;
}
if (IsEmptyRow(rowDict))
{
continue;
}
result.Add((rowNumber, ParseRow(rowDict)));
}
return result;
}
public static void PrepareEntityForImport(
KpiAreaInfo entity,
IReadOnlyDictionary<string, Country> 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<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(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<string, object> rowDict) =>
rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
}

View File

@ -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<WeightRoundingSnapshotRangeDto>? 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;
}
}
}
}

View File

@ -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<string, Country>(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<string, Country>());
act.Should().Throw<InvalidOperationException>().WithMessage("*截止邮编*");
}
}

View File

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