20261008-ide1.0发布 #1
@ -7,6 +7,8 @@ public class KpiAreaInfoImportOutput
|
||||
{
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
public int SkipCount { get; set; }
|
||||
|
||||
public int FailCount { get; set; }
|
||||
|
||||
public List<KpiAreaInfoImportFailRow> Failures { get; set; } = [];
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 单独修改销售产品代码入参。
|
||||
/// </summary>
|
||||
public class SalesProductUpdateCodeInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 销售产品主键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新的产品代码,仅大写字母、数字、-。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ProductCode { get; set; } = string.Empty;
|
||||
}
|
||||
@ -136,6 +136,7 @@ public class KpiAreaService : ApplicationService
|
||||
PrefixLen = input.PrefixLen ?? 0
|
||||
};
|
||||
PrepareAreaInfo(entity);
|
||||
await EnsureInfoNotDuplicateAsync(entity, excludeId: null);
|
||||
await KpiAreaRepository.InsertInfoAsync(entity);
|
||||
}
|
||||
|
||||
@ -160,6 +161,7 @@ public class KpiAreaService : ApplicationService
|
||||
entity.MatchMode = input.MatchMode;
|
||||
entity.PrefixLen = input.PrefixLen ?? 0;
|
||||
PrepareAreaInfo(entity);
|
||||
await EnsureInfoNotDuplicateAsync(entity, excludeId: entity.Id);
|
||||
await KpiAreaRepository.UpdateInfoAsync(entity);
|
||||
}
|
||||
|
||||
@ -214,6 +216,12 @@ public class KpiAreaService : ApplicationService
|
||||
.GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var existingKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in await KpiAreaRepository.GetAllInfosByAreaIdAsync(input.AreaId))
|
||||
{
|
||||
TryAddDuplicateKey(existingKeys, item);
|
||||
}
|
||||
|
||||
var output = new KpiAreaInfoImportOutput();
|
||||
foreach (var (rowNumber, entity) in parsedRows)
|
||||
{
|
||||
@ -221,6 +229,17 @@ public class KpiAreaService : ApplicationService
|
||||
{
|
||||
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode);
|
||||
entity.AreaId = input.AreaId;
|
||||
if (!TryAddDuplicateKey(existingKeys, entity))
|
||||
{
|
||||
output.SkipCount++;
|
||||
output.Failures.Add(new KpiAreaInfoImportFailRow
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Reason = "与已有规则重复,已跳过"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await KpiAreaRepository.InsertInfoAsync(entity);
|
||||
output.SuccessCount++;
|
||||
}
|
||||
@ -314,6 +333,43 @@ public class KpiAreaService : ApplicationService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureInfoNotDuplicateAsync(KpiAreaInfo entity, int? excludeId)
|
||||
{
|
||||
var key = GeoPartitionMatcher.BuildDuplicateKey(entity);
|
||||
var existing = await KpiAreaRepository.GetAllInfosByAreaIdAsync(entity.AreaId);
|
||||
foreach (var item in existing)
|
||||
{
|
||||
if (excludeId.HasValue && item.Id == excludeId.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryBuildDuplicateKey(item, out var existingKey)
|
||||
&& string.Equals(existingKey, key, StringComparison.Ordinal))
|
||||
{
|
||||
throw new BusinessException("分区明细已存在相同规则");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryAddDuplicateKey(HashSet<string> keys, KpiAreaInfo entity) =>
|
||||
TryBuildDuplicateKey(entity, out var key) && keys.Add(key);
|
||||
|
||||
private static bool TryBuildDuplicateKey(KpiAreaInfo entity, out string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
GeoPartitionMatcher.PrepareEntity(entity);
|
||||
key = GeoPartitionMatcher.BuildDuplicateKey(entity);
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
key = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
|
||||
{
|
||||
var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
|
||||
|
||||
@ -11,6 +11,29 @@ namespace Oms.Application.Services.Channels;
|
||||
|
||||
public partial class SalesProductService
|
||||
{
|
||||
/// <summary>
|
||||
/// 规范化并校验产品代码格式。
|
||||
/// </summary>
|
||||
private static string NormalizeProductCode(string? productCode)
|
||||
{
|
||||
var code = (productCode ?? string.Empty).Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new Fake.Application.BusinessException("产品代码不能为空");
|
||||
}
|
||||
|
||||
for (var i = 0; i < code.Length; i++)
|
||||
{
|
||||
var ch = code[i];
|
||||
if (ch is < 'A' or > 'Z' && ch is < '0' or > '9' && ch != '-')
|
||||
{
|
||||
throw new Fake.Application.BusinessException("产品代码仅支持大写字母、数字和-");
|
||||
}
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验产品编码唯一性。
|
||||
/// </summary>
|
||||
@ -344,9 +367,12 @@ public partial class SalesProductService
|
||||
/// <summary>
|
||||
/// 将保存入参映射到销售产品实体。
|
||||
/// </summary>
|
||||
private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input)
|
||||
private static void ApplyProductInput(SalesProduct entity, SalesProductSaveInput input, bool updateProductCode = true)
|
||||
{
|
||||
entity.ProductCode = input.ProductCode;
|
||||
if (updateProductCode)
|
||||
{
|
||||
entity.ProductCode = NormalizeProductCode(input.ProductCode);
|
||||
}
|
||||
entity.ProductName = input.ProductName;
|
||||
entity.ProductGroup = input.ProductGroup;
|
||||
entity.MinTimeliness = input.MinTimeliness;
|
||||
|
||||
@ -74,8 +74,8 @@ public partial class SalesProductService : ApplicationService
|
||||
[HttpPost("/channel/sales-product/create")]
|
||||
public async Task CreateAsync(SalesProductSaveInput input)
|
||||
{
|
||||
await ValidateProductCodeAsync(input.ProductCode, null);
|
||||
var code = input.ProductCode.Trim();
|
||||
var code = NormalizeProductCode(input.ProductCode);
|
||||
await ValidateProductCodeAsync(code, null);
|
||||
var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code);
|
||||
if (deleted != null && deleted.IsDeleted)
|
||||
{
|
||||
@ -99,9 +99,28 @@ public partial class SalesProductService : ApplicationService
|
||||
public async Task UpdateAsync(SalesProductSaveInput input)
|
||||
{
|
||||
var entity = await GetProductOrThrowAsync(input.Id);
|
||||
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
|
||||
await FreeSoftDeletedProductCodeIfNeededAsync(input.ProductCode.Trim(), entity.Id);
|
||||
ApplyProductInput(entity, input);
|
||||
ApplyProductInput(entity, input, updateProductCode: false);
|
||||
await SalesProductRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单独修改销售产品代码。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:产品主键与新代码。</param>
|
||||
/// <returns>无返回体,仅完成产品代码更新。</returns>
|
||||
[HttpPost("/channel/sales-product/product-code/update")]
|
||||
public async Task UpdateProductCodeAsync(SalesProductUpdateCodeInput input)
|
||||
{
|
||||
var entity = await GetProductOrThrowAsync(input.Id);
|
||||
var code = NormalizeProductCode(input.ProductCode);
|
||||
if (string.Equals(entity.ProductCode, code, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ValidateProductCodeAsync(code, entity.Id);
|
||||
await FreeSoftDeletedProductCodeIfNeededAsync(code, entity.Id);
|
||||
entity.ProductCode = code;
|
||||
await SalesProductRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
@ -127,8 +146,7 @@ public partial class SalesProductService : ApplicationService
|
||||
public async Task SaveConfigAsync(SalesProductSaveInput input)
|
||||
{
|
||||
var entity = await GetProductOrThrowAsync(input.Id);
|
||||
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
|
||||
ApplyProductInput(entity, input);
|
||||
ApplyProductInput(entity, input, updateProductCode: false);
|
||||
await SalesProductRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
|
||||
@ -409,6 +409,33 @@ public static class GeoPartitionMatcher
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。
|
||||
/// 须在 PrepareEntity 之后调用。
|
||||
/// </summary>
|
||||
public static string BuildDuplicateKey(KpiAreaInfo entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
var dim = NormalizeDim(entity.MatchDim) ?? DimCountry;
|
||||
var mode = NormalizeMode(entity.MatchMode) ?? ModeAll;
|
||||
var country = NormKey(entity.CountryCode);
|
||||
|
||||
return dim switch
|
||||
{
|
||||
DimState => $"{DimState}|{country}|{NormKey(entity.Province)}",
|
||||
DimCity => $"{DimCity}|{country}|{NormKey(entity.Province)}|{NormKey(entity.City)}",
|
||||
DimZip when mode == ModePrefix =>
|
||||
$"{DimZip}|{ModePrefix}|{country}|{NormKey(entity.LeftPostcode)}|{entity.PrefixLen}",
|
||||
DimZip when mode == ModeExact =>
|
||||
$"{DimZip}|{ModeExact}|{country}|{NormKey(entity.LeftPostcode)}",
|
||||
DimZip =>
|
||||
$"{DimZip}|{ModeRange}|{country}|{NormKey(entity.LeftPostcode)}|{NormKey(entity.RightPostcode)}",
|
||||
_ => $"{DimCountry}|{country}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormKey(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant();
|
||||
|
||||
public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
|
||||
{
|
||||
if (entity == null)
|
||||
|
||||
@ -254,6 +254,55 @@ public class GeoPartitionMatcherTests
|
||||
GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", null)!.Province.Should().Be("NIR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDuplicateKey_SameCategory_ShouldMatchIgnoringCase()
|
||||
{
|
||||
var a = CountryOnly("us");
|
||||
var b = CountryOnly("US");
|
||||
GeoPartitionMatcher.PrepareEntity(a);
|
||||
GeoPartitionMatcher.PrepareEntity(b);
|
||||
GeoPartitionMatcher.BuildDuplicateKey(a).Should().Be(GeoPartitionMatcher.BuildDuplicateKey(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDuplicateKey_DifferentDims_ShouldNotCollide()
|
||||
{
|
||||
var country = CountryOnly("US");
|
||||
var state = State("US", "CA");
|
||||
var zip = Zip("US", "90");
|
||||
GeoPartitionMatcher.PrepareEntity(country);
|
||||
GeoPartitionMatcher.PrepareEntity(state);
|
||||
GeoPartitionMatcher.PrepareEntity(zip);
|
||||
|
||||
var keys = new[]
|
||||
{
|
||||
GeoPartitionMatcher.BuildDuplicateKey(country),
|
||||
GeoPartitionMatcher.BuildDuplicateKey(state),
|
||||
GeoPartitionMatcher.BuildDuplicateKey(zip)
|
||||
};
|
||||
keys.Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDuplicateKey_SameZipRange_ShouldMatch()
|
||||
{
|
||||
var a = Range("US", "90000", "90199");
|
||||
var b = Range("us", "90000", "90199");
|
||||
GeoPartitionMatcher.PrepareEntity(a);
|
||||
GeoPartitionMatcher.PrepareEntity(b);
|
||||
GeoPartitionMatcher.BuildDuplicateKey(a).Should().Be(GeoPartitionMatcher.BuildDuplicateKey(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDuplicateKey_PrefixVsExact_ShouldNotCollide()
|
||||
{
|
||||
var prefix = Zip("US", "90");
|
||||
var exact = Exact("US", "90");
|
||||
GeoPartitionMatcher.PrepareEntity(prefix);
|
||||
GeoPartitionMatcher.PrepareEntity(exact);
|
||||
GeoPartitionMatcher.BuildDuplicateKey(prefix).Should().NotBe(GeoPartitionMatcher.BuildDuplicateKey(exact));
|
||||
}
|
||||
|
||||
private static List<KpiAreaInfo> CompileEntities(params KpiAreaInfo[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user