Init
This commit is contained in:
30
.dockerignore
Normal file
30
.dockerignore
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
**/.classpath
|
||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
!**/.gitignore
|
||||||
|
!.git/HEAD
|
||||||
|
!.git/config
|
||||||
|
!.git/packed-refs
|
||||||
|
!.git/refs/heads/**
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
## Build artifacts
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
[Bb]uild/
|
||||||
|
[Bb]uilds/
|
||||||
|
|
||||||
|
## User-specific files
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
## Visual Studio
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
## Logs and locally generated data
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.svclog
|
||||||
|
*.pidb
|
||||||
|
*.cache
|
||||||
|
*.db
|
||||||
|
Server/logs/
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
packages/
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
|
||||||
|
## Entity Framework secrets
|
||||||
|
appsettings.*.local.json
|
||||||
|
|
||||||
|
## Node / frontend
|
||||||
|
node_modules/
|
||||||
|
wwwroot/dist/
|
||||||
|
|
||||||
|
## Resharper / Rider
|
||||||
|
_ReSharper*/
|
||||||
|
*.DotSettings.user
|
||||||
13
.idea/.gitignore
generated
vendored
Normal file
13
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Rider ignored files
|
||||||
|
/projectSettingsUpdater.xml
|
||||||
|
/modules.xml
|
||||||
|
/.idea.PrefabPDP.iml
|
||||||
|
/contentModel.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
13
.idea/.idea.Prefab/.idea/.gitignore
generated
vendored
Normal file
13
.idea/.idea.Prefab/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Rider ignored files
|
||||||
|
/projectSettingsUpdater.xml
|
||||||
|
/.idea.Prefab.iml
|
||||||
|
/modules.xml
|
||||||
|
/contentModel.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
1
.idea/.idea.Prefab/.idea/.name
generated
Normal file
1
.idea/.idea.Prefab/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Prefab
|
||||||
6
.idea/.idea.Prefab/.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/.idea.Prefab/.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
4
.idea/.idea.Prefab/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.Prefab/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
8
.idea/.idea.Prefab/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.Prefab/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="UserContentModel">
|
||||||
|
<attachedFolders />
|
||||||
|
<explicitIncludes />
|
||||||
|
<explicitExcludes />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
14
.idea/.idea.Prefab/.idea/misc.xml
generated
Normal file
14
.idea/.idea.Prefab/.idea/misc.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectInspectionProfilesVisibleTreeState">
|
||||||
|
<entry key="Project Default">
|
||||||
|
<profile-state>
|
||||||
|
<selected-state>
|
||||||
|
<State>
|
||||||
|
<id>User defined</id>
|
||||||
|
</State>
|
||||||
|
</selected-state>
|
||||||
|
</profile-state>
|
||||||
|
</entry>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/.idea.Prefab/.idea/vcs.xml
generated
Normal file
6
.idea/.idea.Prefab/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
4
.idea/encodings.xml
generated
Normal file
4
.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
8
.idea/indexLayout.xml
generated
Normal file
8
.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="UserContentModel">
|
||||||
|
<attachedFolders />
|
||||||
|
<explicitIncludes />
|
||||||
|
<explicitExcludes />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
14
.idea/misc.xml
generated
Normal file
14
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectInspectionProfilesVisibleTreeState">
|
||||||
|
<entry key="Project Default">
|
||||||
|
<profile-state>
|
||||||
|
<selected-state>
|
||||||
|
<State>
|
||||||
|
<id>User defined</id>
|
||||||
|
</State>
|
||||||
|
</selected-state>
|
||||||
|
</profile-state>
|
||||||
|
</entry>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
70
Prefab.AppHost/AppHost.cs
Normal file
70
Prefab.AppHost/AppHost.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
var builder = DistributedApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
#region Sql Server
|
||||||
|
|
||||||
|
var sqlConfig = builder.Configuration.GetSection("SqlServer");
|
||||||
|
ArgumentNullException.ThrowIfNull(sqlConfig);
|
||||||
|
|
||||||
|
var sqlPassword = builder.AddParameter("prefab-sql-password", secret: true);
|
||||||
|
|
||||||
|
var sqlContainerName = sqlConfig["ContainerName"];
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sqlContainerName);
|
||||||
|
|
||||||
|
IResourceBuilder<SqlServerServerResource> sqlServer;
|
||||||
|
|
||||||
|
var sqlServerHostPort = sqlConfig["HostPort"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(sqlServerHostPort) && int.TryParse(sqlServerHostPort, out var hostPort))
|
||||||
|
{
|
||||||
|
sqlServer = builder.AddSqlServer(sqlContainerName, sqlPassword);
|
||||||
|
sqlServer.WithEndpoint("tcp", e =>
|
||||||
|
{
|
||||||
|
e.Port = hostPort; // host port (what clients connect to)
|
||||||
|
e.TargetPort = 1433; // container port (SQL Server listens on 1433)
|
||||||
|
e.IsProxied = false; // disable Aspire's reverse proxy for this endpoint
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sqlServer = builder.AddSqlServer(sqlContainerName, sqlPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqlServerDataVolume = sqlConfig.GetValue<bool?>("UseDataVolume");
|
||||||
|
if (sqlServerDataVolume.GetValueOrDefault(true))
|
||||||
|
{
|
||||||
|
sqlServer = sqlServer
|
||||||
|
.WithDataVolume()
|
||||||
|
.WithLifetime(ContainerLifetime.Persistent);;
|
||||||
|
}
|
||||||
|
|
||||||
|
var database = sqlServer.AddDatabase("prefab-db");
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
var catalog = builder.AddProject<Projects.Prefab_Catalog_Api>("prefab-catalog")
|
||||||
|
.WithReference(database)
|
||||||
|
.WithEnvironment("ConnectionStrings__PrefabDb", database.Resource.ConnectionStringExpression)
|
||||||
|
.WithEnvironment("ConnectionStrings__PrefabDbReadOnly", database.Resource.ConnectionStringExpression)
|
||||||
|
.WaitFor(sqlServer);
|
||||||
|
|
||||||
|
var catalogClientTransport = builder.Configuration["Prefab__Catalog__Client__Transport"] ?? "InProcess";
|
||||||
|
var catalogClientBaseAddress = builder.Configuration["Prefab__Catalog__Client__BaseAddress"];
|
||||||
|
|
||||||
|
var prefabWeb = builder.AddProject<Projects.Prefab_Web>("prefab-web")
|
||||||
|
.WithReference(database)
|
||||||
|
.WithEnvironment("ConnectionStrings__PrefabDb", database.Resource.ConnectionStringExpression)
|
||||||
|
.WithEnvironment("ConnectionStrings__PrefabDbReadOnly", database.Resource.ConnectionStringExpression)
|
||||||
|
.WaitFor(sqlServer)
|
||||||
|
.WithReference(catalog)
|
||||||
|
.WaitFor(catalog);
|
||||||
|
|
||||||
|
prefabWeb.WithEnvironment("Prefab__Catalog__Client__Transport", catalogClientTransport);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(catalogClientBaseAddress))
|
||||||
|
{
|
||||||
|
prefabWeb.WithEnvironment("Prefab__Catalog__Client__BaseAddress", catalogClientBaseAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Build().Run();
|
||||||
|
|
||||||
23
Prefab.AppHost/Prefab.AppHost.csproj
Normal file
23
Prefab.AppHost/Prefab.AppHost.csproj
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UserSecretsId>7b0d6756-ba6a-4696-8514-2e0e767971c6</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
|
||||||
|
<PackageReference Include="Aspire.Hosting.SqlServer" Version="9.5.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Prefab.Catalog.Api\Prefab.Catalog.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\Prefab.Web\Prefab.Web.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
29
Prefab.AppHost/Properties/launchSettings.json
Normal file
29
Prefab.AppHost/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:17039;http://localhost:15132",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21005",
|
||||||
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:15132",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19189",
|
||||||
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20164"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Prefab.AppHost/appsettings.Development.json
Normal file
16
Prefab.AppHost/appsettings.Development.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SqlServer": {
|
||||||
|
"ContainerName": "prefab-sql",
|
||||||
|
"HostPort": 14330,
|
||||||
|
"UseDataVolume": true
|
||||||
|
},
|
||||||
|
"Parameters": {
|
||||||
|
"prefab-sql-password": "Nq7K3YR2"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Prefab.AppHost/appsettings.json
Normal file
9
Prefab.AppHost/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Aspire.Hosting.Dcp": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Prefab.Base/Catalog/Attributes/AttributeRules.cs
Normal file
14
Prefab.Base/Catalog/Attributes/AttributeRules.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Prefab.Base.Catalog.Attributes;
|
||||||
|
|
||||||
|
public static class AttributeDefinitionRules
|
||||||
|
{
|
||||||
|
public const int NameMaxLength = 256;
|
||||||
|
public const int UnitMaxLength = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ProductAttributeValueRules
|
||||||
|
{
|
||||||
|
public const int ValueMaxLength = 1024;
|
||||||
|
public const int UnitCodeMaxLength = 32;
|
||||||
|
public const int EnumCodeMaxLength = 64;
|
||||||
|
}
|
||||||
11
Prefab.Base/Catalog/Categories/CategoryRules.cs
Normal file
11
Prefab.Base/Catalog/Categories/CategoryRules.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Prefab.Base.Catalog.Categories;
|
||||||
|
|
||||||
|
public class CategoryRules
|
||||||
|
{
|
||||||
|
public const int NameMaxLength = 100;
|
||||||
|
public const int DescriptionMaxLength = 500;
|
||||||
|
|
||||||
|
public const int SlugMaxLength = 128;
|
||||||
|
public const int HeroImageUrlMaxLength = 512;
|
||||||
|
public const int IconMaxLength = 128;
|
||||||
|
}
|
||||||
14
Prefab.Base/Catalog/Options/OptionRules.cs
Normal file
14
Prefab.Base/Catalog/Options/OptionRules.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Prefab.Base.Catalog.Options;
|
||||||
|
|
||||||
|
public static class OptionDefinitionRules
|
||||||
|
{
|
||||||
|
public const int CodeMaxLength = 64;
|
||||||
|
public const int NameMaxLength = 256;
|
||||||
|
public const int UnitMaxLength = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OptionValueRules
|
||||||
|
{
|
||||||
|
public const int CodeMaxLength = 64;
|
||||||
|
public const int LabelMaxLength = 256;
|
||||||
|
}
|
||||||
10
Prefab.Base/Catalog/Products/ProductRules.cs
Normal file
10
Prefab.Base/Catalog/Products/ProductRules.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Prefab.Base.Catalog.Products;
|
||||||
|
|
||||||
|
public static class ProductRules
|
||||||
|
{
|
||||||
|
public const int NameMaxLength = 256;
|
||||||
|
public const int SlugMaxLength = 256;
|
||||||
|
public const int SkuMaxLength = 64;
|
||||||
|
public const int DescriptionMaxLength = 2048;
|
||||||
|
public const string SlugPattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$";
|
||||||
|
}
|
||||||
18
Prefab.Base/Enums.cs
Normal file
18
Prefab.Base/Enums.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the supported default cultures.
|
||||||
|
/// </summary>
|
||||||
|
public enum Culture
|
||||||
|
{
|
||||||
|
[Display(Order = 1, Name = "en-US")]
|
||||||
|
UnitedStates,
|
||||||
|
|
||||||
|
[Display(Order = 2, Name = "en-GB")]
|
||||||
|
UnitedKingdom,
|
||||||
|
|
||||||
|
[Display(Order = 2, Name = "ja-JP")]
|
||||||
|
Japan
|
||||||
|
}
|
||||||
6
Prefab.Base/GenericAttributeChange.cs
Normal file
6
Prefab.Base/GenericAttributeChange.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a change in a custom attribute.
|
||||||
|
/// </summary>
|
||||||
|
public record GenericAttributeChange(string Key, object? OldValue, object? NewValue);
|
||||||
88
Prefab.Base/GenericAttributes.cs
Normal file
88
Prefab.Base/GenericAttributes.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a customizable attribute bag with change tracking for Prefab domain objects.
|
||||||
|
/// </summary>
|
||||||
|
public class GenericAttributes : ICanBeCustomized
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, object> _attributes = new();
|
||||||
|
private readonly List<GenericAttributeChange> _changes = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IReadOnlyDictionary<string, object> IHasGenericAttributes.GenericAttributes => new ReadOnlyDictionary<string, object>(_attributes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ordered list of attribute mutations recorded since the last call to <see cref="ClearChanges"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<GenericAttributeChange> Changes => _changes.AsReadOnly();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides an extension point for enforcing key/value requirements before persisting the attribute.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute key being modified.</param>
|
||||||
|
/// <param name="value">The proposed attribute value.</param>
|
||||||
|
protected virtual void ValidateAttribute(string key, object value)
|
||||||
|
{
|
||||||
|
// Default: no validation. Override in derived classes for custom logic.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets or replaces an attribute value and records the mutation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute key to set.</param>
|
||||||
|
/// <param name="value">The attribute value.</param>
|
||||||
|
public void SetAttribute(string key, object value)
|
||||||
|
{
|
||||||
|
_attributes.TryGetValue(key, out var oldValue);
|
||||||
|
ValidateAttribute(key, value);
|
||||||
|
_attributes[key] = value;
|
||||||
|
_changes.Add(new GenericAttributeChange(key, oldValue, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an attribute if present and records the removal.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute key to remove.</param>
|
||||||
|
/// <returns><c>true</c> when the key existed and was removed; otherwise <c>false</c>.</returns>
|
||||||
|
public bool RemoveAttribute(string key)
|
||||||
|
{
|
||||||
|
if (_attributes.TryGetValue(key, out var oldValue) && _attributes.Remove(key))
|
||||||
|
{
|
||||||
|
_changes.Add(new GenericAttributeChange(key, oldValue, null));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to read an attribute value and cast it to the requested type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute key to read.</param>
|
||||||
|
/// <param name="value">Receives the typed attribute value when successful.</param>
|
||||||
|
/// <typeparam name="T">The expected attribute type.</typeparam>
|
||||||
|
/// <returns><c>true</c> when the attribute exists and matches the requested type.</returns>
|
||||||
|
public bool TryGetAttribute<T>(string key, out T value)
|
||||||
|
{
|
||||||
|
if (_attributes.TryGetValue(key, out var obj) && obj is T t)
|
||||||
|
{
|
||||||
|
value = t;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an immutable snapshot copy of the current attributes.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, object> Snapshot() => new ReadOnlyDictionary<string, object>(new Dictionary<string, object>(_attributes));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all recorded attribute changes while keeping the current attribute values intact.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearChanges() => _changes.Clear();
|
||||||
|
}
|
||||||
30
Prefab.Base/ICanBeCustomized.cs
Normal file
30
Prefab.Base/ICanBeCustomized.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contract for models that expose a customizable attribute bag to module developers.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICanBeCustomized : IHasGenericAttributes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sets or updates a custom attribute value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute name.</param>
|
||||||
|
/// <param name="value">The value to store.</param>
|
||||||
|
void SetAttribute(string key, object value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a custom attribute by key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute to remove.</param>
|
||||||
|
/// <returns><c>true</c> when the key existed and was removed.</returns>
|
||||||
|
bool RemoveAttribute(string key);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to read an attribute value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The attribute to inspect.</param>
|
||||||
|
/// <param name="value">Receives the typed attribute value when present.</param>
|
||||||
|
/// <typeparam name="T">The expected attribute type.</typeparam>
|
||||||
|
/// <returns><c>true</c> when the attribute exists and matches the requested type.</returns>
|
||||||
|
bool TryGetAttribute<T>(string key, out T value);
|
||||||
|
}
|
||||||
9
Prefab.Base/IEvent.cs
Normal file
9
Prefab.Base/IEvent.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker interface for events.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEvent
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
12
Prefab.Base/IHasGenericAttributes.cs
Normal file
12
Prefab.Base/IHasGenericAttributes.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read‑only view of the generic attributes bag.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHasGenericAttributes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the read-only dictionary of custom attributes.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyDictionary<string, object> GenericAttributes { get; }
|
||||||
|
}
|
||||||
12
Prefab.Base/ISortOrder.cs
Normal file
12
Prefab.Base/ISortOrder.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines a contract for entities that expose an explicit sort position.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISortOrder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the relative sort order for the entity.
|
||||||
|
/// </summary>
|
||||||
|
int SortOrder { get; set; }
|
||||||
|
}
|
||||||
53
Prefab.Base/LookupEntity.cs
Normal file
53
Prefab.Base/LookupEntity.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a lookup entity that can be used to represent a PrefabEnum in a database table.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">The type that will represent the entity's backed <see cref="PrefabEnum" /> type.</typeparam>
|
||||||
|
public abstract class LookupEntity<TEnum> : ISortOrder where TEnum : PrefabEnum<TEnum>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The underlying integer value of the enum.
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; } // This will match the PrefabEnum's underlying int value
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the enum member (e.g., "Pending").
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The short name of the enum member (e.g., "Pend").
|
||||||
|
/// </summary>
|
||||||
|
public string? ShortName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional description of the enum member.
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default constructor.
|
||||||
|
/// </summary>
|
||||||
|
protected LookupEntity() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructor to create from a PrefabEnum instance.
|
||||||
|
/// </summary>
|
||||||
|
protected LookupEntity(TEnum prefabEnum)
|
||||||
|
{
|
||||||
|
Id = prefabEnum.Value;
|
||||||
|
Name = prefabEnum.Name;
|
||||||
|
ShortName = null; // Set as needed
|
||||||
|
Description = null; // Set as needed
|
||||||
|
SortOrder = prefabEnum.Value; // Or set as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all PrefabEnum instances for this type.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<TEnum> GetAllEnumInstances() => PrefabEnum<TEnum>.List;
|
||||||
|
}
|
||||||
13
Prefab.Base/Prefab.Base.csproj
Normal file
13
Prefab.Base/Prefab.Base.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentValidation" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Scrutor" Version="6.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
57
Prefab.Base/PrefabEnum.cs
Normal file
57
Prefab.Base/PrefabEnum.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base type for rich enumerations that behave like enums but support additional metadata and behavior.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">Concrete enumeration type deriving from <see cref="PrefabEnum{TEnum}"/>.</typeparam>
|
||||||
|
public abstract class PrefabEnum<TEnum> where TEnum : PrefabEnum<TEnum>
|
||||||
|
{
|
||||||
|
private static readonly List<TEnum> Instances = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the canonical name of the enumeration value.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the integral value associated with the enumeration value.
|
||||||
|
/// </summary>
|
||||||
|
public int Value { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new enumeration value and registers it in the global instance list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Logical name exposed to consumers.</param>
|
||||||
|
/// <param name="value">Numeric identifier for persistence.</param>
|
||||||
|
protected PrefabEnum(string name, int value)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Value = value;
|
||||||
|
Instances.Add(this as TEnum ?? throw new InvalidOperationException("PrefabEnum must be instantiated with a derived type."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all declared enumeration values for <typeparamref name="TEnum"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<TEnum> List => Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves an enumeration value by its logical name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name to match.</param>
|
||||||
|
/// <returns>The matching enumeration.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when no enumeration value matches the supplied name.</exception>
|
||||||
|
public static TEnum FromName(string name) =>
|
||||||
|
Instances.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new ArgumentException($"Invalid name: {name}", nameof(name));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves an enumeration value by its integral value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The integer value to match.</param>
|
||||||
|
/// <returns>The matching enumeration.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when no enumeration value matches the supplied value.</exception>
|
||||||
|
public static TEnum FromValue(int value) =>
|
||||||
|
Instances.FirstOrDefault(x => x.Value == value)
|
||||||
|
?? throw new ArgumentException($"Invalid value: {value}", nameof(value));
|
||||||
|
}
|
||||||
22
Prefab.Base/PrefabEnumJsonConverter.cs
Normal file
22
Prefab.Base/PrefabEnumJsonConverter.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes <see cref="PrefabEnum{TEnum}"/> values as their logical names and deserializes by name or value.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">Enumeration type to convert.</typeparam>
|
||||||
|
public class PrefabEnumJsonConverter<TEnum> : JsonConverter<TEnum> where TEnum : PrefabEnum<TEnum>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch
|
||||||
|
{
|
||||||
|
JsonTokenType.String => PrefabEnum<TEnum>.FromName(reader.GetString()!),
|
||||||
|
JsonTokenType.Number => PrefabEnum<TEnum>.FromValue(reader.GetInt32()),
|
||||||
|
_ => throw new JsonException($"Unsupported token type '{reader.TokenType}' for Prefab enum deserialization.")
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) => writer.WriteStringValue(value.Name);
|
||||||
|
}
|
||||||
65
Prefab.Base/PrefabEnumJsonConverters.cs
Normal file
65
Prefab.Base/PrefabEnumJsonConverters.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Prefab.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates JSON converters that serialize Prefab enums using their logical names.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PrefabEnumJsonConverterFactory : JsonConverterFactory
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool CanConvert(Type typeToConvert)
|
||||||
|
{
|
||||||
|
if (!typeToConvert.IsClass)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseType = typeToConvert;
|
||||||
|
while (baseType is not null)
|
||||||
|
{
|
||||||
|
if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(PrefabEnum<>))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseType = baseType.BaseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var converterType = typeof(PrefabEnumJsonConverter<>).MakeGenericType(typeToConvert);
|
||||||
|
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converter used for concrete Prefab enum types.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class PrefabEnumJsonConverter<TEnum> : JsonConverter<TEnum> where TEnum : PrefabEnum<TEnum>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TEnum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
var name = reader.GetString() ?? string.Empty;
|
||||||
|
return PrefabEnum<TEnum>.FromName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var value))
|
||||||
|
{
|
||||||
|
return PrefabEnum<TEnum>.FromValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new JsonException($"Unexpected token {reader.TokenType} when parsing {typeof(TEnum).Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) => writer.WriteStringValue(value.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Prefab.Catalog.Api/Data/AppDb.cs
Normal file
53
Prefab.Catalog.Api/Data/AppDb.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Data;
|
||||||
|
using Prefab.Handler;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Api.Data;
|
||||||
|
|
||||||
|
public class AppDb : PrefabDb, IPrefabDb,
|
||||||
|
Prefab.Catalog.Data.IModuleDb,
|
||||||
|
Prefab.Catalog.Data.IModuleDbReadOnly
|
||||||
|
{
|
||||||
|
public AppDb(DbContextOptions<AppDb> options, IHandlerContextAccessor accessor)
|
||||||
|
: base(options, accessor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected AppDb(DbContextOptions options, IHandlerContextAccessor accessor)
|
||||||
|
: base(options, accessor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void PrefabOnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
builder.ApplyConfigurationsFromAssembly(typeof(Prefab.Catalog.Data.IModuleDb).Assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
// Additional write-context configuration can be applied here if needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
|
|
||||||
|
public DbSet<Product> Products => Set<Product>();
|
||||||
|
|
||||||
|
public DbSet<OptionDefinition> OptionDefinitions => Set<OptionDefinition>();
|
||||||
|
|
||||||
|
public DbSet<OptionValue> OptionValues => Set<OptionValue>();
|
||||||
|
|
||||||
|
public DbSet<OptionTier> OptionTiers => Set<OptionTier>();
|
||||||
|
|
||||||
|
public DbSet<OptionRuleSet> OptionRuleSets => Set<OptionRuleSet>();
|
||||||
|
|
||||||
|
public DbSet<OptionRuleCondition> OptionRuleConditions => Set<OptionRuleCondition>();
|
||||||
|
|
||||||
|
public DbSet<VariantAxisValue> VariantAxisValues => Set<VariantAxisValue>();
|
||||||
|
|
||||||
|
public DbSet<AttributeDefinition> AttributeDefinitions => Set<AttributeDefinition>();
|
||||||
|
|
||||||
|
public DbSet<ProductAttributeValue> ProductAttributeValues => Set<ProductAttributeValue>();
|
||||||
|
|
||||||
|
public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>();
|
||||||
|
}
|
||||||
20
Prefab.Catalog.Api/Data/AppDbReadOnly.cs
Normal file
20
Prefab.Catalog.Api/Data/AppDbReadOnly.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Handler;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Api.Data;
|
||||||
|
|
||||||
|
public class AppDbReadOnly(DbContextOptions<AppDbReadOnly> options, IHandlerContextAccessor accessor) : AppDb(options, accessor), Prefab.Data.IPrefabDbReadOnly,
|
||||||
|
Prefab.Catalog.Data.IModuleDbReadOnly
|
||||||
|
{
|
||||||
|
protected override void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
base.PrefabOnConfiguring(optionsBuilder);
|
||||||
|
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int SaveChanges()
|
||||||
|
=> throw new InvalidOperationException("This database context is read-only. Saving changes is not allowed.");
|
||||||
|
|
||||||
|
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> throw new InvalidOperationException("This database context is read-only. Saving changes is not allowed.");
|
||||||
|
}
|
||||||
16
Prefab.Catalog.Api/Data/CatalogDbContextFactory.cs
Normal file
16
Prefab.Catalog.Api/Data/CatalogDbContextFactory.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Api.Data;
|
||||||
|
|
||||||
|
internal sealed class CatalogDbContextFactory(
|
||||||
|
IDbContextFactory<AppDb> writeFactory,
|
||||||
|
IDbContextFactory<AppDbReadOnly> readFactory) : ICatalogDbContextFactory
|
||||||
|
{
|
||||||
|
public async ValueTask<IModuleDb> CreateWritableAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await writeFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
public async ValueTask<IModuleDbReadOnly> CreateReadOnlyAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await readFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
30
Prefab.Catalog.Api/Dockerfile
Normal file
30
Prefab.Catalog.Api/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
|
||||||
|
|
||||||
|
# This stage is used when running from VS in fast mode (Default for Debug configuration)
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
|
||||||
|
# This stage is used to build the service project
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["Prefab.Catalog.Api/Prefab.Catalog.Api.csproj", "Prefab.Catalog.Api/"]
|
||||||
|
RUN dotnet restore "./Prefab.Catalog.Api/Prefab.Catalog.Api.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/Prefab.Catalog.Api"
|
||||||
|
RUN dotnet build "./Prefab.Catalog.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
# This stage is used to publish the service project to be copied to the final stage
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./Prefab.Catalog.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "Prefab.Catalog.Api.dll"]
|
||||||
50
Prefab.Catalog.Api/Module.cs
Normal file
50
Prefab.Catalog.Api/Module.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Catalog.Api.Data;
|
||||||
|
using Prefab.Data;
|
||||||
|
using Prefab.Module;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Api;
|
||||||
|
|
||||||
|
public class Module : IModule
|
||||||
|
{
|
||||||
|
public WebApplicationBuilder Build(WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
var writeConnection = builder.Configuration.GetConnectionString("PrefabDb")
|
||||||
|
?? throw new InvalidOperationException("Connection string 'PrefabDb' not found. Ensure the Aspire AppHost exposes the SQL resource.");
|
||||||
|
var readConnection = builder.Configuration.GetConnectionString("PrefabDbReadOnly") ?? writeConnection;
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<IPrefabDb, AppDb>(options =>
|
||||||
|
options.UseSqlServer(writeConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()));
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<IPrefabDbReadOnly, AppDbReadOnly>(options =>
|
||||||
|
options.UseSqlServer(readConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()));
|
||||||
|
|
||||||
|
builder.Services.AddDbContextFactory<AppDb>(options =>
|
||||||
|
options.UseSqlServer(writeConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()), ServiceLifetime.Scoped);
|
||||||
|
|
||||||
|
builder.Services.AddDbContextFactory<AppDbReadOnly>(options =>
|
||||||
|
options.UseSqlServer(readConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()), ServiceLifetime.Scoped);
|
||||||
|
|
||||||
|
builder.Services.AddModuleDbInterfaces(typeof(AppDb));
|
||||||
|
builder.Services.AddModuleDbInterfaces(typeof(AppDbReadOnly));
|
||||||
|
|
||||||
|
builder.Services.AddScoped<ICatalogDbContextFactory, CatalogDbContextFactory>();
|
||||||
|
|
||||||
|
//builder.Services.AddHostedService<DataSeeder>();
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebApplication Configure(WebApplication app)
|
||||||
|
{
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
23
Prefab.Catalog.Api/Prefab.Catalog.Api.csproj
Normal file
23
Prefab.Catalog.Api/Prefab.Catalog.Api.csproj
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>9d7c3534-10cb-48a3-bab8-4b35ca1dabcc</UserSecretsId>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Prefab.Catalog\Prefab.Catalog.csproj" />
|
||||||
|
<ProjectReference Include="..\Prefab.ServiceDefaults\Prefab.ServiceDefaults.csproj" />
|
||||||
|
<ProjectReference Include="..\Prefab.Shared\Prefab.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Prefab.Catalog.Api/Prefab.Catalog.Api.http
Normal file
6
Prefab.Catalog.Api/Prefab.Catalog.Api.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@Prefab.Catalog.Api_HostAddress = http://localhost:5078
|
||||||
|
|
||||||
|
GET {{Prefab.Catalog.Api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
33
Prefab.Catalog.Api/Program.cs
Normal file
33
Prefab.Catalog.Api/Program.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Prefab.Module;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
|
||||||
|
builder.AddPrefab();
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UsePrefab();
|
||||||
|
|
||||||
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
31
Prefab.Catalog.Api/Properties/launchSettings.json
Normal file
31
Prefab.Catalog.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"applicationUrl": "http://localhost:5078"
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"applicationUrl": "https://localhost:7164;http://localhost:5078"
|
||||||
|
},
|
||||||
|
"Container (Dockerfile)": {
|
||||||
|
"commandName": "Docker",
|
||||||
|
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||||
|
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||||
|
},
|
||||||
|
"publishAllPorts": true,
|
||||||
|
"useSSL": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json"
|
||||||
|
}
|
||||||
76
Prefab.Catalog.Api/Workers/DataSeeder.cs
Normal file
76
Prefab.Catalog.Api/Workers/DataSeeder.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using Prefab.Catalog.Api.Data;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Api.Workers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service for seeding data. Collects seed tasks from a channel and executes them concurrently.
|
||||||
|
/// </summary>
|
||||||
|
public class DataSeeder(IServiceProvider serviceProvider, IConfiguration configuration, ILogger<DataSeeder> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly int _workerCount = configuration.GetValue("DataSeederWorkerCount", 4);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await WaitForDatabaseAsync(stoppingToken);
|
||||||
|
|
||||||
|
var tasks = new List<Task>();
|
||||||
|
|
||||||
|
for (var i = 0; i < _workerCount; i++)
|
||||||
|
{
|
||||||
|
tasks.Add(Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await foreach (var seedTask in Prefab.Data.Seeder.Extensions.Channel.Reader.ReadAllAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await seedTask(serviceProvider, stoppingToken);
|
||||||
|
logger.LogInformation("Seed task executed successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error executing seed task.");
|
||||||
|
// Optionally, add retry logic here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, stoppingToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForDatabaseAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var hasLoggedWait = false;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDb>();
|
||||||
|
|
||||||
|
if (await db.Database.CanConnectAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
if (hasLoggedWait)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Database connectivity confirmed. Starting seed workers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Database connectivity probe failed; retrying.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLoggedWait)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Waiting for database availability before processing seed tasks...");
|
||||||
|
hasLoggedWait = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Prefab.Catalog.Api/appsettings.Development.json
Normal file
8
Prefab.Catalog.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Prefab.Catalog.Api/appsettings.json
Normal file
9
Prefab.Catalog.Api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
19
Prefab.Catalog/App/Categories/CategoryClient.cs
Normal file
19
Prefab.Catalog/App/Categories/CategoryClient.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using Prefab.Handler;
|
||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
public sealed class CategoryClient(HandlerInvoker handler) : ICategoryClient
|
||||||
|
{
|
||||||
|
public async Task<Shared.Catalog.Categories.GetCategories.Response> GetCategories(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await handler.Execute<Shared.Catalog.Categories.GetCategories.Response>(cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Shared.Catalog.Categories.CreateCategory.Response> CreateCategory(Shared.Catalog.Categories.CreateCategory.Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response =await handler.Execute<Shared.Catalog.Categories.CreateCategory.Request, Shared.Catalog.Categories.CreateCategory.Response>(request, cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
Prefab.Catalog/App/Categories/CategoryTreeBuilder.cs
Normal file
173
Prefab.Catalog/App/Categories/CategoryTreeBuilder.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using CategoryDtos = Prefab.Shared.Catalog.Products.GetCategoryModels;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds ordered category hierarchies and provides access helpers for rendering browse DTOs.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CategoryTreeBuilder
|
||||||
|
{
|
||||||
|
private static readonly Regex NonSlugCharacters = new("[^a-z0-9]+", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private readonly List<Category> _rootCategories;
|
||||||
|
private readonly Dictionary<Guid, List<Category>> _childrenByParent;
|
||||||
|
private readonly Dictionary<Guid, Category> _categoryById;
|
||||||
|
private readonly Dictionary<string, Category> _categoryBySlug;
|
||||||
|
|
||||||
|
internal CategoryTreeBuilder(IEnumerable<Category> categories)
|
||||||
|
{
|
||||||
|
var active = categories
|
||||||
|
.Where(category => category.DeletedOn == null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_categoryById = active.ToDictionary(category => category.Id);
|
||||||
|
_rootCategories = OrderCategories(active.Where(category => category.ParentId is null)).ToList();
|
||||||
|
_childrenByParent = active
|
||||||
|
.Where(category => category.ParentId.HasValue)
|
||||||
|
.GroupBy(category => category.ParentId!.Value)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => OrderCategories(group).ToList());
|
||||||
|
|
||||||
|
_categoryBySlug = new Dictionary<string, Category>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var category in active)
|
||||||
|
{
|
||||||
|
var slug = CreateSlug(category);
|
||||||
|
if (_categoryBySlug.ContainsKey(slug))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_categoryBySlug[slug] = category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Category? FindBySlug(string slug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizeSlug(slug);
|
||||||
|
return _categoryBySlug.TryGetValue(normalized, out var category) ? category : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryGetCategory(Guid id, out Category category) => _categoryById.TryGetValue(id, out category!);
|
||||||
|
|
||||||
|
internal bool IsLeaf(Category category)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(category);
|
||||||
|
|
||||||
|
return !_childrenByParent.TryGetValue(category.Id, out var children) || children.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string GetSlug(Category category) => CreateSlug(category);
|
||||||
|
|
||||||
|
internal IReadOnlyList<CategoryDtos.CategoryNodeDto> BuildTree(Guid? parentId, int? depth) =>
|
||||||
|
BuildNodes(parentId, NormalizeDepth(depth));
|
||||||
|
|
||||||
|
internal CategoryDtos.CategoryDetailDto CreateDetail(Category category, int? depth)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(category);
|
||||||
|
|
||||||
|
var slug = GetSlug(category);
|
||||||
|
var children = BuildNodes(category.Id, NormalizeDepth(depth));
|
||||||
|
|
||||||
|
return new CategoryDtos.CategoryDetailDto(
|
||||||
|
category.Id,
|
||||||
|
category.Name,
|
||||||
|
slug,
|
||||||
|
category.Description,
|
||||||
|
category.HeroImageUrl,
|
||||||
|
category.Icon,
|
||||||
|
IsLeaf(category),
|
||||||
|
children);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<CategoryDtos.CategoryNodeDto> BuildNodes(Guid? parentId, int depth)
|
||||||
|
{
|
||||||
|
if (depth <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<Category> source;
|
||||||
|
if (parentId is null)
|
||||||
|
{
|
||||||
|
source = _rootCategories;
|
||||||
|
}
|
||||||
|
else if (!_childrenByParent.TryGetValue(parentId.Value, out var children) || children.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
source = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingDepth = depth == int.MaxValue ? int.MaxValue : depth - 1;
|
||||||
|
var nodes = new List<CategoryDtos.CategoryNodeDto>(source.Count);
|
||||||
|
|
||||||
|
foreach (var child in source)
|
||||||
|
{
|
||||||
|
var childNodes = remainingDepth <= 0
|
||||||
|
? []
|
||||||
|
: BuildNodes(child.Id, remainingDepth);
|
||||||
|
|
||||||
|
nodes.Add(new CategoryDtos.CategoryNodeDto(
|
||||||
|
child.Id,
|
||||||
|
child.Name,
|
||||||
|
GetSlug(child),
|
||||||
|
child.Description,
|
||||||
|
child.HeroImageUrl,
|
||||||
|
child.Icon,
|
||||||
|
child.DisplayOrder,
|
||||||
|
child.IsFeatured,
|
||||||
|
IsLeaf(child),
|
||||||
|
childNodes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Category> OrderCategories(IEnumerable<Category> categories) =>
|
||||||
|
categories
|
||||||
|
.OrderBy(category => category.DisplayOrder)
|
||||||
|
.ThenBy(category => category.Name);
|
||||||
|
|
||||||
|
private static int NormalizeDepth(int? depth) =>
|
||||||
|
depth.HasValue && depth.Value > 0 ? depth.Value : int.MaxValue;
|
||||||
|
|
||||||
|
private static string CreateSlug(Category category)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(category.Slug))
|
||||||
|
{
|
||||||
|
return NormalizeSlug(category.Slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Slugify(category.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSlug(string slug) => slug.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string Slugify(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = value.Trim().ToLowerInvariant();
|
||||||
|
var sanitized = NonSlugCharacters.Replace(trimmed, "-");
|
||||||
|
return sanitized.Trim('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
50
Prefab.Catalog/App/Categories/CreateCategory.cs
Normal file
50
Prefab.Catalog/App/Categories/CreateCategory.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Catalog.Domain.Services;
|
||||||
|
using Prefab.Endpoints;
|
||||||
|
using Prefab.Shared.Catalog;
|
||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
using Prefab.Handler;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
internal class CreateCategory : Shared.Catalog.Categories.CreateCategory
|
||||||
|
{
|
||||||
|
public sealed class Endpoint : IEndpointRegistrar
|
||||||
|
{
|
||||||
|
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapPost("/api/catalog/categories", async (ICategoryClient categoryClient, Request request, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var response = await categoryClient.CreateCategory(request, cancellationToken);
|
||||||
|
return Results.Ok(response);
|
||||||
|
})
|
||||||
|
.WithModuleName<IModuleClient>(nameof(CreateCategory))
|
||||||
|
.WithTags(nameof(Categories));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IUniqueChecker checker, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Request, Response>
|
||||||
|
{
|
||||||
|
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var db = await dbFactory.CreateWritableAsync(cancellationToken);
|
||||||
|
|
||||||
|
var category = await Category.Create(
|
||||||
|
request.Name,
|
||||||
|
request.Description,
|
||||||
|
checker, cancellationToken);
|
||||||
|
|
||||||
|
db.Categories.Add(category);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var response = new Response(category.Id);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
Prefab.Catalog/App/Categories/GetCategories.cs
Normal file
55
Prefab.Catalog/App/Categories/GetCategories.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Endpoints;
|
||||||
|
using Prefab.Handler;
|
||||||
|
using Prefab.Module;
|
||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
public class GetCategories : Prefab.Shared.Catalog.Categories.GetCategories
|
||||||
|
{
|
||||||
|
public sealed class Endpoint : IEndpointRegistrar
|
||||||
|
{
|
||||||
|
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapGet("/api/catalog/categories", async (ICategoryClient categoryClient, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var response = await categoryClient.GetCategories(cancellationToken);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.WithModuleName<IModule>(nameof(GetCategories))
|
||||||
|
.WithTags(nameof(Categories));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Response>
|
||||||
|
{
|
||||||
|
public async Task<Response> Execute(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.GroupJoin(
|
||||||
|
db.Categories,
|
||||||
|
child => child.ParentId,
|
||||||
|
parent => parent.Id,
|
||||||
|
(child, parents) => new
|
||||||
|
{
|
||||||
|
Child = child,
|
||||||
|
ParentName = parents.Select(parent => parent.Name).FirstOrDefault()
|
||||||
|
})
|
||||||
|
.Select(x => new ListItem(
|
||||||
|
x.Child.Id,
|
||||||
|
x.Child.Name,
|
||||||
|
x.ParentName ?? string.Empty))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var response = new Response(categories);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Prefab.Catalog/App/Categories/GetCategory.cs
Normal file
69
Prefab.Catalog/App/Categories/GetCategory.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Catalog.Domain.Exceptions;
|
||||||
|
using Prefab.Endpoints;
|
||||||
|
using Prefab.Handler;
|
||||||
|
using Prefab.Module;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a single category node with its direct children.
|
||||||
|
/// </summary>
|
||||||
|
public class GetCategory : Prefab.Shared.Catalog.Products.GetCategory
|
||||||
|
{
|
||||||
|
public sealed class Endpoint : IEndpointRegistrar
|
||||||
|
{
|
||||||
|
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/api/catalog/categories/{slug}",
|
||||||
|
async (IProductClient productClient, string slug, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await productClient.GetCategory(slug, cancellationToken);
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
catch (CatalogNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithModuleName<IModule>(nameof(GetCategory))
|
||||||
|
.WithTags("Categories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
|
||||||
|
: HandlerBase(accessor), IHandler<Request, Response>
|
||||||
|
{
|
||||||
|
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Slug);
|
||||||
|
|
||||||
|
var normalizedSlug = request.Slug.Trim();
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(category => category.DeletedOn == null)
|
||||||
|
.OrderBy(category => category.DisplayOrder)
|
||||||
|
.ThenBy(category => category.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var tree = new CategoryTreeBuilder(categories);
|
||||||
|
var category = tree.FindBySlug(normalizedSlug) ??
|
||||||
|
throw new CatalogNotFoundException("Category", normalizedSlug);
|
||||||
|
|
||||||
|
var detail = tree.CreateDetail(category, depth: null);
|
||||||
|
return new Response(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
330
Prefab.Catalog/App/Categories/GetCategoryModels.cs
Normal file
330
Prefab.Catalog/App/Categories/GetCategoryModels.cs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
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 SharedBrowse = Prefab.Shared.Catalog.Products.GetCategoryModels;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists product model cards for a category slug, including a computed “from price” for browse screens.
|
||||||
|
/// </summary>
|
||||||
|
public class GetCategoryModels : SharedBrowse
|
||||||
|
{
|
||||||
|
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
|
||||||
|
private static readonly Regex NonSlugCharacters = new("[^a-z0-9]+", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public sealed class Endpoint : IEndpointRegistrar
|
||||||
|
{
|
||||||
|
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/api/catalog/categories/{slug}/models",
|
||||||
|
async (
|
||||||
|
IProductClient productClient,
|
||||||
|
string slug,
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
string? sort,
|
||||||
|
string? dir,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await productClient.GetCategoryModels(slug, page, pageSize, sort, dir, cancellationToken);
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
catch (CatalogConflictException conflict)
|
||||||
|
{
|
||||||
|
return Results.Conflict(ApiProblemDetails.Conflict(conflict.Message, conflict.Resource, conflict.Identifier));
|
||||||
|
}
|
||||||
|
catch (CatalogNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithModuleName<IModule>(nameof(GetCategoryModels))
|
||||||
|
.WithTags("Products");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
|
||||||
|
: HandlerBase(accessor), IHandler<Request, Response>
|
||||||
|
{
|
||||||
|
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Slug);
|
||||||
|
|
||||||
|
var normalizedSlug = request.Slug.Trim();
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(category => category.DeletedOn == null)
|
||||||
|
.OrderBy(category => category.DisplayOrder)
|
||||||
|
.ThenBy(category => category.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (categories.Count == 0)
|
||||||
|
{
|
||||||
|
throw new CatalogNotFoundException("Category", normalizedSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tree = new CategoryTreeBuilder(categories);
|
||||||
|
var category = tree.FindBySlug(normalizedSlug) ??
|
||||||
|
throw new CatalogNotFoundException("Category", normalizedSlug);
|
||||||
|
|
||||||
|
if (!tree.IsLeaf(category))
|
||||||
|
{
|
||||||
|
throw new CatalogConflictException("Category", tree.GetSlug(category), "Category is not a leaf.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var models = await db.Products
|
||||||
|
.Where(product => product.DeletedOn == null &&
|
||||||
|
product.Kind == ProductKind.Model &&
|
||||||
|
product.Categories.Any(placement => placement.CategoryId == category.Id && placement.IsPrimary))
|
||||||
|
.Include(product => product.Variants)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var cards = models
|
||||||
|
.Select(CreateCard)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var orderedCards = OrderCards(cards, request.Sort, request.Direction);
|
||||||
|
|
||||||
|
var page = request.Page.HasValue && request.Page.Value > 0 ? request.Page.Value : 1;
|
||||||
|
var pageSize = request.PageSize.HasValue && request.PageSize.Value > 0
|
||||||
|
? request.PageSize.Value
|
||||||
|
: orderedCards.Count == 0 ? 1 : orderedCards.Count;
|
||||||
|
|
||||||
|
var skip = (page - 1) * pageSize;
|
||||||
|
var pagedCards = skip >= orderedCards.Count
|
||||||
|
? new List<SharedBrowse.ProductCardDto>()
|
||||||
|
: orderedCards.Skip(skip).Take(pageSize).ToList();
|
||||||
|
|
||||||
|
var categoryDetail = tree.CreateDetail(category, depth: 1);
|
||||||
|
var payload = new SharedBrowse.CategoryProductsResponse(
|
||||||
|
categoryDetail,
|
||||||
|
pagedCards,
|
||||||
|
orderedCards.Count,
|
||||||
|
page,
|
||||||
|
pageSize);
|
||||||
|
|
||||||
|
return new Response(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SharedBrowse.ProductCardDto CreateCard(Product model)
|
||||||
|
{
|
||||||
|
var fromPrice = CalculateFromPrice(model);
|
||||||
|
|
||||||
|
var slug = !string.IsNullOrWhiteSpace(model.Slug)
|
||||||
|
? model.Slug.Trim().ToLowerInvariant()
|
||||||
|
: Slugify(model.Name);
|
||||||
|
|
||||||
|
return new SharedBrowse.ProductCardDto(
|
||||||
|
model.Id,
|
||||||
|
model.Name,
|
||||||
|
slug,
|
||||||
|
fromPrice,
|
||||||
|
PrimaryImageUrl: null,
|
||||||
|
model.LastModifiedOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SharedBrowse.ProductCardDto> OrderCards(
|
||||||
|
List<SharedBrowse.ProductCardDto> cards,
|
||||||
|
string? sort,
|
||||||
|
string? direction)
|
||||||
|
{
|
||||||
|
var sortKey = string.IsNullOrWhiteSpace(sort) ? "name" : sort.Trim().ToLowerInvariant();
|
||||||
|
var dirKey = string.IsNullOrWhiteSpace(direction) ? "asc" : direction.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
IEnumerable<SharedBrowse.ProductCardDto> ordered = sortKey switch
|
||||||
|
{
|
||||||
|
"price" => cards
|
||||||
|
.OrderBy(card => card.FromPrice ?? decimal.MaxValue)
|
||||||
|
.ThenBy(card => card.Name),
|
||||||
|
_ => cards
|
||||||
|
.OrderBy(card => card.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dirKey == "desc")
|
||||||
|
{
|
||||||
|
ordered = sortKey switch
|
||||||
|
{
|
||||||
|
"price" => cards
|
||||||
|
.OrderByDescending(card => card.FromPrice ?? decimal.MinValue)
|
||||||
|
.ThenByDescending(card => card.Name, StringComparer.OrdinalIgnoreCase),
|
||||||
|
_ => cards
|
||||||
|
.OrderByDescending(card => card.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculatePercentScopeBase(PercentScope? scope, decimal basePrice, decimal numericTotal) =>
|
||||||
|
scope switch
|
||||||
|
{
|
||||||
|
PercentScope.NumericOnly => numericTotal,
|
||||||
|
PercentScope.BasePlusNumeric => basePrice + numericTotal,
|
||||||
|
_ => basePrice
|
||||||
|
};
|
||||||
|
|
||||||
|
private static decimal? CalculateFromPrice(Product model)
|
||||||
|
{
|
||||||
|
var activeOptions = model.Options
|
||||||
|
.Where(option => option.DeletedOn == null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var basePrice = model.Price ?? 0m;
|
||||||
|
var numericTotal = activeOptions
|
||||||
|
.Where(option => option.DataType == OptionDataType.Number)
|
||||||
|
.Sum(CalculateNumberOptionMinDelta);
|
||||||
|
|
||||||
|
var choiceTotal = activeOptions
|
||||||
|
.Where(option => option.DataType == OptionDataType.Choice)
|
||||||
|
.Sum(option => CalculateChoiceOptionMinDelta(option, basePrice, numericTotal));
|
||||||
|
|
||||||
|
var total = basePrice + numericTotal + choiceTotal;
|
||||||
|
return Math.Round(total, 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateNumberOptionMinDelta(OptionDefinition option)
|
||||||
|
{
|
||||||
|
var quantity = option.Min ?? 0m;
|
||||||
|
|
||||||
|
if (option.Step is > 0)
|
||||||
|
{
|
||||||
|
var step = option.Step.Value;
|
||||||
|
quantity = Math.Ceiling(quantity / step) * step;
|
||||||
|
if (option.Min.HasValue)
|
||||||
|
{
|
||||||
|
quantity = Math.Max(quantity, option.Min.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.Max.HasValue && quantity > option.Max.Value)
|
||||||
|
{
|
||||||
|
quantity = option.Max.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var applicableTier = option.Tiers
|
||||||
|
.Where(tier => tier.DeletedOn == null && quantity >= tier.FromInclusive && (tier.ToInclusive == null || quantity <= tier.ToInclusive.Value))
|
||||||
|
.OrderByDescending(tier => tier.FromInclusive)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var rate = applicableTier?.UnitRate ?? option.PricePerUnit ?? 0m;
|
||||||
|
var flat = applicableTier?.FlatDelta ?? 0m;
|
||||||
|
|
||||||
|
return (rate * quantity) + flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateChoiceOptionMinDelta(OptionDefinition option, decimal basePrice, decimal numericTotal)
|
||||||
|
{
|
||||||
|
var values = option.Values
|
||||||
|
.Where(value => value.DeletedOn == null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (values.Count == 0)
|
||||||
|
{
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal? minAbsolute = null;
|
||||||
|
decimal? minPercent = null;
|
||||||
|
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
var delta = value.PriceDelta ?? 0m;
|
||||||
|
|
||||||
|
if (value.PriceDeltaKind == PriceDeltaKind.Absolute)
|
||||||
|
{
|
||||||
|
minAbsolute = minAbsolute.HasValue ? Math.Min(minAbsolute.Value, delta) : delta;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeBase = CalculatePercentScopeBase(option.PercentScope, basePrice, numericTotal);
|
||||||
|
var percentDelta = scopeBase * (delta / 100m);
|
||||||
|
minPercent = minPercent.HasValue ? Math.Min(minPercent.Value, percentDelta) : percentDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minAbsolute.HasValue && minPercent.HasValue)
|
||||||
|
{
|
||||||
|
return Math.Min(minAbsolute.Value, minPercent.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return minAbsolute ?? minPercent ?? 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Validator : AbstractValidator<Request>
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedSorts = ["name", "price"];
|
||||||
|
private static readonly string[] AllowedDirections = ["asc", "desc"];
|
||||||
|
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(request => request.Slug)
|
||||||
|
.Cascade(CascadeMode.Stop)
|
||||||
|
.NotEmpty().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.");
|
||||||
|
|
||||||
|
RuleFor(request => request.Page)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(request => request.Page.HasValue)
|
||||||
|
.WithMessage("Page must be greater than zero.");
|
||||||
|
|
||||||
|
RuleFor(request => request.PageSize)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(request => request.PageSize.HasValue)
|
||||||
|
.WithMessage("PageSize must be greater than zero.");
|
||||||
|
|
||||||
|
RuleFor(request => request.Sort)
|
||||||
|
.Must(sort => string.IsNullOrWhiteSpace(sort) || AllowedSorts.Contains(sort.Trim().ToLowerInvariant()))
|
||||||
|
.WithMessage("Sort must be 'name' or 'price'.");
|
||||||
|
|
||||||
|
RuleFor(request => request.Direction)
|
||||||
|
.Must(dir => string.IsNullOrWhiteSpace(dir) || AllowedDirections.Contains(dir.Trim().ToLowerInvariant()))
|
||||||
|
.WithMessage("Direction must be 'asc' or 'desc'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidSlug(string? slug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = slug.Trim();
|
||||||
|
return SlugRegex.IsMatch(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Slugify(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = value.Trim().ToLowerInvariant();
|
||||||
|
var sanitized = NonSlugCharacters.Replace(trimmed, "-");
|
||||||
|
return sanitized.Trim('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
53
Prefab.Catalog/App/Categories/GetCategoryTree.cs
Normal file
53
Prefab.Catalog/App/Categories/GetCategoryTree.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Endpoints;
|
||||||
|
using Prefab.Handler;
|
||||||
|
using Prefab.Module;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.App.Categories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the category hierarchy for browse experiences.
|
||||||
|
/// </summary>
|
||||||
|
public class GetCategoryTree : Prefab.Shared.Catalog.Products.GetCategoryTree
|
||||||
|
{
|
||||||
|
public sealed class Endpoint : IEndpointRegistrar
|
||||||
|
{
|
||||||
|
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/api/catalog/categories/tree",
|
||||||
|
async (IProductClient productClient, int? depth, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var response = await productClient.GetCategoryTree(depth, cancellationToken);
|
||||||
|
return Results.Ok(response);
|
||||||
|
})
|
||||||
|
.WithModuleName<IModule>(nameof(GetCategoryTree))
|
||||||
|
.WithTags("Categories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Request, Response>
|
||||||
|
{
|
||||||
|
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
var categories = await db.Categories
|
||||||
|
.Where(category => category.DeletedOn == null)
|
||||||
|
.OrderBy(category => category.DisplayOrder)
|
||||||
|
.ThenBy(category => category.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var tree = new CategoryTreeBuilder(categories);
|
||||||
|
var nodes = tree.BuildTree(parentId: null, request.Depth);
|
||||||
|
|
||||||
|
return new Response(nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Prefab.Catalog/Data/Configs/AttributesConfig.cs
Normal file
64
Prefab.Catalog/Data/Configs/AttributesConfig.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Prefab.Base.Catalog.Attributes;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.Configs;
|
||||||
|
|
||||||
|
public sealed class AttributeDefinitionConfig() : Prefab.Data.Configs.EntityConfig<AttributeDefinition>("AttributeDefinitions", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<AttributeDefinition> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(a => a.Id);
|
||||||
|
|
||||||
|
builder.Property(a => a.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(a => a.Name)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(AttributeDefinitionRules.NameMaxLength);
|
||||||
|
|
||||||
|
builder.Property(a => a.Unit)
|
||||||
|
.HasMaxLength(AttributeDefinitionRules.UnitMaxLength);
|
||||||
|
|
||||||
|
builder.HasMany<ProductAttributeValue>()
|
||||||
|
.WithOne(v => v.AttributeDefinition)
|
||||||
|
.HasForeignKey(v => v.AttributeDefinitionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProductAttributeValueConfig() : Prefab.Data.Configs.EntityConfig<ProductAttributeValue>("ProductAttributeValues", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<ProductAttributeValue> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(v => v.Id);
|
||||||
|
|
||||||
|
builder.Property(v => v.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(v => v.Value)
|
||||||
|
.HasMaxLength(ProductAttributeValueRules.ValueMaxLength);
|
||||||
|
|
||||||
|
builder.Property(v => v.UnitCode)
|
||||||
|
.HasMaxLength(ProductAttributeValueRules.UnitCodeMaxLength);
|
||||||
|
|
||||||
|
builder.Property(v => v.EnumCode)
|
||||||
|
.HasMaxLength(ProductAttributeValueRules.EnumCodeMaxLength);
|
||||||
|
|
||||||
|
builder.Property(v => v.NumericValue)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasIndex(v => new { v.ProductId, v.AttributeDefinitionId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
builder.HasOne(v => v.Product)
|
||||||
|
.WithMany(p => p.Attributes)
|
||||||
|
.HasForeignKey(v => v.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Prefab.Catalog/Data/Configs/CategoryConfig.cs
Normal file
43
Prefab.Catalog/Data/Configs/CategoryConfig.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Prefab.Base.Catalog.Categories;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.Configs;
|
||||||
|
|
||||||
|
public class CategoryConfig() : Prefab.Data.Configs.EntityConfig<Category>(nameof(IModuleDb.Categories), IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<Category> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
|
||||||
|
builder.Property(e => e.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(e => e.Name)
|
||||||
|
.HasMaxLength(CategoryRules.NameMaxLength)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.Description)
|
||||||
|
.HasMaxLength(CategoryRules.DescriptionMaxLength);
|
||||||
|
|
||||||
|
builder.Property(e => e.Slug)
|
||||||
|
.HasMaxLength(CategoryRules.SlugMaxLength);
|
||||||
|
|
||||||
|
builder.HasIndex(e => e.Slug)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("[Slug] IS NOT NULL");
|
||||||
|
|
||||||
|
builder.Property(e => e.DisplayOrder);
|
||||||
|
|
||||||
|
builder.Property(e => e.IsFeatured);
|
||||||
|
|
||||||
|
builder.Property(e => e.HeroImageUrl)
|
||||||
|
.HasMaxLength(CategoryRules.HeroImageUrlMaxLength);
|
||||||
|
|
||||||
|
builder.Property(e => e.Icon)
|
||||||
|
.HasMaxLength(CategoryRules.IconMaxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
Prefab.Catalog/Data/Configs/OptionsConfig.cs
Normal file
188
Prefab.Catalog/Data/Configs/OptionsConfig.cs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Prefab.Base.Catalog.Options;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.Configs;
|
||||||
|
|
||||||
|
public sealed class OptionDefinitionConfig() : Prefab.Data.Configs.EntityConfig<OptionDefinition>("OptionDefinitions", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<OptionDefinition> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(o => o.Id);
|
||||||
|
|
||||||
|
builder.Property(o => o.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(o => o.Name)
|
||||||
|
.HasMaxLength(OptionDefinitionRules.NameMaxLength)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(o => o.Code)
|
||||||
|
.HasMaxLength(OptionDefinitionRules.CodeMaxLength)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(o => o.Unit)
|
||||||
|
.HasMaxLength(OptionDefinitionRules.UnitMaxLength);
|
||||||
|
|
||||||
|
builder.Property(o => o.Min)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(o => o.Max)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(o => o.Step)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(o => o.PricePerUnit)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(o => o.PercentScope)
|
||||||
|
.HasConversion(
|
||||||
|
v => v.HasValue ? (int)v.Value : (int?)null,
|
||||||
|
v => v.HasValue ? (PercentScope)v.Value : null);
|
||||||
|
|
||||||
|
builder.HasIndex(o => new { o.ProductId, o.Code })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
builder.HasOne(o => o.Product)
|
||||||
|
.WithMany(p => p.Options)
|
||||||
|
.HasForeignKey(o => o.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasMany(o => o.Values)
|
||||||
|
.WithOne(v => v.OptionDefinition)
|
||||||
|
.HasForeignKey(v => v.OptionDefinitionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasMany(o => o.Tiers)
|
||||||
|
.WithOne(t => t.OptionDefinition)
|
||||||
|
.HasForeignKey(t => t.OptionDefinitionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OptionValueConfig() : Prefab.Data.Configs.EntityConfig<OptionValue>("OptionValues", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<OptionValue> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(v => v.Id);
|
||||||
|
|
||||||
|
builder.Property(v => v.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(v => v.Code)
|
||||||
|
.HasMaxLength(OptionValueRules.CodeMaxLength)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(v => v.Label)
|
||||||
|
.HasMaxLength(OptionValueRules.LabelMaxLength)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(v => v.PriceDelta)
|
||||||
|
.HasPrecision(9, 4);
|
||||||
|
|
||||||
|
builder.HasIndex(v => new { v.OptionDefinitionId, v.Code })
|
||||||
|
.IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OptionTierConfig() : Prefab.Data.Configs.EntityConfig<OptionTier>("OptionTiers", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<OptionTier> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(t => t.Id);
|
||||||
|
|
||||||
|
builder.Property(t => t.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(t => t.FromInclusive)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(t => t.ToInclusive)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(t => t.UnitRate)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(t => t.FlatDelta)
|
||||||
|
.HasPrecision(18, 2);
|
||||||
|
|
||||||
|
builder.HasIndex(t => new { t.OptionDefinitionId, t.FromInclusive, t.ToInclusive });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OptionRuleSetConfig() : Prefab.Data.Configs.EntityConfig<OptionRuleSet>("OptionRuleSets", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<OptionRuleSet> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(r => r.Id);
|
||||||
|
|
||||||
|
builder.Property(r => r.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(r => r.TargetKind)
|
||||||
|
.HasConversion<byte>();
|
||||||
|
|
||||||
|
builder.Property(r => r.Effect)
|
||||||
|
.HasConversion<byte>();
|
||||||
|
|
||||||
|
builder.Property(r => r.Mode)
|
||||||
|
.HasConversion<byte>();
|
||||||
|
|
||||||
|
builder.HasIndex(r => new { r.ProductId, r.TargetKind, r.TargetId });
|
||||||
|
|
||||||
|
builder.HasOne(r => r.Product)
|
||||||
|
.WithMany(p => p.RuleSets)
|
||||||
|
.HasForeignKey(r => r.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OptionRuleConditionConfig() : Prefab.Data.Configs.EntityConfig<OptionRuleCondition>("OptionRuleConditions", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<OptionRuleCondition> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
|
||||||
|
builder.Property(c => c.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(c => c.Operator)
|
||||||
|
.HasConversion<byte>();
|
||||||
|
|
||||||
|
builder.Property(c => c.RightNumber)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(c => c.RightMin)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.Property(c => c.RightMax)
|
||||||
|
.HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(c => c.RuleSet)
|
||||||
|
.WithMany(r => r.Conditions)
|
||||||
|
.HasForeignKey(c => c.RuleSetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(c => c.LeftOptionDefinition)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.LeftOptionDefinitionId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
builder.HasOne(c => c.RightOptionValue)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.RightOptionValueId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Prefab.Catalog/Data/Configs/ProductCategoryConfig.cs
Normal file
25
Prefab.Catalog/Data/Configs/ProductCategoryConfig.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.Configs;
|
||||||
|
|
||||||
|
public sealed class ProductCategoryConfig() : Prefab.Data.Configs.EntityConfig<ProductCategory>("ProductCategories", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<ProductCategory> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(pc => new { pc.ProductId, pc.CategoryId });
|
||||||
|
|
||||||
|
builder.HasOne(pc => pc.Product)
|
||||||
|
.WithMany(p => p.Categories)
|
||||||
|
.HasForeignKey(pc => pc.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasOne(pc => pc.Category)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(pc => pc.CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Prefab.Catalog/Data/Configs/ProductConfig.cs
Normal file
98
Prefab.Catalog/Data/Configs/ProductConfig.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Prefab.Base.Catalog.Products;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.Configs;
|
||||||
|
|
||||||
|
public sealed class ProductConfig() : Prefab.Data.Configs.EntityConfig<Product>("Products", IModuleDb.SchemaName.ToLower())
|
||||||
|
{
|
||||||
|
public override void Configure(EntityTypeBuilder<Product> builder)
|
||||||
|
{
|
||||||
|
base.Configure(builder);
|
||||||
|
|
||||||
|
builder.HasKey(p => p.Id);
|
||||||
|
|
||||||
|
builder.Property(p => p.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
builder.Property(p => p.Name)
|
||||||
|
.HasMaxLength(ProductRules.NameMaxLength)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(p => p.Slug)
|
||||||
|
.HasMaxLength(ProductRules.SlugMaxLength);
|
||||||
|
|
||||||
|
builder.Property(p => p.Sku)
|
||||||
|
.HasMaxLength(ProductRules.SkuMaxLength);
|
||||||
|
|
||||||
|
builder.Property(p => p.Description)
|
||||||
|
.HasMaxLength(ProductRules.DescriptionMaxLength);
|
||||||
|
|
||||||
|
builder.Property(p => p.Price)
|
||||||
|
.HasPrecision(18, 2);
|
||||||
|
|
||||||
|
builder.HasIndex(p => p.Sku)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("[Sku] IS NOT NULL");
|
||||||
|
|
||||||
|
builder.HasIndex(p => p.Slug)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("[Slug] IS NOT NULL");
|
||||||
|
|
||||||
|
builder.HasOne(p => p.ParentProduct)
|
||||||
|
.WithMany(p => p.Variants)
|
||||||
|
.HasForeignKey(p => p.ParentProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasMany(p => p.Attributes)
|
||||||
|
.WithOne(a => a.Product)
|
||||||
|
.HasForeignKey(a => a.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasMany(p => p.Options)
|
||||||
|
.WithOne(o => o.Product)
|
||||||
|
.HasForeignKey(o => o.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasMany(p => p.Categories)
|
||||||
|
.WithOne(pc => pc.Product)
|
||||||
|
.HasForeignKey(pc => pc.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasMany(p => p.RuleSets)
|
||||||
|
.WithOne(r => r.Product)
|
||||||
|
.HasForeignKey(r => r.ProductId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasMany(p => p.AxisValues)
|
||||||
|
.WithOne(v => v.ProductVariant)
|
||||||
|
.HasForeignKey(v => v.ProductVariantId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VariantAxisValueConfig() : IEntityTypeConfiguration<VariantAxisValue>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<VariantAxisValue> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("VariantAxisValues", IModuleDb.SchemaName.ToLower());
|
||||||
|
|
||||||
|
builder.HasKey(v => new { v.ProductVariantId, v.OptionDefinitionId });
|
||||||
|
|
||||||
|
builder.HasOne(v => v.ProductVariant)
|
||||||
|
.WithMany(p => p.AxisValues)
|
||||||
|
.HasForeignKey(v => v.ProductVariantId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
builder.HasOne(v => v.OptionDefinition)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(v => v.OptionDefinitionId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
builder.HasOne(v => v.OptionValue)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(v => v.OptionValueId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Prefab.Catalog/Data/ICatalogDbContextFactory.cs
Normal file
22
Prefab.Catalog/Data/ICatalogDbContextFactory.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Prefab.Catalog.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides factory methods that create catalog module database contexts on demand.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICatalogDbContextFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new writable catalog database context instance for the current operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token used to cancel context creation.</param>
|
||||||
|
/// <returns>An <see cref="IModuleDb"/> instance that must be disposed by the caller.</returns>
|
||||||
|
ValueTask<IModuleDb> CreateWritableAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new read-only catalog database context instance for the current operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token used to cancel context creation.</param>
|
||||||
|
/// <returns>An <see cref="IModuleDbReadOnly"/> instance that must be disposed by the caller.</returns>
|
||||||
|
ValueTask<IModuleDbReadOnly> CreateReadOnlyAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
32
Prefab.Catalog/Data/IModuleDb.cs
Normal file
32
Prefab.Catalog/Data/IModuleDb.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Data;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data;
|
||||||
|
|
||||||
|
public interface IModuleDb : IPrefabDb
|
||||||
|
{
|
||||||
|
static string SchemaName => nameof(Catalog).ToLower();
|
||||||
|
|
||||||
|
DbSet<Category> Categories { get; }
|
||||||
|
|
||||||
|
DbSet<Product> Products { get; }
|
||||||
|
|
||||||
|
DbSet<OptionDefinition> OptionDefinitions { get; }
|
||||||
|
|
||||||
|
DbSet<OptionValue> OptionValues { get; }
|
||||||
|
|
||||||
|
DbSet<OptionTier> OptionTiers { get; }
|
||||||
|
|
||||||
|
DbSet<OptionRuleSet> OptionRuleSets { get; }
|
||||||
|
|
||||||
|
DbSet<OptionRuleCondition> OptionRuleConditions { get; }
|
||||||
|
|
||||||
|
DbSet<VariantAxisValue> VariantAxisValues { get; }
|
||||||
|
|
||||||
|
DbSet<AttributeDefinition> AttributeDefinitions { get; }
|
||||||
|
|
||||||
|
DbSet<ProductAttributeValue> ProductAttributeValues { get; }
|
||||||
|
|
||||||
|
DbSet<ProductCategory> ProductCategories { get; }
|
||||||
|
}
|
||||||
7
Prefab.Catalog/Data/IModuleDbReadOnly.cs
Normal file
7
Prefab.Catalog/Data/IModuleDbReadOnly.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
using Prefab.Data;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data;
|
||||||
|
|
||||||
|
public interface IModuleDbReadOnly : IModuleDb, IPrefabDbReadOnly
|
||||||
|
{
|
||||||
|
}
|
||||||
108
Prefab.Catalog/Data/SeedSupport/OptionRuleSetBuilder.cs
Normal file
108
Prefab.Catalog/Data/SeedSupport/OptionRuleSetBuilder.cs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.SeedSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience builder for constructing <see cref="OptionRuleSet"/> instances with common condition shapes.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OptionRuleSetBuilder
|
||||||
|
{
|
||||||
|
private readonly OptionRuleSet _ruleSet;
|
||||||
|
|
||||||
|
private OptionRuleSetBuilder(OptionRuleSet ruleSet)
|
||||||
|
{
|
||||||
|
_ruleSet = ruleSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OptionRuleSetBuilder ForDefinition(
|
||||||
|
Product product,
|
||||||
|
OptionDefinition targetDefinition,
|
||||||
|
RuleEffect effect,
|
||||||
|
RuleMode mode) =>
|
||||||
|
new(OptionRuleSet.Create(
|
||||||
|
product,
|
||||||
|
OptionRuleTargetKind.OptionDefinition,
|
||||||
|
targetDefinition.Id,
|
||||||
|
effect,
|
||||||
|
mode));
|
||||||
|
|
||||||
|
public static OptionRuleSetBuilder ForValue(
|
||||||
|
Product product,
|
||||||
|
OptionValue targetValue,
|
||||||
|
RuleEffect effect,
|
||||||
|
RuleMode mode) =>
|
||||||
|
new(OptionRuleSet.Create(
|
||||||
|
product,
|
||||||
|
OptionRuleTargetKind.OptionValue,
|
||||||
|
targetValue.Id,
|
||||||
|
effect,
|
||||||
|
mode));
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenEquals(OptionDefinition leftOption, OptionValue value)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.Equal, rightOptionValueId: value.Id);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenNotEquals(OptionDefinition leftOption, OptionValue value)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.NotEqual, rightOptionValueId: value.Id);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenIn(OptionDefinition leftOption, params OptionValue[] values)
|
||||||
|
{
|
||||||
|
if (values == null || values.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one value is required for an IN comparison.", nameof(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueIds = string.Join(",", values.Select(v => v.Id));
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.InList, rightList: valueIds);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenNotIn(OptionDefinition leftOption, params OptionValue[] values)
|
||||||
|
{
|
||||||
|
if (values == null || values.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one value is required for a NOT IN comparison.", nameof(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueIds = string.Join(",", values.Select(v => v.Id));
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.NotInList, rightList: valueIds);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenGreaterThanOrEqual(OptionDefinition leftOption, decimal threshold)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.GreaterThanOrEqual, rightNumber: threshold);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenGreaterThan(OptionDefinition leftOption, decimal threshold)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.GreaterThan, rightNumber: threshold);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenLessThanOrEqual(OptionDefinition leftOption, decimal threshold)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.LessThanOrEqual, rightNumber: threshold);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenLessThan(OptionDefinition leftOption, decimal threshold)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.LessThan, rightNumber: threshold);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSetBuilder WhenBetween(OptionDefinition leftOption, decimal fromInclusive, decimal toInclusive)
|
||||||
|
{
|
||||||
|
_ruleSet.AddCondition(leftOption, RuleOperator.Between, rightMin: fromInclusive, rightMax: toInclusive);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleSet Build() => _ruleSet;
|
||||||
|
}
|
||||||
997
Prefab.Catalog/Data/Seeder.cs
Normal file
997
Prefab.Catalog/Data/Seeder.cs
Normal file
@@ -0,0 +1,997 @@
|
|||||||
|
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<Seeder, IModuleDb>
|
||||||
|
{
|
||||||
|
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<ILogger<Seeder>>();
|
||||||
|
var checker = serviceProvider.GetRequiredService<IUniqueChecker>();
|
||||||
|
|
||||||
|
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<Dictionary<string, Category>> EnsureCategoriesAsync(
|
||||||
|
IModuleDb db,
|
||||||
|
IUniqueChecker checker,
|
||||||
|
ILogger logger,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var categories = new Dictionary<string, Category>(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<Product?> 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<OptionDefinition> 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<OptionDefinition> 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<OptionRuleSet> 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<AttributeDefinition> 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);
|
||||||
|
}
|
||||||
95
Prefab.Catalog/Data/Services/UniqueChecker.cs
Normal file
95
Prefab.Catalog/Data/Services/UniqueChecker.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Catalog.Domain.Services;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Data.Services;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IUniqueChecker"/>
|
||||||
|
public sealed class UniqueChecker(ICatalogDbContextFactory dbFactory) : IUniqueChecker
|
||||||
|
{
|
||||||
|
private static DbContext AsDbContext(IModuleDbReadOnly db)
|
||||||
|
{
|
||||||
|
if (db is not DbContext context)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Catalog context must derive from DbContext.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
var normalized = name.Trim();
|
||||||
|
return !await db.Categories
|
||||||
|
.Where(c => c.DeletedOn == null)
|
||||||
|
.AnyAsync(c => c.Name == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
var context = AsDbContext(db);
|
||||||
|
|
||||||
|
var normalized = name.Trim().ToUpperInvariant();
|
||||||
|
return !await context.Set<Product>()
|
||||||
|
.Where(p => p.DeletedOn == null && p.Kind == ProductKind.Model)
|
||||||
|
.AnyAsync(p => p.Name != null && p.Name.ToUpper() == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
var context = AsDbContext(db);
|
||||||
|
|
||||||
|
var normalized = slug.Trim().ToLowerInvariant();
|
||||||
|
return !await context.Set<Product>()
|
||||||
|
.Where(p => p.DeletedOn == null && p.Kind == ProductKind.Model && p.Slug != null)
|
||||||
|
.AnyAsync(p => p.Slug!.ToLower() == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sku);
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
var context = AsDbContext(db);
|
||||||
|
|
||||||
|
var normalized = sku.Trim().ToUpperInvariant();
|
||||||
|
return !await context.Set<Product>()
|
||||||
|
.Where(p => p.DeletedOn == null && p.Sku != null)
|
||||||
|
.AnyAsync(p => p.Sku!.ToUpper() == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (productId == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Product identifier is required.", nameof(productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(code);
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
|
||||||
|
var context = AsDbContext(db);
|
||||||
|
|
||||||
|
var normalizedCode = code.Trim().ToUpperInvariant();
|
||||||
|
return !await context.Set<OptionDefinition>()
|
||||||
|
.Where(o => o.DeletedOn == null && o.ProductId == productId)
|
||||||
|
.AnyAsync(o => o.Code.ToUpper() == normalizedCode, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
84
Prefab.Catalog/Domain/Entities/Attributes.cs
Normal file
84
Prefab.Catalog/Domain/Entities/Attributes.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using Prefab.Catalog.Domain.Events;
|
||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
public enum AttributeDataType
|
||||||
|
{
|
||||||
|
Text = 1,
|
||||||
|
Number = 3,
|
||||||
|
Boolean = 4,
|
||||||
|
Enum = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AttributeDefinition : EntityWithAuditAndStatus<Guid>
|
||||||
|
{
|
||||||
|
private AttributeDefinition()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public AttributeDataType DataType { get; private set; }
|
||||||
|
|
||||||
|
public string? Unit { get; private set; }
|
||||||
|
|
||||||
|
public static AttributeDefinition Create(string name, AttributeDataType dataType, string? unit = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
var definition = new AttributeDefinition
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = name.Trim(),
|
||||||
|
DataType = dataType,
|
||||||
|
Unit = string.IsNullOrWhiteSpace(unit) ? null : unit.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
definition.AddEvent(new AttributeDefinitionCreated(definition.Id, definition.Name, definition.DataType));
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProductAttributeValue : EntityWithAuditAndStatus<Guid>
|
||||||
|
{
|
||||||
|
private ProductAttributeValue()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid ProductId { get; internal set; }
|
||||||
|
|
||||||
|
public Product Product { get; internal set; } = null!;
|
||||||
|
|
||||||
|
public Guid AttributeDefinitionId { get; internal set; }
|
||||||
|
|
||||||
|
public AttributeDefinition AttributeDefinition { get; internal set; } = null!;
|
||||||
|
|
||||||
|
public string? Value { get; internal set; }
|
||||||
|
|
||||||
|
public decimal? NumericValue { get; internal set; }
|
||||||
|
|
||||||
|
public string? UnitCode { get; internal set; }
|
||||||
|
|
||||||
|
public string? EnumCode { get; internal set; }
|
||||||
|
|
||||||
|
internal static ProductAttributeValue Create(
|
||||||
|
Guid productId,
|
||||||
|
Guid attributeDefinitionId,
|
||||||
|
string? value,
|
||||||
|
decimal? numericValue,
|
||||||
|
string? unitCode,
|
||||||
|
string? enumCode)
|
||||||
|
{
|
||||||
|
return new ProductAttributeValue
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProductId = productId,
|
||||||
|
AttributeDefinitionId = attributeDefinitionId,
|
||||||
|
Value = value,
|
||||||
|
NumericValue = numericValue,
|
||||||
|
UnitCode = unitCode,
|
||||||
|
EnumCode = enumCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
113
Prefab.Catalog/Domain/Entities/Category.cs
Normal file
113
Prefab.Catalog/Domain/Entities/Category.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Prefab.Base.Catalog.Categories;
|
||||||
|
using Prefab.Catalog.Domain.Events;
|
||||||
|
using Prefab.Catalog.Domain.Exceptions;
|
||||||
|
using Prefab.Catalog.Domain.Services;
|
||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
public class Category : EntityWithAuditAndStatus<Guid>
|
||||||
|
{
|
||||||
|
private static readonly Regex SlugRegex = new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private Category()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid? ParentId { get; private set; }
|
||||||
|
|
||||||
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Description { get; private set; }
|
||||||
|
|
||||||
|
public string? Slug { get; private set; }
|
||||||
|
|
||||||
|
public int DisplayOrder { get; private set; }
|
||||||
|
|
||||||
|
public bool IsFeatured { get; private set; }
|
||||||
|
|
||||||
|
public string? HeroImageUrl { get; private set; }
|
||||||
|
|
||||||
|
public string? Icon { get; private set; }
|
||||||
|
|
||||||
|
public static async Task<Category> Create(string name, string? description, IUniqueChecker check, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
ArgumentNullException.ThrowIfNull(check);
|
||||||
|
|
||||||
|
var trimmedName = name.Trim();
|
||||||
|
var trimmedDescription = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||||
|
|
||||||
|
if (!await check.CategoryNameIsUnique(trimmedName, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new DuplicateNameException(trimmedName, nameof(Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = new Category
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = trimmedName,
|
||||||
|
Description = trimmedDescription
|
||||||
|
};
|
||||||
|
|
||||||
|
category.AddEvent(new CategoryCreated(category.Id, category.Name));
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Rename(string newName, IUniqueChecker check, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||||
|
ArgumentNullException.ThrowIfNull(check);
|
||||||
|
|
||||||
|
var trimmed = newName.Trim();
|
||||||
|
if (string.Equals(trimmed, Name, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await check.CategoryNameIsUnique(trimmed, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new DuplicateNameException(trimmed, nameof(Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldName = Name;
|
||||||
|
Name = trimmed;
|
||||||
|
|
||||||
|
AddEvent(new CategoryRenamed(Id, oldName, Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ConfigureMetadata(string slug, int displayOrder, bool isFeatured, string? heroImageUrl, string? icon)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||||
|
|
||||||
|
var trimmedSlug = slug.Trim();
|
||||||
|
if (trimmedSlug.Length > CategoryRules.SlugMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainValidationException($"Category slug cannot exceed {CategoryRules.SlugMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SlugRegex.IsMatch(trimmedSlug))
|
||||||
|
{
|
||||||
|
throw new DomainValidationException("Category slug must use lowercase letters, numbers, and hyphens.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedHeroImageUrl = string.IsNullOrWhiteSpace(heroImageUrl) ? null : heroImageUrl.Trim();
|
||||||
|
if (trimmedHeroImageUrl is not null && trimmedHeroImageUrl.Length > CategoryRules.HeroImageUrlMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainValidationException($"Hero image URL cannot exceed {CategoryRules.HeroImageUrlMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedIcon = string.IsNullOrWhiteSpace(icon) ? null : icon.Trim();
|
||||||
|
if (trimmedIcon is not null && trimmedIcon.Length > CategoryRules.IconMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainValidationException($"Icon cannot exceed {CategoryRules.IconMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Slug = trimmedSlug;
|
||||||
|
DisplayOrder = displayOrder;
|
||||||
|
IsFeatured = isFeatured;
|
||||||
|
HeroImageUrl = trimmedHeroImageUrl;
|
||||||
|
Icon = trimmedIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
268
Prefab.Catalog/Domain/Entities/OptionRules.cs
Normal file
268
Prefab.Catalog/Domain/Entities/OptionRules.cs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies what entity a rule set controls (option definition vs individual value).
|
||||||
|
/// </summary>
|
||||||
|
public enum OptionRuleTargetKind : byte
|
||||||
|
{
|
||||||
|
OptionDefinition = 0,
|
||||||
|
OptionValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result to apply when rule conditions evaluate to true.
|
||||||
|
/// </summary>
|
||||||
|
public enum RuleEffect : byte
|
||||||
|
{
|
||||||
|
Show = 0,
|
||||||
|
Enable = 1,
|
||||||
|
Require = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines whether all or any conditions must be satisfied.
|
||||||
|
/// </summary>
|
||||||
|
public enum RuleMode : byte
|
||||||
|
{
|
||||||
|
All = 0,
|
||||||
|
Any = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operators supported when evaluating rule conditions against shopper selections.
|
||||||
|
/// </summary>
|
||||||
|
public enum RuleOperator : byte
|
||||||
|
{
|
||||||
|
Equal = 0,
|
||||||
|
NotEqual = 1,
|
||||||
|
InList = 2,
|
||||||
|
NotInList = 3,
|
||||||
|
GreaterThan = 4,
|
||||||
|
GreaterThanOrEqual = 5,
|
||||||
|
LessThan = 6,
|
||||||
|
LessThanOrEqual = 7,
|
||||||
|
Between = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups conditions that control visibility, enablement, or requirement for an option or value.
|
||||||
|
/// </summary>
|
||||||
|
public class OptionRuleSet : EntityWithAuditAndStatus<Guid>
|
||||||
|
{
|
||||||
|
private OptionRuleSet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid ProductId { get; internal set; }
|
||||||
|
|
||||||
|
public Product Product { get; internal set; } = null!;
|
||||||
|
|
||||||
|
public OptionRuleTargetKind TargetKind { get; internal set; }
|
||||||
|
|
||||||
|
public Guid TargetId { get; internal set; }
|
||||||
|
|
||||||
|
public RuleEffect Effect { get; internal set; }
|
||||||
|
|
||||||
|
public RuleMode Mode { get; internal set; }
|
||||||
|
|
||||||
|
public List<OptionRuleCondition> Conditions { get; } = [];
|
||||||
|
|
||||||
|
public static OptionRuleSet Create(
|
||||||
|
Product product,
|
||||||
|
OptionRuleTargetKind targetKind,
|
||||||
|
Guid targetId,
|
||||||
|
RuleEffect effect,
|
||||||
|
RuleMode mode)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(product);
|
||||||
|
|
||||||
|
if (targetKind == OptionRuleTargetKind.OptionDefinition)
|
||||||
|
{
|
||||||
|
_ = product.Options.FirstOrDefault(o => o.Id == targetId)
|
||||||
|
?? throw new ArgumentException($"Option definition '{targetId}' does not belong to product '{product.Id}'.", nameof(targetId));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ownsValue = product.Options
|
||||||
|
.SelectMany(o => o.Values)
|
||||||
|
.Any(v => v.Id == targetId);
|
||||||
|
if (!ownsValue)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Option value '{targetId}' does not belong to product '{product.Id}'.", nameof(targetId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ruleSet = new OptionRuleSet
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Product = product,
|
||||||
|
ProductId = product.Id,
|
||||||
|
TargetKind = targetKind,
|
||||||
|
TargetId = targetId,
|
||||||
|
Effect = effect,
|
||||||
|
Mode = mode
|
||||||
|
};
|
||||||
|
|
||||||
|
product.RuleSets.Add(ruleSet);
|
||||||
|
return ruleSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OptionRuleCondition AddCondition(
|
||||||
|
OptionDefinition leftOptionDefinition,
|
||||||
|
RuleOperator @operator,
|
||||||
|
Guid? rightOptionValueId = null,
|
||||||
|
string? rightList = null,
|
||||||
|
decimal? rightNumber = null,
|
||||||
|
decimal? rightMin = null,
|
||||||
|
decimal? rightMax = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(leftOptionDefinition);
|
||||||
|
if (leftOptionDefinition.ProductId != ProductId)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Option definition '{leftOptionDefinition.Id}' does not belong to product '{ProductId}'.", nameof(leftOptionDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateRightHandSide(leftOptionDefinition, @operator, rightOptionValueId, rightList, rightNumber, rightMin, rightMax);
|
||||||
|
|
||||||
|
OptionValue? rightOptionValue = null;
|
||||||
|
if (rightOptionValueId.HasValue)
|
||||||
|
{
|
||||||
|
rightOptionValue = leftOptionDefinition.Values.FirstOrDefault(v => v.Id == rightOptionValueId.Value);
|
||||||
|
if (rightOptionValue is null)
|
||||||
|
{
|
||||||
|
rightOptionValue = Product.Options
|
||||||
|
.SelectMany(o => o.Values)
|
||||||
|
.FirstOrDefault(v => v.Id == rightOptionValueId.Value)
|
||||||
|
?? throw new ArgumentException($"Option value '{rightOptionValueId.Value}' was not found for product '{ProductId}'.", nameof(rightOptionValueId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var condition = new OptionRuleCondition
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
RuleSet = this,
|
||||||
|
RuleSetId = Id,
|
||||||
|
LeftOptionDefinition = leftOptionDefinition,
|
||||||
|
LeftOptionDefinitionId = leftOptionDefinition.Id,
|
||||||
|
Operator = @operator,
|
||||||
|
RightOptionValue = rightOptionValue,
|
||||||
|
RightOptionValueId = rightOptionValueId,
|
||||||
|
RightList = string.IsNullOrWhiteSpace(rightList) ? null : rightList,
|
||||||
|
RightNumber = rightNumber,
|
||||||
|
RightMin = rightMin,
|
||||||
|
RightMax = rightMax
|
||||||
|
};
|
||||||
|
|
||||||
|
Conditions.Add(condition);
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateRightHandSide(
|
||||||
|
OptionDefinition leftOptionDefinition,
|
||||||
|
RuleOperator @operator,
|
||||||
|
Guid? rightOptionValueId,
|
||||||
|
string? rightList,
|
||||||
|
decimal? rightNumber,
|
||||||
|
decimal? rightMin,
|
||||||
|
decimal? rightMax)
|
||||||
|
{
|
||||||
|
var isChoice = leftOptionDefinition.DataType == OptionDataType.Choice;
|
||||||
|
|
||||||
|
switch (@operator)
|
||||||
|
{
|
||||||
|
case RuleOperator.Equal:
|
||||||
|
case RuleOperator.NotEqual:
|
||||||
|
if (isChoice)
|
||||||
|
{
|
||||||
|
if (rightOptionValueId is null && string.IsNullOrWhiteSpace(rightList))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Choice comparisons require a right option value identifier or list.", nameof(rightOptionValueId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (rightNumber is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Numeric comparisons require a numeric value.", nameof(rightNumber));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RuleOperator.InList:
|
||||||
|
case RuleOperator.NotInList:
|
||||||
|
if (string.IsNullOrWhiteSpace(rightList))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("List-based comparisons require a comma delimited list.", nameof(rightList));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RuleOperator.GreaterThan:
|
||||||
|
case RuleOperator.GreaterThanOrEqual:
|
||||||
|
case RuleOperator.LessThan:
|
||||||
|
case RuleOperator.LessThanOrEqual:
|
||||||
|
if (rightNumber is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Range comparisons require a numeric value.", nameof(rightNumber));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RuleOperator.Between:
|
||||||
|
if (rightMin is null || rightMax is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Between comparisons require both minimum and maximum values.", nameof(rightMin));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(@operator), @operator, "Unsupported rule operator.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single conditional expression applied to option selections.
|
||||||
|
/// </summary>
|
||||||
|
public class OptionRuleCondition : EntityWithAuditAndStatus<Guid>
|
||||||
|
{
|
||||||
|
internal OptionRuleCondition()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid RuleSetId { get; internal set; }
|
||||||
|
|
||||||
|
public OptionRuleSet RuleSet { get; internal set; } = null!;
|
||||||
|
|
||||||
|
public Guid LeftOptionDefinitionId { get; internal set; }
|
||||||
|
|
||||||
|
public OptionDefinition LeftOptionDefinition { get; internal set; } = null!;
|
||||||
|
|
||||||
|
public RuleOperator Operator { get; internal set; }
|
||||||
|
|
||||||
|
public Guid? RightOptionValueId { get; internal set; }
|
||||||
|
|
||||||
|
public OptionValue? RightOptionValue { get; internal set; }
|
||||||
|
|
||||||
|
public string? RightList { get; internal set; }
|
||||||
|
|
||||||
|
public decimal? RightNumber { get; internal set; }
|
||||||
|
|
||||||
|
public decimal? RightMin { get; internal set; }
|
||||||
|
|
||||||
|
public decimal? RightMax { get; internal set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records which option value combination maps to an individual variant SKU.
|
||||||
|
/// </summary>
|
||||||
|
public class VariantAxisValue
|
||||||
|
{
|
||||||
|
public Guid ProductVariantId { get; set; }
|
||||||
|
|
||||||
|
public Product ProductVariant { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid OptionDefinitionId { get; set; }
|
||||||
|
|
||||||
|
public OptionDefinition OptionDefinition { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid OptionValueId { get; set; }
|
||||||
|
|
||||||
|
public OptionValue OptionValue { get; set; } = null!;
|
||||||
|
}
|
||||||
543
Prefab.Catalog/Domain/Entities/Options.cs
Normal file
543
Prefab.Catalog/Domain/Entities/Options.cs
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
334
Prefab.Catalog/Domain/Entities/Product.cs
Normal file
334
Prefab.Catalog/Domain/Entities/Product.cs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Prefab.Base.Catalog.Products;
|
||||||
|
using Prefab.Catalog.Domain.Events;
|
||||||
|
using Prefab.Catalog.Domain.Exceptions;
|
||||||
|
using Prefab.Catalog.Domain.Services;
|
||||||
|
using Prefab.Domain.Common;
|
||||||
|
using DomainRuleException = Prefab.Domain.Exceptions.DomainException;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
public enum ProductKind
|
||||||
|
{
|
||||||
|
Model = 0,
|
||||||
|
Variant = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Product : EntityWithAuditAndStatus<Guid>
|
||||||
|
{
|
||||||
|
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private Product()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductKind Kind { get; private set; }
|
||||||
|
|
||||||
|
public string? Sku { get; private set; }
|
||||||
|
|
||||||
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Slug { get; private set; }
|
||||||
|
|
||||||
|
public string? Description { get; private set; }
|
||||||
|
|
||||||
|
public decimal? Price { get; private set; }
|
||||||
|
|
||||||
|
public Guid? ParentProductId { get; private set; }
|
||||||
|
|
||||||
|
public Product? ParentProduct { get; private set; }
|
||||||
|
|
||||||
|
public List<Product> Variants { get; } = [];
|
||||||
|
|
||||||
|
public List<ProductAttributeValue> Attributes { get; } = [];
|
||||||
|
|
||||||
|
public List<OptionDefinition> Options { get; } = [];
|
||||||
|
|
||||||
|
public List<ProductCategory> Categories { get; } = [];
|
||||||
|
|
||||||
|
public List<OptionRuleSet> RuleSets { get; } = [];
|
||||||
|
|
||||||
|
public List<VariantAxisValue> AxisValues { get; } = [];
|
||||||
|
|
||||||
|
public static async Task<Product> CreateModel(
|
||||||
|
string name,
|
||||||
|
string? slug,
|
||||||
|
string? description,
|
||||||
|
IUniqueChecker uniqueChecker,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||||
|
|
||||||
|
var trimmedName = name.Trim();
|
||||||
|
var trimmedSlug = string.IsNullOrWhiteSpace(slug) ? null : slug.Trim();
|
||||||
|
var trimmedDescription = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||||
|
|
||||||
|
if (trimmedName.Length > ProductRules.NameMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product name cannot exceed {ProductRules.NameMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedDescription is not null && trimmedDescription.Length > ProductRules.DescriptionMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product description cannot exceed {ProductRules.DescriptionMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedSlug is not null)
|
||||||
|
{
|
||||||
|
if (trimmedSlug.Length > ProductRules.SlugMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product slug cannot exceed {ProductRules.SlugMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SlugRegex.IsMatch(trimmedSlug))
|
||||||
|
{
|
||||||
|
throw new DomainRuleException("Product slug may only contain lowercase letters, numbers, and single hyphens between segments.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await uniqueChecker.ProductModelNameIsUnique(trimmedName, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new DuplicateNameException(trimmedName, nameof(Product));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(trimmedSlug) &&
|
||||||
|
!await uniqueChecker.ProductSlugIsUnique(trimmedSlug, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new DomainValidationException($"A product with slug '{trimmedSlug}' already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var product = new Product
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Kind = ProductKind.Model,
|
||||||
|
Name = trimmedName,
|
||||||
|
Slug = trimmedSlug,
|
||||||
|
Description = trimmedDescription
|
||||||
|
};
|
||||||
|
|
||||||
|
product.AddEvent(new ProductCreated(product.Id, "Model", product.Name, product.Slug));
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Product> CreateVariant(
|
||||||
|
Guid parentId,
|
||||||
|
string sku,
|
||||||
|
string name,
|
||||||
|
decimal price,
|
||||||
|
IUniqueChecker uniqueChecker,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sku);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
if (price < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedSku = sku.Trim();
|
||||||
|
var trimmedName = name.Trim();
|
||||||
|
|
||||||
|
if (trimmedSku.Length > ProductRules.SkuMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product SKU cannot exceed {ProductRules.SkuMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.Length > ProductRules.NameMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product name cannot exceed {ProductRules.NameMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await uniqueChecker.ProductSkuIsUnique(trimmedSku, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new DomainValidationException($"A product variant with SKU '{trimmedSku}' already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var product = new Product
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Kind = ProductKind.Variant,
|
||||||
|
ParentProductId = parentId,
|
||||||
|
Sku = trimmedSku,
|
||||||
|
Name = trimmedName,
|
||||||
|
Price = price
|
||||||
|
};
|
||||||
|
|
||||||
|
product.AddEvent(new ProductVariantCreated(product.Id, parentId, trimmedSku, trimmedName, price));
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetBasePrice(decimal? price)
|
||||||
|
{
|
||||||
|
if (price is < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(price), price, "Price cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Price == price)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var old = Price;
|
||||||
|
Price = price;
|
||||||
|
AddEvent(new ProductPriceChanged(Id, old, price));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Rename(string name, IUniqueChecker uniqueChecker, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
ArgumentNullException.ThrowIfNull(uniqueChecker);
|
||||||
|
|
||||||
|
var trimmed = name.Trim();
|
||||||
|
|
||||||
|
if (trimmed.Length > ProductRules.NameMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product name cannot exceed {ProductRules.NameMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(trimmed, Name, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Kind == ProductKind.Model)
|
||||||
|
{
|
||||||
|
var isUnique = await uniqueChecker.ProductModelNameIsUnique(trimmed, cancellationToken);
|
||||||
|
if (!isUnique)
|
||||||
|
{
|
||||||
|
throw new DuplicateNameException(trimmed, nameof(Product));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldName = Name;
|
||||||
|
Name = trimmed;
|
||||||
|
AddEvent(new ProductRenamed(Id, oldName, Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ChangeDescription(string? description)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
Description = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = description.Trim();
|
||||||
|
if (trimmed.Length > ProductRules.DescriptionMaxLength)
|
||||||
|
{
|
||||||
|
throw new DomainRuleException($"Product description cannot exceed {ProductRules.DescriptionMaxLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Description = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AttachVariant(Product variant)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(variant);
|
||||||
|
|
||||||
|
if (variant.Kind != ProductKind.Variant)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Only variant products can be attached as variants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
variant.ParentProductId = Id;
|
||||||
|
variant.ParentProduct = this;
|
||||||
|
|
||||||
|
if (Variants.Any(v => v.Id == variant.Id))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants.Add(variant);
|
||||||
|
AddEvent(new ProductVariantAttached(Id, variant.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductAttributeValue UpsertSpec(
|
||||||
|
Guid attributeDefinitionId,
|
||||||
|
string? value,
|
||||||
|
decimal? numericValue,
|
||||||
|
string? unitCode,
|
||||||
|
string? enumCode)
|
||||||
|
{
|
||||||
|
if (attributeDefinitionId == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Attribute definition identifier is required.", nameof(attributeDefinitionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = Attributes.FirstOrDefault(x => x.AttributeDefinitionId == attributeDefinitionId);
|
||||||
|
var trimmedValue = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
var trimmedUnit = string.IsNullOrWhiteSpace(unitCode) ? null : unitCode.Trim();
|
||||||
|
var trimmedEnum = string.IsNullOrWhiteSpace(enumCode) ? null : enumCode.Trim();
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
existing = ProductAttributeValue.Create(
|
||||||
|
Id,
|
||||||
|
attributeDefinitionId,
|
||||||
|
trimmedValue,
|
||||||
|
numericValue,
|
||||||
|
trimmedUnit,
|
||||||
|
trimmedEnum);
|
||||||
|
|
||||||
|
existing.Product = this;
|
||||||
|
Attributes.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Product = this;
|
||||||
|
existing.Value = trimmedValue;
|
||||||
|
existing.NumericValue = numericValue;
|
||||||
|
existing.UnitCode = trimmedUnit;
|
||||||
|
existing.EnumCode = trimmedEnum;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddEvent(new ProductAttributeValueUpserted(Id, attributeDefinitionId));
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductCategory AssignToCategory(Guid categoryId, bool isPrimary)
|
||||||
|
{
|
||||||
|
if (categoryId == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Category identifier is required.", nameof(categoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = Categories.FirstOrDefault(x => x.CategoryId == categoryId);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
existing = new ProductCategory
|
||||||
|
{
|
||||||
|
ProductId = Id,
|
||||||
|
Product = this,
|
||||||
|
CategoryId = categoryId,
|
||||||
|
IsPrimary = isPrimary
|
||||||
|
};
|
||||||
|
Categories.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.IsPrimary = isPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddEvent(new ProductCategoryAssigned(Id, categoryId, isPrimary));
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool UnassignFromCategory(Guid categoryId)
|
||||||
|
{
|
||||||
|
var existing = Categories.FirstOrDefault(x => x.CategoryId == categoryId);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Categories.Remove(existing);
|
||||||
|
AddEvent(new ProductCategoryUnassigned(Id, categoryId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Prefab.Catalog/Domain/Entities/ProductCategory.cs
Normal file
14
Prefab.Catalog/Domain/Entities/ProductCategory.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
public class ProductCategory
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid CategoryId { get; set; }
|
||||||
|
|
||||||
|
public Category Category { get; set; } = null!;
|
||||||
|
|
||||||
|
public bool IsPrimary { get; set; }
|
||||||
|
}
|
||||||
6
Prefab.Catalog/Domain/Events/AttributeEvents.cs
Normal file
6
Prefab.Catalog/Domain/Events/AttributeEvents.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Events;
|
||||||
|
|
||||||
|
public sealed record AttributeDefinitionCreated(Guid AttributeDefinitionId, string Name, AttributeDataType DataType) : Event;
|
||||||
7
Prefab.Catalog/Domain/Events/CategoryEvents.cs
Normal file
7
Prefab.Catalog/Domain/Events/CategoryEvents.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Events;
|
||||||
|
|
||||||
|
public sealed record CategoryCreated(Guid CategoryId, string Name) : Event;
|
||||||
|
|
||||||
|
public sealed record CategoryRenamed(Guid CategoryId, string OldName, string NewName) : Event;
|
||||||
34
Prefab.Catalog/Domain/Events/OptionEvents.cs
Normal file
34
Prefab.Catalog/Domain/Events/OptionEvents.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Events;
|
||||||
|
|
||||||
|
public sealed record OptionDefinitionCreated(
|
||||||
|
Guid ProductId,
|
||||||
|
Guid OptionDefinitionId,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string DataType,
|
||||||
|
bool IsVariantAxis) : Event;
|
||||||
|
|
||||||
|
public sealed record OptionValueAdded(
|
||||||
|
Guid OptionDefinitionId,
|
||||||
|
Guid OptionValueId,
|
||||||
|
string Code,
|
||||||
|
string Label,
|
||||||
|
decimal? PriceDelta,
|
||||||
|
PriceDeltaKind Kind) : Event;
|
||||||
|
|
||||||
|
public sealed record OptionValueChanged(Guid OptionDefinitionId, Guid OptionValueId) : Event;
|
||||||
|
|
||||||
|
public sealed record OptionValueRemoved(Guid OptionDefinitionId, Guid OptionValueId) : Event;
|
||||||
|
|
||||||
|
public sealed record OptionTierAdded(
|
||||||
|
Guid OptionDefinitionId,
|
||||||
|
Guid OptionTierId,
|
||||||
|
decimal FromInclusive,
|
||||||
|
decimal? ToInclusive) : Event;
|
||||||
|
|
||||||
|
public sealed record OptionTierChanged(Guid OptionDefinitionId, Guid OptionTierId) : Event;
|
||||||
|
|
||||||
|
public sealed record OptionTierRemoved(Guid OptionDefinitionId, Guid OptionTierId) : Event;
|
||||||
19
Prefab.Catalog/Domain/Events/ProductEvents.cs
Normal file
19
Prefab.Catalog/Domain/Events/ProductEvents.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using Prefab.Domain.Common;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Events;
|
||||||
|
|
||||||
|
public sealed record ProductCreated(Guid ProductId, string Kind, string Name, string? Slug) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductVariantCreated(Guid ProductId, Guid ParentProductId, string Sku, string Name, decimal Price) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductPriceChanged(Guid ProductId, decimal? OldPrice, decimal? NewPrice) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductRenamed(Guid ProductId, string OldName, string NewName) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductVariantAttached(Guid ParentProductId, Guid VariantProductId) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductAttributeValueUpserted(Guid ProductId, Guid AttributeDefinitionId) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductCategoryAssigned(Guid ProductId, Guid CategoryId, bool IsPrimary) : Event;
|
||||||
|
|
||||||
|
public sealed record ProductCategoryUnassigned(Guid ProductId, Guid CategoryId) : Event;
|
||||||
19
Prefab.Catalog/Domain/Exceptions/DomainException.cs
Normal file
19
Prefab.Catalog/Domain/Exceptions/DomainException.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Prefab.Catalog.Domain.Exceptions;
|
||||||
|
|
||||||
|
public abstract class DomainException(string message) : Exception(message);
|
||||||
|
|
||||||
|
public sealed class CatalogNotFoundException(string resource, string identifier)
|
||||||
|
: DomainException($"{resource} with identifier '{identifier}' was not found.")
|
||||||
|
{
|
||||||
|
public string Resource { get; } = resource;
|
||||||
|
|
||||||
|
public string Identifier { get; } = identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CatalogConflictException(string resource, string identifier, string detail)
|
||||||
|
: DomainException(detail)
|
||||||
|
{
|
||||||
|
public string Resource { get; } = resource;
|
||||||
|
|
||||||
|
public string Identifier { get; } = identifier;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Prefab.Catalog.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a domain rule violation caused by invalid user input.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">Details about the validation failure.</param>
|
||||||
|
public sealed class DomainValidationException(string message) : DomainException(message);
|
||||||
|
|
||||||
14
Prefab.Catalog/Domain/Exceptions/DuplicateNameException.cs
Normal file
14
Prefab.Catalog/Domain/Exceptions/DuplicateNameException.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Prefab.Catalog.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an exception that is thrown when an attempt is made to create or add an entity with a name that already
|
||||||
|
/// exists for the specified type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name of the entity that caused the duplication error. Cannot be null.</param>
|
||||||
|
/// <param name="type">The type of the entity for which the duplicate name was detected. Cannot be null.</param>
|
||||||
|
internal class DuplicateNameException(string name, string type) : DomainException($"A {type} with the name {name} already exists.")
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
|
||||||
|
public string Type { get; } = type;
|
||||||
|
}
|
||||||
14
Prefab.Catalog/Domain/Services/IUniqueChecker.cs
Normal file
14
Prefab.Catalog/Domain/Services/IUniqueChecker.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Prefab.Catalog.Domain.Services;
|
||||||
|
|
||||||
|
public interface IUniqueChecker
|
||||||
|
{
|
||||||
|
Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
253
Prefab.Catalog/Domain/Services/PricingService.cs
Normal file
253
Prefab.Catalog/Domain/Services/PricingService.cs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates price quotes for configured catalog products by combining base price, numeric adjustments, and percent deltas.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPricingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces a unit price and line-item breakdown for a shopper’s selections.
|
||||||
|
/// </summary>
|
||||||
|
Task<QuotePrice.Response> QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default Catalog pricing implementation that reads product/option data on demand and applies the PercentScope rules.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PricingService(IModuleDbReadOnly db) : IPricingService
|
||||||
|
{
|
||||||
|
public async Task<QuotePrice.Response> QuoteAsync(QuotePrice.Request request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
if (request.ProductId is null && string.IsNullOrWhiteSpace(request.Sku))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Either ProductId or Sku must be supplied.", nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
var product = await FindProductAsync(request, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Product could not be located for pricing.");
|
||||||
|
|
||||||
|
var optionsSource = product.Kind == ProductKind.Model
|
||||||
|
? product
|
||||||
|
: product.ParentProduct ?? throw new InvalidOperationException("Variant product is missing its model definition.");
|
||||||
|
|
||||||
|
var optionLookup = optionsSource.Options
|
||||||
|
.Where(o => o.DeletedOn is null)
|
||||||
|
.ToDictionary(o => o.Id);
|
||||||
|
|
||||||
|
var basePrice = product.Price ?? 0m;
|
||||||
|
var numericTotal = 0m;
|
||||||
|
var choiceAbsTotal = 0m;
|
||||||
|
var breakdownEntries = new List<SelectionBreakdown>();
|
||||||
|
|
||||||
|
foreach (var selection in request.Selections)
|
||||||
|
{
|
||||||
|
if (!optionLookup.TryGetValue(selection.OptionDefinitionId, out var option))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Option definition '{selection.OptionDefinitionId}' was not found for product '{optionsSource.Id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (option.DataType)
|
||||||
|
{
|
||||||
|
case OptionDataType.Number:
|
||||||
|
{
|
||||||
|
var (delta, chosenDescription) = HandleNumber(option, selection);
|
||||||
|
numericTotal += delta;
|
||||||
|
breakdownEntries.Add(SelectionBreakdown.Fixed(option.Name, chosenDescription, delta));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OptionDataType.Choice:
|
||||||
|
{
|
||||||
|
if (selection.OptionValueId is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Selection for option '{option.Name}' must include an option value identifier.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = option.Values.FirstOrDefault(v => v.DeletedOn == null && v.Id == selection.OptionValueId)
|
||||||
|
?? throw new InvalidOperationException($"Option value '{selection.OptionValueId}' was not found for option '{option.Name}'.");
|
||||||
|
|
||||||
|
var chosenDescription = value.Label;
|
||||||
|
var delta = value.PriceDelta ?? 0m;
|
||||||
|
|
||||||
|
switch (value.PriceDeltaKind)
|
||||||
|
{
|
||||||
|
case PriceDeltaKind.Absolute:
|
||||||
|
choiceAbsTotal += delta;
|
||||||
|
breakdownEntries.Add(SelectionBreakdown.Fixed(option.Name, chosenDescription, delta));
|
||||||
|
break;
|
||||||
|
case PriceDeltaKind.Percent:
|
||||||
|
breakdownEntries.Add(SelectionBreakdown.Percent(option, value, chosenDescription, delta));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Price delta kind '{value.PriceDeltaKind}' is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Option data type '{option.DataType}' is not supported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentSelections = breakdownEntries.Where(e => e.Kind == BreakdownKind.Percent).ToList();
|
||||||
|
var percentTotal = 0m;
|
||||||
|
PercentScope? selectedScope = null;
|
||||||
|
|
||||||
|
foreach (var entry in percentSelections)
|
||||||
|
{
|
||||||
|
var scope = entry.OptionDefinition.PercentScope ?? PercentScope.BaseOnly;
|
||||||
|
if (selectedScope is null)
|
||||||
|
{
|
||||||
|
selectedScope = scope;
|
||||||
|
}
|
||||||
|
else if (selectedScope.Value != scope)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Percent options for a product must share the same percent scope.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeBase = scope switch
|
||||||
|
{
|
||||||
|
PercentScope.BaseOnly => basePrice,
|
||||||
|
PercentScope.NumericOnly => numericTotal,
|
||||||
|
PercentScope.BasePlusNumeric => basePrice + numericTotal,
|
||||||
|
_ => basePrice
|
||||||
|
};
|
||||||
|
|
||||||
|
var delta = scopeBase * (entry.PercentValue / 100m);
|
||||||
|
entry.Value = delta;
|
||||||
|
percentTotal += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
var final = Math.Max(
|
||||||
|
0m,
|
||||||
|
Math.Round(basePrice + numericTotal + choiceAbsTotal + percentTotal, 2, MidpointRounding.AwayFromZero));
|
||||||
|
|
||||||
|
var breakdown = breakdownEntries
|
||||||
|
.Select(e => new QuotePrice.QuoteBreakdown(
|
||||||
|
e.Option,
|
||||||
|
e.Chosen,
|
||||||
|
Math.Round(e.Value, 2, MidpointRounding.AwayFromZero)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new QuotePrice.Response(final, breakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a model or variant to price, including the parent model so option metadata is always available.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Product?> FindProductAsync(QuotePrice.Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var products = db.Products
|
||||||
|
.Where(p => p.DeletedOn == null)
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(p => p.Options).ThenInclude(o => o.Values)
|
||||||
|
.Include(p => p.Options).ThenInclude(o => o.Tiers)
|
||||||
|
.Include(p => p.ParentProduct).ThenInclude(pp => pp!.Options).ThenInclude(o => o.Values)
|
||||||
|
.Include(p => p.ParentProduct).ThenInclude(pp => pp!.Options).ThenInclude(o => o.Tiers);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Sku))
|
||||||
|
{
|
||||||
|
var sku = request.Sku!.Trim().ToUpperInvariant();
|
||||||
|
return await products.FirstOrDefaultAsync(p => p.Sku != null && p.Sku.ToUpper() == sku, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await products.FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the price delta for numeric options with clamping, stepping, and tier evaluation.
|
||||||
|
/// </summary>
|
||||||
|
private static (decimal Delta, string ChosenDescription) HandleNumber(OptionDefinition option, QuotePrice.Selection selection)
|
||||||
|
{
|
||||||
|
if (selection.NumericValue is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Selection for option '{option.Name}' must include a numeric value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var requested = selection.NumericValue.Value;
|
||||||
|
|
||||||
|
var clamped = option.Min.HasValue ? Math.Max(requested, option.Min.Value) : requested;
|
||||||
|
clamped = option.Max.HasValue ? Math.Min(clamped, option.Max.Value) : clamped;
|
||||||
|
|
||||||
|
if (option.Step is > 0)
|
||||||
|
{
|
||||||
|
var step = option.Step.Value;
|
||||||
|
clamped = Math.Round(clamped / step, MidpointRounding.AwayFromZero) * step;
|
||||||
|
if (option.Min.HasValue)
|
||||||
|
{
|
||||||
|
clamped = Math.Max(clamped, option.Min.Value);
|
||||||
|
}
|
||||||
|
if (option.Max.HasValue)
|
||||||
|
{
|
||||||
|
clamped = Math.Min(clamped, option.Max.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var applicableTier = option.Tiers
|
||||||
|
.Where(t => t.DeletedOn == null && clamped >= t.FromInclusive && (t.ToInclusive == null || clamped <= t.ToInclusive.Value))
|
||||||
|
.OrderByDescending(t => t.FromInclusive)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var rate = applicableTier?.UnitRate ?? option.PricePerUnit ?? 0m;
|
||||||
|
var flat = applicableTier?.FlatDelta ?? 0m;
|
||||||
|
|
||||||
|
var chosenDescription = option.Unit is { Length: > 0 }
|
||||||
|
? $"{clamped:0.##} {option.Unit}"
|
||||||
|
: clamped.ToString("0.##");
|
||||||
|
|
||||||
|
return ((rate * clamped) + flat, chosenDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BreakdownKind
|
||||||
|
{
|
||||||
|
Fixed,
|
||||||
|
Percent
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SelectionBreakdown
|
||||||
|
{
|
||||||
|
private SelectionBreakdown(
|
||||||
|
string option,
|
||||||
|
string chosen,
|
||||||
|
BreakdownKind kind,
|
||||||
|
OptionDefinition optionDefinition,
|
||||||
|
OptionValue? optionValue,
|
||||||
|
decimal value,
|
||||||
|
decimal percentValue)
|
||||||
|
{
|
||||||
|
Option = option;
|
||||||
|
Chosen = chosen;
|
||||||
|
Kind = kind;
|
||||||
|
OptionDefinition = optionDefinition;
|
||||||
|
OptionValue = optionValue;
|
||||||
|
Value = value;
|
||||||
|
PercentValue = percentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Option { get; }
|
||||||
|
|
||||||
|
public string Chosen { get; }
|
||||||
|
|
||||||
|
public BreakdownKind Kind { get; }
|
||||||
|
|
||||||
|
public OptionDefinition OptionDefinition { get; }
|
||||||
|
|
||||||
|
public OptionValue? OptionValue { get; }
|
||||||
|
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
|
||||||
|
public decimal PercentValue { get; }
|
||||||
|
|
||||||
|
public static SelectionBreakdown Fixed(string option, string chosen, decimal value) =>
|
||||||
|
new(option, chosen, BreakdownKind.Fixed, null!, null, value, 0m);
|
||||||
|
|
||||||
|
public static SelectionBreakdown Percent(OptionDefinition option, OptionValue value, string chosen, decimal percent) =>
|
||||||
|
new(option.Name, chosen, BreakdownKind.Percent, option, value, 0m, percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
451
Prefab.Catalog/Domain/Services/RuleEvaluator.cs
Normal file
451
Prefab.Catalog/Domain/Services/RuleEvaluator.cs
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a caller selection for an option definition.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OptionSelection(Guid OptionDefinitionId, Guid? OptionValueId, decimal? NumericValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result produced after applying rule evaluation to a set of selections.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuleEvaluationResult
|
||||||
|
{
|
||||||
|
public RuleEvaluationResult(IReadOnlyCollection<OptionSelection> selections, IReadOnlyCollection<string> errors)
|
||||||
|
{
|
||||||
|
Selections = selections;
|
||||||
|
Errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<OptionSelection> Selections { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> Errors { get; }
|
||||||
|
|
||||||
|
public bool IsValid => Errors.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies OptionRuleSets to a model to determine visibility, enablement, and required selections.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuleEvaluator
|
||||||
|
{
|
||||||
|
public RuleEvaluationResult Evaluate(Product model, IReadOnlyCollection<OptionSelection> selections)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(model);
|
||||||
|
ArgumentNullException.ThrowIfNull(selections);
|
||||||
|
|
||||||
|
var optionById = model.Options
|
||||||
|
.Where(o => o.DeletedOn is null)
|
||||||
|
.ToDictionary(o => o.Id);
|
||||||
|
|
||||||
|
var valueById = model.Options
|
||||||
|
.Where(o => o.DeletedOn is null)
|
||||||
|
.SelectMany(o => o.Values.Where(v => v.DeletedOn is null))
|
||||||
|
.ToDictionary(v => v.Id);
|
||||||
|
|
||||||
|
var selectionMap = selections.ToDictionary(s => s.OptionDefinitionId);
|
||||||
|
|
||||||
|
var visibleRuleSets = model.RuleSets
|
||||||
|
.Where(r => r.DeletedOn is null)
|
||||||
|
.GroupBy(r => (r.Effect, r.TargetKind, r.TargetId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hiddenDefinitions = new HashSet<Guid>();
|
||||||
|
var hiddenValues = new HashSet<Guid>();
|
||||||
|
var disabledDefinitions = new HashSet<Guid>();
|
||||||
|
var disabledValues = new HashSet<Guid>();
|
||||||
|
var requiredDefinitions = new HashSet<Guid>();
|
||||||
|
var requiredValuesByDefinition = new Dictionary<Guid, HashSet<Guid>>();
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
EvaluateShowRules(visibleRuleSets, selectionMap, optionById, valueById, hiddenDefinitions, hiddenValues);
|
||||||
|
var prunedSelections = PruneHiddenSelections(selections, hiddenDefinitions, hiddenValues);
|
||||||
|
|
||||||
|
EvaluateEnableRules(visibleRuleSets, selectionMap, optionById, valueById, hiddenDefinitions, disabledDefinitions, disabledValues);
|
||||||
|
var enabledSelections = RejectDisabledSelections(prunedSelections, disabledDefinitions, disabledValues, optionById, valueById, errors);
|
||||||
|
|
||||||
|
EvaluateRequireRules(
|
||||||
|
visibleRuleSets,
|
||||||
|
selectionMap,
|
||||||
|
optionById,
|
||||||
|
valueById,
|
||||||
|
hiddenDefinitions,
|
||||||
|
requiredDefinitions,
|
||||||
|
requiredValuesByDefinition);
|
||||||
|
|
||||||
|
EnsureRequirementsMet(enabledSelections, optionById, requiredDefinitions, requiredValuesByDefinition, errors);
|
||||||
|
|
||||||
|
return new RuleEvaluationResult(enabledSelections, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvaluateShowRules(
|
||||||
|
IEnumerable<IGrouping<(RuleEffect Effect, OptionRuleTargetKind TargetKind, Guid TargetId), OptionRuleSet>> groupedRuleSets,
|
||||||
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
||||||
|
ISet<Guid> hiddenDefinitions,
|
||||||
|
ISet<Guid> hiddenValues)
|
||||||
|
{
|
||||||
|
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Show))
|
||||||
|
{
|
||||||
|
var shouldShow = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
|
||||||
|
if (shouldShow)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
|
||||||
|
{
|
||||||
|
hiddenDefinitions.Add(group.Key.TargetId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hiddenValues.Add(group.Key.TargetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyCollection<OptionSelection> PruneHiddenSelections(
|
||||||
|
IReadOnlyCollection<OptionSelection> selections,
|
||||||
|
ISet<Guid> hiddenDefinitions,
|
||||||
|
ISet<Guid> hiddenValues)
|
||||||
|
{
|
||||||
|
var result = new List<OptionSelection>(selections.Count);
|
||||||
|
|
||||||
|
foreach (var selection in selections)
|
||||||
|
{
|
||||||
|
if (hiddenDefinitions.Contains(selection.OptionDefinitionId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.OptionValueId.HasValue && hiddenValues.Contains(selection.OptionValueId.Value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvaluateEnableRules(
|
||||||
|
IEnumerable<IGrouping<(RuleEffect Effect, OptionRuleTargetKind TargetKind, Guid TargetId), OptionRuleSet>> groupedRuleSets,
|
||||||
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
||||||
|
ISet<Guid> hiddenDefinitions,
|
||||||
|
ISet<Guid> disabledDefinitions,
|
||||||
|
ISet<Guid> disabledValues)
|
||||||
|
{
|
||||||
|
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Enable))
|
||||||
|
{
|
||||||
|
var targetId = group.Key.TargetId;
|
||||||
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition && hiddenDefinitions.Contains(targetId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldEnable = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
|
||||||
|
if (shouldEnable)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
|
||||||
|
{
|
||||||
|
disabledDefinitions.Add(group.Key.TargetId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
disabledValues.Add(group.Key.TargetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyCollection<OptionSelection> RejectDisabledSelections(
|
||||||
|
IReadOnlyCollection<OptionSelection> selections,
|
||||||
|
ISet<Guid> disabledDefinitions,
|
||||||
|
ISet<Guid> disabledValues,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
||||||
|
ICollection<string> errors)
|
||||||
|
{
|
||||||
|
var result = new List<OptionSelection>(selections.Count);
|
||||||
|
|
||||||
|
foreach (var selection in selections)
|
||||||
|
{
|
||||||
|
if (disabledDefinitions.Contains(selection.OptionDefinitionId))
|
||||||
|
{
|
||||||
|
var option = optionById.GetValueOrDefault(selection.OptionDefinitionId);
|
||||||
|
var optionName = option?.Name ?? selection.OptionDefinitionId.ToString();
|
||||||
|
errors.Add($"Option '{optionName}' is disabled for the current configuration.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.OptionValueId.HasValue && disabledValues.Contains(selection.OptionValueId.Value))
|
||||||
|
{
|
||||||
|
var value = valueById.GetValueOrDefault(selection.OptionValueId.Value);
|
||||||
|
var option = optionById.GetValueOrDefault(selection.OptionDefinitionId);
|
||||||
|
var optionName = option?.Name ?? selection.OptionDefinitionId.ToString();
|
||||||
|
var valueName = value?.Label ?? selection.OptionValueId.Value.ToString();
|
||||||
|
errors.Add($"Option value '{valueName}' for '{optionName}' is disabled for the current configuration.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvaluateRequireRules(
|
||||||
|
IEnumerable<IGrouping<(RuleEffect Effect, OptionRuleTargetKind TargetKind, Guid TargetId), OptionRuleSet>> groupedRuleSets,
|
||||||
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById,
|
||||||
|
ISet<Guid> hiddenDefinitions,
|
||||||
|
ISet<Guid> requiredDefinitions,
|
||||||
|
IDictionary<Guid, HashSet<Guid>> requiredValuesByDefinition)
|
||||||
|
{
|
||||||
|
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Require))
|
||||||
|
{
|
||||||
|
var targetId = group.Key.TargetId;
|
||||||
|
var isSatisfied = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
|
||||||
|
if (!isSatisfied)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
|
||||||
|
{
|
||||||
|
if (!hiddenDefinitions.Contains(targetId))
|
||||||
|
{
|
||||||
|
requiredDefinitions.Add(targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var optionValue = valueById.GetValueOrDefault(targetId);
|
||||||
|
if (optionValue is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hiddenDefinitions.Contains(optionValue.OptionDefinitionId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiredValuesByDefinition.TryGetValue(optionValue.OptionDefinitionId, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<Guid>();
|
||||||
|
requiredValuesByDefinition[optionValue.OptionDefinitionId] = set;
|
||||||
|
}
|
||||||
|
|
||||||
|
set.Add(targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureRequirementsMet(
|
||||||
|
IReadOnlyCollection<OptionSelection> selections,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
ISet<Guid> requiredDefinitions,
|
||||||
|
IDictionary<Guid, HashSet<Guid>> requiredValuesByDefinition,
|
||||||
|
ICollection<string> errors)
|
||||||
|
{
|
||||||
|
var selectionMap = selections.ToDictionary(s => s.OptionDefinitionId);
|
||||||
|
|
||||||
|
foreach (var requiredDefinitionId in requiredDefinitions)
|
||||||
|
{
|
||||||
|
if (!selectionMap.TryGetValue(requiredDefinitionId, out var selection) || selection.OptionValueId is null && selection.NumericValue is null)
|
||||||
|
{
|
||||||
|
var option = optionById.GetValueOrDefault(requiredDefinitionId);
|
||||||
|
var optionName = option?.Name ?? requiredDefinitionId.ToString();
|
||||||
|
errors.Add($"Option '{optionName}' is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in requiredValuesByDefinition)
|
||||||
|
{
|
||||||
|
if (!selectionMap.TryGetValue(kvp.Key, out var selection) || selection.OptionValueId is null)
|
||||||
|
{
|
||||||
|
var option = optionById.GetValueOrDefault(kvp.Key);
|
||||||
|
var optionName = option?.Name ?? kvp.Key.ToString();
|
||||||
|
errors.Add($"Option '{optionName}' requires a specific value.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kvp.Value.Contains(selection.OptionValueId.Value))
|
||||||
|
{
|
||||||
|
var option = optionById.GetValueOrDefault(kvp.Key);
|
||||||
|
var optionName = option?.Name ?? kvp.Key.ToString();
|
||||||
|
errors.Add($"Option '{optionName}' requires a different value for the current configuration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EvaluateRuleSet(
|
||||||
|
OptionRuleSet ruleSet,
|
||||||
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById)
|
||||||
|
{
|
||||||
|
var conditions = ruleSet.Conditions.Where(c => c.DeletedOn is null).ToList();
|
||||||
|
if (conditions.Count == 0)
|
||||||
|
{
|
||||||
|
return ruleSet.Mode switch
|
||||||
|
{
|
||||||
|
RuleMode.All => true,
|
||||||
|
RuleMode.Any => false,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var evaluations = conditions
|
||||||
|
.Select(condition => EvaluateCondition(condition, selectionMap, optionById, valueById))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return ruleSet.Mode switch
|
||||||
|
{
|
||||||
|
RuleMode.All => evaluations.All(result => result),
|
||||||
|
RuleMode.Any => evaluations.Any(result => result),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EvaluateCondition(
|
||||||
|
OptionRuleCondition condition,
|
||||||
|
IReadOnlyDictionary<Guid, OptionSelection> selectionMap,
|
||||||
|
IReadOnlyDictionary<Guid, OptionDefinition> optionById,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById)
|
||||||
|
{
|
||||||
|
if (!optionById.TryGetValue(condition.LeftOptionDefinitionId, out var leftOption))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionMap.TryGetValue(leftOption.Id, out var selection);
|
||||||
|
|
||||||
|
return leftOption.DataType switch
|
||||||
|
{
|
||||||
|
OptionDataType.Choice => EvaluateChoiceCondition(condition, leftOption, selection, valueById),
|
||||||
|
OptionDataType.Number => EvaluateNumericCondition(condition, selection),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EvaluateChoiceCondition(
|
||||||
|
OptionRuleCondition condition,
|
||||||
|
OptionDefinition option,
|
||||||
|
OptionSelection? selection,
|
||||||
|
IReadOnlyDictionary<Guid, OptionValue> valueById)
|
||||||
|
{
|
||||||
|
var selectedValueId = selection?.OptionValueId;
|
||||||
|
var selectedValue = selectedValueId.HasValue ? valueById.GetValueOrDefault(selectedValueId.Value) : null;
|
||||||
|
|
||||||
|
switch (condition.Operator)
|
||||||
|
{
|
||||||
|
case RuleOperator.Equal:
|
||||||
|
if (condition.RightOptionValueId.HasValue)
|
||||||
|
{
|
||||||
|
return selectedValueId.HasValue && selectedValueId.Value == condition.RightOptionValueId.Value;
|
||||||
|
}
|
||||||
|
return MatchesList(condition.RightList, selectedValue, selectedValueId);
|
||||||
|
|
||||||
|
case RuleOperator.NotEqual:
|
||||||
|
if (condition.RightOptionValueId.HasValue)
|
||||||
|
{
|
||||||
|
return !selectedValueId.HasValue || selectedValueId.Value != condition.RightOptionValueId.Value;
|
||||||
|
}
|
||||||
|
return !MatchesList(condition.RightList, selectedValue, selectedValueId);
|
||||||
|
|
||||||
|
case RuleOperator.InList:
|
||||||
|
return MatchesList(condition.RightList, selectedValue, selectedValueId);
|
||||||
|
|
||||||
|
case RuleOperator.NotInList:
|
||||||
|
return !MatchesList(condition.RightList, selectedValue, selectedValueId);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesList(string? csv, OptionValue? selectedValue, Guid? selectedValueId)
|
||||||
|
{
|
||||||
|
if (selectedValueId is null || string.IsNullOrWhiteSpace(csv))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
if (Guid.TryParse(token, out var guidToken))
|
||||||
|
{
|
||||||
|
if (guidToken == selectedValueId.Value)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValue is not null && string.Equals(selectedValue.Code, token, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EvaluateNumericCondition(OptionRuleCondition condition, OptionSelection? selection)
|
||||||
|
{
|
||||||
|
var selected = selection?.NumericValue;
|
||||||
|
if (selected is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return condition.Operator switch
|
||||||
|
{
|
||||||
|
RuleOperator.Equal => condition.RightNumber.HasValue && selected.Value == condition.RightNumber.Value,
|
||||||
|
RuleOperator.NotEqual => !condition.RightNumber.HasValue || selected.Value != condition.RightNumber.Value,
|
||||||
|
RuleOperator.InList => MatchesNumericList(condition.RightList, selected.Value),
|
||||||
|
RuleOperator.NotInList => !MatchesNumericList(condition.RightList, selected.Value),
|
||||||
|
RuleOperator.GreaterThan => condition.RightNumber.HasValue && selected.Value > condition.RightNumber.Value,
|
||||||
|
RuleOperator.GreaterThanOrEqual => condition.RightNumber.HasValue && selected.Value >= condition.RightNumber.Value,
|
||||||
|
RuleOperator.LessThan => condition.RightNumber.HasValue && selected.Value < condition.RightNumber.Value,
|
||||||
|
RuleOperator.LessThanOrEqual => condition.RightNumber.HasValue && selected.Value <= condition.RightNumber.Value,
|
||||||
|
RuleOperator.Between => condition.RightMin.HasValue && condition.RightMax.HasValue && selected.Value >= condition.RightMin.Value && selected.Value <= condition.RightMax.Value,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesNumericList(string? csv, decimal selected)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(csv))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
if (decimal.TryParse(token, NumberStyles.Number, CultureInfo.InvariantCulture, out var value))
|
||||||
|
{
|
||||||
|
if (value == selected)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
Prefab.Catalog/Domain/Services/VariantResolver.cs
Normal file
137
Prefab.Catalog/Domain/Services/VariantResolver.cs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
using Prefab.Catalog.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog.Domain.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of variant lookup based on the shopper's selections.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VariantResolutionResult
|
||||||
|
{
|
||||||
|
public VariantResolutionResult(Product? variant, IReadOnlyCollection<string> errors)
|
||||||
|
{
|
||||||
|
Variant = variant;
|
||||||
|
Errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Product? Variant { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> Errors { get; }
|
||||||
|
|
||||||
|
public bool IsSuccess => Errors.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a model variant using axis selections or SKU.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VariantResolver
|
||||||
|
{
|
||||||
|
public VariantResolutionResult Resolve(Product model, IReadOnlyCollection<OptionSelection> selections, string? sku = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(model);
|
||||||
|
ArgumentNullException.ThrowIfNull(selections);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sku))
|
||||||
|
{
|
||||||
|
var variant = model.Variants
|
||||||
|
.Where(v => v.DeletedOn is null)
|
||||||
|
.FirstOrDefault(v => string.Equals(v.Sku, sku, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (variant is null)
|
||||||
|
{
|
||||||
|
errors.Add($"Variant with SKU '{sku}' was not found for product '{model.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VariantResolutionResult(variant, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var axisDefinitions = model.Options
|
||||||
|
.Where(o => o.DeletedOn is null && o.IsVariantAxis)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (axisDefinitions.Count == 0)
|
||||||
|
{
|
||||||
|
// No variant axes means the base model should be used.
|
||||||
|
return new VariantResolutionResult(null, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectionMap = selections
|
||||||
|
.Where(s => s.OptionValueId.HasValue)
|
||||||
|
.ToDictionary(s => s.OptionDefinitionId, s => s.OptionValueId!.Value);
|
||||||
|
|
||||||
|
foreach (var axis in axisDefinitions)
|
||||||
|
{
|
||||||
|
if (!selectionMap.ContainsKey(axis.Id))
|
||||||
|
{
|
||||||
|
errors.Add($"Selection for variant axis '{axis.Name}' is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
return new VariantResolutionResult(null, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches = new List<Product>();
|
||||||
|
|
||||||
|
foreach (var variant in model.Variants.Where(v => v.DeletedOn is null))
|
||||||
|
{
|
||||||
|
var axisValues = GetAxisValuesForVariant(model, variant);
|
||||||
|
|
||||||
|
if (axisValues.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMatch = true;
|
||||||
|
foreach (var axis in axisDefinitions)
|
||||||
|
{
|
||||||
|
var expectedValue = selectionMap[axis.Id];
|
||||||
|
if (!axisValues.TryGetValue(axis.Id, out var actualValue) || actualValue != expectedValue)
|
||||||
|
{
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatch)
|
||||||
|
{
|
||||||
|
matches.Add(variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count == 1)
|
||||||
|
{
|
||||||
|
return new VariantResolutionResult(matches[0], errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
errors.Add("No variant matches the provided selections.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errors.Add("Multiple variants match the provided selections.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VariantResolutionResult(null, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDictionary<Guid, Guid> GetAxisValuesForVariant(Product model, Product variant)
|
||||||
|
{
|
||||||
|
var axisValues = variant.AxisValues?
|
||||||
|
.ToDictionary(av => av.OptionDefinitionId, av => av.OptionValueId);
|
||||||
|
|
||||||
|
if (axisValues is not null && axisValues.Count > 0)
|
||||||
|
{
|
||||||
|
return axisValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
axisValues = model.AxisValues?
|
||||||
|
.Where(av => av.ProductVariantId == variant.Id)
|
||||||
|
.ToDictionary(av => av.OptionDefinitionId, av => av.OptionValueId);
|
||||||
|
|
||||||
|
return axisValues ?? new Dictionary<Guid, Guid>();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
Prefab.Catalog/Module.cs
Normal file
82
Prefab.Catalog/Module.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Prefab.Catalog.Data;
|
||||||
|
using Prefab.Catalog.Data.Services;
|
||||||
|
using Prefab.Catalog.Domain.Services;
|
||||||
|
using Prefab.Data.Seeder;
|
||||||
|
using Prefab.Module;
|
||||||
|
using Prefab.Shared;
|
||||||
|
using Prefab.Shared.Catalog;
|
||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog;
|
||||||
|
|
||||||
|
public class Module : IModule
|
||||||
|
{
|
||||||
|
public WebApplicationBuilder Build(WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
builder.Services.AddScoped<IUniqueChecker, UniqueChecker>();
|
||||||
|
builder.Services.AddScoped<IPricingService, PricingService>();
|
||||||
|
builder.Services.AddSingleton<RuleEvaluator>();
|
||||||
|
builder.Services.AddSingleton<VariantResolver>();
|
||||||
|
builder.Services.AddSeeder<Seeder>(RunMode.RunAlways);
|
||||||
|
|
||||||
|
ConfigureModuleClient(builder);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebApplication Configure(WebApplication app) => app;
|
||||||
|
|
||||||
|
private static void ConfigureModuleClient(WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
builder.Services.AddOptions<ModuleClientOptions>().Configure<IConfiguration>((options, configuration) =>
|
||||||
|
{
|
||||||
|
configuration.GetSection("Prefab:Catalog:Client").Bind(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddScoped<App.Categories.CategoryClient>();
|
||||||
|
builder.Services.AddScoped<App.Products.ProductClient>();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<CategoryClientHttp>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<ModuleClientOptions>>().Value;
|
||||||
|
var baseAddress = options.BaseAddress ?? new Uri("http://prefab-catalog");
|
||||||
|
client.BaseAddress = baseAddress;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<ProductClientHttp>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<ModuleClientOptions>>().Value;
|
||||||
|
var baseAddress = options.BaseAddress ?? new Uri("http://prefab-catalog");
|
||||||
|
client.BaseAddress = baseAddress;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IModuleClient>(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<ModuleClientOptions>>().Value;
|
||||||
|
|
||||||
|
if (options.Transport == ModuleClientTransport.Http)
|
||||||
|
{
|
||||||
|
var categoryClient = sp.GetRequiredService<CategoryClientHttp>();
|
||||||
|
var productClient = sp.GetRequiredService<ProductClientHttp>();
|
||||||
|
return new ModuleClientHttp(categoryClient, productClient, productClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
var inProcessCategory = sp.GetRequiredService<App.Categories.CategoryClient>();
|
||||||
|
var inProcessProduct = sp.GetRequiredService<App.Products.ProductClient>();
|
||||||
|
return new ModuleClient(inProcessCategory, inProcessProduct, inProcessProduct);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddScoped<ICategoryClient>(sp => sp.GetRequiredService<IModuleClient>().Category);
|
||||||
|
builder.Services.AddScoped<IProductClient>(sp => sp.GetRequiredService<IModuleClient>().Product);
|
||||||
|
builder.Services.AddScoped<IPriceQuoteClient>(sp => sp.GetRequiredService<IModuleClient>().PriceQuote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
17
Prefab.Catalog/ModuleClient.cs
Normal file
17
Prefab.Catalog/ModuleClient.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Prefab.Shared.Catalog;
|
||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Catalog;
|
||||||
|
|
||||||
|
public sealed class ModuleClient(
|
||||||
|
ICategoryClient categoryClient,
|
||||||
|
IProductClient productClient,
|
||||||
|
IPriceQuoteClient priceQuoteClient) : IModuleClient
|
||||||
|
{
|
||||||
|
public ICategoryClient Category { get; } = categoryClient;
|
||||||
|
|
||||||
|
public IProductClient Product { get; } = productClient;
|
||||||
|
|
||||||
|
public IPriceQuoteClient PriceQuote { get; } = priceQuoteClient;
|
||||||
|
}
|
||||||
23
Prefab.Catalog/Prefab.Catalog.csproj
Normal file
23
Prefab.Catalog/Prefab.Catalog.csproj
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Prefab.Shared\Prefab.Shared.csproj" />
|
||||||
|
<ProjectReference Include="..\Prefab\Prefab.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Data\Lookups\" />
|
||||||
|
<Folder Include="Data\Queries\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
126
Prefab.ServiceDefaults/Extensions.cs
Normal file
126
Prefab.ServiceDefaults/Extensions.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OpenTelemetry;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
|
namespace Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||||
|
// This project should be referenced by each service project in your solution.
|
||||||
|
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
private const string HealthEndpointPath = "/health";
|
||||||
|
private const string AlivenessEndpointPath = "/alive";
|
||||||
|
|
||||||
|
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.ConfigureOpenTelemetry();
|
||||||
|
|
||||||
|
builder.AddDefaultHealthChecks();
|
||||||
|
|
||||||
|
builder.Services.AddServiceDiscovery();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||||
|
{
|
||||||
|
// Turn on resilience by default
|
||||||
|
http.AddStandardResilienceHandler();
|
||||||
|
|
||||||
|
// Turn on service discovery by default
|
||||||
|
http.AddServiceDiscovery();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||||
|
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||||
|
// {
|
||||||
|
// options.AllowedSchemes = ["https"];
|
||||||
|
// });
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Logging.AddOpenTelemetry(logging =>
|
||||||
|
{
|
||||||
|
logging.IncludeFormattedMessage = true;
|
||||||
|
logging.IncludeScopes = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOpenTelemetry()
|
||||||
|
.WithMetrics(metrics =>
|
||||||
|
{
|
||||||
|
metrics.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddRuntimeInstrumentation();
|
||||||
|
})
|
||||||
|
.WithTracing(tracing =>
|
||||||
|
{
|
||||||
|
tracing.AddSource(builder.Environment.ApplicationName)
|
||||||
|
.AddAspNetCoreInstrumentation(tracing =>
|
||||||
|
// Exclude health check requests from tracing
|
||||||
|
tracing.Filter = context =>
|
||||||
|
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||||
|
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||||
|
)
|
||||||
|
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||||
|
//.AddGrpcClientInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.AddOpenTelemetryExporters();
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||||
|
|
||||||
|
if (useOtlpExporter)
|
||||||
|
{
|
||||||
|
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||||
|
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||||
|
//{
|
||||||
|
// builder.Services.AddOpenTelemetry()
|
||||||
|
// .UseAzureMonitor();
|
||||||
|
//}
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
// Add a default liveness check to ensure app is responsive
|
||||||
|
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||||
|
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||||
|
app.MapHealthChecks(HealthEndpointPath);
|
||||||
|
|
||||||
|
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||||
|
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("live")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Prefab.ServiceDefaults/Prefab.ServiceDefaults.csproj
Normal file
22
Prefab.ServiceDefaults/Prefab.ServiceDefaults.csproj
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.5.1" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0-rc.1" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0-rc.1" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
32
Prefab.Shared/Catalog/Categories/CategoryClientHttp.cs
Normal file
32
Prefab.Shared/Catalog/Categories/CategoryClientHttp.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
public sealed class CategoryClientHttp(HttpClient httpClient) : ICategoryClient
|
||||||
|
{
|
||||||
|
public async Task<GetCategories.Response> GetCategories(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.GetAsync("api/catalog/categories", cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
throw new RemoteProblemException(response.StatusCode, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<GetCategories.Response>(cancellationToken: cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Received an empty response when requesting categories.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CreateCategory.Response> CreateCategory(CreateCategory.Request request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.PostAsJsonAsync("api/catalog/categories", request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
throw new RemoteProblemException(response.StatusCode, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<CreateCategory.Response>(cancellationToken: cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Received an empty response when creating a category.");
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Prefab.Shared/Catalog/Categories/CreateCategory.cs
Normal file
11
Prefab.Shared/Catalog/Categories/CreateCategory.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
public class CreateCategory
|
||||||
|
{
|
||||||
|
public record Request(
|
||||||
|
string Name,
|
||||||
|
string Description);
|
||||||
|
|
||||||
|
public record Response(
|
||||||
|
Guid Id);
|
||||||
|
}
|
||||||
8
Prefab.Shared/Catalog/Categories/GetCategories.cs
Normal file
8
Prefab.Shared/Catalog/Categories/GetCategories.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
public abstract class GetCategories
|
||||||
|
{
|
||||||
|
public record Response(IReadOnlyList<ListItem> Categories);
|
||||||
|
|
||||||
|
public record ListItem(Guid Id, string Name, string ParentName);
|
||||||
|
}
|
||||||
7
Prefab.Shared/Catalog/Categories/ICategoryClient.cs
Normal file
7
Prefab.Shared/Catalog/Categories/ICategoryClient.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
public interface ICategoryClient
|
||||||
|
{
|
||||||
|
Task<GetCategories.Response> GetCategories(CancellationToken cancellationToken);
|
||||||
|
Task<CreateCategory.Response> CreateCategory(CreateCategory.Request request, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
12
Prefab.Shared/Catalog/IModuleClient.cs
Normal file
12
Prefab.Shared/Catalog/IModuleClient.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
|
||||||
|
namespace Prefab.Shared.Catalog;
|
||||||
|
|
||||||
|
public interface IModuleClient
|
||||||
|
{
|
||||||
|
public ICategoryClient Category { get; }
|
||||||
|
|
||||||
|
public Products.IProductClient Product { get; }
|
||||||
|
|
||||||
|
public Products.IPriceQuoteClient PriceQuote { get; }
|
||||||
|
}
|
||||||
23
Prefab.Shared/Catalog/ModuleClientHttp.cs
Normal file
23
Prefab.Shared/Catalog/ModuleClientHttp.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Prefab.Shared.Catalog.Categories;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Shared.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides HTTP-based access to module-related operations, exposing product and category management functionality
|
||||||
|
/// through their respective clients.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="categoryClient">The category client used to perform category-related operations. Cannot be null.</param>
|
||||||
|
/// <param name="productClient">The product client used to retrieve product configuration. Cannot be null.</param>
|
||||||
|
/// <param name="priceQuoteClient">The client used to request price quotations. Cannot be null.</param>
|
||||||
|
public sealed class ModuleClientHttp(
|
||||||
|
ICategoryClient categoryClient,
|
||||||
|
IProductClient productClient,
|
||||||
|
IPriceQuoteClient priceQuoteClient) : IModuleClient
|
||||||
|
{
|
||||||
|
public ICategoryClient Category { get; } = categoryClient;
|
||||||
|
|
||||||
|
public IProductClient Product { get; } = productClient;
|
||||||
|
|
||||||
|
public IPriceQuoteClient PriceQuote { get; } = priceQuoteClient;
|
||||||
|
}
|
||||||
8
Prefab.Shared/Catalog/Products/GetCategory.cs
Normal file
8
Prefab.Shared/Catalog/Products/GetCategory.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
public abstract class GetCategory
|
||||||
|
{
|
||||||
|
public sealed record Request(string Slug);
|
||||||
|
|
||||||
|
public sealed record Response(GetCategoryModels.CategoryDetailDto Category);
|
||||||
|
}
|
||||||
50
Prefab.Shared/Catalog/Products/GetCategoryModels.cs
Normal file
50
Prefab.Shared/Catalog/Products/GetCategoryModels.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
namespace Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
public abstract class GetCategoryModels
|
||||||
|
{
|
||||||
|
public sealed record Request(
|
||||||
|
string Slug,
|
||||||
|
int? Page,
|
||||||
|
int? PageSize,
|
||||||
|
string? Sort,
|
||||||
|
string? Direction);
|
||||||
|
|
||||||
|
public sealed record Response(CategoryProductsResponse Result);
|
||||||
|
|
||||||
|
public sealed record CategoryProductsResponse(
|
||||||
|
CategoryDetailDto Category,
|
||||||
|
IReadOnlyList<ProductCardDto> Products,
|
||||||
|
int Total,
|
||||||
|
int Page,
|
||||||
|
int PageSize);
|
||||||
|
|
||||||
|
public sealed record CategoryDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string? Description,
|
||||||
|
string? HeroImageUrl,
|
||||||
|
string? Icon,
|
||||||
|
bool IsLeaf,
|
||||||
|
IReadOnlyList<CategoryNodeDto> Children);
|
||||||
|
|
||||||
|
public sealed record CategoryNodeDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
string? Description,
|
||||||
|
string? HeroImageUrl,
|
||||||
|
string? Icon,
|
||||||
|
int DisplayOrder,
|
||||||
|
bool IsFeatured,
|
||||||
|
bool IsLeaf,
|
||||||
|
IReadOnlyList<CategoryNodeDto> Children);
|
||||||
|
|
||||||
|
public sealed record ProductCardDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
decimal? FromPrice,
|
||||||
|
string? PrimaryImageUrl,
|
||||||
|
DateTimeOffset LastModifiedOn);
|
||||||
|
}
|
||||||
8
Prefab.Shared/Catalog/Products/GetCategoryTree.cs
Normal file
8
Prefab.Shared/Catalog/Products/GetCategoryTree.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
public abstract class GetCategoryTree
|
||||||
|
{
|
||||||
|
public sealed record Request(int? Depth);
|
||||||
|
|
||||||
|
public sealed record Response(IReadOnlyList<GetCategoryModels.CategoryNodeDto> Categories);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user