diff --git a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs index 5927568..db2cd35 100644 --- a/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs +++ b/src/Oms.Application/Dtos/Channels/KpiAreaInfoImportOutput.cs @@ -7,6 +7,8 @@ public class KpiAreaInfoImportOutput { public int SuccessCount { get; set; } + public int SkipCount { get; set; } + public int FailCount { get; set; } public List Failures { get; set; } = []; diff --git a/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs new file mode 100644 index 0000000..ec05ce5 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/SalesProductUpdateCodeInput.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Channels; + +/// +/// 单独修改销售产品代码入参。 +/// +public class SalesProductUpdateCodeInput +{ + /// + /// 销售产品主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 新的产品代码,仅大写字母、数字、-。 + /// + [Required] + public string ProductCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Channels/KpiAreaService.cs b/src/Oms.Application/Services/Channels/KpiAreaService.cs index 7c76aa1..4a2d60f 100644 --- a/src/Oms.Application/Services/Channels/KpiAreaService.cs +++ b/src/Oms.Application/Services/Channels/KpiAreaService.cs @@ -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(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 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); diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 5c3203d..4deba9e 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -11,6 +11,29 @@ namespace Oms.Application.Services.Channels; public partial class SalesProductService { + /// + /// 规范化并校验产品代码格式。 + /// + 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; + } + /// /// 校验产品编码唯一性。 /// @@ -344,9 +367,12 @@ public partial class SalesProductService /// /// 将保存入参映射到销售产品实体。 /// - 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; diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index 9ebf67f..5f75ce2 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -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); + } + + /// + /// 单独修改销售产品代码。 + /// + /// 入参:产品主键与新代码。 + /// 无返回体,仅完成产品代码更新。 + [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); } diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs index 4eab0fd..ace4b34 100644 --- a/src/Oms.Application/Shared/GeoPartitionMatcher.cs +++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs @@ -409,6 +409,33 @@ public static class GeoPartitionMatcher } } + /// + /// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。 + /// 须在 PrepareEntity 之后调用。 + /// + 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) diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs index e2a1afc..5d10a15 100644 --- a/src/Oms.Tests/GeoPartitionMatcherTests.cs +++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs @@ -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 CompileEntities(params KpiAreaInfo[] items) { foreach (var item in items)