diff --git a/Development Project/Development Project.sln b/Development Project/Development Project.sln index 637bfd9..353045b 100644 --- a/Development Project/Development Project.sln +++ b/Development Project/Development Project.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31410.357 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11716.220 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Interview.Web", "Interview.Web\Interview.Web.csproj", "{EE17B748-4D84-46AE-9E83-8D04B92DD6A9}" EndProject @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.Core", "Sparcpoi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sparcpoint.SqlServer.Abstractions", "Sparcpoint.SqlServer.Abstractions\Sparcpoint.SqlServer.Abstractions.csproj", "{4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Interview.Web.Tests", "Interview.Web.Tests\Interview.Web.Tests.csproj", "{E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,14 @@ Global {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|Any CPU.Build.0 = Release|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.ActiveCfg = Release|Any CPU {4E72CD4A-C9FE-4A04-9F07-A770E3AD7297}.Release|x86.Build.0 = Release|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Debug|x86.Build.0 = Debug|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Release|Any CPU.Build.0 = Release|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Release|x86.ActiveCfg = Release|Any CPU + {E94AE77D-BCB5-2BB9-ECF7-03B0DFD866EE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Development Project/Interview.Web.Tests/Controllers/InventoryControllerTests.cs b/Development Project/Interview.Web.Tests/Controllers/InventoryControllerTests.cs new file mode 100644 index 0000000..605fadd --- /dev/null +++ b/Development Project/Interview.Web.Tests/Controllers/InventoryControllerTests.cs @@ -0,0 +1,125 @@ +using Interview.Web.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Transactions; + +namespace Interview.Web.Tests.Controllers +{ + //EVAL: added tests for InventoryController covering all APIs and possible scenarios including exception handling and not found scenarios + [TestClass] + public class InventoryControllerTests + { + private Mock _mockInventoryService; + private Mock> _mockLogger; + private InventoryController _controller; + + [TestInitialize] + public void Setup() + { + _mockInventoryService = new Mock(); + _mockLogger = new Mock>(); + _controller = new InventoryController(_mockInventoryService.Object, _mockLogger.Object); + } + + + [TestMethod] + public async Task AddProduct_ReturnsOk_WhenProductIsAdded() + { + // Arrange + var request = new AddToInventoryRequestDto { ProductInstanceId = 1 }; + _mockInventoryService.Setup(s => s.AddProductToInventoryAsync(request)).ReturnsAsync(1); + + // Act + var result = await _controller.AddProduct(request); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + _mockInventoryService.Verify(s => s.AddProductToInventoryAsync(request), Times.Once); + } + + [TestMethod] + public async Task DeleteTransaction_ReturnsNoContent_WhenTransactionIsDeleted() + { + // Arrange + var transactionId = 1; + _mockInventoryService.Setup(s => s.RemoveInventoryTransactionAsync(transactionId)).ReturnsAsync(1); + + // Act + var result = await _controller.RemoveTransaction(transactionId); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + _mockInventoryService.Verify(s => s.RemoveInventoryTransactionAsync(transactionId), Times.Once); + } + + [TestMethod] + public async Task DeleteTransaction_ReturnsNotFound_WhenTransactionDoesNotExist() + { + // Arrange + var transactionId = 999; + _mockInventoryService.Setup(s => s.RemoveInventoryTransactionAsync(transactionId)).ReturnsAsync(0); + + // Act + var result = await _controller.RemoveTransaction(transactionId); + + // Assert + Assert.IsInstanceOfType(result, typeof(NotFoundObjectResult)); + _mockInventoryService.Verify(s => s.RemoveInventoryTransactionAsync(transactionId), Times.Once); + } + + [TestMethod] + public async Task DeleteProduct_ReturnsNoContent_WhenProductIsDeleted() + { + // Arrange + var productId = 1; + _mockInventoryService.Setup(s => s.RemoveProductFromInventoryAsync(productId)).ReturnsAsync(1); + + // Act + var result = await _controller.RemoveProduct(productId); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + _mockInventoryService.Verify(s => s.RemoveProductFromInventoryAsync(productId), Times.Once); + } + + [TestMethod] + public async Task DeleteProduct_ReturnsNotFound_WhenProductDoesNotExist() + { + // Arrange + var productId = 999; + _mockInventoryService.Setup(s => s.RemoveProductFromInventoryAsync(productId)).ReturnsAsync(0); + + // Act + var result = await _controller.RemoveProduct(productId); + + // Assert + Assert.IsInstanceOfType(result, typeof(NotFoundObjectResult)); + _mockInventoryService.Verify(s => s.RemoveProductFromInventoryAsync(productId), Times.Once); + } + + [TestMethod] + public async Task GetInventoryCount_ReturnsCount() + { + + // Arrange + int productId = 1; + _mockInventoryService.Setup(s => s.GetProuctInventoryCountAsync(productId)).ReturnsAsync(100); + + // Act + var result = await _controller.GetInventoryCount(productId); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + _mockInventoryService.Verify(s => s.GetProuctInventoryCountAsync(productId), Times.Once); + + } + } +} \ No newline at end of file diff --git a/Development Project/Interview.Web.Tests/Controllers/ProductControllerTests.cs b/Development Project/Interview.Web.Tests/Controllers/ProductControllerTests.cs new file mode 100644 index 0000000..7707555 --- /dev/null +++ b/Development Project/Interview.Web.Tests/Controllers/ProductControllerTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Abstract.Services; +using Interview.Web.Controllers; +using Sparcpoint.Models.Entity; +using System.Collections.Generic; + +namespace Interview.Web.Tests.Controllers +{ + //EVAL: added tests for ProductController covering all APIs and possible scenarios including exception handling and not found scenarios + [TestClass] + public class ProductControllerTests + { + private Mock _mockProductService; + private Mock> _mockLogger; + private ProductController _controller; + + [TestInitialize] + public void Setup() + { + _mockProductService = new Mock(); + _mockLogger = new Mock>(); + _controller = new ProductController(_mockProductService.Object, _mockLogger.Object); + } + + [TestMethod] + public async Task CreateProduct_ReturnsInternalServerError_WhenNameIsEmpty() + { + // Arrange + var request = new CreateProductRequestDto { Name = "" }; + _mockProductService.Setup(s => s.AddProductAsync(request)) + .ThrowsAsync(new ArgumentException("Product name cannot be empty.")); + + // Act + var result = await _controller.CreateProduct(request); + + // Assert + Assert.IsInstanceOfType(result, typeof(ObjectResult)); + var objectResult = result as ObjectResult; + Assert.AreEqual(500, objectResult.StatusCode); + Assert.AreEqual("An error occurred while creating the product.", objectResult.Value); + _mockProductService.Verify(s => s.AddProductAsync(request), Times.Once); + } + + [TestMethod] + public async Task CreateProduct_ReturnsCreated_WhenProductIsCreated() + { + // Arrange + var request = new CreateProductRequestDto { Name = "Valid Product" }; + _mockProductService.Setup(s => s.AddProductAsync(request)).ReturnsAsync(1); + + // Act + var result = await _controller.CreateProduct(request); + + // Assert + Assert.IsInstanceOfType(result, typeof(ObjectResult)); + var objectResult = result as ObjectResult; + Assert.AreEqual(201, objectResult.StatusCode); + _mockProductService.Verify(s => s.AddProductAsync(request), Times.Once); + } + + [TestMethod] + public async Task GetProductById_ReturnsProduct_WhenIdIsValid() + { + // Arrange + var productId = 1; + var product = new Product { InstanceId = productId, Name = "Test Product" }; + _mockProductService.Setup(s => s.GetProductByIdAsync(productId)).ReturnsAsync(product); + + // Act + var result = await _controller.GetProduct(productId); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = result as OkObjectResult; + Assert.AreEqual(product, okResult.Value); + _mockProductService.Verify(s => s.GetProductByIdAsync(productId), Times.Once); + } + + [TestMethod] + public async Task GetProductById_ReturnsNotFound_WhenIdIsInvalid() + { + // Arrange + var productId = 999; + _mockProductService.Setup(s => s.GetProductByIdAsync(productId)) + .ThrowsAsync(new KeyNotFoundException("Product not found.")); + + // Act + var result = await _controller.GetProduct(productId); + + // Assert + Assert.IsInstanceOfType(result, typeof(ObjectResult)); + var objectResult = result as ObjectResult; + Assert.AreEqual(500, objectResult.StatusCode); + Assert.AreEqual("An error occurred while searching for products.", objectResult.Value); + _mockProductService.Verify(s => s.GetProductByIdAsync(productId), Times.Once); + + } + + [TestMethod] + public async Task SearchProducts_ReturnsProducts_WhenCriteriaIsValid() + { + // Arrange + var searchCriteria = new SearchProductRequestDto { Name = "Test" }; + var products = new List { new Product { InstanceId = 1, Name = "Test Product" } }; + _mockProductService.Setup(s => s.SearchProductAsync(searchCriteria)).ReturnsAsync(products); + + // Act + var result = await _controller.SearchProducts(searchCriteria); + + // Assert + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = result as OkObjectResult; + Assert.AreEqual(products, okResult.Value); + _mockProductService.Verify(s=> s.SearchProductAsync(searchCriteria), Times.Once); + } + } +} \ No newline at end of file diff --git a/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj b/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj new file mode 100644 index 0000000..c861761 --- /dev/null +++ b/Development Project/Interview.Web.Tests/Interview.Web.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Development Project/Interview.Web/Controllers/InventoryController.cs b/Development Project/Interview.Web/Controllers/InventoryController.cs new file mode 100644 index 0000000..e760991 --- /dev/null +++ b/Development Project/Interview.Web/Controllers/InventoryController.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Models.DTOs; +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Transactions; + +namespace Interview.Web.Controllers +{ + [Route("api/v1/inventory")] + public class InventoryController : Controller + { + private readonly IInventoryService _inventoryService; + private readonly ILogger _logger; + public InventoryController(IInventoryService inventoryService, ILogger logger) + { + _inventoryService = inventoryService; + _logger = logger; + } + + //EVAL: - 1. API to add products to the inventory + [HttpPost] + public async Task AddProduct([FromBody] AddToInventoryRequestDto request) + { + try + { + var inventoryID = await _inventoryService.AddProductToInventoryAsync(request); + return Ok(new { inventoryID }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding to inventory"); + return StatusCode(500, "An error occurred while adding to inventory."); + } + + } + + //EVAL: - 2. API to remove products from inventory + [HttpDelete("product/{productId}")] + public async Task RemoveProduct([FromRoute] int productId) + { + try + { + var rowsDeleted = await _inventoryService.RemoveProductFromInventoryAsync(productId); + if(rowsDeleted == 0) + { + return NotFound(new { Message = $"No inventory record found for Product Id {productId}." }); + } + return Ok(new { Message = "Product deleted successfully from inventory" }); + + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing product from inventory for product Id : {ProductId}", productId); + return StatusCode(500, "An error occurred while removing product from inventory."); + } + + } + //EVAL: 3- API to remove products from inventory + [HttpDelete("transaction/{transactionId}")] + public async Task RemoveTransaction([FromRoute] int transactionId) + { + try + { + int rowsDeleted = await _inventoryService.RemoveInventoryTransactionAsync(transactionId); + if (rowsDeleted == 0) + { + return NotFound(new { Message = $"No inventory transaction found with ID {transactionId}." }); + } + return Ok(new { Message = "Transaction deleted successfully from inventory" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing inventory transaction with ID: {TransactionID}", transactionId); + return StatusCode(500, "An error occurred while removing inventory transaction."); + } + + } + + //EVAL: 4- API to get inventory count for a product + [HttpGet("product/{productId}/count")] + public async Task GetInventoryCount([FromRoute] int productId) + { + try + { + decimal inventoryCount = await _inventoryService.GetProuctInventoryCountAsync(productId); + return Ok(new { inventoryCount }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting inventory transaction count for Product ID: {ProductId}", productId); + return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while getting inventory transaction count"); + } + + } + } +} diff --git a/Development Project/Interview.Web/Controllers/ProductController.cs b/Development Project/Interview.Web/Controllers/ProductController.cs index 267f4ec..0016f2a 100644 --- a/Development Project/Interview.Web/Controllers/ProductController.cs +++ b/Development Project/Interview.Web/Controllers/ProductController.cs @@ -1,4 +1,9 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; using System; using System.Collections.Generic; using System.Linq; @@ -9,11 +14,62 @@ namespace Interview.Web.Controllers [Route("api/v1/products")] public class ProductController : Controller { - // NOTE: Sample Action - [HttpGet] - public Task GetAllProducts() + private readonly IProductService _productService; + private readonly ILogger _logger; + + public ProductController(IProductService productService, ILogger logger) + { + _productService = productService; + _logger = logger; + } + + //EVAL - 1. API to add products to the system with necessary details and predefined metadata + [HttpPost] + + public async Task CreateProduct([FromBody] CreateProductRequestDto request) + { + try + { + int productId = await _productService.AddProductAsync(request); + return StatusCode(StatusCodes.Status201Created, new { productId }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating product"); + return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the product."); + } + } + + //EVAL: 2. Get product details by product id + [HttpGet("{productId}")] + public async Task GetProduct([FromRoute] int productId) + { + try + { + var product = await _productService.GetProductByIdAsync(productId); + return Ok(product); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching products"); + return StatusCode(500, "An error occurred while searching for products."); + } + } + + //EVAL - 3. API to search product details by category, metadata or genral details + [HttpPost("Search")] + public async Task SearchProducts([FromBody] SearchProductRequestDto request) { - return Task.FromResult((IActionResult)Ok(new object[] { })); + try + { + var products = await _productService.SearchProductAsync(request); + return Ok(products); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching products"); + return StatusCode(500, "An error occurred while searching for products."); + } } } } diff --git a/Development Project/Interview.Web/Interview.Web.csproj b/Development Project/Interview.Web/Interview.Web.csproj index d1e4d6e..91b866d 100644 --- a/Development Project/Interview.Web/Interview.Web.csproj +++ b/Development Project/Interview.Web/Interview.Web.csproj @@ -4,4 +4,16 @@ net8.0 + + + + + + + + + + + + diff --git a/Development Project/Interview.Web/Properties/launchSettings.json b/Development Project/Interview.Web/Properties/launchSettings.json index 4dcb6fa..d442827 100644 --- a/Development Project/Interview.Web/Properties/launchSettings.json +++ b/Development Project/Interview.Web/Properties/launchSettings.json @@ -19,6 +19,7 @@ "Interview.Web": { "commandName": "Project", "launchBrowser": true, + "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/Development Project/Interview.Web/Startup.cs b/Development Project/Interview.Web/Startup.cs index 56452f2..dfd9559 100644 --- a/Development Project/Interview.Web/Startup.cs +++ b/Development Project/Interview.Web/Startup.cs @@ -4,6 +4,12 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi; +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Implementations.Repositories; +using Sparcpoint.Implementations.Services; +using Sparcpoint.SqlServer.Abstractions; using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +29,38 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + services.AddControllers(); + services.AddScoped(provider => + { + var configuration = provider.GetRequiredService(); + var connectionString = configuration.GetConnectionString("DefaultConnection"); + + return new SqlServerExecutor(connectionString); + }); + + //EVAL: usage of dependency injection to manage service and repository lifetimes, ensuring that each HTTP request gets its own instance of the services and repositories, which promotes better resource management and testability. + // Services + services.AddScoped(); + services.AddScoped(); + + + //Repositories + services.AddScoped(); + services.AddScoped(); + + + //EVAL: Swagger configuration for API documentation and testing, providing a user-friendly interface for developers to interact with the API endpoints and understand the available operations. + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Sparcpoint Inventory API", + Version = "v1", + Description = "Inventory management system for Sparcpoint's clients." + }); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -32,6 +69,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory Management v1")); } else { diff --git a/Development Project/Interview.Web/appsettings.Development.json b/Development Project/Interview.Web/appsettings.Development.json index 8983e0f..2bee80f 100644 --- a/Development Project/Interview.Web/appsettings.Development.json +++ b/Development Project/Interview.Web/appsettings.Development.json @@ -5,5 +5,8 @@ "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=Sparcpoint.Inventory.Database;Trusted_Connection=True;" } } diff --git a/Development Project/Sparcpoint.Core/Abstract/Repositories/IInventoryRepository.cs b/Development Project/Sparcpoint.Core/Abstract/Repositories/IInventoryRepository.cs new file mode 100644 index 0000000..7f45c3f --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/Repositories/IInventoryRepository.cs @@ -0,0 +1,21 @@ +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Abstract.Repositories +{ + //EVAL: Use of interfaces - Repository + public interface IInventoryRepository + { + Task AddProductToInventoryAsync(AddToInventoryRequestDto request); + + Task RemoveInventoryTransactionAsync(int transactionId); + + Task RemoveProductFromInventoryAsync(int productId); + + Task GetProuctInventoryCountAsync(int productId); + } +} diff --git a/Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs b/Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs new file mode 100644 index 0000000..ed16717 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/Repositories/IProductRepository.cs @@ -0,0 +1,18 @@ +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Abstract.Repositories +{ + public interface IProductRepository + { + Task AddProductAsync(CreateProductRequestDto request); + + Task> SearchProductAsync (SearchProductRequestDto request); + + Task GetProductByIdAsync(int productId); + } +} diff --git a/Development Project/Sparcpoint.Core/Abstract/Services/IInventoryService.cs b/Development Project/Sparcpoint.Core/Abstract/Services/IInventoryService.cs new file mode 100644 index 0000000..bd112b0 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/Services/IInventoryService.cs @@ -0,0 +1,21 @@ +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Abstract.Services +{ + public interface IInventoryService + { + Task AddProductToInventoryAsync(AddToInventoryRequestDto request); + + Task RemoveInventoryTransactionAsync(int transactionId); + + Task RemoveProductFromInventoryAsync(int productId); + + Task GetProuctInventoryCountAsync(int productId); + + } +} diff --git a/Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs b/Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs new file mode 100644 index 0000000..4a134c0 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Abstract/Services/IProductService.cs @@ -0,0 +1,18 @@ +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Abstract.Services +{ + public interface IProductService + { + Task AddProductAsync(CreateProductRequestDto request); + + Task> SearchProductAsync(SearchProductRequestDto request); + + Task GetProductByIdAsync(int productId); + } +} diff --git a/Development Project/Sparcpoint.Core/Constants/ProductAttributeKeys.cs b/Development Project/Sparcpoint.Core/Constants/ProductAttributeKeys.cs new file mode 100644 index 0000000..b75c18b --- /dev/null +++ b/Development Project/Sparcpoint.Core/Constants/ProductAttributeKeys.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Constants +{ + //EVAL: constans to hold predefined set of product attribute keys as per recommendation, can be extended to support dynamic attributes in the future + public sealed class ProductAttributeKeys + { + public const string AdditionalSku = "AdditionalSKU"; + public const string Color = "Color"; + public const string Length = "Length"; + public const string PackageUnit = "PackageUnit"; + } +} diff --git a/Development Project/Sparcpoint.Core/Implementations/Repositories/InventoryRepository.cs b/Development Project/Sparcpoint.Core/Implementations/Repositories/InventoryRepository.cs new file mode 100644 index 0000000..b6bd97a --- /dev/null +++ b/Development Project/Sparcpoint.Core/Implementations/Repositories/InventoryRepository.cs @@ -0,0 +1,81 @@ +using Dapper; +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.Transactions; + +namespace Sparcpoint.Implementations.Repositories +{ + public class InventoryRepository : IInventoryRepository + { + private readonly ISqlExecutor _executor; + + public InventoryRepository(ISqlExecutor executor) + { + _executor = executor; + } + + public async Task AddProductToInventoryAsync(AddToInventoryRequestDto request) + { + const string addprodsql = @" + INSERT INTO [Transactions].[InventoryTransactions] (ProductInstanceId, Quantity, StartedTimestamp, CompletedTimestamp,TypeCategory) + VALUES (@ProductInstanceID, @Quantity, @StartedTimestamp, @CompletedTimestamp,@TypeCategory); + SELECT CAST(SCOPE_IDENTITY() AS INT);"; + return await _executor.ExecuteAsync(async (connection, transaction) => + { + var parameters = new + { + request.ProductInstanceId, + request.Quantity, + request.StartedTimestamp, + request.CompletedTimestamp, + request.TypeCategory + }; + return await connection.ExecuteScalarAsync(addprodsql, parameters, transaction); + }); + } + + //EVAL: Delete transation record for a given transaction id, this will be used to remove the inventory record for a specific transaction, this is a soft delete and can be extended in the future to support hard delete or archiving of old transactions based on requirement + public async Task RemoveInventoryTransactionAsync(int transactionId) + { + string sql = "DELETE FROM [Transactions].[InventoryTransactions] WHERE TransactionID = @TransactionID"; + return await _executor.ExecuteAsync(async (connection, transaction) => + { + return await connection.ExecuteAsync(sql, new { TransactionID = transactionId }, transaction); + }); + } + + //EVAL: Delete all inventory records for a given product id, this will be used to remove the inventory record for a specific product, this is a soft delete and can be extended in the future to support hard delete or archiving of old transactions based on requirement + public async Task RemoveProductFromInventoryAsync(int productId) + { + string sql = "DELETE FROM [Transactions].[InventoryTransactions] WHERE ProductInstanceId = @ProductInstanceId"; + + return await _executor.ExecuteAsync(async (connection, transaction) => + { + return await connection.ExecuteAsync(sql, new { ProductInstanceId = productId }, transaction); + }); + } + + public async Task GetProuctInventoryCountAsync(int productId) + { + const string sql = @" + SELECT COALESCE(SUM(Quantity), 0.0) + FROM [Transactions].[InventoryTransactions] + WHERE ProductInstanceId = @ProductId;"; + return await _executor.ExecuteAsync(async (connection, transaction) => + { + var parameters = new + { + ProductId = productId, + CurrentTime = DateTime.UtcNow + }; + return await connection.ExecuteScalarAsync(sql, parameters, transaction); + }); + } + } +} diff --git a/Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs b/Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs new file mode 100644 index 0000000..a930309 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Implementations/Repositories/ProductRepository.cs @@ -0,0 +1,195 @@ +using Dapper; +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Constants; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using Sparcpoint.SqlServer.Abstractions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + + +namespace Sparcpoint.Implementations.Repositories +{ + public class ProductRepository : IProductRepository + { + private readonly ISqlExecutor _executor; + + public ProductRepository(ISqlExecutor executor) + { + _executor = executor; + } + public async Task AddProductAsync(CreateProductRequestDto request) + { + return await _executor.ExecuteAsync(async(connection, transaction) => + { + string addprodsql = @" + INSERT INTO [Instances].[Products] (Name, Description, ProductImageUris, ValidSkus) + VALUES (@Name, @Description, @ImageUris, @Skus); + SELECT SCOPE_IDENTITY();"; + + var prodID = Convert.ToInt32(await connection.ExecuteScalarAsync(addprodsql, new + { + request.Name, + request.Description, + request.ImageUris, + request.Skus + }, transaction)); + + //EVAL: using predefined set of additional attributes for products as per recommendation, can be extended to support dynamic attributes in the future + //Using + var metadata = new List(); + + if (!string.IsNullOrEmpty(request.AdditionalSku)) + { + metadata.Add(new ProductAttribute + { + Key = ProductAttributeKeys.AdditionalSku, + Value = request.AdditionalSku + }); + } + if (!string.IsNullOrEmpty(request.Color)) + { + metadata.Add(new ProductAttribute + { + Key = ProductAttributeKeys.Color, + Value = request.Color + }); + } + if (request.Length.HasValue) + { + metadata.Add(new ProductAttribute + { + Key = ProductAttributeKeys.Length, + Value = request.Length.Value.ToString() + }); + } + if (!string.IsNullOrEmpty(request.PackageUnit)) + { + metadata.Add(new ProductAttribute + { + Key = ProductAttributeKeys.PackageUnit, + Value = request.PackageUnit + }); + } + + if (metadata.Any()) + { + string addmetasql = @" + INSERT INTO [Instances].[ProductAttributes] (InstanceId, [Key], [Value]) + VALUES (@InstanceId, @Key, @Value);"; + foreach (var attr in metadata) + { + await connection.ExecuteAsync(addmetasql, new + { + InstanceId = prodID, + attr.Key, + attr.Value + }, transaction); + } + } + if (request.CategoryIds != null && request.CategoryIds.Any()) + { + string catSql = "INSERT INTO [Instances].[ProductCategories] (InstanceId, CategoryInstanceId) VALUES (@InstanceId, @CategoryId)"; + foreach (var catId in request.CategoryIds) + { + await connection.ExecuteAsync(catSql, new { InstanceId = prodID, CategoryId = catId }, transaction); + } + } + + return prodID; + }); + + } + + //EVAL: Getting product by product ID + public async Task GetProductByIdAsync(int productId) + { + return await _executor.ExecuteAsync(async (connection, transaction) => + { + var sql = @" + SELECT p.* + FROM[Instances].[Products] p + WHERE p.InstanceId = @Id"; + var product = await connection.QueryFirstOrDefaultAsync(sql, new { Id = productId }, transaction); + if (product != null) + { + //get attributes + var attrSql = @" + SELECT [Key], [Value] + FROM [Instances].[ProductAttributes] + WHERE InstanceId = @ProductId"; + var attributes = await connection.QueryAsync(attrSql, new { ProductId = productId }, transaction); + product.Metadata = attributes.ToList(); + } + return product; + }); + } + + //EVAL: Search product based on genral details, meta data and all combination. if no search parameter supplied all products will be returned + public async Task> SearchProductAsync(SearchProductRequestDto request) + { + return await _executor.ExecuteAsync>(async (connection, transaction) => + { + var sql = @" + SELECT p.* + FROM[Instances].[Products] p"; + //if search criteria icludes category + if(request.CategoryIds != null && request.CategoryIds.Any()) + { + sql += @" INNER JOIN [Instances].[ProductCategories] pc ON p.InstanceId = pc.InstanceId"; + } + //if search criteria includes attributes + if (request.MetaData != null && request.MetaData.Any()) + { + sql += @" INNER JOIN [Instances].[ProductAttributes] pc ON p.InstanceID = pc.InstanceId"; + } + sql += " WHERE 1=1"; + var parameters = new DynamicParameters(); + if (!string.IsNullOrEmpty(request.Name)) + { + sql += " AND p.Name LIKE @Name"; + parameters.Add("Name", $"%{request.Name}%"); + } + if (request.CategoryIds?.Any() == true) + { + sql += " AND pc.CategoryInstanceId IN @CategoryIds"; + parameters.Add("CategoryIds", request.CategoryIds); + } + if (!string.IsNullOrEmpty(request.ValidSku)) + { + sql += " AND p.ValidSkus LIKE @ValidSKU"; + parameters.Add("ValidSKU", $"%{request.ValidSku}%"); + } + if (!string.IsNullOrEmpty(request.ProductImageUri)) + { + sql += " AND p.ProductImageUris LIKE @ProductImageUris"; + parameters.Add("ProductImageUris", $"%{request.ProductImageUri}%"); + } + if (request.MetaData != null && request.MetaData.Count > 0) + { + int i = 0; + foreach (var attr in request.MetaData) + { + sql += $@" + AND EXISTS ( + SELECT 1 + FROM [Instances].[ProductAttributes] pa + WHERE pa.InstanceId = p.InstanceId + AND pa.[Key] = @AttrKey{i} + AND pa.Value = @AttrVal{i} + )"; + parameters.Add($"AttrKey{i}", attr.Key); + parameters.Add($"AttrVal{i}", attr.Value); + i++; + } + } + return await connection.QueryAsync(sql, parameters, transaction); + + }); + } + } +} diff --git a/Development Project/Sparcpoint.Core/Implementations/Services/InventoryService.cs b/Development Project/Sparcpoint.Core/Implementations/Services/InventoryService.cs new file mode 100644 index 0000000..fcfaf26 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Implementations/Services/InventoryService.cs @@ -0,0 +1,46 @@ +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Implementations.Services +{ + public class InventoryService : IInventoryService + { + private readonly IInventoryRepository _inventoryRepository; + + public InventoryService(IInventoryRepository inventoryRepository) + { + _inventoryRepository = inventoryRepository; + + } + public async Task AddProductToInventoryAsync(AddToInventoryRequestDto request) + { + var transID = await _inventoryRepository.AddProductToInventoryAsync(request); + if (transID <= 0) + { + throw new InvalidOperationException("Unable to Add Product inventory - No Trans id returned"); + } + return transID; + } + + public async Task RemoveInventoryTransactionAsync(int transactionId) + { + return await _inventoryRepository.RemoveInventoryTransactionAsync(transactionId); + } + + public async Task RemoveProductFromInventoryAsync(int productId) + { + return await _inventoryRepository.RemoveProductFromInventoryAsync(productId); + } + + public async Task GetProuctInventoryCountAsync(int productId) + { + return await _inventoryRepository.GetProuctInventoryCountAsync(productId); + } + } +} diff --git a/Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs b/Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs new file mode 100644 index 0000000..07ee2f6 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Implementations/Services/ProductService.cs @@ -0,0 +1,54 @@ +using Sparcpoint.Abstract.Repositories; +using Sparcpoint.Abstract.Services; +using Sparcpoint.Models.DTOs; +using Sparcpoint.Models.Entity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Sparcpoint.Implementations.Services +{ + public class ProductService : IProductService + { + private readonly IProductRepository _productRepository; + + public ProductService(IProductRepository productRepository) + { + _productRepository = productRepository; + + } + + public async Task AddProductAsync(CreateProductRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new ArgumentException("Product name cannot be empty.", nameof(request.Name)); + } + var prodID = await _productRepository.AddProductAsync(request); + if (prodID <= 0) + { + throw new InvalidOperationException("Unable to create Product - No Prod id returned"); + } + return prodID; + } + + public async Task> SearchProductAsync(SearchProductRequestDto request) + { + var products = await _productRepository.SearchProductAsync(request); + + return products; + } + + public async Task GetProductByIdAsync(int productId) + { + var product = await _productRepository.GetProductByIdAsync(productId); + if (product == null) + { + throw new KeyNotFoundException($"No product found with id {productId}"); + } + return product; + } + } +} diff --git a/Development Project/Sparcpoint.Core/Models/DTOs/AddToInventoryRequestDto.cs b/Development Project/Sparcpoint.Core/Models/DTOs/AddToInventoryRequestDto.cs new file mode 100644 index 0000000..8b7c5a6 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/DTOs/AddToInventoryRequestDto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Models.DTOs +{ + //EVAL: Following standards for organising Model, DTO in seperate folder + public class AddToInventoryRequestDto + { + public int ProductInstanceId { get; set; } + public decimal Quantity { get; set; } + public DateTime StartedTimestamp { get; set; } + public DateTime? CompletedTimestamp { get; set; } + public string TypeCategory { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Models/DTOs/CreateProductRequestDto.cs b/Development Project/Sparcpoint.Core/Models/DTOs/CreateProductRequestDto.cs new file mode 100644 index 0000000..446ba19 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/DTOs/CreateProductRequestDto.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Models.DTOs +{ + public class CreateProductRequestDto + { + public string Name { get; set; } + + public string Description { get; set; } + + public string ImageUris { get; set; } + + public string Skus { get; set; } + + public List CategoryIds { get; set; } + + //EVAL: Consistent pre-defined extra attributes for each product instead of adding metada dynamically each time + public string AdditionalSku { get; set; } + public string Color { get; set; } + + public decimal? Length { get; set; } + + public string PackageUnit { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Models/DTOs/SearchProductRequestDto.cs b/Development Project/Sparcpoint.Core/Models/DTOs/SearchProductRequestDto.cs new file mode 100644 index 0000000..12cd7d5 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/DTOs/SearchProductRequestDto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Models.DTOs +{ + public class SearchProductRequestDto + { + public string Name { get; set; } + public string Category { get; set; } + public string ValidSku { get; set; } + public string ProductImageUri { get; set; } + public List CategoryIds { get; set; } = new List(); + public Dictionary MetaData { get; set; } = new Dictionary(); + } +} diff --git a/Development Project/Sparcpoint.Core/Models/Entity/InventoryTransaction.cs b/Development Project/Sparcpoint.Core/Models/Entity/InventoryTransaction.cs new file mode 100644 index 0000000..7e55b57 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/Entity/InventoryTransaction.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Models.Entity +{ + public class InventoryTransaction + { + public int TransactionID { get; set; } + public int ProductInstanceID { get; set; } + public decimal Quantity { get; set; } + public DateTime StartedTimestamp { get; set; } + public DateTime? CompletedTimestamp { get; set; } + public string TypeCategory { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Models/Entity/Product.cs b/Development Project/Sparcpoint.Core/Models/Entity/Product.cs new file mode 100644 index 0000000..626b047 --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/Entity/Product.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Models.Entity +{ + public class Product + { + public int InstanceId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string ProductImageUris { get; set; } + public string ValidSkus { get; set; } + + public DateTime CreatedTimestamp { get; set; } + + public IList Metadata { get; set; } = new List(); + + public IList CategoryIds { get; set; } = new List(); + } +} diff --git a/Development Project/Sparcpoint.Core/Models/Entity/ProductAttribute.cs b/Development Project/Sparcpoint.Core/Models/Entity/ProductAttribute.cs new file mode 100644 index 0000000..2bd820f --- /dev/null +++ b/Development Project/Sparcpoint.Core/Models/Entity/ProductAttribute.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Sparcpoint.Models.Entity +{ + public class ProductAttribute + { + public string Key { get; set; } + public string Value { get; set; } + } +} diff --git a/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj b/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj index fdceab5..df14724 100644 --- a/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj +++ b/Development Project/Sparcpoint.Core/Sparcpoint.Core.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -18,4 +18,8 @@ + + + +