Skip to content

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

PropertyValue
ServiceInventory.Apis
CAE placementPrivate CAE
Gateway route prefix/inventory-apis/
SourcePlatforms/Src/AppsPortal/Accounting/ (Inventory sub-module)
ArchitectureClean Architecture + CQRS + MediatR
Auth realmmicrotec (Keycloak)

Domain Model


API Endpoints

Items

MethodRouteOperationCQRS Handler
GET/ItemsList items (paged, filterable)GetAllItemsQuery
GET/Items/{Id}Get item by IDGetByIdItemQuery
GET/Items/DropDownItems for select fieldsGetDropdownItemQuery
POST/ItemsCreate itemAddItemCommand
PUT/ItemsUpdate itemEditItemCommand
DELETE/Items/{Id}Delete itemDeleteItemCommand
GET/Items/ExportExport items to ExcelExportItemQuery
GET/Items/StockBalanceCurrent stock by warehouseGetItemStockBalanceQuery

Warehouses

MethodRouteOperationCQRS Handler
GET/WarehousesList warehousesGetAllWarehousesQuery
GET/Warehouses/{Id}Get warehouse by IDGetByIdWarehouseQuery
GET/Warehouses/DropDownWarehouses for select fieldsGetDropdownWarehouseQuery
POST/WarehousesCreate warehouseAddWarehouseCommand
PUT/WarehousesUpdate warehouseEditWarehouseCommand
DELETE/Warehouses/{Id}Delete warehouseDeleteWarehouseCommand

Stock Transfers

MethodRouteOperationCQRS Handler
GET/StockTransfersList transfers (paged)GetAllStockTransfersQuery
GET/StockTransfers/{Id}Get transfer by IDGetByIdStockTransferQuery
POST/StockTransfersCreate transferAddStockTransferCommand
PUT/StockTransfersEdit draft transferEditStockTransferCommand
POST/StockTransfers/Confirm/{Id}Confirm and move stockConfirmStockTransferCommand
POST/StockTransfers/Cancel/{Id}Cancel transferCancelStockTransferCommand

Stock Adjustments

MethodRouteOperationCQRS Handler
GET/StockAdjustmentsList adjustments (paged)GetAllStockAdjustmentsQuery
GET/StockAdjustments/{Id}Get adjustment by IDGetByIdStockAdjustmentQuery
POST/StockAdjustmentsCreate adjustmentAddStockAdjustmentCommand
PUT/StockAdjustmentsEdit draft adjustmentEditStockAdjustmentCommand
POST/StockAdjustments/Post/{Id}Post adjustment and update balancesPostStockAdjustmentCommand

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

TypeDescriptionStock Effect
IncreaseStock counted higher than system+ Quantity
DecreaseStock counted lower than system– Quantity
WriteOffDamaged, expired, or lost goods– Quantity
InitialLoadFirst-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.csproj

Deployment

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.

Internal Documentation — Microtec Platform Team