Init
This commit is contained in:
267
Prefab.Catalog/App/Products/GetProductDetail.cs
Normal file
267
Prefab.Catalog/App/Products/GetProductDetail.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Base.Catalog.Products;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using SharedProductDetail = Prefab.Shared.Catalog.Products.GetProductDetail;
|
||||
|
||||
namespace Prefab.Catalog.App.Products;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the PDP configuration endpoint that surfaces model details, options, specs, and metadata for a given product slug.
|
||||
/// </summary>
|
||||
public class GetProductDetail : SharedProductDetail
|
||||
{
|
||||
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
|
||||
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet(
|
||||
"/api/catalog/products/{slug}",
|
||||
async (IProductClient productClient, string slug, CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await productClient.GetProductDetail(slug, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (CatalogNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
|
||||
}
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(GetProductDetail))
|
||||
.WithTags("Products");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler responsible for loading the requested product model and projecting it into the shared DTO that powers the PDP.
|
||||
/// </summary>
|
||||
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
|
||||
: HandlerBase(accessor), IHandler<string, Response>
|
||||
{
|
||||
public async Task<Response> Execute(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
|
||||
var normalizedSlug = slug.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var product = await db.Products
|
||||
.Where(model => model.DeletedOn == null && model.Kind == ProductKind.Model && model.Slug == normalizedSlug)
|
||||
.Include(model => model.Variants).ThenInclude(variant => variant.AxisValues)
|
||||
.Include(model => model.Options).ThenInclude(option => option.Values)
|
||||
.Include(model => model.Options).ThenInclude(option => option.Tiers)
|
||||
.Include(model => model.RuleSets).ThenInclude(rule => rule.Conditions)
|
||||
.Include(model => model.Attributes).ThenInclude(attribute => attribute.AttributeDefinition)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (product is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", normalizedSlug);
|
||||
}
|
||||
|
||||
var optionDtos = MapOptions(product);
|
||||
var specDtos = MapSpecs(product);
|
||||
var genericAttributes = await LoadGenericAttributesAsync(db, product.Id, cancellationToken);
|
||||
|
||||
var payload = new SharedProductDetail.ProductDetailDto(
|
||||
product.Id,
|
||||
product.Name,
|
||||
product.Slug ?? normalizedSlug,
|
||||
product.Description,
|
||||
product.Price,
|
||||
optionDtos,
|
||||
specDtos,
|
||||
genericAttributes);
|
||||
|
||||
return new Response(payload);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SharedProductDetail.OptionDefinitionDto> MapOptions(Product product)
|
||||
{
|
||||
var activeOptions = product.Options
|
||||
.Where(option => option.DeletedOn == null)
|
||||
.OrderBy(option => option.Name)
|
||||
.ToList();
|
||||
|
||||
var optionLookup = activeOptions.ToDictionary(option => option.Id);
|
||||
var valueLookup = activeOptions
|
||||
.SelectMany(option => option.Values.Where(value => value.DeletedOn == null))
|
||||
.ToDictionary(value => value.Id);
|
||||
|
||||
var (definitionRules, valueRules) = MapRuleSets(product, optionLookup, valueLookup);
|
||||
|
||||
var optionDtos = new List<SharedProductDetail.OptionDefinitionDto>(activeOptions.Count);
|
||||
foreach (var option in activeOptions)
|
||||
{
|
||||
var tiers = option.Tiers
|
||||
.Where(tier => tier.DeletedOn == null)
|
||||
.OrderBy(tier => tier.FromInclusive)
|
||||
.Select(tier => new SharedProductDetail.OptionTierDto(tier.FromInclusive, tier.ToInclusive, tier.UnitRate, tier.FlatDelta))
|
||||
.ToList();
|
||||
|
||||
var valueDtos = option.Values
|
||||
.Where(value => value.DeletedOn == null)
|
||||
.OrderBy(value => value.Label)
|
||||
.Select(value => new SharedProductDetail.OptionValueDto(
|
||||
value.Id,
|
||||
value.Code,
|
||||
value.Label,
|
||||
value.PriceDelta,
|
||||
(int)value.PriceDeltaKind,
|
||||
TryGetRules(valueRules, value.Id)))
|
||||
.ToList();
|
||||
|
||||
optionDtos.Add(new SharedProductDetail.OptionDefinitionDto(
|
||||
option.Id,
|
||||
option.Code,
|
||||
option.Name,
|
||||
(int)option.DataType,
|
||||
option.IsVariantAxis,
|
||||
option.Unit,
|
||||
option.Min,
|
||||
option.Max,
|
||||
option.Step,
|
||||
option.PricePerUnit,
|
||||
tiers,
|
||||
valueDtos,
|
||||
TryGetRules(definitionRules, option.Id)));
|
||||
}
|
||||
|
||||
return optionDtos;
|
||||
}
|
||||
|
||||
private static (Dictionary<Guid, List<SharedProductDetail.RuleSetDto>> DefinitionRules, Dictionary<Guid, List<SharedProductDetail.RuleSetDto>> ValueRules) MapRuleSets(
|
||||
Product product,
|
||||
IReadOnlyDictionary<Guid, OptionDefinition> optionLookup,
|
||||
IReadOnlyDictionary<Guid, OptionValue> valueLookup)
|
||||
{
|
||||
var definitionRules = new Dictionary<Guid, List<SharedProductDetail.RuleSetDto>>();
|
||||
var valueRules = new Dictionary<Guid, List<SharedProductDetail.RuleSetDto>>();
|
||||
|
||||
foreach (var ruleSet in product.RuleSets.Where(rule => rule.DeletedOn == null))
|
||||
{
|
||||
var conditions = new List<SharedProductDetail.ConditionDto>();
|
||||
|
||||
foreach (var condition in ruleSet.Conditions.Where(condition => condition.DeletedOn == null))
|
||||
{
|
||||
if (!optionLookup.TryGetValue(condition.LeftOptionDefinitionId, out var leftOption))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? rightValueCode = null;
|
||||
if (condition.RightOptionValueId.HasValue && valueLookup.TryGetValue(condition.RightOptionValueId.Value, out var rightValue))
|
||||
{
|
||||
rightValueCode = rightValue.Code;
|
||||
}
|
||||
|
||||
conditions.Add(new SharedProductDetail.ConditionDto(
|
||||
leftOption.Code,
|
||||
condition.Operator.ToString(),
|
||||
rightValueCode,
|
||||
condition.RightList,
|
||||
condition.RightNumber,
|
||||
condition.RightMin,
|
||||
condition.RightMax));
|
||||
}
|
||||
|
||||
if (conditions.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var dto = new SharedProductDetail.RuleSetDto(
|
||||
ruleSet.Effect.ToString(),
|
||||
ruleSet.Mode.ToString(),
|
||||
conditions);
|
||||
|
||||
var target = ruleSet.TargetKind == OptionRuleTargetKind.OptionDefinition
|
||||
? definitionRules
|
||||
: valueRules;
|
||||
|
||||
if (!target.TryGetValue(ruleSet.TargetId, out var bucket))
|
||||
{
|
||||
bucket = new List<SharedProductDetail.RuleSetDto>();
|
||||
target[ruleSet.TargetId] = bucket;
|
||||
}
|
||||
|
||||
bucket.Add(dto);
|
||||
}
|
||||
|
||||
return (definitionRules, valueRules);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SharedProductDetail.RuleSetDto>? TryGetRules(
|
||||
IReadOnlyDictionary<Guid, List<SharedProductDetail.RuleSetDto>> source,
|
||||
Guid targetId) =>
|
||||
source.TryGetValue(targetId, out var rules) ? rules : null;
|
||||
|
||||
private static IReadOnlyList<SharedProductDetail.SpecDto> MapSpecs(Product product) =>
|
||||
product.Attributes
|
||||
.Where(attribute => attribute.DeletedOn == null && attribute.AttributeDefinition.DeletedOn == null)
|
||||
.Select(attribute => new SharedProductDetail.SpecDto(
|
||||
attribute.AttributeDefinition.Name,
|
||||
attribute.Value,
|
||||
attribute.NumericValue,
|
||||
attribute.UnitCode))
|
||||
.ToList();
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, string>?> LoadGenericAttributesAsync(
|
||||
IModuleDbReadOnly db,
|
||||
Guid productId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attributes = await db.GenericAttributes
|
||||
.Where(attribute =>
|
||||
attribute.DeletedOn == null &&
|
||||
attribute.EntityId == productId &&
|
||||
attribute.KeyGroup == typeof(Product).FullName)
|
||||
.Select(attribute => new { attribute.Key, attribute.Value })
|
||||
.ToDictionaryAsync(attribute => attribute.Key, attribute => attribute.Value, cancellationToken);
|
||||
|
||||
return attributes.Count == 0 ? null : attributes;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Validator : AbstractValidator<string>
|
||||
{
|
||||
public Validator()
|
||||
{
|
||||
RuleFor(slug => slug)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotEmpty().WithMessage("Slug is required.")
|
||||
.Must(slug => !string.IsNullOrWhiteSpace(slug?.Trim()))
|
||||
.WithMessage("Slug is required.")
|
||||
.Must(slug => slug is null || slug.Trim().Length <= ProductRules.SlugMaxLength)
|
||||
.WithMessage($"Slug cannot exceed {ProductRules.SlugMaxLength} characters.")
|
||||
.Must(IsValidSlug)
|
||||
.WithMessage("Slug must use lowercase letters, numbers, and hyphens.");
|
||||
}
|
||||
|
||||
private static bool IsValidSlug(string? slug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = slug.Trim();
|
||||
return SlugRegex.IsMatch(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
Prefab.Catalog/App/Products/ProductClient.cs
Normal file
44
Prefab.Catalog/App/Products/ProductClient.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Prefab.Handler;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
|
||||
namespace Prefab.Catalog.App.Products;
|
||||
|
||||
public sealed class ProductClient(HandlerInvoker handler) : IProductClient, IPriceQuoteClient
|
||||
{
|
||||
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken)
|
||||
{
|
||||
return handler.Execute<Categories.GetCategoryTree.Request, GetCategoryTree.Response>(
|
||||
new Categories.GetCategoryTree.Request(depth),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
return handler.Execute<Categories.GetCategory.Request, GetCategory.Response>(
|
||||
new Categories.GetCategory.Request(slug.Trim()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GetCategoryModels.Response> GetCategoryModels(string slug, int? page, int? pageSize, string? sort, string? direction, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
var request = new Categories.GetCategoryModels.Request(slug.Trim(), page, pageSize, sort, direction);
|
||||
return handler.Execute<Categories.GetCategoryModels.Request, GetCategoryModels.Response>(request, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||
return handler.Execute<string, GetProductDetail.Response>(slug.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
|
||||
Quote(request, cancellationToken);
|
||||
|
||||
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return handler.Execute<QuotePrice.Request, QuotePrice.Response>(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
211
Prefab.Catalog/App/Products/QuotePrice.cs
Normal file
211
Prefab.Catalog/App/Products/QuotePrice.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Base.Catalog.Products;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Endpoints;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Module;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using SharedQuotePrice = Prefab.Shared.Catalog.Products.QuotePrice;
|
||||
|
||||
namespace Prefab.Catalog.App.Products;
|
||||
|
||||
public class QuotePrice : SharedQuotePrice
|
||||
{
|
||||
public sealed class Endpoint : IEndpointRegistrar
|
||||
{
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(
|
||||
"/api/catalog/price-quotes",
|
||||
async (IPriceQuoteClient client, Request request, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await client.Quote(request, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithModuleName<IModule>(nameof(QuotePrice))
|
||||
.WithTags("Products");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Handler(
|
||||
ICatalogDbContextFactory dbFactory,
|
||||
RuleEvaluator ruleEvaluator,
|
||||
VariantResolver variantResolver,
|
||||
IPricingService pricingService) : IHandler<Request, Response>
|
||||
{
|
||||
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var trimmedSku = string.IsNullOrWhiteSpace(request.Sku) ? null : request.Sku.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||
|
||||
var (model, initialVariant) = await LoadProductContextAsync(db, request, trimmedSku, cancellationToken);
|
||||
|
||||
var optionSelections = (request.Selections ?? Array.Empty<Selection>())
|
||||
.Select(selection => new OptionSelection(selection.OptionDefinitionId, selection.OptionValueId, selection.NumericValue))
|
||||
.ToList();
|
||||
|
||||
var evaluation = ruleEvaluator.Evaluate(model, optionSelections);
|
||||
if (!evaluation.IsValid)
|
||||
{
|
||||
throw new DomainValidationException(string.Join("; ", evaluation.Errors));
|
||||
}
|
||||
|
||||
var variantResult = variantResolver.Resolve(model, evaluation.Selections, trimmedSku);
|
||||
if (!variantResult.IsSuccess)
|
||||
{
|
||||
throw new DomainValidationException(string.Join("; ", variantResult.Errors));
|
||||
}
|
||||
|
||||
var resolvedVariant = variantResult.Variant ?? initialVariant;
|
||||
|
||||
var sanitizedSelections = evaluation.Selections
|
||||
.Select(selection => new Selection(selection.OptionDefinitionId, selection.OptionValueId, selection.NumericValue))
|
||||
.ToList();
|
||||
|
||||
var pricingRequest = new Request(
|
||||
resolvedVariant?.Id ?? request.ProductId ?? model.Id,
|
||||
resolvedVariant?.Sku ?? trimmedSku,
|
||||
sanitizedSelections);
|
||||
|
||||
return await pricingService.QuoteAsync(pricingRequest, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<(Product Model, Product? Variant)> LoadProductContextAsync(
|
||||
IModuleDbReadOnly db,
|
||||
Request request,
|
||||
string? trimmedSku,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trimmedSku))
|
||||
{
|
||||
var variantInfo = await db.Products
|
||||
.Where(product => product.DeletedOn == null && product.Sku != null && product.Sku == trimmedSku)
|
||||
.Select(product => new { product.Id, product.Kind, product.ParentProductId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (variantInfo is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", trimmedSku);
|
||||
}
|
||||
|
||||
if (variantInfo.Kind == ProductKind.Model)
|
||||
{
|
||||
var modelForSku = await LoadModelAsync(db, variantInfo.Id, cancellationToken);
|
||||
return (modelForSku, null);
|
||||
}
|
||||
|
||||
if (variantInfo.ParentProductId is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", trimmedSku);
|
||||
}
|
||||
|
||||
var model = await LoadModelAsync(db, variantInfo.ParentProductId.Value, cancellationToken);
|
||||
var variant = model.Variants.FirstOrDefault(product => product.Id == variantInfo.Id && product.DeletedOn == null)
|
||||
?? throw new CatalogNotFoundException("Product", trimmedSku);
|
||||
|
||||
return (model, variant);
|
||||
}
|
||||
|
||||
if (request.ProductId.HasValue)
|
||||
{
|
||||
var identifier = request.ProductId.Value;
|
||||
var productInfo = await db.Products
|
||||
.Where(product => product.DeletedOn == null && product.Id == identifier)
|
||||
.Select(product => new { product.Id, product.Kind, product.ParentProductId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (productInfo is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", identifier.ToString());
|
||||
}
|
||||
|
||||
if (productInfo.Kind == ProductKind.Model)
|
||||
{
|
||||
var model = await LoadModelAsync(db, productInfo.Id, cancellationToken);
|
||||
return (model, null);
|
||||
}
|
||||
|
||||
if (productInfo.ParentProductId is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", identifier.ToString());
|
||||
}
|
||||
|
||||
var parentModel = await LoadModelAsync(db, productInfo.ParentProductId.Value, cancellationToken);
|
||||
var variant = parentModel.Variants.FirstOrDefault(product => product.Id == productInfo.Id && product.DeletedOn == null)
|
||||
?? throw new CatalogNotFoundException("Product", identifier.ToString());
|
||||
|
||||
return (parentModel, variant);
|
||||
}
|
||||
|
||||
throw new DomainValidationException("Either productId or sku must be provided.");
|
||||
}
|
||||
|
||||
private static async Task<Product> LoadModelAsync(IModuleDbReadOnly db, Guid modelId, CancellationToken cancellationToken)
|
||||
{
|
||||
var model = await db.Products
|
||||
.Where(product => product.DeletedOn == null && product.Id == modelId && product.Kind == ProductKind.Model)
|
||||
.Include(product => product.Options).ThenInclude(option => option.Values)
|
||||
.Include(product => product.Options).ThenInclude(option => option.Tiers)
|
||||
.Include(product => product.RuleSets).ThenInclude(rule => rule.Conditions)
|
||||
.Include(product => product.Variants).ThenInclude(variant => variant.AxisValues)
|
||||
.Include(product => product.AxisValues)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (model is null)
|
||||
{
|
||||
throw new CatalogNotFoundException("Product", modelId.ToString());
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Validator : AbstractValidator<Request>
|
||||
{
|
||||
public Validator()
|
||||
{
|
||||
RuleFor(request => request)
|
||||
.Must(HasLookupKey)
|
||||
.WithMessage("Either productId or sku must be provided.");
|
||||
|
||||
RuleFor(request => request.Sku)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.Must(sku => string.IsNullOrWhiteSpace(sku) || sku.Trim().Length > 0)
|
||||
.WithMessage("Sku cannot be empty.")
|
||||
.MaximumLength(ProductRules.SkuMaxLength)
|
||||
.When(request => !string.IsNullOrWhiteSpace(request.Sku));
|
||||
|
||||
RuleFor(request => request.Selections)
|
||||
.NotNull().WithMessage("Selections cannot be null.");
|
||||
|
||||
RuleForEach(request => request.Selections)
|
||||
.SetValidator(new SelectionValidator());
|
||||
}
|
||||
|
||||
private static bool HasLookupKey(Request request) =>
|
||||
request.ProductId.HasValue || !string.IsNullOrWhiteSpace(request.Sku);
|
||||
|
||||
private sealed class SelectionValidator : AbstractValidator<Selection>
|
||||
{
|
||||
public SelectionValidator()
|
||||
{
|
||||
RuleFor(selection => selection.OptionDefinitionId)
|
||||
.NotEmpty().WithMessage("OptionDefinitionId is required.");
|
||||
|
||||
RuleFor(selection => selection)
|
||||
.Must(selection => selection.OptionValueId.HasValue || selection.NumericValue.HasValue)
|
||||
.WithMessage("Each selection must include optionValueId or numericValue.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user