544 lines
17 KiB
C#
544 lines
17 KiB
C#
using Prefab.Base.Catalog.Options;
|
|
using Prefab.Catalog.Domain.Events;
|
|
using Prefab.Catalog.Domain.Services;
|
|
using Prefab.Domain.Common;
|
|
using DomainRuleException = Prefab.Domain.Exceptions.DomainException;
|
|
|
|
namespace Prefab.Catalog.Domain.Entities;
|
|
|
|
public enum OptionDataType
|
|
{
|
|
Choice = 0,
|
|
Number = 1
|
|
}
|
|
|
|
public enum PriceDeltaKind
|
|
{
|
|
Absolute = 0,
|
|
Percent = 1
|
|
}
|
|
|
|
public enum PercentScope
|
|
{
|
|
BaseOnly = 0,
|
|
NumericOnly = 1,
|
|
BasePlusNumeric = 2
|
|
}
|
|
|
|
public class OptionDefinition : EntityWithAuditAndStatus<Guid>
|
|
{
|
|
private OptionDefinition()
|
|
{
|
|
}
|
|
|
|
public Guid ProductId { get; private set; }
|
|
|
|
public Product Product { get; private set; } = null!;
|
|
|
|
public string Code { get; private set; } = string.Empty;
|
|
|
|
public string Name { get; private set; } = string.Empty;
|
|
|
|
public OptionDataType DataType { get; private set; }
|
|
|
|
public bool IsVariantAxis { get; private set; }
|
|
|
|
public string? Unit { get; private set; }
|
|
|
|
public decimal? Min { get; private set; }
|
|
|
|
public decimal? Max { get; private set; }
|
|
|
|
public decimal? Step { get; private set; }
|
|
|
|
public decimal? PricePerUnit { get; private set; }
|
|
|
|
public PercentScope? PercentScope { get; private set; }
|
|
|
|
public List<OptionValue> Values { get; } = [];
|
|
|
|
public List<OptionTier> Tiers { get; } = [];
|
|
|
|
public static async Task<OptionDefinition> CreateChoice(
|
|
Product product,
|
|
string code,
|
|
string name,
|
|
IUniqueChecker uniqueChecker,
|
|
bool isVariantAxis = false,
|
|
PercentScope? percentScope = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(product);
|
|
if (product.Kind != ProductKind.Model)
|
|
{
|
|
throw new InvalidOperationException("Options can only be defined on product models.");
|
|
}
|
|
|
|
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
|
|
var trimmedCode = code.Trim();
|
|
var trimmedName = name.Trim();
|
|
|
|
if (trimmedCode.Length > OptionDefinitionRules.CodeMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option code cannot exceed {OptionDefinitionRules.CodeMaxLength} characters.");
|
|
}
|
|
|
|
if (trimmedName.Length > OptionDefinitionRules.NameMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option name cannot exceed {OptionDefinitionRules.NameMaxLength} characters.");
|
|
}
|
|
|
|
if (!await uniqueChecker.OptionCodeIsUniqueForProduct(product.Id, trimmedCode, cancellationToken))
|
|
{
|
|
throw new InvalidOperationException($"An option with code '{trimmedCode}' already exists for product '{product.Id}'.");
|
|
}
|
|
|
|
var option = new OptionDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ProductId = product.Id,
|
|
Product = product,
|
|
Code = trimmedCode,
|
|
Name = trimmedName,
|
|
DataType = OptionDataType.Choice,
|
|
IsVariantAxis = isVariantAxis,
|
|
PercentScope = percentScope
|
|
};
|
|
|
|
product.Options.Add(option);
|
|
|
|
option.AddEvent(new OptionDefinitionCreated(product.Id, option.Id, option.Code, option.Name, OptionDataType.Choice.ToString(), isVariantAxis));
|
|
return option;
|
|
}
|
|
|
|
public static Task<OptionDefinition> CreateNumber(
|
|
Product product,
|
|
string code,
|
|
string name,
|
|
string unit,
|
|
decimal? min,
|
|
decimal? max,
|
|
decimal? step,
|
|
decimal? pricePerUnit,
|
|
IUniqueChecker uniqueChecker,
|
|
bool isVariantAxis = false,
|
|
CancellationToken cancellationToken = default) =>
|
|
CreateNumberInternal(product, code, name, unit, min, max, step, pricePerUnit, isVariantAxis, uniqueChecker, cancellationToken);
|
|
|
|
private static async Task<OptionDefinition> CreateNumberInternal(
|
|
Product product,
|
|
string code,
|
|
string name,
|
|
string unit,
|
|
decimal? min,
|
|
decimal? max,
|
|
decimal? step,
|
|
decimal? pricePerUnit,
|
|
bool isVariantAxis,
|
|
IUniqueChecker uniqueChecker,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(product);
|
|
if (product.Kind != ProductKind.Model)
|
|
{
|
|
throw new InvalidOperationException("Options can only be defined on product models.");
|
|
}
|
|
|
|
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(unit);
|
|
|
|
var trimmedCode = code.Trim();
|
|
var trimmedName = name.Trim();
|
|
var trimmedUnit = unit.Trim();
|
|
|
|
if (trimmedCode.Length > OptionDefinitionRules.CodeMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option code cannot exceed {OptionDefinitionRules.CodeMaxLength} characters.");
|
|
}
|
|
|
|
if (trimmedName.Length > OptionDefinitionRules.NameMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option name cannot exceed {OptionDefinitionRules.NameMaxLength} characters.");
|
|
}
|
|
|
|
if (trimmedUnit.Length > OptionDefinitionRules.UnitMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option unit cannot exceed {OptionDefinitionRules.UnitMaxLength} characters.");
|
|
}
|
|
|
|
if (min is not null && max is not null && min > max)
|
|
{
|
|
throw new ArgumentException("Minimum value cannot be greater than maximum.", nameof(min));
|
|
}
|
|
|
|
if (step is not null && step <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(step), step, "Step must be greater than zero.");
|
|
}
|
|
|
|
if (pricePerUnit is not null && pricePerUnit < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(pricePerUnit), pricePerUnit, "Price per unit cannot be negative.");
|
|
}
|
|
|
|
if (!await uniqueChecker.OptionCodeIsUniqueForProduct(product.Id, trimmedCode, cancellationToken))
|
|
{
|
|
throw new InvalidOperationException($"An option with code '{trimmedCode}' already exists for product '{product.Id}'.");
|
|
}
|
|
|
|
var option = new OptionDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ProductId = product.Id,
|
|
Product = product,
|
|
Code = trimmedCode,
|
|
Name = trimmedName,
|
|
DataType = OptionDataType.Number,
|
|
IsVariantAxis = isVariantAxis,
|
|
Unit = trimmedUnit,
|
|
Min = min,
|
|
Max = max,
|
|
Step = step,
|
|
PricePerUnit = pricePerUnit
|
|
};
|
|
|
|
product.Options.Add(option);
|
|
|
|
option.AddEvent(new OptionDefinitionCreated(product.Id, option.Id, option.Code, option.Name, OptionDataType.Number.ToString(), isVariantAxis));
|
|
return option;
|
|
}
|
|
|
|
public OptionValue AddValue(
|
|
string code,
|
|
string label,
|
|
decimal? priceDelta,
|
|
PriceDeltaKind priceDeltaKind = PriceDeltaKind.Absolute)
|
|
{
|
|
if (DataType != OptionDataType.Choice)
|
|
{
|
|
throw new InvalidOperationException("Values can only be added to choice options.");
|
|
}
|
|
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(label);
|
|
|
|
var trimmedCode = code.Trim();
|
|
var trimmedLabel = label.Trim();
|
|
|
|
if (trimmedCode.Length > OptionValueRules.CodeMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option value code cannot exceed {OptionValueRules.CodeMaxLength} characters.");
|
|
}
|
|
|
|
if (trimmedLabel.Length > OptionValueRules.LabelMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option value label cannot exceed {OptionValueRules.LabelMaxLength} characters.");
|
|
}
|
|
|
|
if (priceDelta is not null && priceDeltaKind == PriceDeltaKind.Percent &&
|
|
(priceDelta < -100m || priceDelta > 100m))
|
|
{
|
|
throw new DomainRuleException("Percent-based price deltas must be between -100 and 100.");
|
|
}
|
|
|
|
if (Values.Any(v => string.Equals(v.Code, trimmedCode, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new InvalidOperationException($"An option value with code '{trimmedCode}' already exists.");
|
|
}
|
|
|
|
var value = OptionValue.Create(Id, this, trimmedCode, trimmedLabel, priceDelta, priceDeltaKind);
|
|
|
|
Values.Add(value);
|
|
AddEvent(new OptionValueAdded(Id, value.Id, value.Code, value.Label, value.PriceDelta, value.PriceDeltaKind));
|
|
return value;
|
|
}
|
|
|
|
public void ChangeValue(Guid valueId, string? label, decimal? priceDelta, PriceDeltaKind? priceDeltaKind)
|
|
{
|
|
if (DataType != OptionDataType.Choice)
|
|
{
|
|
throw new InvalidOperationException("Values can only be changed for choice options.");
|
|
}
|
|
|
|
var value = Values.FirstOrDefault(v => v.Id == valueId)
|
|
?? throw new InvalidOperationException($"Option value '{valueId}' was not found.");
|
|
|
|
var changed = false;
|
|
|
|
if (label is not null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(label))
|
|
{
|
|
throw new ArgumentException("Label cannot be empty.", nameof(label));
|
|
}
|
|
|
|
var trimmedLabel = label.Trim();
|
|
|
|
if (trimmedLabel.Length > OptionValueRules.LabelMaxLength)
|
|
{
|
|
throw new DomainRuleException($"Option value label cannot exceed {OptionValueRules.LabelMaxLength} characters.");
|
|
}
|
|
|
|
if (!string.Equals(value.Label, trimmedLabel, StringComparison.Ordinal))
|
|
{
|
|
value.Label = trimmedLabel;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (priceDelta != value.PriceDelta)
|
|
{
|
|
var deltaKind = priceDeltaKind ?? value.PriceDeltaKind;
|
|
if (priceDelta is not null && deltaKind == PriceDeltaKind.Percent &&
|
|
(priceDelta < -100m || priceDelta > 100m))
|
|
{
|
|
throw new DomainRuleException("Percent-based price deltas must be between -100 and 100.");
|
|
}
|
|
|
|
value.PriceDelta = priceDelta;
|
|
changed = true;
|
|
}
|
|
|
|
if (priceDeltaKind.HasValue && priceDeltaKind.Value != value.PriceDeltaKind)
|
|
{
|
|
value.PriceDeltaKind = priceDeltaKind.Value;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
AddEvent(new OptionValueChanged(Id, value.Id));
|
|
}
|
|
}
|
|
|
|
public void RemoveValue(Guid valueId)
|
|
{
|
|
if (DataType != OptionDataType.Choice)
|
|
{
|
|
throw new InvalidOperationException("Values can only be removed from choice options.");
|
|
}
|
|
|
|
var value = Values.FirstOrDefault(v => v.Id == valueId)
|
|
?? throw new InvalidOperationException($"Option value '{valueId}' was not found.");
|
|
|
|
Values.Remove(value);
|
|
AddEvent(new OptionValueRemoved(Id, value.Id));
|
|
}
|
|
|
|
public OptionTier AddTier(
|
|
decimal fromInclusive,
|
|
decimal? toInclusive,
|
|
decimal unitRate,
|
|
decimal? flatDelta)
|
|
{
|
|
if (DataType != OptionDataType.Number)
|
|
{
|
|
throw new InvalidOperationException("Tiers can only be added to number options.");
|
|
}
|
|
|
|
if (fromInclusive < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(fromInclusive), fromInclusive, "FromInclusive cannot be negative.");
|
|
}
|
|
|
|
if (toInclusive is not null && toInclusive < fromInclusive)
|
|
{
|
|
throw new ArgumentException("ToInclusive must be greater than or equal to FromInclusive.", nameof(toInclusive));
|
|
}
|
|
|
|
if (unitRate < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(unitRate), unitRate, "Unit rate cannot be negative.");
|
|
}
|
|
|
|
if (flatDelta is not null && flatDelta < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(flatDelta), flatDelta, "Flat delta cannot be negative.");
|
|
}
|
|
|
|
var tier = OptionTier.Create(Id, this, fromInclusive, toInclusive, unitRate, flatDelta);
|
|
Tiers.Add(tier);
|
|
AddEvent(new OptionTierAdded(Id, tier.Id, tier.FromInclusive, tier.ToInclusive));
|
|
return tier;
|
|
}
|
|
|
|
public void ChangeTier(Guid tierId, decimal? fromInclusive, decimal? toInclusive, decimal? unitRate, decimal? flatDelta)
|
|
{
|
|
if (DataType != OptionDataType.Number)
|
|
{
|
|
throw new InvalidOperationException("Tiers can only be changed for number options.");
|
|
}
|
|
|
|
var tier = Tiers.FirstOrDefault(t => t.Id == tierId)
|
|
?? throw new InvalidOperationException($"Option tier '{tierId}' was not found.");
|
|
|
|
var newFrom = fromInclusive ?? tier.FromInclusive;
|
|
var newTo = toInclusive ?? tier.ToInclusive;
|
|
var newRate = unitRate ?? tier.UnitRate;
|
|
var newFlat = flatDelta ?? tier.FlatDelta;
|
|
|
|
if (newFrom < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(fromInclusive), newFrom, "FromInclusive cannot be negative.");
|
|
}
|
|
|
|
if (newTo is not null && newTo < newFrom)
|
|
{
|
|
throw new ArgumentException("ToInclusive must be greater than or equal to FromInclusive.", nameof(toInclusive));
|
|
}
|
|
|
|
if (newRate < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(unitRate), newRate, "Unit rate cannot be negative.");
|
|
}
|
|
|
|
if (newFlat is not null && newFlat < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(flatDelta), newFlat, "Flat delta cannot be negative.");
|
|
}
|
|
|
|
var changed = false;
|
|
|
|
if (tier.FromInclusive != newFrom)
|
|
{
|
|
tier.FromInclusive = newFrom;
|
|
changed = true;
|
|
}
|
|
|
|
if (tier.ToInclusive != newTo)
|
|
{
|
|
tier.ToInclusive = newTo;
|
|
changed = true;
|
|
}
|
|
|
|
if (tier.UnitRate != newRate)
|
|
{
|
|
tier.UnitRate = newRate;
|
|
changed = true;
|
|
}
|
|
|
|
if (tier.FlatDelta != newFlat)
|
|
{
|
|
tier.FlatDelta = newFlat;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
AddEvent(new OptionTierChanged(Id, tier.Id));
|
|
}
|
|
}
|
|
|
|
public void RemoveTier(Guid tierId)
|
|
{
|
|
if (DataType != OptionDataType.Number)
|
|
{
|
|
throw new InvalidOperationException("Tiers can only be removed from number options.");
|
|
}
|
|
|
|
var tier = Tiers.FirstOrDefault(t => t.Id == tierId)
|
|
?? throw new InvalidOperationException($"Option tier '{tierId}' was not found.");
|
|
|
|
Tiers.Remove(tier);
|
|
AddEvent(new OptionTierRemoved(Id, tier.Id));
|
|
}
|
|
|
|
public void SetPercentScope(PercentScope scope)
|
|
{
|
|
if (DataType != OptionDataType.Choice)
|
|
{
|
|
throw new InvalidOperationException("Percent scope applies only to choice options.");
|
|
}
|
|
|
|
if (PercentScope == scope)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PercentScope = scope;
|
|
}
|
|
}
|
|
|
|
public class OptionValue : EntityWithAuditAndStatus<Guid>
|
|
{
|
|
private OptionValue()
|
|
{
|
|
}
|
|
|
|
public Guid OptionDefinitionId { get; private set; }
|
|
|
|
public OptionDefinition OptionDefinition { get; private set; } = null!;
|
|
|
|
public string Code { get; private set; } = string.Empty;
|
|
|
|
public string Label { get; internal set; } = string.Empty;
|
|
|
|
public decimal? PriceDelta { get; internal set; }
|
|
|
|
public PriceDeltaKind PriceDeltaKind { get; internal set; }
|
|
|
|
internal static OptionValue Create(
|
|
Guid optionDefinitionId,
|
|
OptionDefinition optionDefinition,
|
|
string code,
|
|
string label,
|
|
decimal? priceDelta,
|
|
PriceDeltaKind priceDeltaKind)
|
|
{
|
|
return new OptionValue
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OptionDefinitionId = optionDefinitionId,
|
|
OptionDefinition = optionDefinition,
|
|
Code = code,
|
|
Label = label,
|
|
PriceDelta = priceDelta,
|
|
PriceDeltaKind = priceDeltaKind
|
|
};
|
|
}
|
|
}
|
|
|
|
public class OptionTier : EntityWithAuditAndStatus<Guid>
|
|
{
|
|
private OptionTier()
|
|
{
|
|
}
|
|
|
|
public Guid OptionDefinitionId { get; private set; }
|
|
|
|
public OptionDefinition OptionDefinition { get; private set; } = null!;
|
|
|
|
public decimal FromInclusive { get; internal set; }
|
|
|
|
public decimal? ToInclusive { get; internal set; }
|
|
|
|
public decimal UnitRate { get; internal set; }
|
|
|
|
public decimal? FlatDelta { get; internal set; }
|
|
|
|
internal static OptionTier Create(
|
|
Guid optionDefinitionId,
|
|
OptionDefinition optionDefinition,
|
|
decimal fromInclusive,
|
|
decimal? toInclusive,
|
|
decimal unitRate,
|
|
decimal? flatDelta)
|
|
{
|
|
return new OptionTier
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
OptionDefinitionId = optionDefinitionId,
|
|
OptionDefinition = optionDefinition,
|
|
FromInclusive = fromInclusive,
|
|
ToInclusive = toInclusive,
|
|
UnitRate = unitRate,
|
|
FlatDelta = flatDelta
|
|
};
|
|
}
|
|
}
|