Skip to content

Commit

Permalink
Merge pull request #36 from neozhu/feature/stock
Browse files Browse the repository at this point in the history
Tutorial: Add Stock Entity and Features for Inventory Management
  • Loading branch information
neozhu authored Jan 8, 2025
2 parents 7391f65 + dd50625 commit e87ebed
Show file tree
Hide file tree
Showing 58 changed files with 3,091 additions and 113 deletions.
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 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

0 comments on commit e87ebed

Please sign in to comment.