feat: 销售产品独立改代码,分区明细导入保存按规则去重
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
14c42db752
commit
8d7df40bdf
@ -7,6 +7,8 @@ public class KpiAreaInfoImportOutput
|
|||||||
{
|
{
|
||||||
public int SuccessCount { get; set; }
|
public int SuccessCount { get; set; }
|
||||||
|
|
||||||
|
public int SkipCount { get; set; }
|
||||||
|
|
||||||
public int FailCount { get; set; }
|
public int FailCount { get; set; }
|
||||||
|
|
||||||
public List<KpiAreaInfoImportFailRow> Failures { 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
|
PrefixLen = input.PrefixLen ?? 0
|
||||||
};
|
};
|
||||||
PrepareAreaInfo(entity);
|
PrepareAreaInfo(entity);
|
||||||
|
await EnsureInfoNotDuplicateAsync(entity, excludeId: null);
|
||||||
await KpiAreaRepository.InsertInfoAsync(entity);
|
await KpiAreaRepository.InsertInfoAsync(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,6 +161,7 @@ public class KpiAreaService : ApplicationService
|
|||||||
entity.MatchMode = input.MatchMode;
|
entity.MatchMode = input.MatchMode;
|
||||||
entity.PrefixLen = input.PrefixLen ?? 0;
|
entity.PrefixLen = input.PrefixLen ?? 0;
|
||||||
PrepareAreaInfo(entity);
|
PrepareAreaInfo(entity);
|
||||||
|
await EnsureInfoNotDuplicateAsync(entity, excludeId: entity.Id);
|
||||||
await KpiAreaRepository.UpdateInfoAsync(entity);
|
await KpiAreaRepository.UpdateInfoAsync(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,6 +216,12 @@ public class KpiAreaService : ApplicationService
|
|||||||
.GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(x => x.CountryCode.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(g => g.Key, g => g.First(), 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();
|
var output = new KpiAreaInfoImportOutput();
|
||||||
foreach (var (rowNumber, entity) in parsedRows)
|
foreach (var (rowNumber, entity) in parsedRows)
|
||||||
{
|
{
|
||||||
@ -221,6 +229,17 @@ public class KpiAreaService : ApplicationService
|
|||||||
{
|
{
|
||||||
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode);
|
KpiAreaInfoImportHelper.PrepareEntityForImport(entity, countriesByCode);
|
||||||
entity.AreaId = input.AreaId;
|
entity.AreaId = input.AreaId;
|
||||||
|
if (!TryAddDuplicateKey(existingKeys, entity))
|
||||||
|
{
|
||||||
|
output.SkipCount++;
|
||||||
|
output.Failures.Add(new KpiAreaInfoImportFailRow
|
||||||
|
{
|
||||||
|
RowNumber = rowNumber,
|
||||||
|
Reason = "与已有规则重复,已跳过"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await KpiAreaRepository.InsertInfoAsync(entity);
|
await KpiAreaRepository.InsertInfoAsync(entity);
|
||||||
output.SuccessCount++;
|
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)
|
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity)
|
||||||
{
|
{
|
||||||
var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
|
var (matchDim, matchMode, prefixLen) = GeoPartitionMatcher.Describe(entity);
|
||||||
|
|||||||
@ -11,6 +11,29 @@ namespace Oms.Application.Services.Channels;
|
|||||||
|
|
||||||
public partial class SalesProductService
|
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>
|
||||||
/// 校验产品编码唯一性。
|
/// 校验产品编码唯一性。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -344,9 +367,12 @@ public partial class SalesProductService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将保存入参映射到销售产品实体。
|
/// 将保存入参映射到销售产品实体。
|
||||||
/// </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.ProductName = input.ProductName;
|
||||||
entity.ProductGroup = input.ProductGroup;
|
entity.ProductGroup = input.ProductGroup;
|
||||||
entity.MinTimeliness = input.MinTimeliness;
|
entity.MinTimeliness = input.MinTimeliness;
|
||||||
|
|||||||
@ -74,8 +74,8 @@ public partial class SalesProductService : ApplicationService
|
|||||||
[HttpPost("/channel/sales-product/create")]
|
[HttpPost("/channel/sales-product/create")]
|
||||||
public async Task CreateAsync(SalesProductSaveInput input)
|
public async Task CreateAsync(SalesProductSaveInput input)
|
||||||
{
|
{
|
||||||
await ValidateProductCodeAsync(input.ProductCode, null);
|
var code = NormalizeProductCode(input.ProductCode);
|
||||||
var code = input.ProductCode.Trim();
|
await ValidateProductCodeAsync(code, null);
|
||||||
var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code);
|
var deleted = await SalesProductRepository.GetByProductCodeIncludingDeletedAsync(code);
|
||||||
if (deleted != null && deleted.IsDeleted)
|
if (deleted != null && deleted.IsDeleted)
|
||||||
{
|
{
|
||||||
@ -99,9 +99,28 @@ public partial class SalesProductService : ApplicationService
|
|||||||
public async Task UpdateAsync(SalesProductSaveInput input)
|
public async Task UpdateAsync(SalesProductSaveInput input)
|
||||||
{
|
{
|
||||||
var entity = await GetProductOrThrowAsync(input.Id);
|
var entity = await GetProductOrThrowAsync(input.Id);
|
||||||
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
|
ApplyProductInput(entity, input, updateProductCode: false);
|
||||||
await FreeSoftDeletedProductCodeIfNeededAsync(input.ProductCode.Trim(), entity.Id);
|
await SalesProductRepository.UpdateAsync(entity);
|
||||||
ApplyProductInput(entity, input);
|
}
|
||||||
|
|
||||||
|
/// <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);
|
await SalesProductRepository.UpdateAsync(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,8 +146,7 @@ public partial class SalesProductService : ApplicationService
|
|||||||
public async Task SaveConfigAsync(SalesProductSaveInput input)
|
public async Task SaveConfigAsync(SalesProductSaveInput input)
|
||||||
{
|
{
|
||||||
var entity = await GetProductOrThrowAsync(input.Id);
|
var entity = await GetProductOrThrowAsync(input.Id);
|
||||||
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
|
ApplyProductInput(entity, input, updateProductCode: false);
|
||||||
ApplyProductInput(entity, input);
|
|
||||||
await SalesProductRepository.UpdateAsync(entity);
|
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)
|
public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
|
||||||
{
|
{
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
|
|||||||
@ -254,6 +254,55 @@ public class GeoPartitionMatcherTests
|
|||||||
GeoPartitionMatcher.MatchEntity(rules, "GB", "NIR", "Belfast", null)!.Province.Should().Be("NIR");
|
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)
|
private static List<KpiAreaInfo> CompileEntities(params KpiAreaInfo[] items)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user