# Inventory Service - Service Documentation > Auto-generated from source code audit on 2026-03-13 ## Overview **Service**: inventory-service-net **Framework**: .NET 10.0 (C# 14), ASP.NET Core Web API **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) **Database**: PostgreSQL (Neon) via EF Core 10 + Npgsql 10 **Port**: 5018 (Development) **Base Route**: `api/v1/inventory` **Database Name**: `inventory_service` The Inventory Service manages stock levels, reservations, transactions, and low-stock alerts for shop inventory items. It supports both catalog-linked finished goods and standalone raw materials/consumables. Features multi-tenant row-level security via global query filters and PostgreSQL session variables, idempotent bulk deduction for kitchen ticket integration, and a full audit trail of all inventory movements. --- ## API Endpoints | Method | Route | Description | Request Body / Query | Response | |--------|-------|-------------|---------------------|----------| | GET | `/api/v1/inventory` | Get inventory by shop (paginated) | `?shopId={guid}&skip=0&take=50` | `ApiResponse>` | | GET | `/api/v1/inventory/{productId}` | Get stock level by product+shop | `?shopId={guid}` | `ApiResponse` | | POST | `/api/v1/inventory/stock/bulk` | Bulk stock check (POS cart validation) | `BulkStockCheckRequest` | `ApiResponse>` | | PUT | `/api/v1/inventory/alerts` | Set low stock alert threshold | `SetLowStockAlertRequest` | `ApiResponse` | | POST | `/api/v1/inventory/items` | Create new inventory item | `CreateInventoryItemRequest` | `ApiResponse` (201 Created) | | POST | `/api/v1/inventory/stock-in` | Stock in (add inventory) | `StockInRequest` | `ApiResponse` | | POST | `/api/v1/inventory/stock-out` | Stock out (by productId+shopId) | `StockOutRequest` | `ApiResponse` | | POST | `/api/v1/inventory/stock-out-by-id` | Stock out by inventory item ID | `StockOutByIdRequest` | `ApiResponse` | | POST | `/api/v1/inventory/reserve` | Reserve stock for order | `ReserveStockRequest` | `ApiResponse` | | POST | `/api/v1/inventory/release` | Release stock reservation | `ReleaseReservationRequest` | `ApiResponse` | | POST | `/api/v1/inventory/adjust` | Manual stock adjustment | `AdjustStockRequest` | `ApiResponse` | | POST | `/api/v1/inventory/wastage` | Record wastage/shrinkage | `RecordWastageRequest` | `ApiResponse` | | POST | `/api/v1/inventory/stocktake` | Perform stocktake (inventory count) | `StocktakeRequest` | `ApiResponse` | | POST | `/api/v1/inventory/deduct` | Bulk deduct (kitchen ticket served) | `DeductInventoryRequest` | `ApiResponse` | | DELETE | `/api/v1/inventory/items/{id}` | Delete inventory item | - | `ApiResponse` | | GET | `/api/v1/inventory/transactions` | Get transaction history | `?inventoryItemId={guid}&shopId={guid}&skip=0&take=50` | `ApiResponse>` | | GET | `/api/v1/inventory/low-stock` | Get low stock items | `?shopId={guid}&skip=0&take=50` | `ApiResponse>` | | GET | `/health` | Health check (all) | - | Healthy/Unhealthy | | GET | `/health/live` | Liveness probe | - | Healthy | | GET | `/health/ready` | Readiness probe (DB check) | - | Healthy/Unhealthy | --- ## Commands ### CreateInventoryItemCommand - **Input**: `ShopId (Guid)`, `Name (string)`, `ItemTypeId (int)`, `Unit (string)`, `CostPerUnit (decimal)`, `InitialQuantity (int)`, `ReorderLevel (int)`, `SupplierName (string?)`, `ExpiryDate (DateTime?)` - **Returns**: `Guid` (new item ID) - **Logic**: Resolves `ItemType` from `Enumeration.FromValue(ItemTypeId)`. Creates `InventoryItem` with full details (raw materials / consumables constructor). Persists via repository + UnitOfWork. - **Validator**: `CreateInventoryItemCommandValidator` -- ShopId NotEmpty, Name NotEmpty + MaxLength(200), ItemTypeId InclusiveBetween(1,3), Unit NotEmpty + MaxLength(20), CostPerUnit >= 0, InitialQuantity >= 0, ReorderLevel >= 0. ### StockInCommand - **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `Notes (string?)`, `ReferenceId (Guid?)`, `InvoiceImageUrl (string?)`, `UnitCost (decimal?)` - **Returns**: `Guid` (inventory item ID) - **Logic**: Finds existing item by ProductId+ShopId, or creates a new `InventoryItem` (FinishedGood type). Calls `item.StockIn()` which increments quantity and creates an `InventoryTransaction(type=In)`. Raises `StockChangedDomainEvent`. - **Validator**: `StockInCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0. ### StockOutCommand - **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `Notes (string?)`, `ReferenceId (Guid?)` - **Returns**: `bool` - **Logic**: Finds item by ProductId+ShopId. Calls `item.StockOut()` which validates available quantity, decrements quantity, creates `InventoryTransaction(type=Out)`. Raises `StockChangedDomainEvent`. Returns false if item not found. - **Validator**: `StockOutCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0. ### StockOutByIdCommand - **Input**: `InventoryItemId (Guid)`, `Amount (int)`, `Notes (string?)`, `ReferenceId (Guid?)` - **Returns**: `bool` - **Logic**: Finds item by ID directly (for recipe ingredient deduction). Calls `item.StockOut()`. Returns false if not found. - **Validator**: None (no dedicated validator). ### ReserveStockCommand - **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `OrderId (Guid)` - **Returns**: `bool` - **Logic**: Finds item by ProductId+ShopId. Calls `item.Reserve()` which validates available quantity, increments `_reservedQuantity`, creates `InventoryTransaction(type=Reserve)`. Returns false if not found. - **Validator**: `ReserveStockCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0, OrderId NotEmpty. ### ReleaseReservationCommand - **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `OrderId (Guid)` - **Returns**: `bool` - **Logic**: Finds item by ProductId+ShopId. Calls `item.ReleaseReservation()` which validates reserved >= amount, decrements `_reservedQuantity`, creates `InventoryTransaction(type=Release)`. Returns false if not found. - **Validator**: `ReleaseReservationCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0, OrderId NotEmpty. ### AdjustStockCommand - **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `NewQuantity (int)`, `Notes (string)` - **Returns**: `bool` - **Logic**: Finds item by ProductId+ShopId. Calls `item.Adjust()` which sets quantity directly and creates `InventoryTransaction(type=Adjustment)` with diff. Raises `StockChangedDomainEvent`. Returns false if not found. - **Validator**: `AdjustStockCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, NewQuantity >= 0, Notes NotEmpty. ### SetLowStockAlertCommand - **Input**: `ShopId (Guid)`, `ProductId (Guid)`, `MinimumLevel (int)` - **Returns**: `bool` - **Logic**: Finds item by ProductId+ShopId. Calls `item.SetReorderLevel()` which validates >= 0 and updates `_reorderLevel`. Returns false if not found. - **Validator**: `SetLowStockAlertCommandValidator` -- ShopId NotEmpty, ProductId NotEmpty, MinimumLevel >= 0. ### RecordWastageCommand - **Input**: `InventoryItemId (Guid)`, `Amount (int)`, `Reason (string)`, `Notes (string?)` - **Returns**: `bool` - **Logic**: Finds item by ID. Calls `item.RecordWastage()` which validates amount > 0 and reason non-empty, decrements quantity, creates `InventoryTransaction(type=Wastage)`. Raises `StockChangedDomainEvent`. Returns false if not found. - **Validator**: None (domain validation only). ### StocktakeCommand - **Input**: `ShopId (Guid)`, `Items (List)` where `StocktakeItem(InventoryItemId, CountedQuantity)` - **Returns**: `StocktakeResult(Discrepancies, TotalItemsCounted)` - **Logic**: Iterates items, compares counted vs expected quantity, records discrepancies, calls `item.Adjust()` for each. Saves all in one transaction. - **Validator**: None. ### DeleteInventoryItemCommand - **Input**: `InventoryItemId (Guid)` - **Returns**: `bool` - **Logic**: Finds item by ID. Calls `repository.Delete()`. Saves via `SaveEntitiesAsync`. Returns false if not found. - **Validator**: None. ### DeductInventoryCommand - **Input**: `ShopId (Guid)`, `ReferenceId (Guid)`, `ReferenceType (string)`, `Reason (string)`, `Items (List)` where `DeductionItem(InventoryItemId, Amount, Unit, IngredientName)` - **Returns**: `DeductInventoryResult(Success, ItemsDeducted, ItemsSkipped, Items)` - **Logic**: **Idempotency check** via `IRequestManager` -- skips if ReferenceId already processed. Records request. Iterates items: if item not found, skip; if insufficient stock, partially deducts what's available; otherwise full deduction via `item.StockOut()`. Each item failure is isolated (continues with remaining). Saves all changes in one transaction via `SaveEntitiesAsync`. - **Validator**: `DeductInventoryCommandValidator` -- ShopId NotEmpty, ReferenceId NotEmpty, ReferenceType NotEmpty, Items NotEmpty, each item: InventoryItemId NotEmpty, Amount > 0. --- ## Queries ### GetInventoryByShopQuery - **Input**: `ShopId (Guid)`, `Skip (int, default=0)`, `Take (int, default=50)` - **Returns**: `PagedResult` - **Logic**: Filters by ShopId, orders by ProductId, applies skip/take pagination. ### GetStockLevelQuery - **Input**: `ProductId (Guid)`, `ShopId (Guid)` - **Returns**: `InventoryItemDto?` - **Logic**: Finds single item by ProductId+ShopId. Returns null if not found. ### GetStockLevelsQuery (Bulk) - **Input**: `ShopId (Guid)`, `ProductIds (List)` - **Returns**: `IReadOnlyList` - **Logic**: Fetches all items matching ShopId and ProductIds. Maps to `StockLevelDto`. Products without inventory records return zero stock with `IsLowStock=true`. ### GetTransactionsQuery - **Input**: `InventoryItemId (Guid)`, `Skip (int)`, `Take (int)` - **Returns**: `PagedResult` - **Logic**: Loads item with `.Include(Transactions)`, orders by CreatedAt descending, applies pagination. ### GetTransactionsByShopQuery - **Input**: `ShopId (Guid)`, `Skip (int)`, `Take (int)` - **Returns**: `PagedResult` - **Logic**: Loads all items for shop with `.Include(Transactions)`, flattens all transactions, orders by CreatedAt descending, applies pagination. ### GetLowStockItemsQuery - **Input**: `ShopId (Guid?, optional)`, `Skip (int)`, `Take (int)` - **Returns**: `PagedResult` - **Logic**: Filters items where `(Quantity - ReservedQuantity) <= ReorderLevel`. Optional ShopId filter. Orders by available quantity ascending. --- ## Domain Model ### InventoryItem (Aggregate Root) **Entity**: `InventoryService.Domain.AggregatesModel.InventoryAggregate.InventoryItem` **Extends**: `Entity`, implements `IAggregateRoot` **Private Fields / Public Properties**: | Field | Type | Property | Description | |-------|------|----------|-------------| | `_productId` | `Guid` | `ProductId` | Product ID from Catalog Service | | `_shopId` | `Guid` | `ShopId` | Shop that owns this inventory | | `_name` | `string?` | `Name` | Item name (for raw materials not linked to catalog) | | `_itemType` | `ItemType` | `ItemType` | Enumeration (RawMaterial/FinishedGood/Consumable) | | - | `int` | `ItemTypeId` | EF Core mapping for item type (private set) | | `_unit` | `string` | `Unit` | Unit of measure (g, ml, pcs, kg, L) default "pcs" | | `_costPerUnit` | `decimal` | `CostPerUnit` | Purchase price per unit | | `_supplierName` | `string?` | `SupplierName` | Supplier reference | | `_expiryDate` | `DateTime?` | `ExpiryDate` | For perishable items | | `_quantity` | `int` | `Quantity` | Total quantity in stock | | `_reservedQuantity` | `int` | `ReservedQuantity` | Reserved for orders | | - (computed) | `int` | `AvailableQuantity` | `Quantity - ReservedQuantity` | | `_reorderLevel` | `int` | `ReorderLevel` | Low stock alert threshold, default 10 | | `_transactions` | `List` | `Transactions` | Transaction history (readonly collection) | | `_createdAt` | `DateTime` | `CreatedAt` | Creation timestamp | | `_updatedAt` | `DateTime?` | `UpdatedAt` | Last update timestamp | **Constructors**: 1. `InventoryItem(productId, shopId, reorderLevel=10)` -- For finished goods linked to catalog product. Sets ItemType=FinishedGood, Unit="pcs", Quantity=0. 2. `InventoryItem(shopId, name, itemType, unit, costPerUnit, initialQuantity, reorderLevel, supplierName?, expiryDate?)` -- For raw materials/consumables. Generates own ProductId. **Behavior Methods**: | Method | Logic | Domain Event | |--------|-------|-------------| | `StockIn(amount, notes?, referenceId?, invoiceImageUrl?, unitCost?)` | Validates amount > 0. Increments `_quantity`. Creates Transaction(type=In). | `StockChangedDomainEvent` | | `StockOut(amount, notes?, referenceId?)` | Validates amount > 0 and AvailableQuantity >= amount. Decrements `_quantity`. Creates Transaction(type=Out, negative qty). | `StockChangedDomainEvent` | | `Reserve(amount, orderId)` | Validates AvailableQuantity >= amount. Increments `_reservedQuantity`. Creates Transaction(type=Reserve). | None | | `ReleaseReservation(amount, orderId)` | Validates `_reservedQuantity >= amount`. Decrements `_reservedQuantity`. Creates Transaction(type=Release, negative qty). | None | | `SetReorderLevel(reorderLevel)` | Validates >= 0. Updates `_reorderLevel`. | None | | `Adjust(newQuantity, notes)` | Calculates diff. Sets `_quantity = newQuantity`. Creates Transaction(type=Adjustment, diff). | `StockChangedDomainEvent` | | `RecordWastage(amount, reason, notes?)` | Validates amount > 0 and reason non-empty. Decrements `_quantity`. Creates Transaction(type=Wastage, negative qty). | `StockChangedDomainEvent` | | `UpdateItemDetails(name?, unit?, costPerUnit?, supplierName?, expiryDate?)` | Updates non-null fields. Validates cost >= 0. | None | ### InventoryTransaction (Owned Entity) **Entity**: `InventoryService.Domain.AggregatesModel.InventoryAggregate.InventoryTransaction` **Extends**: `Entity` (NOT an aggregate root -- owned by InventoryItem) | Field | Type | Property | Description | |-------|------|----------|-------------| | `_inventoryItemId` | `Guid` | `InventoryItemId` | Parent inventory item | | `_type` | `TransactionType` | `Type` | Enumeration (In/Out/Adjustment/Reserve/Release/Wastage) | | - | `int` | `TypeId` | EF Core mapping (private set) | | `_quantity` | `int` | `Quantity` | Positive for in/reserve, negative for out/release/wastage | | `_referenceId` | `Guid?` | `ReferenceId` | Order ID, PO ID, etc. | | `_notes` | `string?` | `Notes` | Transaction notes (max 500 chars) | | `_invoiceImageUrl` | `string?` | `InvoiceImageUrl` | Invoice image URL for stock-in (max 1000 chars) | | `_unitCost` | `decimal?` | `UnitCost` | Cost per unit at time of stock-in | | `_createdAt` | `DateTime` | `CreatedAt` | Transaction timestamp | ### Enumerations **ItemType** (Enumeration pattern): | Id | Name | Description | |----|------|-------------| | 1 | RawMaterial | Ingredients for production (coffee beans, milk, sugar) | | 2 | FinishedGood | Ready-to-sell products (Cappuccino, Espresso) | | 3 | Consumable | Operational supplies (cups, napkins, straws) | **TransactionType** (Enumeration pattern): | Id | Name | Description | |----|------|-------------| | 1 | In | Stock in - receiving inventory | | 2 | Out | Stock out - releasing inventory | | 3 | Adjustment | Manual adjustment / stocktake | | 4 | Reserve | Reserve for order | | 5 | Release | Release reservation | | 6 | Wastage | Damaged, expired, spilled items | ### Domain Events | Event | Payload | Raised By | |-------|---------|-----------| | `StockChangedDomainEvent` | `InventoryItem Item` | `StockIn`, `StockOut`, `Adjust`, `RecordWastage` | ### Domain Exception `DomainException` -- thrown for business rule violations (insufficient stock, invalid amounts, empty required fields). --- ## Database Schema **Database**: `inventory_service` (PostgreSQL) ### Table: `inventory_items` | Column | Type | Nullable | Default | Description | |--------|------|----------|---------|-------------| | `id` | `uuid` | NOT NULL | - | Primary key (ValueGeneratedNever) | | `product_id` | `uuid` | NOT NULL | - | Product reference (catalog or self-generated) | | `shop_id` | `uuid` | NOT NULL | - | Shop owner | | `name` | `varchar(200)` | NULL | - | Item name (raw materials) | | `item_type_id` | `int` | NOT NULL | `2` | Item type (1=RawMaterial, 2=FinishedGood, 3=Consumable) | | `unit` | `varchar(20)` | NOT NULL | `'pcs'` | Unit of measure | | `cost_per_unit` | `numeric(18,4)` | NOT NULL | `0` | Purchase price per unit | | `supplier_name` | `varchar(200)` | NULL | - | Supplier reference | | `expiry_date` | `timestamptz` | NULL | - | Expiry date | | `quantity` | `int` | NOT NULL | - | Total quantity in stock | | `reserved_quantity` | `int` | NOT NULL | - | Quantity reserved for orders | | `reorder_level` | `int` | NOT NULL | `10` | Low stock alert threshold | | `updated_at` | `timestamptz` | NULL | - | Last update timestamp | **Indexes**: - `PK_inventory_items` -- PRIMARY KEY on `id` - `ix_inventory_product_id` -- INDEX on `product_id` - `ix_inventory_shop_id` -- INDEX on `shop_id` **Note**: `AvailableQuantity` is computed in-memory (`Quantity - ReservedQuantity`), not stored. `CreatedAt`, `Name`, `ItemType`, `Unit`, `CostPerUnit`, `SupplierName`, `ExpiryDate` are mapped via private fields and ignored as navigation properties in EF config. ### Table: `inventory_transactions` (Owned by InventoryItem) | Column | Type | Nullable | Default | Description | |--------|------|----------|---------|-------------| | `id` | `uuid` | NOT NULL | - | Primary key (ValueGeneratedNever) | | `inventory_item_id` | `uuid` | NOT NULL | - | FK to inventory_items.id (CASCADE delete) | | `type_id` | `int` | NOT NULL | - | Transaction type (1-6) | | `quantity` | `int` | NOT NULL | - | Amount (positive for in, negative for out) | | `reference_id` | `uuid` | NULL | - | External reference (order ID, etc.) | | `notes` | `varchar(500)` | NULL | - | Transaction notes | | `invoice_image_url` | `varchar(1000)` | NULL | - | Invoice image URL | | `unit_cost` | `numeric(18,4)` | NULL | - | Unit cost at time of transaction | | `created_at` | `timestamptz` | NOT NULL | - | Transaction timestamp | **Indexes**: - `PK_inventory_transactions` -- PRIMARY KEY on `id` - `IX_inventory_transactions_inventory_item_id` -- INDEX on `inventory_item_id` ### Table: `client_requests` (Idempotency) | Column | Type | Nullable | Default | Description | |--------|------|----------|---------|-------------| | `id` | `uuid` | NOT NULL | - | Request identifier (ReferenceId) | | `name` | `varchar(200)` | NOT NULL | - | Command type name | | `time` | `timestamptz` | NOT NULL | - | Request timestamp | **Note**: `transaction_types` and `item_types` tables were configured as EF seed data tables but are currently **ignored** in `OnModelCreating` (`modelBuilder.Ignore()` / `modelBuilder.Ignore()`). The `transaction_types` table was dropped in migration `AddItemTypeAndCostFields`. Enumerations are resolved in-memory via the DDD Enumeration pattern. ### Migrations History | Migration | Date | Description | |-----------|------|-------------| | `20260117180439_InitialInventory` | 2026-01-17 | Creates `inventory_items`, `inventory_transactions`, `transaction_types` tables with seed data | | `20260305095121_AddItemTypeAndCostFields` | 2026-03-05 | Drops `transaction_types` table. Adds `name`, `item_type_id`, `unit`, `cost_per_unit`, `supplier_name`, `expiry_date` to items. Adds `invoice_image_url`, `unit_cost` to transactions. | | `20260306175523_PhaseTwo` | 2026-03-06 | Creates `client_requests` table for idempotency tracking | --- ## Multi-Tenant Architecture ### Row-Level Security (RLS) The service implements two-layer tenant isolation: 1. **EF Core Global Query Filter** (`InventoryContext.OnModelCreating`): - Filters `InventoryItem` by `ShopId == currentShopId` from `IInventoryTenantProvider` - Bypassed for service-to-service calls (`X-Service-Call: internal` header) and admin/superadmin roles 2. **PostgreSQL Session Variables** (`TenantMiddleware`): - Sets `app.current_shop_id` and `app.current_merchant_id` via `SET LOCAL` for defense-in-depth - Skipped for service calls and admin users ### Tenant Context Sources - **JWT Claims**: `shop_id`, `merchant_id`, `sub` (user ID), `role` - **HTTP Headers**: `X-Shop-Id` (fallback for shop ID), `X-Service-Call: internal` (service-to-service bypass) --- ## Dependencies ### NuGet Packages **API Layer** (`InventoryService.API.csproj`): | Package | Version | |---------|---------| | MediatR | 12.4.1 | | FluentValidation | 11.11.0 | | FluentValidation.DependencyInjectionExtensions | 11.11.0 | | Microsoft.EntityFrameworkCore.Design | 10.0.0 | | Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 | | Swashbuckle.AspNetCore | 7.2.0 | | Swashbuckle.AspNetCore.Annotations | 7.2.0 | | AspNetCore.HealthChecks.NpgSql | 8.0.2 | | AspNetCore.HealthChecks.Redis | 8.0.1 | | Hellang.Middleware.ProblemDetails | 6.5.1 | | Serilog.AspNetCore | 8.0.3 | | Serilog.Sinks.Console | 6.0.0 | | Serilog.Sinks.Seq | 8.0.0 | **Domain Layer** (`InventoryService.Domain.csproj`): | Package | Version | |---------|---------| | MediatR.Contracts | 2.0.1 | **Infrastructure Layer** (`InventoryService.Infrastructure.csproj`): | Package | Version | |---------|---------| | Microsoft.EntityFrameworkCore | 10.0.0 | | Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | | Microsoft.EntityFrameworkCore.Tools | 10.0.0 | | MediatR | 12.4.1 | | Dapper | 2.1.35 | | Microsoft.Extensions.Http.Polly | 9.0.0 | | Polly | 8.5.0 | | StackExchange.Redis | 2.8.16 | ### Project References - `InventoryService.API` -> `InventoryService.Domain`, `InventoryService.Infrastructure` - `InventoryService.Infrastructure` -> `InventoryService.Domain` - `InventoryService.Domain` -> None (pure domain, no external dependencies) --- ## Configuration ### appsettings.json ```json { "ConnectionStrings": { "DefaultConnection": "" }, "Redis": { "ConnectionString": "localhost:6379" }, "Jwt": { "Secret": "...", "Issuer": "goodgo-platform", "Audience": "goodgo-services", "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 } } ``` ### Environment Variables - `ASPNETCORE_ENVIRONMENT` -- Development / Production - `DATABASE_URL` -- Fallback connection string - `Jwt:Authority` -- IAM IdentityServer URL (default: `http://localhost:5001`) ### Middleware Pipeline Order 1. `UseSerilogRequestLogging()` 2. `UseProblemDetails()` (RFC 7807) 3. `UseSwagger()` (Development only) 4. `UseCors()` (AllowAny) 5. `UseRouting()` 6. `UseAuthentication()` (JWT Bearer via IAM IdentityServer OIDC) 7. `UseAuthorization()` 8. `UseTenantMiddleware()` (after auth -- sets PG session variables for RLS) 9. `MapHealthChecks()` (`/health`, `/health/live`, `/health/ready`) 10. `MapControllers()` 11. Auto-apply EF Core migrations on startup ### MediatR Pipeline Behaviors (in order) 1. `LoggingBehavior` -- Logs request name, elapsed time via Stopwatch 2. `ValidatorBehavior` -- Runs FluentValidation validators, throws `ValidationException` on failure 3. `TransactionBehavior` -- Wraps Commands in DB transaction (skips Queries by name suffix), uses `ExecutionStrategy` for retry --- ## DTOs ### Request DTOs | DTO | Fields | |-----|--------| | `CreateInventoryItemRequest` | ShopId, Name, ItemTypeId, Unit, CostPerUnit, InitialQuantity(=0), ReorderLevel(=10), SupplierName?, ExpiryDate? | | `StockInRequest` | ProductId, ShopId, Amount, Notes?, ReferenceId?, InvoiceImageUrl?, UnitCost? | | `StockOutRequest` | ProductId, ShopId, Amount, Notes?, ReferenceId? | | `StockOutByIdRequest` | InventoryItemId, Amount, Notes?, ReferenceId? | | `ReserveStockRequest` | ProductId, ShopId, Amount, OrderId | | `ReleaseReservationRequest` | ProductId, ShopId, Amount, OrderId | | `AdjustStockRequest` | ProductId, ShopId, NewQuantity, Notes | | `SetLowStockAlertRequest` | ShopId, ProductId, MinimumLevel | | `RecordWastageRequest` | InventoryItemId, Amount, Reason, Notes? | | `StocktakeRequest` | ShopId, Items(List of {InventoryItemId, CountedQuantity}) | | `BulkStockCheckRequest` | ShopId, ProductIds(List) | | `DeductInventoryRequest` | ShopId, ReferenceId, ReferenceType, Reason?, Items(List of {InventoryItemId, Amount, Unit, IngredientName}) | ### Response DTOs | DTO | Fields | |-----|--------| | `InventoryItemDto` | Id, ProductId, ShopId, Name?, ItemType, ItemTypeId, Unit, CostPerUnit, SupplierName?, ExpiryDate?, Quantity, ReservedQuantity, AvailableQuantity, ReorderLevel, UpdatedAt? | | `InventoryTransactionDto` | Id, InventoryItemId, TransactionType, Quantity, ReferenceId?, Notes?, InvoiceImageUrl?, UnitCost?, CreatedAt | | `StockLevelDto` | ProductId, Available, Reserved, Minimum, IsLowStock | | `StocktakeResult` | Discrepancies(List), TotalItemsCounted | | `StocktakeDiscrepancy` | InventoryItemId, ItemName?, ExpectedQuantity, CountedQuantity, Difference | | `DeductInventoryResult` | Success, ItemsDeducted, ItemsSkipped, Items(List) | | `DeductionResultItem` | InventoryItemId, IngredientName, Deducted, Error? | | `ApiResponse` | Success, Data?, Error? | | `PagedResult` | Items, TotalCount | --- ## Tests ### Unit Tests (`InventoryService.UnitTests`) **File**: `Application/Commands/DeductInventoryCommandHandlerTests.cs` | Test | Description | |------|-------------| | `Handle_WithValidCommand_ShouldDeductInventory` | Full deduction of available stock | | `Handle_WithInsufficientStock_ShouldReturnPartialDeduction` | Partial deduction when stock < requested | | `Handle_WithNonExistentItem_ShouldReturnNotFound` | Item not found returns Success=false | | `Handle_WithZeroStockAvailable_ShouldSkipDeduction` | Zero stock skips with error message | | `Handle_WithDuplicateRequest_ShouldReturnIdempotentResult` | Duplicate ReferenceId detected and skipped | | `Handle_WithMultipleItems_ShouldDeductAll` | Multiple items deducted in single operation | | `Handle_WithMixedResults_ShouldDeductFoundItemsAndSkipMissing` | Mix of found and missing items | | `Handle_WithItemExceptionDuringDeduction_ShouldContinueWithRemainingItems` | Error resilience -- continues after individual item failure | | `Constructor_WithNullRepository_ShouldThrowArgumentNullException` | Null guard | | `Constructor_WithNullRequestManager_ShouldThrowArgumentNullException` | Null guard | | `Constructor_WithNullLogger_ShouldThrowArgumentNullException` | Null guard | | `Handle_ShouldRecordIdempotencyRequest` | Verifies idempotency request is recorded | ### Functional Tests (`InventoryService.FunctionalTests`) **Factory**: `CustomWebApplicationFactory` -- swaps PostgreSQL for InMemoryDatabase | Test | Description | |------|-------------| | `GetInventory_WithoutShopId_ShouldReturnBadRequest` | Validates shopId is required | | `GetLowStockItems_ShouldReturnOk` | Low stock endpoint returns 200 | | `HealthCheck_ShouldReturnHealthy` | Liveness probe returns 200 | --- ## Cross-Service Integration | Consumer Service | Endpoint Used | Purpose | |-----------------|---------------|---------| | **fnb-engine-net** | `POST /api/v1/inventory/deduct` | Bulk ingredient deduction when kitchen ticket is served | | **order-service-net** (planned) | `POST /api/v1/inventory/reserve` / `POST /api/v1/inventory/release` | Reserve stock for orders, release on cancellation | | **POS Frontend** | `POST /api/v1/inventory/stock/bulk` | Cart validation -- check stock availability before order | | **POS Frontend** | `GET /api/v1/inventory` | Admin inventory management dashboard | | **POS Frontend** | `GET /api/v1/inventory/transactions` | Transaction history display | --- ## File Structure ``` services/inventory-service-net/ global.json # SDK: .NET 10.0.101 src/ InventoryService.API/ Program.cs # DI + middleware pipeline + auto-migration Controllers/ InventoryController.cs # 17 endpoints (1 controller) Application/ Behaviors/ LoggingBehavior.cs # Request logging with Stopwatch ValidatorBehavior.cs # FluentValidation pipeline TransactionBehavior.cs # Auto-transaction for commands Commands/ InventoryCommands.cs # 11 command records InventoryCommandHandlers.cs # 10 command handlers DeductInventory/ DeductInventoryCommand.cs # Bulk deduction command + result records DeductInventoryCommandHandler.cs # Idempotent bulk deduction handler Queries/ InventoryQueries.cs # 6 query records InventoryQueryHandlers.cs # 6 query handlers Validations/ InventoryValidators.cs # 7 validators DeductInventoryCommandValidator.cs # Deduct command validator DTOs/ InventoryDtos.cs # All DTOs, request/response records, ApiResponse Mappers/ InventoryMapper.cs # Extension methods: ToDto() with fallback type resolution Infrastructure/Tenant/ ITenantProvider.cs # Tenant context interface HttpContextTenantProvider.cs # JWT claims + header extraction InventoryTenantProviderAdapter.cs # API->Infrastructure adapter Middleware/ TenantMiddleware.cs # PostgreSQL session variable setter for RLS InventoryService.Domain/ AggregatesModel/InventoryAggregate/ InventoryItem.cs # Aggregate root (344 lines) InventoryTransaction.cs # Owned entity IInventoryRepository.cs # Repository interface (13 methods) ItemType.cs # Enumeration (3 values) TransactionType.cs # Enumeration (6 values) Events/ InventoryDomainEvents.cs # StockChangedDomainEvent Exceptions/ DomainException.cs # Business rule exception SeedWork/ Entity.cs, IAggregateRoot.cs, IRepository.cs, IUnitOfWork.cs, Enumeration.cs, ValueObject.cs InventoryService.Infrastructure/ InventoryContext.cs # DbContext + IUnitOfWork + domain event dispatch + multi-tenant filter DependencyInjection.cs # AddInfrastructure() extension Repositories/ InventoryRepository.cs # IInventoryRepository implementation EntityConfigurations/ InventoryItemEntityTypeConfiguration.cs # Fluent API (snake_case, private field mapping, owned transactions) ItemTypeEntityTypeConfiguration.cs # Seed data config (currently ignored in OnModelCreating) TransactionTypeEntityTypeConfiguration.cs # Seed data config (currently ignored in OnModelCreating) Idempotency/ ClientRequest.cs # Idempotency entity IRequestManager.cs # Interface RequestManager.cs # Implementation Migrations/ 20260117180439_InitialInventory.cs 20260305095121_AddItemTypeAndCostFields.cs 20260306175523_PhaseTwo.cs tests/ InventoryService.UnitTests/ Application/Commands/ DeductInventoryCommandHandlerTests.cs # 12 tests InventoryService.FunctionalTests/ CustomWebApplicationFactory.cs # InMemoryDatabase swap Controllers/ InventoryControllerTests.cs # 3 tests ```