feat: 销售产品独立改代码,分区明细导入保存按规则去重

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-06 15:37:03 +08:00
parent 14c42db752
commit 8d7df40bdf
7 changed files with 208 additions and 9 deletions

View File

@ -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; } = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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