Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries, Domain Model, Database Schema, Integration Events, Dependencies, Configuration. Generated by 23 parallel audit agents reading actual source code. Key corrections from audit: - inventory-service: 12 commands/6 queries (was listed as scaffold) - promotion-service: 12 commands/10 queries (was listed as 0) - mission-service: 4 commands/7 queries (was listed as 0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
581 lines
32 KiB
Markdown
581 lines
32 KiB
Markdown
# 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<PagedResult<InventoryItemDto>>` |
|
|
| GET | `/api/v1/inventory/{productId}` | Get stock level by product+shop | `?shopId={guid}` | `ApiResponse<InventoryItemDto>` |
|
|
| POST | `/api/v1/inventory/stock/bulk` | Bulk stock check (POS cart validation) | `BulkStockCheckRequest` | `ApiResponse<IReadOnlyList<StockLevelDto>>` |
|
|
| PUT | `/api/v1/inventory/alerts` | Set low stock alert threshold | `SetLowStockAlertRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/items` | Create new inventory item | `CreateInventoryItemRequest` | `ApiResponse<Guid>` (201 Created) |
|
|
| POST | `/api/v1/inventory/stock-in` | Stock in (add inventory) | `StockInRequest` | `ApiResponse<Guid>` |
|
|
| POST | `/api/v1/inventory/stock-out` | Stock out (by productId+shopId) | `StockOutRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/stock-out-by-id` | Stock out by inventory item ID | `StockOutByIdRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/reserve` | Reserve stock for order | `ReserveStockRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/release` | Release stock reservation | `ReleaseReservationRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/adjust` | Manual stock adjustment | `AdjustStockRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/wastage` | Record wastage/shrinkage | `RecordWastageRequest` | `ApiResponse<bool>` |
|
|
| POST | `/api/v1/inventory/stocktake` | Perform stocktake (inventory count) | `StocktakeRequest` | `ApiResponse<StocktakeResult>` |
|
|
| POST | `/api/v1/inventory/deduct` | Bulk deduct (kitchen ticket served) | `DeductInventoryRequest` | `ApiResponse<DeductInventoryResult>` |
|
|
| DELETE | `/api/v1/inventory/items/{id}` | Delete inventory item | - | `ApiResponse<bool>` |
|
|
| GET | `/api/v1/inventory/transactions` | Get transaction history | `?inventoryItemId={guid}&shopId={guid}&skip=0&take=50` | `ApiResponse<PagedResult<InventoryTransactionDto>>` |
|
|
| GET | `/api/v1/inventory/low-stock` | Get low stock items | `?shopId={guid}&skip=0&take=50` | `ApiResponse<PagedResult<InventoryItemDto>>` |
|
|
| 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<ItemType>(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<StocktakeItem>)` 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<DeductionItem>)` 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<InventoryItemDto>`
|
|
- **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<Guid>)`
|
|
- **Returns**: `IReadOnlyList<StockLevelDto>`
|
|
- **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<InventoryTransactionDto>`
|
|
- **Logic**: Loads item with `.Include(Transactions)`, orders by CreatedAt descending, applies pagination.
|
|
|
|
### GetTransactionsByShopQuery
|
|
- **Input**: `ShopId (Guid)`, `Skip (int)`, `Take (int)`
|
|
- **Returns**: `PagedResult<InventoryTransactionDto>`
|
|
- **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<InventoryItemDto>`
|
|
- **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<InventoryTransaction>` | `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<TransactionType>()` / `modelBuilder.Ignore<ItemType>()`). 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": "<Neon PostgreSQL connection string>"
|
|
},
|
|
"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<Guid>) |
|
|
| `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<T>` | Success, Data?, Error? |
|
|
| `PagedResult<T>` | 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<T>
|
|
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
|
|
```
|