using System.Globalization; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Prefab.Catalog.Data.SeedSupport; using Prefab.Catalog.Domain.Entities; using Prefab.Catalog.Domain.Services; using Prefab.Data.Entities; using Prefab.Data.Seeder; namespace Prefab.Catalog.Data; public class Seeder : PrefabDbSeeder { private static readonly CategorySeed[] Categories = { new("ceiling-supports", "Ceiling Supports", 1, true), new("boxes-and-covers", "Boxes & Covers", 2, true), new("prefab-assemblies", "Prefabricated Assemblies", 3, true), new("rod-strut-hardware", "Rod & Strut Hardware", 4, false) }; private const string LoadGuidanceKey = "catalog.product.safety.load_guidance"; private const string RequireGroundTailKey = "catalog.product.safety.require_ground_tail"; private const string CatalogDocsKey = "catalog.product.docs"; private const string FaceStyleAttributeName = "face_style"; private const string FaceStyleValue = "Decora"; private const string BoxSizeAttributeName = "box_size_in"; private const string BoxDepthAttributeName = "box_depth_in"; private const string BoxCapacityAttributeName = "box_capacity"; private const string BoxConstructionAttributeName = "box_construction"; private const string BoxSideKnockoutsAttributeName = "box_side_knockouts"; private const string BoxBottomKnockoutsAttributeName = "box_bottom_knockouts"; private const string BoxMaterialAttributeName = "box_material"; private const string AreaClassificationAttributeName = "area_classification"; private const string RodLengthAttributeName = "rod_length_in"; private const string RodDiameterAttributeName = "rod_diameter_in"; private const string RodFinishAttributeName = "rod_finish"; private const string RodMaterialAttributeName = "rod_material"; private const string BoxesCoversDocTitle = "Eaton 4\" Steel Square Overview"; private const string BoxesCoversDocUrl = "https://www.eaton.com/us/en-us/catalog/crouse-hinds/4-inch-square-steel-boxes.html"; private const string RodStrutDocTitle = "Eaton Channel Nuts, U-Bolts, ATR & Hardware"; private const string RodStrutDocUrl = "https://www.eaton.com/us/en-us/catalog/crouse-hinds/channel-nuts-u-bolts-threaded-rod.html"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; protected override Task ApplyViews(IModuleDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken) => Task.CompletedTask; protected override async Task SeedData(IModuleDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken) { var logger = serviceProvider.GetRequiredService>(); var checker = serviceProvider.GetRequiredService(); logger.LogInformation("Seeding catalog MVP products and categories."); var categories = await EnsureCategoriesAsync(db, checker, logger, cancellationToken); var ceilingSupports = categories["ceiling-supports"]; var boxesCovers = categories["boxes-and-covers"]; var prefabAssemblies = categories["prefab-assemblies"]; var rodStrutHardware = categories["rod-strut-hardware"]; var faceStyleDefinition = await EnsureAttributeDefinitionAsync(db, FaceStyleAttributeName, AttributeDataType.Text, cancellationToken); await EnsureCeilingTBarAssemblyAsync(db, checker, ceilingSupports, faceStyleDefinition, cancellationToken); await EnsureFanHangerAssemblyAsync(db, checker, ceilingSupports, cancellationToken); await EnsureFourInchSteelSquareAsync(db, checker, boxesCovers, cancellationToken); await EnsureAllThreadedRodAsync(db, checker, rodStrutHardware, cancellationToken); await EnsureRaisedDeviceAssemblyAsync(db, checker, prefabAssemblies, faceStyleDefinition, cancellationToken); await EnsurePigtailedDeviceAsync(db, checker, prefabAssemblies, faceStyleDefinition, cancellationToken); await db.SaveChangesAsync(cancellationToken); logger.LogInformation("Catalog MVP seeding complete."); } private static async Task> EnsureCategoriesAsync( IModuleDb db, IUniqueChecker checker, ILogger logger, CancellationToken cancellationToken) { var categories = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var seed in Categories) { var existing = await db.Categories .FirstOrDefaultAsync(c => c.Slug == seed.Slug, cancellationToken); if (existing is null) { var category = await Category.Create(seed.Name, null, checker, cancellationToken); category.ConfigureMetadata(seed.Slug, seed.DisplayOrder, seed.IsFeatured, null, null); db.Categories.Add(category); categories[seed.Slug] = category; logger.LogInformation("Created category {Slug}.", seed.Slug); continue; } if (!string.Equals(existing.Name, seed.Name, StringComparison.Ordinal)) { await existing.Rename(seed.Name, checker, cancellationToken); } existing.ConfigureMetadata(seed.Slug, seed.DisplayOrder, seed.IsFeatured, existing.HeroImageUrl, existing.Icon); categories[seed.Slug] = existing; } return categories; } private static async Task EnsureCeilingTBarAssemblyAsync( IModuleDb db, IUniqueChecker checker, Category category, AttributeDefinition faceStyleDefinition, CancellationToken cancellationToken) { const string slug = "ceiling-tbar-box-assembly"; var product = await LoadModelAsync(db, slug, cancellationToken); if (product is null) { product = await Product.CreateModel( name: "Ceiling T-Bar Box Assembly", slug, description: "Factory-assembled T-Bar box light assembly with flexible bracket options.", checker, cancellationToken); db.Products.Add(product); } else if (!string.Equals(product.Name, "Ceiling T-Bar Box Assembly", StringComparison.Ordinal)) { await product.Rename("Ceiling T-Bar Box Assembly", checker, cancellationToken); } product.SetBasePrice(39.00m); product.ChangeDescription("Factory-assembled T-Bar box light assembly with flexible bracket options."); product.AssignToCategory(category.Id, true); var boxShape = await EnsureChoiceOptionAsync(product, checker, "box_shape", "Box Shape", isVariantAxis: true, cancellationToken: cancellationToken); var octagon = EnsureChoiceValue(boxShape, "octagon", "Octagon", 0m, PriceDeltaKind.Absolute); var square = EnsureChoiceValue(boxShape, "square", "Square", 0m, PriceDeltaKind.Absolute); var boxDepth = await EnsureChoiceOptionAsync(product, checker, "box_depth", "Box Depth", isVariantAxis: true, cancellationToken: cancellationToken); var depth15 = EnsureChoiceValue(boxDepth, "1_5", "1.5 in", 0m, PriceDeltaKind.Absolute); var depth2125 = EnsureChoiceValue(boxDepth, "2_125", "2.125 in", 0m, PriceDeltaKind.Absolute); var blankCover = await EnsureChoiceOptionAsync(product, checker, "blank_cover", "Blank Cover", cancellationToken: cancellationToken); EnsureChoiceValue(blankCover, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(blankCover, "yes", "Yes", 1.25m, PriceDeltaKind.Absolute); var circuitMarking = await EnsureChoiceOptionAsync(product, checker, "circuit_marking", "Circuit Marking", cancellationToken: cancellationToken); EnsureChoiceValue(circuitMarking, "none", "None", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(circuitMarking, "laser", "Laser", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(circuitMarking, "stamped", "Stamped", 0m, PriceDeltaKind.Absolute); var groundTail = await EnsureChoiceOptionAsync(product, checker, "ground_tail", "Ground Tail", cancellationToken: cancellationToken); EnsureChoiceValue(groundTail, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(groundTail, "yes", "Yes", 0.75m, PriceDeltaKind.Absolute); var boxColor = await EnsureChoiceOptionAsync(product, checker, "box_color", "Box Color", cancellationToken: cancellationToken); EnsureChoiceValue(boxColor, "white", "White", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(boxColor, "red", "Red", 0.50m, PriceDeltaKind.Absolute); var bracketType = await EnsureChoiceOptionAsync(product, checker, "bracket_type", "Bracket Type", cancellationToken: cancellationToken); var fixedBracket = EnsureChoiceValue(bracketType, "fixed_24in", "Fixed 24\"", 0m, PriceDeltaKind.Absolute); var adjustableBracket = EnsureChoiceValue(bracketType, "adjustable", "Adjustable", 2.00m, PriceDeltaKind.Absolute); var dedicatedDropwire = await EnsureChoiceOptionAsync(product, checker, "dedicated_dropwire", "Dedicated Dropwire", cancellationToken: cancellationToken); EnsureChoiceValue(dedicatedDropwire, "no", "No", 0m, PriceDeltaKind.Absolute); var dedicatedYes = EnsureChoiceValue(dedicatedDropwire, "yes", "Yes", 3.00m, PriceDeltaKind.Absolute); var flexWhip = await EnsureNumberOptionAsync( product, checker, "flex_whip_length", "Flex Whip Length", unit: "ft", min: 1m, max: 100m, step: 1m, pricePerUnit: 0.85m, cancellationToken); AddTierIfMissing(flexWhip, 26m, null, 0.60m, 5.00m); AddRuleIfMissing( product, OptionRuleTargetKind.OptionDefinition, boxColor.Id, RuleEffect.Show, () => OptionRuleSetBuilder .ForDefinition(product, boxColor, RuleEffect.Show, RuleMode.All) .WhenEquals(boxShape, square) .Build()); AddRuleIfMissing( product, OptionRuleTargetKind.OptionDefinition, dedicatedDropwire.Id, RuleEffect.Show, () => OptionRuleSetBuilder .ForDefinition(product, dedicatedDropwire, RuleEffect.Show, RuleMode.All) .WhenEquals(bracketType, adjustableBracket) .Build()); await UpsertGenericAttributeAsync(db, product, LoadGuidanceKey, "Adjustable bracket assemblies require an independent support.", cancellationToken); await EnsureVariantAsync(product, checker, "TBA-OCT-15", $"Octagon / {depth15.Label}", 39.00m, cancellationToken, (boxShape, octagon), (boxDepth, depth15)); await EnsureVariantAsync(product, checker, "TBA-OCT-2125", $"Octagon / {depth2125.Label}", 39.00m, cancellationToken, (boxShape, octagon), (boxDepth, depth2125)); await EnsureVariantAsync(product, checker, "TBA-SQR-15", $"Square / {depth15.Label}", 39.00m, cancellationToken, (boxShape, square), (boxDepth, depth15)); await EnsureVariantAsync(product, checker, "TBA-SQR-2125", $"Square / {depth2125.Label}", 39.00m, cancellationToken, (boxShape, square), (boxDepth, depth2125)); } private static async Task EnsureFanHangerAssemblyAsync( IModuleDb db, IUniqueChecker checker, Category category, CancellationToken cancellationToken) { const string slug = "fan-hanger-assembly"; var product = await LoadModelAsync(db, slug, cancellationToken); if (product is null) { product = await Product.CreateModel( name: "Fan Hanger Assembly", slug, description: "Pre-assembled fan hanger with KwikWire suspension kits.", checker, cancellationToken); db.Products.Add(product); } else if (!string.Equals(product.Name, "Fan Hanger Assembly", StringComparison.Ordinal)) { await product.Rename("Fan Hanger Assembly", checker, cancellationToken); } product.SetBasePrice(42.00m); product.ChangeDescription("Pre-assembled fan hanger with KwikWire suspension kits."); product.AssignToCategory(category.Id, true); var suspensionKit = await EnsureChoiceOptionAsync(product, checker, "suspension_kit", "Suspension Kit", isVariantAxis: true, cancellationToken: cancellationToken); var kit10 = EnsureChoiceValue(suspensionKit, "kwikwire_10ft", "KwikWire 10ft", 0m, PriceDeltaKind.Absolute); var kit15 = EnsureChoiceValue(suspensionKit, "kwikwire_15ft", "KwikWire 15ft", 0m, PriceDeltaKind.Absolute); var boxDepth = await EnsureChoiceOptionAsync(product, checker, "box_depth", "Box Depth", isVariantAxis: true, cancellationToken: cancellationToken); var depth15 = EnsureChoiceValue(boxDepth, "1_5", "1.5 in", 0m, PriceDeltaKind.Absolute); var depth2125 = EnsureChoiceValue(boxDepth, "2_125", "2.125 in", 0m, PriceDeltaKind.Absolute); var groundTail = await EnsureChoiceOptionAsync(product, checker, "ground_tail", "Ground Tail", cancellationToken: cancellationToken); EnsureChoiceValue(groundTail, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(groundTail, "yes", "Yes", 0.75m, PriceDeltaKind.Absolute); var blankCover = await EnsureChoiceOptionAsync(product, checker, "blank_cover", "Blank Cover", cancellationToken: cancellationToken); EnsureChoiceValue(blankCover, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(blankCover, "yes", "Yes", 1.25m, PriceDeltaKind.Absolute); var dedicatedDropwire = await EnsureChoiceOptionAsync(product, checker, "dedicated_dropwire", "Dedicated Dropwire", cancellationToken: cancellationToken); EnsureChoiceValue(dedicatedDropwire, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(dedicatedDropwire, "yes", "Yes", 3.00m, PriceDeltaKind.Absolute); AddRuleIfMissing( product, OptionRuleTargetKind.OptionDefinition, dedicatedDropwire.Id, RuleEffect.Show, () => OptionRuleSetBuilder .ForDefinition(product, dedicatedDropwire, RuleEffect.Show, RuleMode.All) .WhenNotIn(suspensionKit, kit10, kit15) .Build()); await UpsertGenericAttributeAsync(db, product, RequireGroundTailKey, bool.TrueString.ToLowerInvariant(), cancellationToken); await EnsureVariantAsync(product, checker, "FH-KW10-15", $"KwikWire 10ft / {depth15.Label}", 42.00m, cancellationToken, (suspensionKit, kit10), (boxDepth, depth15)); await EnsureVariantAsync(product, checker, "FH-KW10-2125", $"KwikWire 10ft / {depth2125.Label}", 42.00m, cancellationToken, (suspensionKit, kit10), (boxDepth, depth2125)); await EnsureVariantAsync(product, checker, "FH-KW15-15", $"KwikWire 15ft / {depth15.Label}", 42.00m, cancellationToken, (suspensionKit, kit15), (boxDepth, depth15)); await EnsureVariantAsync(product, checker, "FH-KW15-2125", $"KwikWire 15ft / {depth2125.Label}", 42.00m, cancellationToken, (suspensionKit, kit15), (boxDepth, depth2125)); } private static async Task EnsureFourInchSteelSquareAsync( IModuleDb db, IUniqueChecker checker, Category category, CancellationToken cancellationToken) { const string slug = "four-inch-steel-square"; var product = await LoadModelAsync(db, slug, cancellationToken); if (product is null) { product = await Product.CreateModel( name: "4\" Square Boxes & Rings (Steel)", slug, description: "Steel 4\" square drawn boxes and raised rings for commercial rough-in work.", checker, cancellationToken); db.Products.Add(product); } else if (!string.Equals(product.Name, "4\" Square Boxes & Rings (Steel)", StringComparison.Ordinal)) { await product.Rename("4\" Square Boxes & Rings (Steel)", checker, cancellationToken); } product.SetBasePrice(0m); product.ChangeDescription("Steel 4\" square drawn boxes and raised rings for commercial rough-in work."); product.AssignToCategory(category.Id, true); await EnsureVariantAsync(product, checker, "TP412PF", "4\" Square Outlet Box - 1.5\" Depth (Drawn) with Pigtail", 4.79m, cancellationToken); await EnsureVariantAsync(product, checker, "TP428", "4\" Square Extension Ring - 1.5\" Depth", 3.59m, cancellationToken); await EnsureVariantAsync(product, checker, "TP833", "4\" Square Extension Ring - 1.5\" Depth, Air-Plenum Rated", 5.29m, cancellationToken); await EnsureVariantAsync(product, checker, "TP480", "4\" Square Mud Ring - 1-Device, Flat", 2.29m, cancellationToken); var boxSizeDefinition = await EnsureAttributeDefinitionAsync(db, BoxSizeAttributeName, AttributeDataType.Number, cancellationToken, unit: "in"); var boxDepthDefinition = await EnsureAttributeDefinitionAsync(db, BoxDepthAttributeName, AttributeDataType.Number, cancellationToken, unit: "in"); var boxCapacityDefinition = await EnsureAttributeDefinitionAsync(db, BoxCapacityAttributeName, AttributeDataType.Text, cancellationToken); var boxConstructionDefinition = await EnsureAttributeDefinitionAsync(db, BoxConstructionAttributeName, AttributeDataType.Enum, cancellationToken); var boxSideKnockoutsDefinition = await EnsureAttributeDefinitionAsync(db, BoxSideKnockoutsAttributeName, AttributeDataType.Text, cancellationToken); var boxBottomKnockoutsDefinition = await EnsureAttributeDefinitionAsync(db, BoxBottomKnockoutsAttributeName, AttributeDataType.Text, cancellationToken); var boxMaterialDefinition = await EnsureAttributeDefinitionAsync(db, BoxMaterialAttributeName, AttributeDataType.Enum, cancellationToken); var areaClassificationDefinition = await EnsureAttributeDefinitionAsync(db, AreaClassificationAttributeName, AttributeDataType.Enum, cancellationToken); await UpsertProductDocsAsync( db, product, new[] { (BoxesCoversDocTitle, BoxesCoversDocUrl) }, cancellationToken); var variants = product.Variants .Where(variant => variant.DeletedOn == null && !string.IsNullOrWhiteSpace(variant.Sku)) .ToDictionary(variant => variant.Sku!, StringComparer.OrdinalIgnoreCase); if (variants.TryGetValue("TP412PF", out var tp412pf)) { tp412pf.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null); tp412pf.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null); tp412pf.UpsertSpec(boxCapacityDefinition.Id, "22 cu in", null, null, null); tp412pf.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn"); tp412pf.UpsertSpec(boxSideKnockoutsDefinition.Id, "(8) 3/4 in", null, null, null); tp412pf.UpsertSpec(boxBottomKnockoutsDefinition.Id, "(3) 1/2 in, (2) 3/4 in", null, null, null); tp412pf.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel"); } if (variants.TryGetValue("TP428", out var tp428)) { tp428.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null); tp428.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null); tp428.UpsertSpec(boxCapacityDefinition.Id, "22 cu in", null, null, null); tp428.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn"); tp428.UpsertSpec(boxSideKnockoutsDefinition.Id, "(8) 3/4 in", null, null, null); tp428.UpsertSpec(boxBottomKnockoutsDefinition.Id, "(3) 1/2 in, (2) 3/4 in", null, null, null); tp428.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel"); } if (variants.TryGetValue("TP833", out var tp833)) { tp833.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null); tp833.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null); tp833.UpsertSpec(boxCapacityDefinition.Id, "30.3 cu in", null, null, null); tp833.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn"); tp833.UpsertSpec(boxSideKnockoutsDefinition.Id, "No knockouts", null, null, null); tp833.UpsertSpec(boxBottomKnockoutsDefinition.Id, "No knockouts", null, null, null); tp833.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel"); tp833.UpsertSpec(areaClassificationDefinition.Id, "Air Plenum", null, null, "air-plenum"); } if (variants.TryGetValue("TP480", out var tp480)) { tp480.UpsertSpec(boxSizeDefinition.Id, FormatDecimal(4m), 4m, boxSizeDefinition.Unit, null); tp480.UpsertSpec(boxDepthDefinition.Id, FormatDecimal(1.5m), 1.5m, boxDepthDefinition.Unit, null); tp480.UpsertSpec(boxCapacityDefinition.Id, "N/A", null, null, null); tp480.UpsertSpec(boxConstructionDefinition.Id, "Drawn", null, null, "drawn"); tp480.UpsertSpec(boxSideKnockoutsDefinition.Id, "No knockouts", null, null, null); tp480.UpsertSpec(boxBottomKnockoutsDefinition.Id, "No knockouts", null, null, null); tp480.UpsertSpec(boxMaterialDefinition.Id, "Steel", null, null, "steel"); tp480.UpsertSpec(areaClassificationDefinition.Id, "Air Plenum", null, null, "air-plenum"); } } private static async Task EnsureAllThreadedRodAsync( IModuleDb db, IUniqueChecker checker, Category category, CancellationToken cancellationToken) { const string slug = "all-threaded-rod"; var product = await LoadModelAsync(db, slug, cancellationToken); if (product is null) { product = await Product.CreateModel( name: "Threaded Rod (All-Thread)", slug, description: "All-thread rod family with stocked diameters, lengths, and finishes for trapeze hangers and hardware kits.", checker, cancellationToken); db.Products.Add(product); } else if (!string.Equals(product.Name, "Threaded Rod (All-Thread)", StringComparison.Ordinal)) { await product.Rename("Threaded Rod (All-Thread)", checker, cancellationToken); } product.SetBasePrice(0m); product.ChangeDescription("All-thread rod family with stocked diameters, lengths, finishes, and optional hardware packs."); product.AssignToCategory(category.Id, true); await UpsertGenericAttributeAsync(db, product, LoadGuidanceKey, "Verify load tables; size and finish affect ratings.", cancellationToken); await UpsertProductDocsAsync( db, product, new[] { (RodStrutDocTitle, RodStrutDocUrl) }, cancellationToken); var lengthDefinition = await EnsureAttributeDefinitionAsync(db, RodLengthAttributeName, AttributeDataType.Number, cancellationToken, unit: "in"); var diameterDefinition = await EnsureAttributeDefinitionAsync(db, RodDiameterAttributeName, AttributeDataType.Number, cancellationToken, unit: "in"); var finishDefinition = await EnsureAttributeDefinitionAsync(db, RodFinishAttributeName, AttributeDataType.Enum, cancellationToken); var materialDefinition = await EnsureAttributeDefinitionAsync(db, RodMaterialAttributeName, AttributeDataType.Enum, cancellationToken); var diameterOption = await EnsureChoiceOptionAsync(product, checker, "diameter", "Diameter", isVariantAxis: true, cancellationToken: cancellationToken); var diameterQuarter = EnsureChoiceValue(diameterOption, "1_4", "1/4\"", 0m, PriceDeltaKind.Absolute); var diameterThreeEighths = EnsureChoiceValue(diameterOption, "3_8", "3/8\"", 0m, PriceDeltaKind.Absolute); var diameterHalf = EnsureChoiceValue(diameterOption, "1_2", "1/2\"", 0m, PriceDeltaKind.Absolute); var lengthOption = await EnsureChoiceOptionAsync(product, checker, "length", "Length", isVariantAxis: true, cancellationToken: cancellationToken); var length36 = EnsureChoiceValue(lengthOption, "36_in", "36\" (3 ft)", 0m, PriceDeltaKind.Absolute); var length72 = EnsureChoiceValue(lengthOption, "72_in", "72\" (6 ft)", 0m, PriceDeltaKind.Absolute); var length120 = EnsureChoiceValue(lengthOption, "120_in", "120\" (10 ft)", 0m, PriceDeltaKind.Absolute); var length144 = EnsureChoiceValue(lengthOption, "144_in", "144\" (12 ft)", 0m, PriceDeltaKind.Absolute); var finishOption = await EnsureChoiceOptionAsync(product, checker, "finish", "Finish", isVariantAxis: true, cancellationToken: cancellationToken); var finishZinc = EnsureChoiceValue(finishOption, "zinc", "Zinc-Plated", 0m, PriceDeltaKind.Absolute); var finishSs304 = EnsureChoiceValue(finishOption, "ss304", "Stainless 304", 0m, PriceDeltaKind.Absolute); var hardwarePackA = await EnsureChoiceOptionAsync(product, checker, "hardware_pack_a", "Hardware Pack A", cancellationToken: cancellationToken); var packAYes = EnsureChoiceValue(hardwarePackA, "yes", "Yes", 12.00m, PriceDeltaKind.Absolute); var packANo = EnsureChoiceValue(hardwarePackA, "no", "No", 0m, PriceDeltaKind.Absolute); var packARodCoupler = await EnsureChoiceOptionAsync(product, checker, "pack_a_rod_coupler", "Pack A Rod Coupler", cancellationToken: cancellationToken); EnsureChoiceValue(packARodCoupler, "none", "None", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packARodCoupler, "1_4", "1/4\"", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packARodCoupler, "3_8", "3/8\"", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packARodCoupler, "1_2", "1/2\"", 0m, PriceDeltaKind.Absolute); var packANuts = await EnsureChoiceOptionAsync(product, checker, "pack_a_nuts", "Pack A Nuts", cancellationToken: cancellationToken); EnsureChoiceValue(packANuts, "hex", "Hex", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packANuts, "jam", "Jam", 0m, PriceDeltaKind.Absolute); var packAWashers = await EnsureChoiceOptionAsync(product, checker, "pack_a_washers", "Pack A Washers", cancellationToken: cancellationToken); EnsureChoiceValue(packAWashers, "flat", "Flat", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packAWashers, "lock", "Lock", 0m, PriceDeltaKind.Absolute); var packABeamClamp = await EnsureChoiceOptionAsync(product, checker, "pack_a_beam_clamp", "Pack A Beam Clamp", cancellationToken: cancellationToken); EnsureChoiceValue(packABeamClamp, "bc1", "BC-1", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packABeamClamp, "bc2", "BC-2", 0m, PriceDeltaKind.Absolute); var packASammy = await EnsureChoiceOptionAsync(product, checker, "pack_a_sammy", "Pack A Sammy", cancellationToken: cancellationToken); EnsureChoiceValue(packASammy, "wood", "Wood", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packASammy, "steel", "Steel", 0m, PriceDeltaKind.Absolute); var hardwarePackB = await EnsureChoiceOptionAsync(product, checker, "hardware_pack_b", "Hardware Pack B", cancellationToken: cancellationToken); var packBYes = EnsureChoiceValue(hardwarePackB, "yes", "Yes", 12.00m, PriceDeltaKind.Absolute); var packBNo = EnsureChoiceValue(hardwarePackB, "no", "No", 0m, PriceDeltaKind.Absolute); _ = packANo; _ = packBNo; _ = length72; var packBRodCoupler = await EnsureChoiceOptionAsync(product, checker, "pack_b_rod_coupler", "Pack B Rod Coupler", cancellationToken: cancellationToken); EnsureChoiceValue(packBRodCoupler, "none", "None", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBRodCoupler, "1_4", "1/4\"", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBRodCoupler, "3_8", "3/8\"", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBRodCoupler, "1_2", "1/2\"", 0m, PriceDeltaKind.Absolute); var packBNuts = await EnsureChoiceOptionAsync(product, checker, "pack_b_nuts", "Pack B Nuts", cancellationToken: cancellationToken); EnsureChoiceValue(packBNuts, "hex", "Hex", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBNuts, "jam", "Jam", 0m, PriceDeltaKind.Absolute); var packBWashers = await EnsureChoiceOptionAsync(product, checker, "pack_b_washers", "Pack B Washers", cancellationToken: cancellationToken); EnsureChoiceValue(packBWashers, "flat", "Flat", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBWashers, "lock", "Lock", 0m, PriceDeltaKind.Absolute); var packBBeamClamp = await EnsureChoiceOptionAsync(product, checker, "pack_b_beam_clamp", "Pack B Beam Clamp", cancellationToken: cancellationToken); EnsureChoiceValue(packBBeamClamp, "bc1", "BC-1", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBBeamClamp, "bc2", "BC-2", 0m, PriceDeltaKind.Absolute); var packBSammy = await EnsureChoiceOptionAsync(product, checker, "pack_b_sammy", "Pack B Sammy", cancellationToken: cancellationToken); EnsureChoiceValue(packBSammy, "wood", "Wood", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(packBSammy, "steel", "Steel", 0m, PriceDeltaKind.Absolute); foreach (var option in new[] { packARodCoupler, packANuts, packAWashers, packABeamClamp, packASammy }) { AddRuleIfMissing( product, OptionRuleTargetKind.OptionDefinition, option.Id, RuleEffect.Show, () => OptionRuleSetBuilder .ForDefinition(product, option, RuleEffect.Show, RuleMode.All) .WhenEquals(hardwarePackA, packAYes) .Build()); } foreach (var option in new[] { packBRodCoupler, packBNuts, packBWashers, packBBeamClamp, packBSammy }) { AddRuleIfMissing( product, OptionRuleTargetKind.OptionDefinition, option.Id, RuleEffect.Show, () => OptionRuleSetBuilder .ForDefinition(product, option, RuleEffect.Show, RuleMode.All) .WhenEquals(hardwarePackB, packBYes) .Build()); } await EnsureVariantAsync( product, checker, "ATR-025-36-SS", "All-Thread Rod - 1/4\" x 36\", Stainless 304", 8.50m, cancellationToken, (diameterOption, diameterQuarter), (lengthOption, length36), (finishOption, finishSs304)); await EnsureVariantAsync( product, checker, "ATR-038-144-SS", "All-Thread Rod - 3/8\" x 144\", Stainless 304", 34.00m, cancellationToken, (diameterOption, diameterThreeEighths), (lengthOption, length144), (finishOption, finishSs304)); await EnsureVariantAsync( product, checker, "ATR-050-120-ZN", "All-Thread Rod - 1/2\" x 120\", Zinc-Plated", 22.00m, cancellationToken, (diameterOption, diameterHalf), (lengthOption, length120), (finishOption, finishZinc)); var variants = product.Variants .Where(variant => variant.DeletedOn == null && !string.IsNullOrWhiteSpace(variant.Sku)) .ToDictionary(variant => variant.Sku!, StringComparer.OrdinalIgnoreCase); if (variants.TryGetValue("ATR-025-36-SS", out var atr02536)) { atr02536.UpsertSpec(diameterDefinition.Id, FormatDecimal(0.25m), 0.25m, diameterDefinition.Unit, null); atr02536.UpsertSpec(lengthDefinition.Id, FormatDecimal(36m), 36m, lengthDefinition.Unit, null); atr02536.UpsertSpec(finishDefinition.Id, "Stainless 304", null, null, "ss304"); atr02536.UpsertSpec(materialDefinition.Id, "Stainless 304", null, null, "stainless_304"); } if (variants.TryGetValue("ATR-038-144-SS", out var atr038144)) { atr038144.UpsertSpec(diameterDefinition.Id, FormatDecimal(0.375m), 0.375m, diameterDefinition.Unit, null); atr038144.UpsertSpec(lengthDefinition.Id, FormatDecimal(144m), 144m, lengthDefinition.Unit, null); atr038144.UpsertSpec(finishDefinition.Id, "Stainless 304", null, null, "ss304"); atr038144.UpsertSpec(materialDefinition.Id, "Stainless 304", null, null, "stainless_304"); } if (variants.TryGetValue("ATR-050-120-ZN", out var atr050120)) { atr050120.UpsertSpec(diameterDefinition.Id, FormatDecimal(0.5m), 0.5m, diameterDefinition.Unit, null); atr050120.UpsertSpec(lengthDefinition.Id, FormatDecimal(120m), 120m, lengthDefinition.Unit, null); atr050120.UpsertSpec(finishDefinition.Id, "Zinc-Plated", null, null, "zinc"); } } private static async Task EnsureRaisedDeviceAssemblyAsync( IModuleDb db, IUniqueChecker checker, Category category, AttributeDefinition faceStyleDefinition, CancellationToken cancellationToken) { const string slug = "raised-device-assembly"; var product = await LoadModelAsync(db, slug, cancellationToken); if (product is null) { product = await Product.CreateModel( name: "Raised Device Assembly", slug, description: "Raised device assembly with configurable device, lead, and wiring options.", checker, cancellationToken); db.Products.Add(product); } else if (!string.Equals(product.Name, "Raised Device Assembly", StringComparison.Ordinal)) { await product.Rename("Raised Device Assembly", checker, cancellationToken); } product.SetBasePrice(29.00m); product.ChangeDescription("Raised device assembly with configurable device, lead, and wiring options."); product.AssignToCategory(category.Id, true); var deviceType = await EnsureChoiceOptionAsync(product, checker, "device_type", "Device Type", cancellationToken: cancellationToken); var duplex15 = EnsureChoiceValue(deviceType, "duplex15", "Duplex 15A", 0m, PriceDeltaKind.Absolute); var duplex20 = EnsureChoiceValue(deviceType, "duplex20", "Duplex 20A", 0m, PriceDeltaKind.Absolute); var decora15 = EnsureChoiceValue(deviceType, "decora15", "Decora 15A", 0m, PriceDeltaKind.Absolute); var gfci15 = EnsureChoiceValue(deviceType, "gfci15", "GFCI 15A", 0m, PriceDeltaKind.Absolute); var gfci20 = EnsureChoiceValue(deviceType, "gfci20", "GFCI 20A", 0m, PriceDeltaKind.Absolute); var deviceColor = await EnsureChoiceOptionAsync(product, checker, "device_color", "Device Color", cancellationToken: cancellationToken); EnsureChoiceValue(deviceColor, "white", "White", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(deviceColor, "ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute); EnsureChoiceValue(deviceColor, "gray", "Gray", 0.50m, PriceDeltaKind.Absolute); var grade = await EnsureChoiceOptionAsync(product, checker, "grade", "Grade", cancellationToken: cancellationToken); EnsureChoiceValue(grade, "res", "Residential", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(grade, "spec", "Spec", 1.00m, PriceDeltaKind.Absolute); EnsureChoiceValue(grade, "xhd", "X-Heavy Duty", 2.00m, PriceDeltaKind.Absolute); var boxSize = await EnsureChoiceOptionAsync(product, checker, "box_size", "Box Size", cancellationToken: cancellationToken); EnsureChoiceValue(boxSize, "4sq", "4\" Square", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(boxSize, "4_11_16", "4-11/16\" Square", 0.75m, PriceDeltaKind.Absolute); var leadLength = await EnsureNumberOptionAsync( product, checker, "lead_length", "Lead Length", unit: "ft", min: 1m, max: 100m, step: 1m, pricePerUnit: 0.70m, cancellationToken); AddTierIfMissing(leadLength, 26m, null, 0.60m, 5.00m); var wireSize = await EnsureChoiceOptionAsync(product, checker, "wire_size", "Wire Size", cancellationToken: cancellationToken); var awg14Raised = EnsureChoiceValue(wireSize, "awg14", "AWG 14", 0m, PriceDeltaKind.Percent); _ = EnsureChoiceValue(wireSize, "awg12", "AWG 12", 15m, PriceDeltaKind.Percent); wireSize.SetPercentScope(PercentScope.NumericOnly); var wagos = await EnsureChoiceOptionAsync(product, checker, "wagos", "Wago Connectors", cancellationToken: cancellationToken); EnsureChoiceValue(wagos, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(wagos, "yes", "Yes", 2.00m, PriceDeltaKind.Absolute); AddRuleIfMissing( product, OptionRuleTargetKind.OptionValue, awg14Raised.Id, RuleEffect.Show, () => OptionRuleSetBuilder .ForValue(product, awg14Raised, RuleEffect.Show, RuleMode.All) .WhenNotIn(deviceType, duplex20, gfci20) .Build()); product.UpsertSpec(faceStyleDefinition.Id, FaceStyleValue, null, null, null); } private static async Task EnsurePigtailedDeviceAsync( IModuleDb db, IUniqueChecker checker, Category category, AttributeDefinition faceStyleDefinition, CancellationToken cancellationToken) { const string slug = "pigtailed-device"; var product = await LoadModelAsync(db, slug, cancellationToken); if (product is null) { product = await Product.CreateModel( name: "Pigtailed Device", slug, description: "Pigtailed device assembly with configurable wiring.", checker, cancellationToken); db.Products.Add(product); } else if (!string.Equals(product.Name, "Pigtailed Device", StringComparison.Ordinal)) { await product.Rename("Pigtailed Device", checker, cancellationToken); } product.SetBasePrice(24.00m); product.ChangeDescription("Pigtailed device assembly with configurable wiring."); product.AssignToCategory(category.Id, true); var deviceType = await EnsureChoiceOptionAsync(product, checker, "device_type", "Device Type", cancellationToken: cancellationToken); EnsureChoiceValue(deviceType, "duplex15", "Duplex 15A", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(deviceType, "decora15", "Decora 15A", 0m, PriceDeltaKind.Absolute); var gfci15 = EnsureChoiceValue(deviceType, "gfci15", "GFCI 15A", 0m, PriceDeltaKind.Absolute); var deviceColor = await EnsureChoiceOptionAsync(product, checker, "device_color", "Device Color", cancellationToken: cancellationToken); EnsureChoiceValue(deviceColor, "white", "White", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(deviceColor, "ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute); var grade = await EnsureChoiceOptionAsync(product, checker, "grade", "Grade", cancellationToken: cancellationToken); EnsureChoiceValue(grade, "res", "Residential", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(grade, "spec", "Spec", 1.00m, PriceDeltaKind.Absolute); var leadLength = await EnsureNumberOptionAsync( product, checker, "lead_length", "Lead Length", unit: "ft", min: 1m, max: 100m, step: 1m, pricePerUnit: 0.55m, cancellationToken); AddTierIfMissing(leadLength, 50m, null, 0.45m, 4.00m); var wireSize = await EnsureChoiceOptionAsync(product, checker, "wire_size", "Wire Size", cancellationToken: cancellationToken); var awg14Pigtailed = EnsureChoiceValue(wireSize, "awg14", "AWG 14", 0m, PriceDeltaKind.Percent); _ = EnsureChoiceValue(wireSize, "awg12", "AWG 12", 15m, PriceDeltaKind.Percent); wireSize.SetPercentScope(PercentScope.NumericOnly); var wagos = await EnsureChoiceOptionAsync(product, checker, "wagos", "Wago Connectors", cancellationToken: cancellationToken); EnsureChoiceValue(wagos, "no", "No", 0m, PriceDeltaKind.Absolute); EnsureChoiceValue(wagos, "yes", "Yes", 2.00m, PriceDeltaKind.Absolute); product.UpsertSpec(faceStyleDefinition.Id, FaceStyleValue, null, null, null); } private static async Task LoadModelAsync(IModuleDb db, string slug, CancellationToken cancellationToken) => await db.Products .Where(p => p.DeletedOn == null && p.Kind == ProductKind.Model && p.Slug == slug) .Include(p => p.Options).ThenInclude(o => o.Values) .Include(p => p.Options).ThenInclude(o => o.Tiers) .Include(p => p.RuleSets).ThenInclude(r => r.Conditions) .Include(p => p.Variants).ThenInclude(v => v.AxisValues) .Include(p => p.Attributes) .Include(p => p.Categories) .FirstOrDefaultAsync(cancellationToken); private static async Task EnsureChoiceOptionAsync( Product product, IUniqueChecker checker, string code, string name, bool isVariantAxis = false, PercentScope? percentScope = null, CancellationToken cancellationToken = default) { var existing = product.Options.FirstOrDefault(o => o.DeletedOn == null && string.Equals(o.Code, code, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { if (percentScope.HasValue) { existing.SetPercentScope(percentScope.Value); } return existing; } return await OptionDefinition.CreateChoice(product, code, name, checker, isVariantAxis, percentScope, cancellationToken); } private static async Task EnsureNumberOptionAsync( Product product, IUniqueChecker checker, string code, string name, string unit, decimal? min, decimal? max, decimal? step, decimal? pricePerUnit, CancellationToken cancellationToken) { var existing = product.Options.FirstOrDefault(o => o.DeletedOn == null && string.Equals(o.Code, code, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { return existing; } return await OptionDefinition.CreateNumber( product, code, name, unit, min, max, step, pricePerUnit, checker, cancellationToken: cancellationToken); } private static OptionValue EnsureChoiceValue( OptionDefinition option, string code, string label, decimal priceDelta, PriceDeltaKind deltaKind) { var existing = option.Values.FirstOrDefault(v => v.DeletedOn == null && string.Equals(v.Code, code, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { option.ChangeValue(existing.Id, label, priceDelta, deltaKind); return existing; } return option.AddValue(code, label, priceDelta, deltaKind); } private static void AddTierIfMissing( OptionDefinition option, decimal fromInclusive, decimal? toInclusive, decimal unitRate, decimal? flatDelta) { var existing = option.Tiers.FirstOrDefault(tier => tier.DeletedOn == null && tier.FromInclusive == fromInclusive && Nullable.Equals(tier.ToInclusive, toInclusive)); if (existing is not null) { option.ChangeTier(existing.Id, fromInclusive, toInclusive, unitRate, flatDelta); return; } option.AddTier(fromInclusive, toInclusive, unitRate, flatDelta); } private static async Task EnsureVariantAsync( Product model, IUniqueChecker checker, string sku, string variantName, decimal price, CancellationToken cancellationToken, params (OptionDefinition Definition, OptionValue Value)[] axisSelections) { var existing = model.Variants .FirstOrDefault(v => v.DeletedOn == null && string.Equals(v.Sku, sku, StringComparison.OrdinalIgnoreCase)); Product variant; if (existing is null) { variant = await Product.CreateVariant( model.Id, sku, variantName, price, checker, cancellationToken); model.AttachVariant(variant); } else { variant = existing; } variant.SetBasePrice(price); foreach (var (definition, value) in axisSelections) { var axisValue = variant.AxisValues .FirstOrDefault(av => av.OptionDefinitionId == definition.Id); if (axisValue is null) { axisValue = new VariantAxisValue { ProductVariantId = variant.Id, ProductVariant = variant, OptionDefinitionId = definition.Id, OptionDefinition = definition, OptionValueId = value.Id, OptionValue = value }; variant.AxisValues.Add(axisValue); } else { axisValue.OptionValueId = value.Id; axisValue.OptionValue = value; } } } private static void AddRuleIfMissing( Product product, OptionRuleTargetKind targetKind, Guid targetId, RuleEffect effect, Func factory) { var exists = product.RuleSets.Any(rule => rule.DeletedOn == null && rule.TargetKind == targetKind && rule.TargetId == targetId && rule.Effect == effect); if (!exists) { factory(); } } private static string FormatDecimal(decimal value) => value.ToString("0.##", CultureInfo.InvariantCulture); private static Task UpsertProductDocsAsync( IModuleDb db, Product product, IEnumerable<(string Title, string Url)> docs, CancellationToken cancellationToken) { var payload = JsonSerializer.Serialize( docs.Select(doc => new { title = doc.Title, url = doc.Url }), JsonOptions); return UpsertGenericAttributeAsync(db, product, CatalogDocsKey, payload, cancellationToken); } private static async Task UpsertGenericAttributeAsync( IModuleDb db, Product product, string key, string value, CancellationToken cancellationToken) { var keyGroup = typeof(Product).FullName ?? "Product"; var existing = await db.GenericAttributes .FirstOrDefaultAsync(attr => attr.EntityId == product.Id && attr.KeyGroup == keyGroup && attr.Key == key && attr.DeletedOn == null, cancellationToken); if (existing is null) { var attribute = new GenericAttribute { EntityId = product.Id, KeyGroup = keyGroup, Key = key, Type = typeof(string).FullName ?? "System.String", Value = value }; db.GenericAttributes.Add(attribute); return; } if (!string.Equals(existing.Value, value, StringComparison.Ordinal)) { existing.Value = value; } } private static async Task EnsureAttributeDefinitionAsync( IModuleDb db, string name, AttributeDataType dataType, CancellationToken cancellationToken, string? unit = null) { var existing = await db.AttributeDefinitions .FirstOrDefaultAsync(def => def.DeletedOn == null && def.Name == name, cancellationToken); if (existing is not null) { return existing; } var definition = AttributeDefinition.Create(name, dataType, unit); db.AttributeDefinitions.Add(definition); return definition; } private sealed record CategorySeed(string Slug, string Name, int DisplayOrder, bool IsFeatured); }