diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0edcb7a..fb60f14 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -31,7 +31,11 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Build and push CleanAspire.Standalone image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/cleanaspire-standalone:${{ steps.version.outputs.version }} -f src/CleanAspire.ClientApp/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/cleanaspire-standalone:${{ steps.version.outputs.version }} + - name: Build and push CleanAspire.WebApp image run: | docker build -t ${{ secrets.DOCKER_USERNAME }}/cleanaspire-webapp:${{ steps.version.outputs.version }} -f src/CleanAspire.WebApp/Dockerfile . diff --git a/CleanAspire.sln b/CleanAspire.sln index 5dd7a3b..615ad95 100644 --- a/CleanAspire.sln +++ b/CleanAspire.sln @@ -112,9 +112,9 @@ Global {E29307F2-485B-47B4-9CA7-A7EA6949134B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C379C278-2AFA-4DD5-96F5-34D17AAE1188} - RESX_AutoCreateNewLanguageFiles = True - RESX_ConfirmAddLanguageFile = True RESX_ShowPerformanceTraces = True + RESX_ConfirmAddLanguageFile = True + RESX_AutoCreateNewLanguageFiles = True + SolutionGuid = {C379C278-2AFA-4DD5-96F5-34D17AAE1188} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index be57dd1..13a5353 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ By incorporating robust offline capabilities, CleanAspire empowers developers to - The system detects the online/offline status and fetches data from **IndexedDB** when offline, ensuring uninterrupted access to key features. +### How to Create a New Object in a CRUD Application: A Step-by-Step Guide +https://github.com/neozhu/cleanaspire/issues/34 ### 🌟 Why Choose CleanAspire? @@ -87,7 +89,7 @@ By incorporating robust offline capabilities, CleanAspire empowers developers to version: '3.8' services: apiservice: - image: blazordevlab/cleanaspire-api:0.0.61 + image: blazordevlab/cleanaspire-api:0.0.62 environment: - ASPNETCORE_ENVIRONMENT=Development - AllowedHosts=* @@ -96,7 +98,7 @@ services: - ASPNETCORE_HTTPS_PORTS=443 - DatabaseSettings__DBProvider=sqlite - DatabaseSettings__ConnectionString=Data Source=CleanAspireDb.db - - AllowedCorsOrigins=https://cleanaspire.blazorserver.com,https://localhost:7114 + - AllowedCorsOrigins=https://cleanaspire.blazorserver.com,https://standalone.blazorserver.com,https://localhost:7114 - Authentication__Google__ClientId= - Authentication__Google__ClientSecret= - SendGrid__ApiKey= @@ -108,9 +110,8 @@ services: - "8019:80" - "8018:443" - blazorweb: - image: blazordevlab/cleanaspire-webapp:0.0.61 + image: blazordevlab/cleanaspire-webapp:0.0.62 environment: - ASPNETCORE_ENVIRONMENT=Production - AllowedHosts=* @@ -121,6 +122,12 @@ services: - "8015:80" - "8014:443" + standalone: + image: blazordevlab/cleanaspire-standalone:0.0.62 + ports: + - "8020:80" + - "8021:443" + ``` diff --git a/src/CleanAspire.Api/Endpoints/StockEndpointRegistrar.cs b/src/CleanAspire.Api/Endpoints/StockEndpointRegistrar.cs new file mode 100644 index 0000000..93e316c --- /dev/null +++ b/src/CleanAspire.Api/Endpoints/StockEndpointRegistrar.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CleanAspire.Application.Common.Models; +using CleanAspire.Application.Features.Stocks.Commands; +using CleanAspire.Application.Features.Stocks.DTOs; +using CleanAspire.Application.Features.Stocks.Queryies; +using Mediator; +using Microsoft.AspNetCore.Mvc; + +namespace CleanAspire.Api.Endpoints; + +public class StockEndpointRegistrar(ILogger logger) : IEndpointRegistrar +{ + public void RegisterRoutes(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/stocks").WithTags("stocks").RequireAuthorization(); + + // Dispatch stock + group.MapPost("/dispatch", ([FromServices] IMediator mediator, [FromBody] StockDispatchingCommand command) => mediator.Send(command)) + .Produces(StatusCodes.Status200OK) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Dispatch stock") + .WithDescription("Dispatches a specified quantity of stock from a location."); + + // Receive stock + group.MapPost("/receive", ([FromServices] IMediator mediator, [FromBody] StockReceivingCommand command) => mediator.Send(command)) + .Produces(StatusCodes.Status200OK) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Receive stock") + .WithDescription("Receives a specified quantity of stock into a location."); + + // Get stocks with pagination + group.MapPost("/pagination", ([FromServices] IMediator mediator, [FromBody] StocksWithPaginationQuery query) => mediator.Send(query)) + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) + .WithSummary("Get stocks with pagination") + .WithDescription("Returns a paginated list of stocks based on search keywords, page size, and sorting options."); + } + +} diff --git a/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs b/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs index 3aad564..6b2fbf2 100644 --- a/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs +++ b/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs @@ -98,6 +98,13 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Detail = ex.Message, Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}" }, + InvalidOperationException ex => new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Invalid Operation", + Detail = ex.Message, + Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}" + }, _ => new ProblemDetails { Status = StatusCodes.Status500InternalServerError, diff --git a/src/CleanAspire.Api/OpenApiTransformersExtensions.cs b/src/CleanAspire.Api/OpenApiTransformersExtensions.cs index 2dc2343..e10f1f1 100644 --- a/src/CleanAspire.Api/OpenApiTransformersExtensions.cs +++ b/src/CleanAspire.Api/OpenApiTransformersExtensions.cs @@ -4,6 +4,7 @@ using Bogus; using CleanAspire.Application.Features.Products.Commands; +using CleanAspire.Application.Features.Stocks.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.Data; @@ -107,7 +108,18 @@ public ExampleChemaTransformer() ["Currency"] = new OpenApiString("USD"), ["UOM"] = new OpenApiString("PCS") }; - + _examples[typeof(StockDispatchingCommand)] = new OpenApiObject + { + ["ProductId"] = new OpenApiString(Guid.NewGuid().ToString()), + ["Quantity"] = new OpenApiInteger(5), + ["Location"] = new OpenApiString("WH-01"), + }; + _examples[typeof(StockReceivingCommand)] = new OpenApiObject + { + ["ProductId"] = new OpenApiString(Guid.NewGuid().ToString()), + ["Quantity"] = new OpenApiInteger(10), + ["Location"] = new OpenApiString("WH-01"), + }; } public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { diff --git a/src/CleanAspire.Application/Common/Interfaces/IApplicationDbContext.cs b/src/CleanAspire.Application/Common/Interfaces/IApplicationDbContext.cs index 12b2243..e646d88 100644 --- a/src/CleanAspire.Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/CleanAspire.Application/Common/Interfaces/IApplicationDbContext.cs @@ -1,10 +1,35 @@ namespace CleanAspire.Application.Common.Interfaces; +/// +/// Represents the application database context interface. +/// public interface IApplicationDbContext { + /// + /// Gets or sets the Products DbSet. + /// DbSet Products { get; set; } + + /// + /// Gets or sets the AuditTrails DbSet. + /// DbSet AuditTrails { get; set; } + + /// + /// Gets or sets the Tenants DbSet. + /// DbSet Tenants { get; set; } - Task SaveChangesAsync(CancellationToken cancellationToken=default); + + /// + /// Gets or sets the Stocks DbSet. + /// + DbSet Stocks { get; set; } + + /// + /// Saves all changes made in this context to the database. + /// + /// A CancellationToken to observe while waiting for the task to complete. + /// A task that represents the asynchronous save operation. The task result contains the number of state entries written to the database. + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/CleanAspire.Application/Common/Models/PaginatedResult.cs b/src/CleanAspire.Application/Common/Models/PaginatedResult.cs index b29ec12..c728966 100644 --- a/src/CleanAspire.Application/Common/Models/PaginatedResult.cs +++ b/src/CleanAspire.Application/Common/Models/PaginatedResult.cs @@ -2,6 +2,7 @@ namespace CleanAspire.Application.Common.Models; public class PaginatedResult { + public PaginatedResult() { } public PaginatedResult(IEnumerable items, int total, int pageIndex, int pageSize) { Items = items; diff --git a/src/CleanAspire.Application/Common/QueryableExtensions/QueryableExtensions.cs b/src/CleanAspire.Application/Common/QueryableExtensions/QueryableExtensions.cs index 87698a6..43b0e36 100644 --- a/src/CleanAspire.Application/Common/QueryableExtensions/QueryableExtensions.cs +++ b/src/CleanAspire.Application/Common/QueryableExtensions/QueryableExtensions.cs @@ -59,9 +59,9 @@ public static IOrderedQueryable OrderBy(this IQueryable source, string public static async Task> ProjectToPaginatedDataAsync( this IOrderedQueryable query, Expression>? condition, - int pageNumber, + int pageNumber, int pageSize, - Func mapperFunc, + Func mapperFunc, CancellationToken cancellationToken = default) where T : class, IEntity { if (condition != null) diff --git a/src/CleanAspire.Application/Features/Products/Queries/ProductsWithPaginationQuery.cs b/src/CleanAspire.Application/Features/Products/Queries/ProductsWithPaginationQuery.cs index 77f28ad..6cac77d 100644 --- a/src/CleanAspire.Application/Features/Products/Queries/ProductsWithPaginationQuery.cs +++ b/src/CleanAspire.Application/Features/Products/Queries/ProductsWithPaginationQuery.cs @@ -1,7 +1,7 @@ using CleanAspire.Application.Features.Products.DTOs; namespace CleanAspire.Application.Features.Products.Queries; -public record ProductsWithPaginationQuery(string Keywords, int PageNumber = 1, int PageSize = 15, string OrderBy = "Id", string SortDirection = "Descending") : IFusionCacheRequest> +public record ProductsWithPaginationQuery(string Keywords, int PageNumber = 0, int PageSize = 15, string OrderBy = "Id", string SortDirection = "Descending") : IFusionCacheRequest> { public IEnumerable? Tags => new[] { "products" }; public string CacheKey => $"productswithpagination_{Keywords}_{PageNumber}_{PageSize}_{OrderBy}_{SortDirection}"; diff --git a/src/CleanAspire.Application/Features/Stocks/Commands/StockDispatchingCommand.cs b/src/CleanAspire.Application/Features/Stocks/Commands/StockDispatchingCommand.cs new file mode 100644 index 0000000..afa0f5f --- /dev/null +++ b/src/CleanAspire.Application/Features/Stocks/Commands/StockDispatchingCommand.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Application.Pipeline; + +namespace CleanAspire.Application.Features.Stocks.Commands; +public record StockDispatchingCommand : IFusionCacheRefreshRequest, IRequiresValidation +{ + public string ProductId { get; init; } = string.Empty; + public int Quantity { get; init; } + public string Location { get; init; } = string.Empty; + public IEnumerable? Tags => new[] { "stocks" }; +} +public class StockDispatchingCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public StockDispatchingCommandHandler(IApplicationDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async ValueTask Handle(StockDispatchingCommand request, CancellationToken cancellationToken) + { + // Validate that the product exists + var product = await _context.Products + .FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken); + + if (product == null) + { + throw new KeyNotFoundException($"Product with Product ID '{request.ProductId}' was not found."); + } + + // Check if the stock record exists for the given ProductId and Location + var existingStock = await _context.Stocks + .FirstOrDefaultAsync(s => s.ProductId == request.ProductId && s.Location == request.Location, cancellationToken); + + if (existingStock == null) + { + throw new KeyNotFoundException($"No stock record found for Product ID '{request.ProductId}' at Location '{request.Location}'."); + } + + // Validate that the stock quantity is sufficient + if (existingStock.Quantity < request.Quantity) + { + throw new InvalidOperationException($"Insufficient stock quantity. Available: {existingStock.Quantity}, Requested: {request.Quantity}"); + } + + // Reduce the stock quantity + existingStock.Quantity -= request.Quantity; + + // If stock quantity is zero, remove the stock record + if (existingStock.Quantity == 0) + { + _context.Stocks.Remove(existingStock); + } + else + { + // Update the stock record + _context.Stocks.Update(existingStock); + } + + // Save changes to the database + await _context.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + +} diff --git a/src/CleanAspire.Application/Features/Stocks/Commands/StockReceivingCommand.cs b/src/CleanAspire.Application/Features/Stocks/Commands/StockReceivingCommand.cs new file mode 100644 index 0000000..5bc7061 --- /dev/null +++ b/src/CleanAspire.Application/Features/Stocks/Commands/StockReceivingCommand.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Application.Common.Interfaces; +using CleanAspire.Application.Features.Stocks.DTOs; +using CleanAspire.Application.Pipeline; + +namespace CleanAspire.Application.Features.Stocks.Commands; +public record StockReceivingCommand : IFusionCacheRefreshRequest, IRequiresValidation +{ + public string ProductId { get; init; } = string.Empty; + public int Quantity { get; init; } + public string Location { get; init; } = string.Empty; + public IEnumerable? Tags => new[] { "stocks" }; +} +public class StockReceivingCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public StockReceivingCommandHandler(IApplicationDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async ValueTask Handle(StockReceivingCommand request, CancellationToken cancellationToken) + { + // Validate that the product exists + var product = await _context.Products + .FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken); + + if (product == null) + { + throw new KeyNotFoundException($"Product with Product ID '{request.ProductId}' was not found."); + } + + // Check if the stock record already exists for the given ProductId and Location + var existingStock = await _context.Stocks + .FirstOrDefaultAsync(s => s.ProductId == request.ProductId && s.Location == request.Location, cancellationToken); + + if (existingStock != null) + { + // If the stock record exists, update the quantity + existingStock.Quantity += request.Quantity; + _context.Stocks.Update(existingStock); + } + else + { + // If no stock record exists, create a new one + var newStockEntry = new Stock + { + ProductId = request.ProductId, + Location = request.Location, + Quantity = request.Quantity, + }; + + _context.Stocks.Add(newStockEntry); + } + + // Save changes to the database + await _context.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/CleanAspire.Application/Features/Stocks/DTOs/StockDto.cs b/src/CleanAspire.Application/Features/Stocks/DTOs/StockDto.cs new file mode 100644 index 0000000..b2b39c6 --- /dev/null +++ b/src/CleanAspire.Application/Features/Stocks/DTOs/StockDto.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Application.Features.Products.DTOs; +using CleanAspire.Domain.Common; + +namespace CleanAspire.Application.Features.Stocks.DTOs; +/// +/// Data Transfer Object for Stock. +/// +public class StockDto +{ + /// + /// Gets or sets the unique identifier for the stock. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the unique identifier for the product. + /// + public string? ProductId { get; set; } + + /// + /// Gets or sets the product details. + /// + public ProductDto? Product { get; set; } + + /// + /// Gets or sets the quantity of the stock. + /// + public int Quantity { get; set; } + + /// + /// Gets or sets the location of the stock. + /// + public string Location { get; set; } = string.Empty; + + /// + /// Gets or sets the date and time when the stock was created. + /// + public DateTime? Created { get; set; } + + /// + /// Gets or sets the date and time when the stock was last modified. + /// + public DateTime? LastModified { get; set; } +} + diff --git a/src/CleanAspire.Application/Features/Stocks/Queryies/StocksWithPaginationQuery.cs b/src/CleanAspire.Application/Features/Stocks/Queryies/StocksWithPaginationQuery.cs new file mode 100644 index 0000000..d466341 --- /dev/null +++ b/src/CleanAspire.Application/Features/Stocks/Queryies/StocksWithPaginationQuery.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Application.Features.Products.DTOs; +using CleanAspire.Application.Features.Stocks.DTOs; + +namespace CleanAspire.Application.Features.Stocks.Queryies; +public record StocksWithPaginationQuery(string Keywords, int PageNumber = 0, int PageSize = 15, string OrderBy = "Id", string SortDirection = "Descending") : IFusionCacheRequest> +{ + public IEnumerable? Tags => new[] { "stocks" }; + public string CacheKey => $"stockswithpagination_{Keywords}_{PageNumber}_{PageSize}_{OrderBy}_{SortDirection}"; +} + +public class StocksWithPaginationQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + + public StocksWithPaginationQueryHandler(IApplicationDbContext context) + { + _context = context; + } + + public async ValueTask> Handle(StocksWithPaginationQuery request, CancellationToken cancellationToken) + { + var data = await _context.Stocks.Include(x => x.Product).OrderBy(request.OrderBy, request.SortDirection) + .ProjectToPaginatedDataAsync( + condition: x => x.Location.Contains(request.Keywords) || (x.Product != null && (x.Product.Name.Contains(request.Keywords) || x.Product.SKU.Contains(request.Keywords) || x.Product.Description.Contains(request.Keywords))), + pageNumber: request.PageNumber, + pageSize: request.PageSize, + mapperFunc: t => new StockDto + { + Id = t.Id, + ProductId = t.ProductId, + Product = t.ProductId != null ? new ProductDto + { + Category = (ProductCategoryDto)t.Product?.Category, + Currency = t.Product?.Currency, + Description = t.Product?.Description, + Id = t.Product?.Id, + Name = t.Product?.Name, + Price = t.Product?.Price ?? 0, + SKU = t.Product?.SKU, + UOM = t.Product?.UOM, + } : null, + Quantity = t.Quantity, + Location = t.Location + }, + cancellationToken: cancellationToken); + return data; + } +} diff --git a/src/CleanAspire.Application/Features/Stocks/Validators/StockDispatchingCommandValidator.cs b/src/CleanAspire.Application/Features/Stocks/Validators/StockDispatchingCommandValidator.cs new file mode 100644 index 0000000..7cfcc35 --- /dev/null +++ b/src/CleanAspire.Application/Features/Stocks/Validators/StockDispatchingCommandValidator.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Application.Features.Stocks.Commands; + +namespace CleanAspire.Application.Features.Stocks.Validators; +public class StockDispatchingCommandValidator : AbstractValidator +{ + public StockDispatchingCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("ProductId is required."); + + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("Quantity must be greater than 0."); + + RuleFor(x => x.Location) + .NotEmpty() + .WithMessage("Location is required."); + } +} diff --git a/src/CleanAspire.Application/Features/Stocks/Validators/StockReceivingCommandValidator.cs b/src/CleanAspire.Application/Features/Stocks/Validators/StockReceivingCommandValidator.cs new file mode 100644 index 0000000..43ef171 --- /dev/null +++ b/src/CleanAspire.Application/Features/Stocks/Validators/StockReceivingCommandValidator.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Application.Features.Stocks.Commands; + +namespace CleanAspire.Application.Features.Stocks.Validators; +public class StockReceivingCommandValidator : AbstractValidator +{ + public StockReceivingCommandValidator() + { + RuleFor(x => x.ProductId) + .NotEmpty() + .WithMessage("ProductId is required."); + + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("Quantity must be greater than 0."); + + RuleFor(x => x.Location) + .NotEmpty() + .WithMessage("Location is required."); + } +} diff --git a/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj b/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj index 3f3d870..ea5182f 100644 --- a/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj +++ b/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj @@ -11,14 +11,6 @@ CleanAspire.ClientApp CleanAspire.ClientApp - @@ -41,8 +33,4 @@ - - - - diff --git a/src/CleanAspire.ClientApp/Client/.kiota/workspace.json b/src/CleanAspire.ClientApp/Client/.kiota/workspace.json deleted file mode 100644 index 3ce81de..0000000 --- a/src/CleanAspire.ClientApp/Client/.kiota/workspace.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "1.0.0", - "clients": {}, - "plugins": {} -} \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Client/ApiClient.cs b/src/CleanAspire.ClientApp/Client/ApiClient.cs index fde5296..0dca6a0 100644 --- a/src/CleanAspire.ClientApp/Client/ApiClient.cs +++ b/src/CleanAspire.ClientApp/Client/ApiClient.cs @@ -11,6 +11,7 @@ using CleanAspire.Api.Client.Register; using CleanAspire.Api.Client.ResendConfirmationEmail; using CleanAspire.Api.Client.ResetPassword; +using CleanAspire.Api.Client.Stocks; using CleanAspire.Api.Client.Tenants; using CleanAspire.Api.Client.Webpushr; using Microsoft.Kiota.Abstractions.Extensions; @@ -82,6 +83,11 @@ public partial class ApiClient : BaseRequestBuilder { get => new global::CleanAspire.Api.Client.ResetPassword.ResetPasswordRequestBuilder(PathParameters, RequestAdapter); } + /// The stocks property + public global::CleanAspire.Api.Client.Stocks.StocksRequestBuilder Stocks + { + get => new global::CleanAspire.Api.Client.Stocks.StocksRequestBuilder(PathParameters, RequestAdapter); + } /// The tenants property public global::CleanAspire.Api.Client.Tenants.TenantsRequestBuilder Tenants { diff --git a/src/CleanAspire.ClientApp/Client/Models/PaginatedResultOfStockDto.cs b/src/CleanAspire.ClientApp/Client/Models/PaginatedResultOfStockDto.cs new file mode 100644 index 0000000..2780ec1 --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Models/PaginatedResultOfStockDto.cs @@ -0,0 +1,85 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace CleanAspire.Api.Client.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PaginatedResultOfStockDto : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The currentPage property + public int? CurrentPage { get; set; } + /// The hasNextPage property + public bool? HasNextPage { get; set; } + /// The hasPreviousPage property + public bool? HasPreviousPage { get; set; } + /// The items property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public List? Items { get; set; } +#nullable restore +#else + public List Items { get; set; } +#endif + /// The totalItems property + public int? TotalItems { get; set; } + /// The totalPages property + public int? TotalPages { get; set; } + /// + /// Instantiates a new and sets the default values. + /// + public PaginatedResultOfStockDto() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::CleanAspire.Api.Client.Models.PaginatedResultOfStockDto CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::CleanAspire.Api.Client.Models.PaginatedResultOfStockDto(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "currentPage", n => { CurrentPage = n.GetIntValue(); } }, + { "hasNextPage", n => { HasNextPage = n.GetBoolValue(); } }, + { "hasPreviousPage", n => { HasPreviousPage = n.GetBoolValue(); } }, + { "items", n => { Items = n.GetCollectionOfObjectValues(global::CleanAspire.Api.Client.Models.StockDto.CreateFromDiscriminatorValue)?.AsList(); } }, + { "totalItems", n => { TotalItems = n.GetIntValue(); } }, + { "totalPages", n => { TotalPages = n.GetIntValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteIntValue("currentPage", CurrentPage); + writer.WriteBoolValue("hasNextPage", HasNextPage); + writer.WriteBoolValue("hasPreviousPage", HasPreviousPage); + writer.WriteCollectionOfObjectValues("items", Items); + writer.WriteIntValue("totalItems", TotalItems); + writer.WriteIntValue("totalPages", TotalPages); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Models/ProductDto2.cs b/src/CleanAspire.ClientApp/Client/Models/ProductDto2.cs new file mode 100644 index 0000000..909d1b7 --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Models/ProductDto2.cs @@ -0,0 +1,123 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace CleanAspire.Api.Client.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ProductDto2 : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The category property + public global::CleanAspire.Api.Client.Models.NullableOfProductCategoryDto? Category { get; set; } + /// The currency property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Currency { get; set; } +#nullable restore +#else + public string Currency { get; set; } +#endif + /// The description property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Description { get; set; } +#nullable restore +#else + public string Description { get; set; } +#endif + /// The id property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Id { get; set; } +#nullable restore +#else + public string Id { get; set; } +#endif + /// The name property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Name { get; set; } +#nullable restore +#else + public string Name { get; set; } +#endif + /// The price property + public double? Price { get; set; } + /// The sku property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Sku { get; set; } +#nullable restore +#else + public string Sku { get; set; } +#endif + /// The uom property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Uom { get; set; } +#nullable restore +#else + public string Uom { get; set; } +#endif + /// + /// Instantiates a new and sets the default values. + /// + public ProductDto2() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::CleanAspire.Api.Client.Models.ProductDto2 CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::CleanAspire.Api.Client.Models.ProductDto2(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "category", n => { Category = n.GetEnumValue(); } }, + { "currency", n => { Currency = n.GetStringValue(); } }, + { "description", n => { Description = n.GetStringValue(); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "name", n => { Name = n.GetStringValue(); } }, + { "price", n => { Price = n.GetDoubleValue(); } }, + { "sku", n => { Sku = n.GetStringValue(); } }, + { "uom", n => { Uom = n.GetStringValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("category", Category); + writer.WriteStringValue("currency", Currency); + writer.WriteStringValue("description", Description); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("name", Name); + writer.WriteDoubleValue("price", Price); + writer.WriteStringValue("sku", Sku); + writer.WriteStringValue("uom", Uom); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Models/StockDispatchingCommand.cs b/src/CleanAspire.ClientApp/Client/Models/StockDispatchingCommand.cs new file mode 100644 index 0000000..7e3581b --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Models/StockDispatchingCommand.cs @@ -0,0 +1,89 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace CleanAspire.Api.Client.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class StockDispatchingCommand : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The location property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Location { get; set; } +#nullable restore +#else + public string Location { get; set; } +#endif + /// The productId property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? ProductId { get; set; } +#nullable restore +#else + public string ProductId { get; set; } +#endif + /// The quantity property + public int? Quantity { get; set; } + /// The tags property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public List? Tags { get; set; } +#nullable restore +#else + public List Tags { get; set; } +#endif + /// + /// Instantiates a new and sets the default values. + /// + public StockDispatchingCommand() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::CleanAspire.Api.Client.Models.StockDispatchingCommand CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::CleanAspire.Api.Client.Models.StockDispatchingCommand(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "location", n => { Location = n.GetStringValue(); } }, + { "productId", n => { ProductId = n.GetStringValue(); } }, + { "quantity", n => { Quantity = n.GetIntValue(); } }, + { "tags", n => { Tags = n.GetCollectionOfPrimitiveValues()?.AsList(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("location", Location); + writer.WriteStringValue("productId", ProductId); + writer.WriteIntValue("quantity", Quantity); + writer.WriteCollectionOfPrimitiveValues("tags", Tags); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Models/StockDto.cs b/src/CleanAspire.ClientApp/Client/Models/StockDto.cs new file mode 100644 index 0000000..57d09dc --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Models/StockDto.cs @@ -0,0 +1,107 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace CleanAspire.Api.Client.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class StockDto : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The created property + public DateTimeOffset? Created { get; set; } + /// The id property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Id { get; set; } +#nullable restore +#else + public string Id { get; set; } +#endif + /// The lastModified property + public DateTimeOffset? LastModified { get; set; } + /// The location property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Location { get; set; } +#nullable restore +#else + public string Location { get; set; } +#endif + /// The product property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public global::CleanAspire.Api.Client.Models.ProductDto2? Product { get; set; } +#nullable restore +#else + public global::CleanAspire.Api.Client.Models.ProductDto2 Product { get; set; } +#endif + /// The productId property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? ProductId { get; set; } +#nullable restore +#else + public string ProductId { get; set; } +#endif + /// The quantity property + public int? Quantity { get; set; } + /// + /// Instantiates a new and sets the default values. + /// + public StockDto() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::CleanAspire.Api.Client.Models.StockDto CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::CleanAspire.Api.Client.Models.StockDto(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "created", n => { Created = n.GetDateTimeOffsetValue(); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "lastModified", n => { LastModified = n.GetDateTimeOffsetValue(); } }, + { "location", n => { Location = n.GetStringValue(); } }, + { "product", n => { Product = n.GetObjectValue(global::CleanAspire.Api.Client.Models.ProductDto2.CreateFromDiscriminatorValue); } }, + { "productId", n => { ProductId = n.GetStringValue(); } }, + { "quantity", n => { Quantity = n.GetIntValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteDateTimeOffsetValue("created", Created); + writer.WriteStringValue("id", Id); + writer.WriteDateTimeOffsetValue("lastModified", LastModified); + writer.WriteStringValue("location", Location); + writer.WriteObjectValue("product", Product); + writer.WriteStringValue("productId", ProductId); + writer.WriteIntValue("quantity", Quantity); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Models/StockReceivingCommand.cs b/src/CleanAspire.ClientApp/Client/Models/StockReceivingCommand.cs new file mode 100644 index 0000000..9429b52 --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Models/StockReceivingCommand.cs @@ -0,0 +1,89 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace CleanAspire.Api.Client.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class StockReceivingCommand : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The location property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Location { get; set; } +#nullable restore +#else + public string Location { get; set; } +#endif + /// The productId property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? ProductId { get; set; } +#nullable restore +#else + public string ProductId { get; set; } +#endif + /// The quantity property + public int? Quantity { get; set; } + /// The tags property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public List? Tags { get; set; } +#nullable restore +#else + public List Tags { get; set; } +#endif + /// + /// Instantiates a new and sets the default values. + /// + public StockReceivingCommand() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::CleanAspire.Api.Client.Models.StockReceivingCommand CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::CleanAspire.Api.Client.Models.StockReceivingCommand(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "location", n => { Location = n.GetStringValue(); } }, + { "productId", n => { ProductId = n.GetStringValue(); } }, + { "quantity", n => { Quantity = n.GetIntValue(); } }, + { "tags", n => { Tags = n.GetCollectionOfPrimitiveValues()?.AsList(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("location", Location); + writer.WriteStringValue("productId", ProductId); + writer.WriteIntValue("quantity", Quantity); + writer.WriteCollectionOfPrimitiveValues("tags", Tags); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Models/StocksWithPaginationQuery.cs b/src/CleanAspire.ClientApp/Client/Models/StocksWithPaginationQuery.cs new file mode 100644 index 0000000..68fadca --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Models/StocksWithPaginationQuery.cs @@ -0,0 +1,115 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace CleanAspire.Api.Client.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class StocksWithPaginationQuery : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The cacheKey property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? CacheKey { get; set; } +#nullable restore +#else + public string CacheKey { get; set; } +#endif + /// The keywords property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Keywords { get; set; } +#nullable restore +#else + public string Keywords { get; set; } +#endif + /// The orderBy property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? OrderBy { get; set; } +#nullable restore +#else + public string OrderBy { get; set; } +#endif + /// The pageNumber property + public int? PageNumber { get; set; } + /// The pageSize property + public int? PageSize { get; set; } + /// The sortDirection property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? SortDirection { get; set; } +#nullable restore +#else + public string SortDirection { get; set; } +#endif + /// The tags property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public List? Tags { get; set; } +#nullable restore +#else + public List Tags { get; set; } +#endif + /// + /// Instantiates a new and sets the default values. + /// + public StocksWithPaginationQuery() + { + AdditionalData = new Dictionary(); + OrderBy = "Id"; + SortDirection = "Descending"; + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::CleanAspire.Api.Client.Models.StocksWithPaginationQuery CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::CleanAspire.Api.Client.Models.StocksWithPaginationQuery(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "cacheKey", n => { CacheKey = n.GetStringValue(); } }, + { "keywords", n => { Keywords = n.GetStringValue(); } }, + { "orderBy", n => { OrderBy = n.GetStringValue(); } }, + { "pageNumber", n => { PageNumber = n.GetIntValue(); } }, + { "pageSize", n => { PageSize = n.GetIntValue(); } }, + { "sortDirection", n => { SortDirection = n.GetStringValue(); } }, + { "tags", n => { Tags = n.GetCollectionOfPrimitiveValues()?.AsList(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("cacheKey", CacheKey); + writer.WriteStringValue("keywords", Keywords); + writer.WriteStringValue("orderBy", OrderBy); + writer.WriteIntValue("pageNumber", PageNumber); + writer.WriteIntValue("pageSize", PageSize); + writer.WriteStringValue("sortDirection", SortDirection); + writer.WriteCollectionOfPrimitiveValues("tags", Tags); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Stocks/Dispatch/DispatchRequestBuilder.cs b/src/CleanAspire.ClientApp/Client/Stocks/Dispatch/DispatchRequestBuilder.cs new file mode 100644 index 0000000..dc64245 --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Stocks/Dispatch/DispatchRequestBuilder.cs @@ -0,0 +1,106 @@ +// +#pragma warning disable CS0618 +using CleanAspire.Api.Client.Models; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace CleanAspire.Api.Client.Stocks.Dispatch +{ + /// + /// Builds and executes requests for operations under \stocks\dispatch + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class DispatchRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public DispatchRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks/dispatch", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public DispatchRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks/dispatch", rawUrl) + { + } + /// + /// Dispatches a specified quantity of stock from a location. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 422 status code + /// When receiving a 500 status code +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task PostAsync(global::CleanAspire.Api.Client.Models.StockDispatchingCommand body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task PostAsync(global::CleanAspire.Api.Client.Models.StockDispatchingCommand body, Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "422", global::CleanAspire.Api.Client.Models.HttpValidationProblemDetails.CreateFromDiscriminatorValue }, + { "500", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::CleanAspire.Api.Client.Models.Unit.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + /// + /// Dispatches a specified quantity of stock from a location. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToPostRequestInformation(global::CleanAspire.Api.Client.Models.StockDispatchingCommand body, Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToPostRequestInformation(global::CleanAspire.Api.Client.Models.StockDispatchingCommand body, Action> requestConfiguration = default) + { +#endif + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::CleanAspire.Api.Client.Stocks.Dispatch.DispatchRequestBuilder WithUrl(string rawUrl) + { + return new global::CleanAspire.Api.Client.Stocks.Dispatch.DispatchRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class DispatchRequestBuilderPostRequestConfiguration : RequestConfiguration + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Stocks/Pagination/PaginationRequestBuilder.cs b/src/CleanAspire.ClientApp/Client/Stocks/Pagination/PaginationRequestBuilder.cs new file mode 100644 index 0000000..efe88c0 --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Stocks/Pagination/PaginationRequestBuilder.cs @@ -0,0 +1,104 @@ +// +#pragma warning disable CS0618 +using CleanAspire.Api.Client.Models; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace CleanAspire.Api.Client.Stocks.Pagination +{ + /// + /// Builds and executes requests for operations under \stocks\pagination + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PaginationRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public PaginationRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks/pagination", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public PaginationRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks/pagination", rawUrl) + { + } + /// + /// Returns a paginated list of stocks based on search keywords, page size, and sorting options. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 500 status code +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task PostAsync(global::CleanAspire.Api.Client.Models.StocksWithPaginationQuery body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task PostAsync(global::CleanAspire.Api.Client.Models.StocksWithPaginationQuery body, Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "500", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::CleanAspire.Api.Client.Models.PaginatedResultOfStockDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + /// + /// Returns a paginated list of stocks based on search keywords, page size, and sorting options. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToPostRequestInformation(global::CleanAspire.Api.Client.Models.StocksWithPaginationQuery body, Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToPostRequestInformation(global::CleanAspire.Api.Client.Models.StocksWithPaginationQuery body, Action> requestConfiguration = default) + { +#endif + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::CleanAspire.Api.Client.Stocks.Pagination.PaginationRequestBuilder WithUrl(string rawUrl) + { + return new global::CleanAspire.Api.Client.Stocks.Pagination.PaginationRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PaginationRequestBuilderPostRequestConfiguration : RequestConfiguration + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Stocks/Receive/ReceiveRequestBuilder.cs b/src/CleanAspire.ClientApp/Client/Stocks/Receive/ReceiveRequestBuilder.cs new file mode 100644 index 0000000..d984bbc --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Stocks/Receive/ReceiveRequestBuilder.cs @@ -0,0 +1,106 @@ +// +#pragma warning disable CS0618 +using CleanAspire.Api.Client.Models; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace CleanAspire.Api.Client.Stocks.Receive +{ + /// + /// Builds and executes requests for operations under \stocks\receive + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class ReceiveRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public ReceiveRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks/receive", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public ReceiveRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks/receive", rawUrl) + { + } + /// + /// Receives a specified quantity of stock into a location. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 422 status code + /// When receiving a 500 status code +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task PostAsync(global::CleanAspire.Api.Client.Models.StockReceivingCommand body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task PostAsync(global::CleanAspire.Api.Client.Models.StockReceivingCommand body, Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "422", global::CleanAspire.Api.Client.Models.HttpValidationProblemDetails.CreateFromDiscriminatorValue }, + { "500", global::CleanAspire.Api.Client.Models.ProblemDetails.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::CleanAspire.Api.Client.Models.Unit.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + /// + /// Receives a specified quantity of stock into a location. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToPostRequestInformation(global::CleanAspire.Api.Client.Models.StockReceivingCommand body, Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToPostRequestInformation(global::CleanAspire.Api.Client.Models.StockReceivingCommand body, Action> requestConfiguration = default) + { +#endif + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::CleanAspire.Api.Client.Stocks.Receive.ReceiveRequestBuilder WithUrl(string rawUrl) + { + return new global::CleanAspire.Api.Client.Stocks.Receive.ReceiveRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class ReceiveRequestBuilderPostRequestConfiguration : RequestConfiguration + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Client/Stocks/StocksRequestBuilder.cs b/src/CleanAspire.ClientApp/Client/Stocks/StocksRequestBuilder.cs new file mode 100644 index 0000000..720db63 --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/Stocks/StocksRequestBuilder.cs @@ -0,0 +1,53 @@ +// +#pragma warning disable CS0618 +using CleanAspire.Api.Client.Stocks.Dispatch; +using CleanAspire.Api.Client.Stocks.Pagination; +using CleanAspire.Api.Client.Stocks.Receive; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace CleanAspire.Api.Client.Stocks +{ + /// + /// Builds and executes requests for operations under \stocks + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class StocksRequestBuilder : BaseRequestBuilder + { + /// The dispatch property + public global::CleanAspire.Api.Client.Stocks.Dispatch.DispatchRequestBuilder Dispatch + { + get => new global::CleanAspire.Api.Client.Stocks.Dispatch.DispatchRequestBuilder(PathParameters, RequestAdapter); + } + /// The pagination property + public global::CleanAspire.Api.Client.Stocks.Pagination.PaginationRequestBuilder Pagination + { + get => new global::CleanAspire.Api.Client.Stocks.Pagination.PaginationRequestBuilder(PathParameters, RequestAdapter); + } + /// The receive property + public global::CleanAspire.Api.Client.Stocks.Receive.ReceiveRequestBuilder Receive + { + get => new global::CleanAspire.Api.Client.Stocks.Receive.ReceiveRequestBuilder(PathParameters, RequestAdapter); + } + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public StocksRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public StocksRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/stocks", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/CleanAspire.ClientApp/Components/Autocompletes/ProductAutocomplete.cs b/src/CleanAspire.ClientApp/Components/Autocompletes/ProductAutocomplete.cs new file mode 100644 index 0000000..5d68a6a --- /dev/null +++ b/src/CleanAspire.ClientApp/Components/Autocompletes/ProductAutocomplete.cs @@ -0,0 +1,58 @@ + + +using CleanAspire.Api.Client; +using CleanAspire.Api.Client.Models; +using CleanAspire.ClientApp.Services; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CleanAspire.ClientApp.Components.Autocompletes; + +public class ProductAutocomplete : MudAutocomplete +{ + public ProductAutocomplete() + { + SearchFunc = SearchKeyValues; + ToStringFunc = dto => dto?.Name; + Dense = true; + ResetValueOnEmptyText = true; + ShowProgressIndicator = true; + } + [Parameter] public string? DefaultProductId { get; set; } + public List? Products { get; set; } = new(); + [Inject] private ApiClient ApiClient { get; set; } = default!; + [Inject] private ApiClientServiceProxy ApiClientServiceProxy { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + Products = await ApiClientServiceProxy.QueryAsync("_allproducts", () => ApiClient.Products.GetAsync(), tags: null, expiration: TimeSpan.FromMinutes(60)); + if (!string.IsNullOrEmpty(DefaultProductId)) + { + var defaultProduct = Products?.FirstOrDefault(p => p.Id == DefaultProductId); + if (defaultProduct != null) + { + Value = defaultProduct; + await ValueChanged.InvokeAsync(Value); + } + } + StateHasChanged(); // Trigger a re-render after the tenants are loaded + } + } + private async Task> SearchKeyValues(string? value, CancellationToken cancellation) + { + IEnumerable result; + + if (string.IsNullOrWhiteSpace(value)) + result = Products ?? new List(); + else + result = Products? + .Where(x => x.Name?.Contains(value, StringComparison.InvariantCultureIgnoreCase) == true || + x.Sku?.Contains(value, StringComparison.InvariantCultureIgnoreCase) == true || + x.Description?.Contains(value, StringComparison.InvariantCultureIgnoreCase) == true) + .ToList() ?? new List(); + + return await Task.FromResult(result); + } +} diff --git a/src/CleanAspire.ClientApp/Dockerfile b/src/CleanAspire.ClientApp/Dockerfile new file mode 100644 index 0000000..5500042 --- /dev/null +++ b/src/CleanAspire.ClientApp/Dockerfile @@ -0,0 +1,42 @@ +# Stage 1: Build the Blazor Client Application +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Install Python for AOT compilation +RUN apt-get update && apt-get install -y python3 python3-pip && ln -s /usr/bin/python3 /usr/bin/python + +# Copy the project files and restore dependencies +COPY ["src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj", "src/CleanAspire.ClientApp/"] +RUN dotnet restore "src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj" + +# Install wasm-tools for AOT +RUN dotnet workload install wasm-tools --skip-manifest-update +RUN dotnet workload update + +# Copy the entire source code and build the application in Release mode +COPY . . +RUN dotnet publish -c Release -o /app/publish -p:DefineConstants=STANDALONE + +# Stage 2: Serve the Blazor Client Application using Nginx +FROM nginx:alpine AS final +WORKDIR /usr/share/nginx/html + +# Install OpenSSL to create a self-signed certificate +RUN apk add --no-cache openssl && \ + openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt -subj "/CN=localhost" + +# Clean the default nginx content +RUN rm -rf ./* + +# Copy the build output from the previous stage +COPY --from=build /app/publish/wwwroot . + +# Copy the generated self-signed certificate and configure Nginx for HTTPS +COPY src/CleanAspire.ClientApp/nginx.conf /etc/nginx/nginx.conf + +# Expose port 80 for HTTP traffic and 443 for HTTPS traffic +EXPOSE 80 +EXPOSE 443 + +# Start Nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Pages/Products/Index.razor b/src/CleanAspire.ClientApp/Pages/Products/Index.razor index 5eece75..0c87761 100644 --- a/src/CleanAspire.ClientApp/Pages/Products/Index.razor +++ b/src/CleanAspire.ClientApp/Pages/Products/Index.razor @@ -1,5 +1,4 @@ @page "/products/index" -@using System.Globalization @using CleanAspire.ClientApp.Pages.Products.Components @using CleanAspire.ClientApp.Services.Products diff --git a/src/CleanAspire.ClientApp/Pages/Stocks/Index.razor b/src/CleanAspire.ClientApp/Pages/Stocks/Index.razor new file mode 100644 index 0000000..1cedda7 --- /dev/null +++ b/src/CleanAspire.ClientApp/Pages/Stocks/Index.razor @@ -0,0 +1,151 @@ +@page "/stocks/index" +@Title + + + + + + + + @L[Title] + @L["Check product stock levels."] + + + + + + + @L["Refresh"] + + + + @L["Receiving"] + + + + + + + + + + + + + + + + + + @context.Item.Product?.Sku + + + + + @context.Item.Product?.Name + @context.Item.Product?.Description + + + + + + + @context.Item.Product?.Uom + + + + + @($"{((context.Item.Quantity ?? 0) * (context.Item.Product?.Price ?? 0)).ToString("#,#")} {context.Item.Product?.Currency}") + + + + + + + +@code { + public string Title = "Stock Inquiry"; + private HashSet _selectedItems = new(); + private StockDto _currentDto = new(); + private MudDataGrid _table = default!; + private int _defaultPageSize = 10; + private string _keywords = string.Empty; + private bool _loading = false; + private readonly string[] tags = new[] { "stocks" }; + private readonly TimeSpan timeSpan = TimeSpan.FromSeconds(30); + private async Task> ServerReload(GridState state) + { + try + { + _loading = true; + var query = new StocksWithPaginationQuery(); + query.PageNumber = state.Page; + query.PageSize = state.PageSize; + query.Keywords = _keywords; + query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; + query.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); + var cacheKey = $"_{query.Keywords}_{query.PageSize}_{query.PageNumber}_{query.OrderBy}_{query.SortDirection}"; + var result = await ApiClientServiceProxy.QueryAsync(cacheKey, () => ApiClient.Stocks.Pagination.PostAsync(query), tags, timeSpan); + return new GridData { TotalItems = (int)result.TotalItems, Items = result.Items }; + } + finally + { + _loading = false; + } + } + AggregateDefinition _qtyAggregation = new AggregateDefinition + { + Type = AggregateType.Sum, + NumberFormat = "#,#", + DisplayFormat = "Total quantity is {value}" + }; + private async Task OnSearch(string text) + { + _selectedItems = new HashSet(); + _keywords = text; + await _table.ReloadServerData(); + } + private async Task Receive() + { + var parameters = new DialogParameters + { + {x=>x.Inbound, true} + }; + await DialogServiceHelper.ShowDialogAsync(L["Stock receiving"], parameters, new DialogOptions() { MaxWidth = MaxWidth.Small }, + async (state) => + { + if (state is not null && !state.Canceled) + { + await ApiClientServiceProxy.ClearCache(tags); + await _table.ReloadServerData(); + _selectedItems = new(); + } + }); + } + private async Task Dispatch(StockDto item) + { + var parameters = new DialogParameters + { + {x=>x.Inbound, false}, + {x=>x.model, new StockDialog.Model(){ Location = item.Location, Quantity=item.Quantity, ProductId=item.ProductId } } + }; + await DialogServiceHelper.ShowDialogAsync(L["Stock dispatching"], parameters, new DialogOptions() { MaxWidth = MaxWidth.Small }, + async (state) => + { + if (state is not null && !state.Canceled) + { + await ApiClientServiceProxy.ClearCache(tags); + await _table.ReloadServerData(); + _selectedItems = new(); + } + }); + } +} diff --git a/src/CleanAspire.ClientApp/Pages/Stocks/StockDialog.razor b/src/CleanAspire.ClientApp/Pages/Stocks/StockDialog.razor new file mode 100644 index 0000000..7c7b810 --- /dev/null +++ b/src/CleanAspire.ClientApp/Pages/Stocks/StockDialog.razor @@ -0,0 +1,92 @@ +@using System.ComponentModel.DataAnnotations +@using CleanAspire.ClientApp.Components.Autocompletes + + + +
+ + + +
+
+
+ + @L["Cancel"] + @L["Save"] + +
+ +@code { + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] + public bool Inbound { get; set; } + [Parameter] + public Model model { get; set; } = new Model(); + private MudForm editForm = default!; + public string Localtion { get; set; } = string.Empty; + private bool _saving = false; + private void Cancel() => MudDialog.Cancel(); + private async Task Submit() + { + await editForm.Validate(); // Validate manually before submitting. + if (editForm.IsValid) + { + _saving = true; + if (Inbound) + { + var result = await ApiClientServiceProxy.ExecuteAsync(() => ApiClient.Stocks.Receive.PostAsync(new StockReceivingCommand () { ProductId = model.Product.Id, Location = model.Location, Quantity = model.Quantity })); + result.Switch( + ok => + { + Snackbar.Add(L["Stock received successfully."], Severity.Success); + }, + invalid => + { + Snackbar.Add(L[invalid.Detail ?? "Failed validation"], Severity.Error); + }, + error => + { + Snackbar.Add(L["Stock receiving failed. Please try again."], Severity.Error); + } + ); + } + else + { + var result = await ApiClientServiceProxy.ExecuteAsync(() => ApiClient.Stocks.Dispatch.PostAsync(new StockDispatchingCommand() { ProductId = model.Product.Id, Location = model.Location, Quantity = model.Quantity })); + result.Switch( + ok => + { + Snackbar.Add(L["Stock dispatched successfully."], Severity.Success); + }, + invalid => + { + Snackbar.Add(L[invalid.Detail ?? "Failed validation"], Severity.Error); + }, + error => + { + Snackbar.Add(L["Stock dispatching failed. Please try again."], Severity.Error); + } + ); + } + MudDialog.Close(DialogResult.Ok(model)); + _saving = false; + } + } + + private IEnumerable ValidateQuantity(int? value) + { + if (!value.HasValue || value <= 0) + { + yield return L["Quantity must be greater than 0."]; + } + } + public class Model + { + public string? ProductId { get; set; } + [Required(ErrorMessage = "Product id is required.")] + public ProductDto? Product { get; set; } + [Required(ErrorMessage = "Location id is required.")] + public string Location { get; set; } + public int? Quantity { get; set; } + } +} diff --git a/src/CleanAspire.ClientApp/Program.cs b/src/CleanAspire.ClientApp/Program.cs index 160a715..b022bc5 100644 --- a/src/CleanAspire.ClientApp/Program.cs +++ b/src/CleanAspire.ClientApp/Program.cs @@ -1,14 +1,13 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using CleanAspire.ClientApp; -using Microsoft.JSInterop; -using System.Globalization; -using CleanAspire.ClientApp.Services.Interfaces; +using Microsoft.AspNetCore.Components.Web; var builder = WebAssemblyHostBuilder.CreateDefault(args); -//builder.RootComponents.Add("#app"); -//builder.RootComponents.Add("head::after"); - +#if STANDALONE +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); +#endif // register the cookie handler builder.Services.AddCoreServices(builder.Configuration); builder.Services.AddHttpClients(builder.Configuration); diff --git a/src/CleanAspire.ClientApp/Properties/launchSettings.json b/src/CleanAspire.ClientApp/Properties/launchSettings.json index 05afea0..f052442 100644 --- a/src/CleanAspire.ClientApp/Properties/launchSettings.json +++ b/src/CleanAspire.ClientApp/Properties/launchSettings.json @@ -1,25 +1,26 @@ { - "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": true, "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5180", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "ASPNETCORE_ENVIRONMENT": "Development", + "BLAZOR_RENDER_MODE": "Standalone" + }, + "dotnetRunMessages": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5180" }, "https": { "commandName": "Project", - "dotnetRunMessages": true, "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7123;http://localhost:5180", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "ASPNETCORE_ENVIRONMENT": "Standalone" + }, + "dotnetRunMessages": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7123;http://localhost:5180" } - } -} + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.de-DE.resx b/src/CleanAspire.ClientApp/Resources/App.de-DE.resx index 6e004f6..122d18a 100644 --- a/src/CleanAspire.ClientApp/Resources/App.de-DE.resx +++ b/src/CleanAspire.ClientApp/Resources/App.de-DE.resx @@ -648,4 +648,40 @@ Blazor Aspire + + Produkt ist erforderlich + + + Standort ist erforderlich + + + Menge muss größer als 0 sein + + + Lagerbestand erfolgreich erhalten + + + Lagerbestand erfolgreich versendet + + + Befehle + + + Produktname + + + Standort + + + Menge + + + Empfang + + + Lagerbestandsempfang + + + Lagerbestandversand + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.es-ES.resx b/src/CleanAspire.ClientApp/Resources/App.es-ES.resx index fe47e20..e4201a4 100644 --- a/src/CleanAspire.ClientApp/Resources/App.es-ES.resx +++ b/src/CleanAspire.ClientApp/Resources/App.es-ES.resx @@ -648,4 +648,40 @@ Blazor Aspire + + El producto es obligatorio + + + La ubicación es obligatoria + + + La cantidad debe ser mayor que 0 + + + Stock recibido con éxito + + + Stock enviado con éxito + + + Comandos + + + Nombre del producto + + + Ubicación + + + Cantidad + + + Recepción + + + Recepción de stock + + + Despacho de stock + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.fr-FR.resx b/src/CleanAspire.ClientApp/Resources/App.fr-FR.resx index 41d574d..8b36922 100644 --- a/src/CleanAspire.ClientApp/Resources/App.fr-FR.resx +++ b/src/CleanAspire.ClientApp/Resources/App.fr-FR.resx @@ -648,4 +648,40 @@ Blazor Aspire + + Le produit est requis + + + L'emplacement est requis + + + La quantité doit être supérieure à 0 + + + Stock reçu avec succès + + + Stock expédié avec succès + + + Commandes + + + Nom du produit + + + Emplacement + + + Quantité + + + Réception + + + Réception de stock + + + Expédition de stock + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.ja-JP.resx b/src/CleanAspire.ClientApp/Resources/App.ja-JP.resx index ba5a713..19f67bc 100644 --- a/src/CleanAspire.ClientApp/Resources/App.ja-JP.resx +++ b/src/CleanAspire.ClientApp/Resources/App.ja-JP.resx @@ -648,4 +648,40 @@ ブレイザー アスパイア + + 製品が必要です + + + 場所が必要です + + + 数量は0より大きくなければなりません + + + 在庫を正常に受け取りました + + + 在庫を正常に出荷しました + + + コマンド + + + 製品名 + + + 場所 + + + 数量 + + + 受け取り + + + 在庫受け取り + + + 在庫出荷 + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.ko-KR.resx b/src/CleanAspire.ClientApp/Resources/App.ko-KR.resx index 5203bbe..e5c297b 100644 --- a/src/CleanAspire.ClientApp/Resources/App.ko-KR.resx +++ b/src/CleanAspire.ClientApp/Resources/App.ko-KR.resx @@ -648,4 +648,40 @@ 블레이저 어스파이어 + + 제품이 필요합니다 + + + 위치가 필요합니다 + + + 수량은 0보다 커야 합니다 + + + 재고가 성공적으로 입고되었습니다 + + + 재고가 성공적으로 출고되었습니다 + + + 명령 + + + 제품 이름 + + + 위치 + + + 수량 + + + 수령 + + + 재고 수령 + + + 재고 출고 + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.pt-BR.resx b/src/CleanAspire.ClientApp/Resources/App.pt-BR.resx index f66ea7c..68ac05d 100644 --- a/src/CleanAspire.ClientApp/Resources/App.pt-BR.resx +++ b/src/CleanAspire.ClientApp/Resources/App.pt-BR.resx @@ -648,4 +648,40 @@ Blazor Aspire + + O produto é obrigatório + + + A localização é obrigatória + + + A quantidade deve ser maior que 0 + + + Estoque recebido com sucesso + + + Estoque despachado com sucesso + + + Comandos + + + Nome do produto + + + Localização + + + Quantidade + + + Recebimento + + + Recebimento de estoque + + + Despacho de estoque + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.resx b/src/CleanAspire.ClientApp/Resources/App.resx index 3566018..98a473a 100644 --- a/src/CleanAspire.ClientApp/Resources/App.resx +++ b/src/CleanAspire.ClientApp/Resources/App.resx @@ -648,4 +648,40 @@ Blazor Aspire + + Product is required. + + + Location is required. + + + Quantity must be greater than 0. + + + Stock received successfully. + + + Stock dispatched successfully. + + + Commands + + + Product Name + + + Location + + + Quantity + + + Receiving + + + Stock receiving + + + Stock dispatching + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Resources/App.zh-CN.resx b/src/CleanAspire.ClientApp/Resources/App.zh-CN.resx index 77155d7..e025907 100644 --- a/src/CleanAspire.ClientApp/Resources/App.zh-CN.resx +++ b/src/CleanAspire.ClientApp/Resources/App.zh-CN.resx @@ -648,4 +648,40 @@ Blazor 渴望 + + 产品是必需的 + + + 位置是必需的 + + + 数量必须大于0 + + + 库存接收成功 + + + 库存发货成功 + + + 命令 + + + 产品名称 + + + 位置 + + + 数量 + + + 收货 + + + 收货 + + + 发货 + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs b/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs index eb5903f..b5adbb4 100644 --- a/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs +++ b/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs @@ -26,14 +26,14 @@ public static class NavbarMenu { Label = "All Products", Href = "/products/index", - Status = PageStatus.New, + Status = PageStatus.Completed, Description = "View all available products in our inventory." }, new MenuItem { Label = "Stock Inquiry", - Href = "", - Status = PageStatus.Completed, + Href = "/stocks/index", + Status = PageStatus.New, Description = "Check product stock levels." }, new MenuItem diff --git a/src/CleanAspire.ClientApp/_Imports.razor b/src/CleanAspire.ClientApp/_Imports.razor index 4dc2b75..498fb8b 100644 --- a/src/CleanAspire.ClientApp/_Imports.razor +++ b/src/CleanAspire.ClientApp/_Imports.razor @@ -1,5 +1,6 @@ @using System.Net.Http @using System.Net.Http.Json +@using System.Globalization @using CleanAspire.Api.Client @using CleanAspire.Api.Client.Models @using CleanAspire.ClientApp diff --git a/src/CleanAspire.ClientApp/nginx.conf b/src/CleanAspire.ClientApp/nginx.conf new file mode 100644 index 0000000..8061dbe --- /dev/null +++ b/src/CleanAspire.ClientApp/nginx.conf @@ -0,0 +1,31 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Define a server block here + server { + listen 80; + listen 443 ssl; + + ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt; + ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key; + + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + error_page 404 /404.html; + location = /40x.html { + } + } +} \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/wwwroot/appsettings.json b/src/CleanAspire.ClientApp/wwwroot/appsettings.json index ad8d29d..e83e9a6 100644 --- a/src/CleanAspire.ClientApp/wwwroot/appsettings.json +++ b/src/CleanAspire.ClientApp/wwwroot/appsettings.json @@ -7,7 +7,7 @@ }, "ClientAppSettings": { "AppName": "Blazor Aspire", - "Version": "v0.0.61", + "Version": "v0.0.62", "ServiceBaseUrl": "https://apiservice.blazorserver.com" } } diff --git a/src/CleanAspire.Domain/Entities/Stock.cs b/src/CleanAspire.Domain/Entities/Stock.cs new file mode 100644 index 0000000..8f2cc32 --- /dev/null +++ b/src/CleanAspire.Domain/Entities/Stock.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CleanAspire.Domain.Common; + +namespace CleanAspire.Domain.Entities; + +/// +/// Represents a stock entity. +/// +public class Stock : BaseAuditableEntity, IAuditTrial +{ + /// + /// Gets or sets the product ID. + /// + public string? ProductId { get; set; } + + /// + /// Gets or sets the product associated with the stock. + /// + public Product? Product { get; set; } + + /// + /// Gets or sets the quantity of the stock. + /// + public int Quantity { get; set; } + + /// + /// Gets or sets the location of the stock. + /// + public string Location { get; set; } = string.Empty; +} diff --git a/src/CleanAspire.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanAspire.Infrastructure/Persistence/ApplicationDbContext.cs index 95392f6..6a2eb37 100644 --- a/src/CleanAspire.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/CleanAspire.Infrastructure/Persistence/ApplicationDbContext.cs @@ -8,28 +8,58 @@ using System.Reflection; namespace CleanAspire.Infrastructure.Persistence; -#nullable disable +/// +/// Represents the application database context. +/// public class ApplicationDbContext : IdentityDbContext, IApplicationDbContext { + /// + /// Initializes a new instance of the class. + /// + /// The options to be used by a . public ApplicationDbContext(DbContextOptions options) : base(options) { } - public DbSet Tenants { get; set; } + + /// + /// Gets or sets the Tenants DbSet. + /// + public DbSet Tenants { get; set; } + + /// + /// Gets or sets the AuditTrails DbSet. + /// public DbSet AuditTrails { get; set; } + + /// + /// Gets or sets the Products DbSet. + /// public DbSet Products { get; set; } + + /// + /// Gets or sets the Stocks DbSet. + /// + public DbSet Stocks { get; set; } + + /// + /// Configures the schema needed for the identity framework. + /// + /// The builder being used to construct the model for this context. protected override void OnModelCreating(ModelBuilder builder) { - base.OnModelCreating(builder); builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); - } + + /// + /// Configures the conventions to be used for this context. + /// + /// The builder being used to configure conventions for this context. protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { base.ConfigureConventions(configurationBuilder); configurationBuilder.Properties().HaveMaxLength(450); } - } diff --git a/src/CleanAspire.Infrastructure/Persistence/Configurations/StockConfiguration.cs b/src/CleanAspire.Infrastructure/Persistence/Configurations/StockConfiguration.cs new file mode 100644 index 0000000..230872e --- /dev/null +++ b/src/CleanAspire.Infrastructure/Persistence/Configurations/StockConfiguration.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using CleanAspire.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanAspire.Infrastructure.Persistence.Configurations; +/// +/// Configures the Stock entity. +/// +public class StockConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the properties and relationships of the Stock entity. + /// + /// The builder to be used to configure the Stock entity. + public void Configure(EntityTypeBuilder builder) + { + /// + /// Configures the ProductId property of the Stock entity. + /// + builder.Property(x => x.ProductId).HasMaxLength(50).IsRequired(); + + /// + /// Configures the relationship between the Stock and Product entities. + /// + builder.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade); + + /// + /// Configures the Location property of the Stock entity. + /// + builder.Property(x => x.Location).HasMaxLength(12).IsRequired(); + + /// + /// Ignores the DomainEvents property of the Stock entity. + /// + builder.Ignore(e => e.DomainEvents); + } +} diff --git a/src/CleanAspire.Infrastructure/Persistence/Seed/ApplicationDbContextInitializer.cs b/src/CleanAspire.Infrastructure/Persistence/Seed/ApplicationDbContextInitializer.cs index 709cc87..9096d14 100644 --- a/src/CleanAspire.Infrastructure/Persistence/Seed/ApplicationDbContextInitializer.cs +++ b/src/CleanAspire.Infrastructure/Persistence/Seed/ApplicationDbContextInitializer.cs @@ -114,60 +114,98 @@ private async Task SeedDataAsync() if (await _context.Products.AnyAsync()) return; _logger.LogInformation("Seeding data..."); var products = new List -{ - new Product -{ - Name = "Sony Bravia 65-inch 4K TV", - Description = "Sony's 65-inch Bravia 4K Ultra HD smart TV with HDR support and X-Motion Clarity. Features a slim bezel, Dolby Vision, and an immersive sound system. Perfect for high-definition streaming and gaming.", - Price = 1200, - SKU = "BRAVIA-65-4K", - UOM = "PCS", - Currency = "USD", - Category = ProductCategory.Electronics -}, -new Product -{ - Name = "Tesla Model S Plaid", - Description = "Tesla's flagship electric vehicle with a top speed of 200 mph and 0-60 in under 2 seconds. Equipped with Autopilot, long-range battery, and premium interior. Suitable for eco-conscious luxury seekers.", - Price = 120000, - SKU = "TESLA-MODEL-S-PLAID", - UOM = "PCS", - Currency = "USD", - Category = ProductCategory.Electronics -}, -new Product -{ - Name = "Apple iPhone 14 Pro Max", - Description = "Apple's latest iPhone featuring a 6.7-inch OLED display, A16 Bionic chip, advanced camera system with 48 MP main camera, and longer battery life. Ideal for photography and heavy app users.", - Price = 1099, - SKU = "IP14PRO-MAX", - UOM = "PCS", - Currency = "USD", - Category = ProductCategory.Electronics -}, -new Product -{ - Name = "Sony WH-1000XM5 Noise Cancelling Headphones", - Description = "Premium noise-cancelling over-ear headphones with 30-hour battery life, adaptive sound control, and Hi-Res audio support. Designed for frequent travelers and audiophiles seeking uninterrupted sound.", - Price = 349, - SKU = "WH-1000XM5", - UOM = "PCS", - Currency = "USD", - Category = ProductCategory.Electronics -}, -new Product -{ - Name = "Apple MacBook Pro 16-inch M2 Max", - Description = "Apple’s most powerful laptop featuring the M2 Max chip, a stunning 16-inch Liquid Retina XDR display, 64GB of unified memory, and up to 8TB SSD storage. Ideal for creative professionals needing high performance.", - Price = 4200, - SKU = "MACBOOK-PRO-M2-MAX", - UOM = "PCS", - Currency = "USD", - Category = ProductCategory.Electronics -} -}; + { + new Product + { + Name = "Ikea LACK Coffee Table", + Description = "Simple and stylish coffee table from Ikea, featuring a modern design and durable surface. Perfect for living rooms or offices.", + Price = 25, + SKU = "LACK-COFFEE-TABLE", + UOM = "PCS", + Currency = "USD", + Category = ProductCategory.Furniture + }, + new Product + { + Name = "Nike Air Zoom Pegasus 40", + Description = "Lightweight and responsive running shoes with advanced cushioning and a breathable mesh upper. Ideal for athletes and daily runners.", + Price = 130, + SKU = "NIKE-PEGASUS-40", + UOM = "PCS", + Currency = "USD", + Category = ProductCategory.Sports + }, + new Product + { + Name = "Adidas Yoga Mat", + Description = "Non-slip yoga mat with a 6mm thickness for optimal cushioning and support during workouts. Suitable for yoga, pilates, or general exercises.", + Price = 45, + SKU = "ADIDAS-YOGA-MAT", + UOM = "PCS", + Currency = "USD", + Category = ProductCategory.Sports + }, + new Product + { + Name = "Ikea HEMNES Bed Frame", + Description = "Solid wood bed frame with a classic design. Offers excellent durability and comfort. Compatible with standard-size mattresses.", + Price = 199, + SKU = "HEMNES-BED-FRAME", + UOM = "PCS", + Currency = "USD", + Category = ProductCategory.Furniture + }, + new Product + { + Name = "Under Armour Men's HeatGear Compression Shirt", + Description = "High-performance compression shirt designed to keep you cool and dry during intense workouts. Made from moisture-wicking fabric.", + Price = 35, + SKU = "UA-HEATGEAR-SHIRT", + UOM = "PCS", + Currency = "USD", + Category = ProductCategory.Sports + }, + new Product + { + Name = "Apple iPhone 15 Pro", + Description = "Apple's latest flagship smartphone featuring a 6.1-inch Super Retina XDR display, A17 Pro chip, titanium frame, and advanced camera system with 5x telephoto lens. Ideal for tech enthusiasts and professional users.", + Price = 1199, + SKU = "IP15PRO", + UOM = "PCS", + Currency = "USD", + Category = ProductCategory.Electronics + } + }; + await _context.Products.AddRangeAsync(products); await _context.SaveChangesAsync(); + var stocks = new List + { + new Stock + { + ProductId = products.FirstOrDefault(p => p.Name == "Ikea LACK Coffee Table")?.Id, + Product = products.FirstOrDefault(p => p.Name == "Ikea LACK Coffee Table"), + Quantity = 50, + Location = "FU-WH-0001" + }, + new Stock + { + ProductId = products.FirstOrDefault(p => p.Name == "Nike Air Zoom Pegasus 40")?.Id, + Product = products.FirstOrDefault(p => p.Name == "Nike Air Zoom Pegasus 40"), + Quantity = 100, + Location = "SP-WH-0001" + }, + new Stock + { + ProductId = products.FirstOrDefault(p => p.Name == "Apple iPhone 15 Pro")?.Id, + Product = products.FirstOrDefault(p => p.Name == "Apple iPhone 15 Pro"), + Quantity = 200, + Location = "EL-WH-0001" + } + }; + + await _context.Stocks.AddRangeAsync(stocks); + await _context.SaveChangesAsync(); } } diff --git a/src/CleanAspire.WebApp/appsettings.json b/src/CleanAspire.WebApp/appsettings.json index 1f91fcd..05f0e46 100644 --- a/src/CleanAspire.WebApp/appsettings.json +++ b/src/CleanAspire.WebApp/appsettings.json @@ -8,7 +8,7 @@ "AllowedHosts": "*", "ClientAppSettings": { "AppName": "Blazor Aspire", - "Version": "v0.0.61", + "Version": "v0.0.62", "ServiceBaseUrl": "https://apiservice.blazorserver.com" } } diff --git a/src/Migrators/Migrators.SQLite/Migrations/20250107010844_stock.Designer.cs b/src/Migrators/Migrators.SQLite/Migrations/20250107010844_stock.Designer.cs new file mode 100644 index 0000000..955fe1b --- /dev/null +++ b/src/Migrators/Migrators.SQLite/Migrations/20250107010844_stock.Designer.cs @@ -0,0 +1,541 @@ +// +using System; +using CleanAspire.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanAspire.Migrators.SQLite.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250107010844_stock")] + partial class stock + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("CleanAspire.Domain.Entities.AuditTrail", b => + { + b.Property("Id") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("AffectedColumns") + .HasColumnType("TEXT"); + + b.Property("AuditType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateTime") + .HasColumnType("TEXT"); + + b.Property("DebugView") + .HasMaxLength(2147483647) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2147483647) + .HasColumnType("TEXT"); + + b.Property("NewValues") + .HasColumnType("TEXT"); + + b.Property("OldValues") + .HasColumnType("TEXT"); + + b.Property("PrimaryKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TableName") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AuditTrails"); + }); + + modelBuilder.Entity("CleanAspire.Domain.Entities.Product", b => + { + b.Property("Id") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("SKU") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("UOM") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("CleanAspire.Domain.Entities.Stock", b => + { + b.Property("Id") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("TEXT"); + + b.Property("ProductId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("Stocks"); + }); + + modelBuilder.Entity("CleanAspire.Domain.Entities.Tenant", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("CleanAspire.Domain.Identities.ApplicationUser", b => + { + b.Property("Id") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AvatarUrl") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LanguageCode") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RefreshToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("SuperiorId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeZoneId") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SuperiorId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CleanAspire.Domain.Entities.AuditTrail", b => + { + b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("CleanAspire.Domain.Entities.Stock", b => + { + b.HasOne("CleanAspire.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("CleanAspire.Domain.Identities.ApplicationUser", b => + { + b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", "Superior") + .WithMany() + .HasForeignKey("SuperiorId"); + + b.Navigation("Superior"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrators/Migrators.SQLite/Migrations/20250107010844_stock.cs b/src/Migrators/Migrators.SQLite/Migrations/20250107010844_stock.cs new file mode 100644 index 0000000..4b0fc14 --- /dev/null +++ b/src/Migrators/Migrators.SQLite/Migrations/20250107010844_stock.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanAspire.Migrators.SQLite.Migrations +{ + /// + public partial class stock : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Stocks", + columns: table => new + { + Id = table.Column(type: "TEXT", maxLength: 450, nullable: false), + ProductId = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + Location = table.Column(type: "TEXT", maxLength: 12, nullable: false), + Created = table.Column(type: "TEXT", nullable: true), + CreatedBy = table.Column(type: "TEXT", maxLength: 450, nullable: true), + LastModified = table.Column(type: "TEXT", nullable: true), + LastModifiedBy = table.Column(type: "TEXT", maxLength: 450, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Stocks", x => x.Id); + table.ForeignKey( + name: "FK_Stocks_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Stocks_ProductId", + table: "Stocks", + column: "ProductId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Stocks"); + } + } +} diff --git a/src/Migrators/Migrators.SQLite/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrators/Migrators.SQLite/Migrations/ApplicationDbContextModelSnapshot.cs index d6a3285..9193308 100644 --- a/src/Migrators/Migrators.SQLite/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrators/Migrators.SQLite/Migrations/ApplicationDbContextModelSnapshot.cs @@ -123,6 +123,46 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Products"); }); + modelBuilder.Entity("CleanAspire.Domain.Entities.Stock", b => + { + b.Property("Id") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("TEXT"); + + b.Property("ProductId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("Stocks"); + }); + modelBuilder.Entity("CleanAspire.Domain.Entities.Tenant", b => { b.Property("Id") @@ -422,6 +462,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Owner"); }); + modelBuilder.Entity("CleanAspire.Domain.Entities.Stock", b => + { + b.HasOne("CleanAspire.Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + modelBuilder.Entity("CleanAspire.Domain.Identities.ApplicationUser", b => { b.HasOne("CleanAspire.Domain.Identities.ApplicationUser", "Superior") diff --git a/tests/CleanAspire.Tests/CleanAspire.Tests.csproj b/tests/CleanAspire.Tests/CleanAspire.Tests.csproj index b30b282..d4deea9 100644 --- a/tests/CleanAspire.Tests/CleanAspire.Tests.csproj +++ b/tests/CleanAspire.Tests/CleanAspire.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/tests/CleanAspire.Tests/StockEndpointTests.cs b/tests/CleanAspire.Tests/StockEndpointTests.cs new file mode 100644 index 0000000..b1fc399 --- /dev/null +++ b/tests/CleanAspire.Tests/StockEndpointTests.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Net.Http.Json; +using Projects; +using Aspire.Hosting; +using Newtonsoft.Json.Linq; + +namespace CleanAspire.Tests; + + + +[TestFixture] +public class StockEndpointTests +{ + // Adjust the backend route prefix to match your actual routes + private const string ApiBaseUrl = "/stocks"; + + private HttpClient _httpClient = null!; + private HttpClientHandler _httpClientHandler = null!; + + private DistributedApplication? _app; + private ResourceNotificationService? _resourceNotificationService; + + [SetUp] + public async Task Setup() + { + var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); + appHost.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + + _app = await appHost.BuildAsync(); + _resourceNotificationService = _app.Services.GetRequiredService(); + await _app.StartAsync(); + + _httpClient = _app.CreateHttpClient("apiservice"); + + await _resourceNotificationService + .WaitForResourceAsync("apiservice", KnownResourceStates.Running) + .WaitAsync(TimeSpan.FromSeconds(30)); + + await LoginAsync(); + } + + /// + /// Performs a login request to obtain an authentication cookie. + /// Make sure to modify it to your actual backend login route, for example "/account/login" or "/api/login". + /// Here we use "/login?useCookies=true" as an example. + /// + private async Task LoginAsync() + { + var loginRequest = new + { + Email = "administrator", + Password = "P@ssw0rd!" + }; + + // Ensure that your server-side Minimal API or Controller has defined POST /login + // and that Cookie Auth is enabled + var response = await _httpClient.PostAsJsonAsync("/login?useCookies=true", loginRequest); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + $"Login should return 200 OK. But was {response.StatusCode}"); + } + + /// + /// This is a complete stock process test: + /// 1) Pagination query of stocks + /// 2) Receiving new stock + /// 3) Dispatching stock + /// + [Test] + public async Task FullStockProcessTest() + { + // -------- STEP 1: Test pagination query -------- + var query = new + { + keywords = "", + pageNumber = 0, + pageSize = 15, + orderBy = "Id", + sortDirection = "Descending", + }; + + var paginationResponse = await _httpClient.PostAsJsonAsync($"{ApiBaseUrl}/pagination", query); + Assert.That(paginationResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK), + $"Pagination query at {ApiBaseUrl}/pagination should return 200 OK, but was {paginationResponse.StatusCode}"); + var jsonString = await paginationResponse.Content.ReadAsStringAsync(); + var paginatedResult = JObject.Parse(jsonString); + Assert.That(paginatedResult, Is.Not.Null, "Pagination result should not be null."); + Assert.That(Convert.ToInt32(paginatedResult["totalItems"]), Is.GreaterThan(0), "Pagination should return at least one item."); + + // Retrieve the first ProductId for subsequent steps + var productId = paginatedResult["items"][0]["productId"]?.ToString(); + Assert.That(productId, Is.Not.Null.And.Not.Empty, "ProductId should not be null or empty."); + + // -------- STEP 2: Test stock receiving -------- + var receiveCommand = new + { + ProductId = productId, + Quantity = 50, + Location = "WH-02" + }; + + var receiveResponse = await _httpClient.PostAsJsonAsync($"{ApiBaseUrl}/receive", receiveCommand); + Assert.That(receiveResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK), + $"Stock receiving at {ApiBaseUrl}/receive should return 200 OK, but was {receiveResponse.StatusCode}"); + + // -------- STEP 3: Test stock dispatching -------- + var dispatchCommand = new + { + ProductId = productId, + Quantity = 20, + Location = "WH-02" + }; + + var dispatchResponse = await _httpClient.PostAsJsonAsync($"{ApiBaseUrl}/dispatch", dispatchCommand); + Assert.That(dispatchResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK), + $"Stock dispatching at {ApiBaseUrl}/dispatch should return 200 OK, but was {dispatchResponse.StatusCode}"); + } + + [TearDown] + public async Task TearDown() + { + // Dispose of the HttpClient and Handler + _httpClient?.Dispose(); + _httpClientHandler?.Dispose(); + + // Dispose of the _app if it's not null + if (_app is not null) + { + await _app.DisposeAsync(); + } + } +} + + + +