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