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 { 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 Values { get; } = []; public List Tiers { get; } = []; public static async Task 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 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 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 { 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 { 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 }; } }