Appearance
Inventory API
The Inventory service manages stock items, warehouses, transfers, and stock adjustments for all tenants. It runs as a dedicated microservice (Inventory.Apis) on the Private CAE, separate from AppsPortal, to allow independent scaling during high-volume stock operations.
Overview
| Property | Value |
|---|---|
| Service | Inventory.Apis |
| CAE placement | Private CAE |
| Gateway route prefix | /inventory-apis/ |
| Source | Platforms/Src/AppsPortal/Accounting/ (Inventory sub-module) |
| Architecture | Clean Architecture + CQRS + MediatR |
| Auth realm | microtec (Keycloak) |
Domain Model
API Endpoints
Items
| Method | Route | Operation | CQRS Handler |
|---|---|---|---|
GET | /Items | List items (paged, filterable) | GetAllItemsQuery |
GET | /Items/{Id} | Get item by ID | GetByIdItemQuery |
GET | /Items/DropDown | Items for select fields | GetDropdownItemQuery |
POST | /Items | Create item | AddItemCommand |
PUT | /Items | Update item | EditItemCommand |
DELETE | /Items/{Id} | Delete item | DeleteItemCommand |
GET | /Items/Export | Export items to Excel | ExportItemQuery |
GET | /Items/StockBalance | Current stock by warehouse | GetItemStockBalanceQuery |
Warehouses
| Method | Route | Operation | CQRS Handler |
|---|---|---|---|
GET | /Warehouses | List warehouses | GetAllWarehousesQuery |
GET | /Warehouses/{Id} | Get warehouse by ID | GetByIdWarehouseQuery |
GET | /Warehouses/DropDown | Warehouses for select fields | GetDropdownWarehouseQuery |
POST | /Warehouses | Create warehouse | AddWarehouseCommand |
PUT | /Warehouses | Update warehouse | EditWarehouseCommand |
DELETE | /Warehouses/{Id} | Delete warehouse | DeleteWarehouseCommand |
Stock Transfers
| Method | Route | Operation | CQRS Handler |
|---|---|---|---|
GET | /StockTransfers | List transfers (paged) | GetAllStockTransfersQuery |
GET | /StockTransfers/{Id} | Get transfer by ID | GetByIdStockTransferQuery |
POST | /StockTransfers | Create transfer | AddStockTransferCommand |
PUT | /StockTransfers | Edit draft transfer | EditStockTransferCommand |
POST | /StockTransfers/Confirm/{Id} | Confirm and move stock | ConfirmStockTransferCommand |
POST | /StockTransfers/Cancel/{Id} | Cancel transfer | CancelStockTransferCommand |
Stock Adjustments
| Method | Route | Operation | CQRS Handler |
|---|---|---|---|
GET | /StockAdjustments | List adjustments (paged) | GetAllStockAdjustmentsQuery |
GET | /StockAdjustments/{Id} | Get adjustment by ID | GetByIdStockAdjustmentQuery |
POST | /StockAdjustments | Create adjustment | AddStockAdjustmentCommand |
PUT | /StockAdjustments | Edit draft adjustment | EditStockAdjustmentCommand |
POST | /StockAdjustments/Post/{Id} | Post adjustment and update balances | PostStockAdjustmentCommand |
CQRS Examples
AddItemCommand
csharp
// Commands/AddItem/AddItemCommand.cs
public record AddItemCommand(AddItemDto Dto) : ICommand<int>;
// Commands/AddItem/Dto/AddItemDto.cs
public record AddItemDto(
string Code,
string NameAr,
string NameEn,
ItemType Type,
int CategoryId,
int UnitOfMeasureId,
decimal CostPrice,
decimal SalePrice,
bool IsStockable);
// Commands/AddItem/AddItemCommandValidator.cs
public class AddItemCommandValidator : AbstractValidator<AddItemCommand>
{
public AddItemCommandValidator()
{
RuleFor(x => x.Dto.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Dto.NameAr).NotEmpty().MaximumLength(200);
RuleFor(x => x.Dto.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.Dto.CostPrice).GreaterThanOrEqualTo(0);
RuleFor(x => x.Dto.SalePrice).GreaterThanOrEqualTo(0);
RuleFor(x => x.Dto.CategoryId).GreaterThan(0);
RuleFor(x => x.Dto.UnitOfMeasureId).GreaterThan(0);
}
}
// Commands/AddItem/AddItemCommandHandler.cs
public class AddItemCommandHandler(
IUnitOfWork<InventoryDbContext> unitOfWork) : IRequestHandler<AddItemCommand, ApiResponse<int>>
{
public async Task<ApiResponse<int>> Handle(AddItemCommand request, CancellationToken ct)
{
var item = Item.Create(
request.Dto.Code,
request.Dto.NameAr,
request.Dto.NameEn,
request.Dto.Type,
request.Dto.CategoryId,
request.Dto.UnitOfMeasureId,
request.Dto.CostPrice,
request.Dto.SalePrice,
request.Dto.IsStockable);
await unitOfWork.Repository<Item>().AddAsync(item, ct);
await unitOfWork.SaveChangesAsync(ct);
return ApiResponse.Success(item.Id);
}
}ConfirmStockTransferCommand
csharp
// Commands/ConfirmStockTransfer/ConfirmStockTransferCommand.cs
public record ConfirmStockTransferCommand(int Id) : ICommand;
public class ConfirmStockTransferCommandHandler(
IUnitOfWork<InventoryDbContext> unitOfWork,
IClockService clock) : IRequestHandler<ConfirmStockTransferCommand, ApiResponse>
{
public async Task<ApiResponse> Handle(ConfirmStockTransferCommand request, CancellationToken ct)
{
var transfer = await unitOfWork.Repository<StockTransfer>()
.GetQueryable()
.Include(t => t.Lines)
.FirstOrDefaultAsync(t => t.Id == request.Id, ct)
?? throw new NotFoundException(nameof(StockTransfer), request.Id);
transfer.Confirm(clock.GetNow());
// Move stock: decrement source, increment destination
foreach (var line in transfer.Lines)
{
var fromBalance = await GetOrCreateBalance(line.ItemId, transfer.FromWarehouseId, ct);
var toBalance = await GetOrCreateBalance(line.ItemId, transfer.ToWarehouseId, ct);
fromBalance.Decrement(line.TransferredQty);
toBalance.Increment(line.TransferredQty);
}
await unitOfWork.SaveChangesAsync(ct);
return ApiResponse.Success();
}
private async Task<StockBalance> GetOrCreateBalance(int itemId, int warehouseId, CancellationToken ct)
{
var balance = await unitOfWork.Repository<StockBalance>()
.GetQueryable()
.FirstOrDefaultAsync(b => b.ItemId == itemId && b.WarehouseId == warehouseId, ct);
if (balance is null)
{
balance = StockBalance.Create(itemId, warehouseId);
await unitOfWork.Repository<StockBalance>().AddAsync(balance, ct);
}
return balance;
}
}Stock Balance Query
Querying stock balances always uses AsNoTracking() for read performance:
csharp
// Queries/GetItemStockBalance/GetItemStockBalanceQueryHandler.cs
public class GetItemStockBalanceQueryHandler(
IUnitOfWork<InventoryDbContext> unitOfWork)
: IRequestHandler<GetItemStockBalanceQuery, ApiResponse<List<StockBalanceDto>>>
{
public async Task<ApiResponse<List<StockBalanceDto>>> Handle(
GetItemStockBalanceQuery request, CancellationToken ct)
{
var balances = await unitOfWork.Repository<StockBalance>()
.GetQueryable()
.AsNoTracking()
.Where(b => b.ItemId == request.ItemId)
.Select(b => new StockBalanceDto(
b.WarehouseId,
b.Warehouse.NameAr,
b.Warehouse.NameEn,
b.QuantityOnHand,
b.QuantityReserved,
b.QuantityAvailable))
.ToListAsync(ct);
return ApiResponse.Success(balances);
}
}Transfer Flow
Adjustment Types
| Type | Description | Stock Effect |
|---|---|---|
Increase | Stock counted higher than system | + Quantity |
Decrease | Stock counted lower than system | – Quantity |
WriteOff | Damaged, expired, or lost goods | – Quantity |
InitialLoad | First-time stock loading | + Quantity |
Configuration
json
// appsettings.json (Inventory.Apis)
{
"ConnectionStrings": {
"AdminConnection": "Server=...;Database=MicrotecAdmin;..."
},
"Keycloak": {
"Authority": "https://<keycloak-host>/realms/microtec",
"Audience": "account"
}
}Local Build
bash
cd Platforms
# Build Inventory API project only
dotnet build Src/AppsPortal/Accounting/AppsPortal.Apis/AppsPortal.Apis.csproj
# Run Inventory service
dotnet run --project Src/Inventory/Inventory.Apis/Inventory.Apis.csprojDeployment
Inventory is listed in the pipeline service config at:
Devops/azure/config/container-backend/services-config.json → "inventory"The pipeline auto-detects changes in the Inventory source path and rebuilds the container image for the target environment.