Files
pos-system/services/inventory-service-net/SERVICE_DOCS.md
Ho Ngoc Hai f3779c4ebe docs: add SERVICE_DOCS.md for all 24 microservices from per-service code audit
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>
2026-03-13 17:54:53 +07:00

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
```