feat(store): auto-generate store code on create and make update code optional
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 44s

This commit is contained in:
2026-02-18 08:27:37 +08:00
parent 1b185af718
commit b3429e2a0d
6 changed files with 56 additions and 35 deletions

View File

@@ -14,11 +14,6 @@ public sealed record CreateStoreCommand : IRequest
/// </summary> /// </summary>
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
/// <summary>
/// 门店编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary> /// <summary>
/// 联系电话。 /// 联系电话。
/// </summary> /// </summary>

View File

@@ -23,9 +23,9 @@ public sealed record UpdateStoreCommand : IRequest
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
/// <summary> /// <summary>
/// 门店编码。 /// 门店编码(可选,未传时保持原值)
/// </summary> /// </summary>
public string Code { get; init; } = string.Empty; public string? Code { get; init; }
/// <summary> /// <summary>
/// 联系电话。 /// 联系电话。

View File

@@ -1,4 +1,5 @@
using MediatR; using MediatR;
using System.Security.Cryptography;
using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Commands;
using TakeoutSaaS.Application.App.Stores.Enums; using TakeoutSaaS.Application.App.Stores.Enums;
using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Application.App.Stores.Services;
@@ -24,7 +25,7 @@ public sealed class CreateStoreCommandHandler(
// 1. 解析上下文 // 1. 解析上下文
var context = storeContextService.GetRequiredContext(); var context = storeContextService.GetRequiredContext();
// 2. 校验编码唯一性 // 2. 生成唯一门店编码
var existingStores = await storeRepository.SearchAsync( var existingStores = await storeRepository.SearchAsync(
context.TenantId, context.TenantId,
context.MerchantId, context.MerchantId,
@@ -35,12 +36,7 @@ public sealed class CreateStoreCommandHandler(
keyword: null, keyword: null,
includeDeleted: false, includeDeleted: false,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
var normalizedCode = request.Code.Trim(); var generatedCode = GenerateUniqueStoreCode(existingStores);
var hasDuplicateCode = existingStores.Any(store => string.Equals(store.Code, normalizedCode, StringComparison.OrdinalIgnoreCase));
if (hasDuplicateCode)
{
throw new BusinessException(ErrorCodes.Conflict, "门店编码已存在");
}
// 3. 组装门店实体 // 3. 组装门店实体
var serviceTypes = request.ServiceTypes?.Count > 0 var serviceTypes = request.ServiceTypes?.Count > 0
@@ -50,7 +46,7 @@ public sealed class CreateStoreCommandHandler(
{ {
TenantId = context.TenantId, TenantId = context.TenantId,
MerchantId = context.MerchantId, MerchantId = context.MerchantId,
Code = normalizedCode, Code = generatedCode,
Name = request.Name.Trim(), Name = request.Name.Trim(),
Phone = request.ContactPhone.Trim(), Phone = request.ContactPhone.Trim(),
ManagerName = request.ManagerName.Trim(), ManagerName = request.ManagerName.Trim(),
@@ -68,4 +64,27 @@ public sealed class CreateStoreCommandHandler(
await storeRepository.AddStoreAsync(store, cancellationToken); await storeRepository.AddStoreAsync(store, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken); await storeRepository.SaveChangesAsync(cancellationToken);
} }
/// <summary>
/// 生成当前租户商户内唯一的门店编码。
/// </summary>
private static string GenerateUniqueStoreCode(IReadOnlyList<Store> existingStores)
{
var existingCodeSet = existingStores
.Select(store => store.Code.Trim())
.Where(code => !string.IsNullOrWhiteSpace(code))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
const int maxRetryCount = 20;
for (var attempt = 0; attempt < maxRetryCount; attempt++)
{
var candidate = $"ST{DateTime.UtcNow:yyMMddHHmmss}{RandomNumberGenerator.GetInt32(100, 1000)}";
if (existingCodeSet.Add(candidate))
{
return candidate;
}
}
throw new BusinessException(ErrorCodes.Conflict, "门店编码生成失败,请稍后重试");
}
} }

View File

@@ -30,28 +30,33 @@ public sealed class UpdateStoreCommandHandler(
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他商户门店"); throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他商户门店");
} }
// 3. 校验编码唯一性 // 3. 若传入编码且发生变化,则校验唯一性并更新编码。
var existingStores = await storeRepository.SearchAsync( var normalizedCode = request.Code?.Trim();
context.TenantId, if (!string.IsNullOrWhiteSpace(normalizedCode) &&
context.MerchantId, !string.Equals(existing.Code, normalizedCode, StringComparison.OrdinalIgnoreCase))
status: null,
auditStatus: null,
businessStatus: null,
ownershipType: null,
keyword: null,
includeDeleted: false,
cancellationToken: cancellationToken);
var normalizedCode = request.Code.Trim();
var hasDuplicateCode = existingStores.Any(store =>
store.Id != existing.Id &&
string.Equals(store.Code, normalizedCode, StringComparison.OrdinalIgnoreCase));
if (hasDuplicateCode)
{ {
throw new BusinessException(ErrorCodes.Conflict, "门店编码已存在"); var existingStores = await storeRepository.SearchAsync(
context.TenantId,
context.MerchantId,
status: null,
auditStatus: null,
businessStatus: null,
ownershipType: null,
keyword: null,
includeDeleted: false,
cancellationToken: cancellationToken);
var hasDuplicateCode = existingStores.Any(store =>
store.Id != existing.Id &&
string.Equals(store.Code, normalizedCode, StringComparison.OrdinalIgnoreCase));
if (hasDuplicateCode)
{
throw new BusinessException(ErrorCodes.Conflict, "门店编码已存在");
}
existing.Code = normalizedCode;
} }
// 4. 更新门店字段 // 4. 更新门店字段
existing.Code = normalizedCode;
existing.Name = request.Name.Trim(); existing.Name = request.Name.Trim();
existing.Phone = request.ContactPhone.Trim(); existing.Phone = request.ContactPhone.Trim();
existing.ManagerName = request.ManagerName.Trim(); existing.ManagerName = request.ManagerName.Trim();

View File

@@ -15,7 +15,6 @@ public sealed class CreateStoreCommandValidator : AbstractValidator<CreateStoreC
{ {
// 1. 校验核心字段 // 1. 校验核心字段
RuleFor(command => command.Name).NotEmpty().MaximumLength(128); RuleFor(command => command.Name).NotEmpty().MaximumLength(128);
RuleFor(command => command.Code).NotEmpty().MaximumLength(32);
RuleFor(command => command.ContactPhone).NotEmpty().MaximumLength(32); RuleFor(command => command.ContactPhone).NotEmpty().MaximumLength(32);
RuleFor(command => command.ManagerName).NotEmpty().MaximumLength(64); RuleFor(command => command.ManagerName).NotEmpty().MaximumLength(64);
RuleFor(command => command.Address).NotEmpty().MaximumLength(256); RuleFor(command => command.Address).NotEmpty().MaximumLength(256);

View File

@@ -16,7 +16,10 @@ public sealed class UpdateStoreCommandValidator : AbstractValidator<UpdateStoreC
// 1. 校验标识与核心字段 // 1. 校验标识与核心字段
RuleFor(command => command.Id).GreaterThan(0); RuleFor(command => command.Id).GreaterThan(0);
RuleFor(command => command.Name).NotEmpty().MaximumLength(128); RuleFor(command => command.Name).NotEmpty().MaximumLength(128);
RuleFor(command => command.Code).NotEmpty().MaximumLength(32); RuleFor(command => command.Code)
.NotEmpty()
.MaximumLength(32)
.When(command => command.Code is not null);
RuleFor(command => command.ContactPhone).NotEmpty().MaximumLength(32); RuleFor(command => command.ContactPhone).NotEmpty().MaximumLength(32);
RuleFor(command => command.ManagerName).NotEmpty().MaximumLength(64); RuleFor(command => command.ManagerName).NotEmpty().MaximumLength(64);
RuleFor(command => command.Address).NotEmpty().MaximumLength(256); RuleFor(command => command.Address).NotEmpty().MaximumLength(256);