Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tutorial: Add Stock Entity and Features for Inventory Management #36

Merged
merged 35 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
82fa5d4
Step 1: Add the Stock Entity in the CleanAspire.Domain Project
neozhu Jan 6, 2025
485b2a7
create StockConfiguration
neozhu Jan 6, 2025
efd5431
Step 2: Add Stock Features in the CleanAspire.Application Project
neozhu Jan 6, 2025
766484e
create StockDispatchingCommandValidator, StockReceivingCommandValidator
neozhu Jan 6, 2025
8805bdf
Step 3: Add and Register the Stock Endpoint in the CleanAspire.Api Pr…
neozhu Jan 7, 2025
23fc94e
add-migration stock
neozhu Jan 7, 2025
d96f978
testing api
neozhu Jan 7, 2025
2084320
add unit test
neozhu Jan 7, 2025
c1fdb23
Update StockEndpointTests.cs
neozhu Jan 7, 2025
95c0bcd
Step 5: Add a Blazor Page for Stock Operations in the CleanAspire.Cli…
neozhu Jan 7, 2025
e77e5b8
ver 0.0.62
neozhu Jan 7, 2025
1b29c8d
add language resource
neozhu Jan 7, 2025
b4eca5f
Update README.md
neozhu Jan 7, 2025
fb32ea6
Update NavbarMenu.cs
neozhu Jan 7, 2025
7bf43ca
commit
neozhu Jan 8, 2025
a7595e1
add build blazor webassembly standalone image
neozhu Jan 8, 2025
07f3028
fix dockerfile
neozhu Jan 8, 2025
0255481
Update Program.cs
neozhu Jan 8, 2025
5ffa9a2
Update Dockerfile
neozhu Jan 8, 2025
f488563
Update CleanAspire.ClientApp.csproj
neozhu Jan 8, 2025
e910eac
Update Dockerfile
neozhu Jan 8, 2025
0b67a77
Update index.html
neozhu Jan 8, 2025
3051b3c
Update Dockerfile
neozhu Jan 8, 2025
2059c1c
Update CleanAspire.ClientApp.csproj
neozhu Jan 8, 2025
d9d7e5c
Update Dockerfile
neozhu Jan 8, 2025
fcf4fb9
Update CleanAspire.ClientApp.csproj
neozhu Jan 8, 2025
0e5b3ac
Update Dockerfile
neozhu Jan 8, 2025
536ccec
Update Dockerfile
neozhu Jan 8, 2025
b7976e6
Update Dockerfile
neozhu Jan 8, 2025
33441fa
Update Dockerfile
neozhu Jan 8, 2025
3448142
commit
neozhu Jan 8, 2025
e82d6ef
Update CleanAspire.ClientApp.csproj
neozhu Jan 8, 2025
deb8d8a
commit
neozhu Jan 8, 2025
42c641b
commit
neozhu Jan 8, 2025
dd50625
add standalone container
neozhu Jan 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
6 changes: 3 additions & 3 deletions CleanAspire.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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=*
Expand All @@ -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=<your client id>
- Authentication__Google__ClientSecret=<your client secret>
- SendGrid__ApiKey=<your API key>
Expand All @@ -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=*
Expand All @@ -121,6 +122,12 @@ services:
- "8015:80"
- "8014:443"

standalone:
image: blazordevlab/cleanaspire-standalone:0.0.62
ports:
- "8020:80"
- "8021:443"



```
Expand Down
47 changes: 47 additions & 0 deletions src/CleanAspire.Api/Endpoints/StockEndpointRegistrar.cs
Original file line number Diff line number Diff line change
@@ -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<ProductEndpointRegistrar> 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<Unit>(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<Unit>(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<PaginatedResult<StockDto>>(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.");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ public async ValueTask<bool> 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,
Expand Down
14 changes: 13 additions & 1 deletion src/CleanAspire.Api/OpenApiTransformersExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
namespace CleanAspire.Application.Common.Interfaces;

/// <summary>
/// Represents the application database context interface.
/// </summary>
public interface IApplicationDbContext
{
/// <summary>
/// Gets or sets the Products DbSet.
/// </summary>
DbSet<Product> Products { get; set; }

/// <summary>
/// Gets or sets the AuditTrails DbSet.
/// </summary>
DbSet<AuditTrail> AuditTrails { get; set; }

/// <summary>
/// Gets or sets the Tenants DbSet.
/// </summary>
DbSet<Tenant> Tenants { get; set; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken=default);

/// <summary>
/// Gets or sets the Stocks DbSet.
/// </summary>
DbSet<Stock> Stocks { get; set; }

/// <summary>
/// Saves all changes made in this context to the database.
/// </summary>
/// <param name="cancellationToken">A CancellationToken to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous save operation. The task result contains the number of state entries written to the database.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace CleanAspire.Application.Common.Models;
public class PaginatedResult<T>
{
public PaginatedResult() { }

Check warning on line 5 in src/CleanAspire.Application/Common/Models/PaginatedResult.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Items' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public PaginatedResult(IEnumerable<T> items, int total, int pageIndex, int pageSize)
{
Items = items;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, string
public static async Task<PaginatedResult<TResult>> ProjectToPaginatedDataAsync<T, TResult>(
this IOrderedQueryable<T> query,
Expression<Func<T, bool>>? condition,
int pageNumber,
int pageNumber,
int pageSize,
Func<T, TResult> mapperFunc,
Func<T, TResult> mapperFunc,
CancellationToken cancellationToken = default) where T : class, IEntity
{
if (condition != null)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PaginatedResult<ProductDto>>
public record ProductsWithPaginationQuery(string Keywords, int PageNumber = 0, int PageSize = 15, string OrderBy = "Id", string SortDirection = "Descending") : IFusionCacheRequest<PaginatedResult<ProductDto>>
{
public IEnumerable<string>? Tags => new[] { "products" };
public string CacheKey => $"productswithpagination_{Keywords}_{PageNumber}_{PageSize}_{OrderBy}_{SortDirection}";
Expand All @@ -20,7 +20,7 @@
{
var data = await _context.Products.OrderBy(request.OrderBy, request.SortDirection)
.ProjectToPaginatedDataAsync(
condition: x => x.SKU.Contains(request.Keywords) || x.Name.Contains(request.Keywords) || x.Description.Contains(request.Keywords),

Check warning on line 23 in src/CleanAspire.Application/Features/Products/Queries/ProductsWithPaginationQuery.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
pageNumber: request.PageNumber,
pageSize: request.PageSize,
mapperFunc: t => new ProductDto
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Unit>, IRequiresValidation
{
public string ProductId { get; init; } = string.Empty;
public int Quantity { get; init; }
public string Location { get; init; } = string.Empty;
public IEnumerable<string>? Tags => new[] { "stocks" };
}
public class StockDispatchingCommandHandler : IRequestHandler<StockDispatchingCommand, Unit>
{
private readonly IApplicationDbContext _context;

public StockDispatchingCommandHandler(IApplicationDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}

public async ValueTask<Unit> 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;
}

}
Original file line number Diff line number Diff line change
@@ -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<Unit>, IRequiresValidation
{
public string ProductId { get; init; } = string.Empty;
public int Quantity { get; init; }
public string Location { get; init; } = string.Empty;
public IEnumerable<string>? Tags => new[] { "stocks" };
}
public class StockReceivingCommandHandler : IRequestHandler<StockReceivingCommand, Unit>
{
private readonly IApplicationDbContext _context;

public StockReceivingCommandHandler(IApplicationDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}

public async ValueTask<Unit> 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;
}
}
Loading
Loading