Files
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

574 lines
26 KiB
Markdown

# FnB Engine Service - Documentation
> Auto-generated from source code audit on 2026-03-13
## Overview
**Service Name**: fnb-engine-net (FnbEngine)
**Port**: 5019 (Development)
**Database**: `fnb_engine` (PostgreSQL via Neon)
**SDK**: .NET 10.0 (C# 14)
**Architecture**: Clean Architecture + CQRS (MediatR 12.4.1)
The FnB Engine is the core Food & Beverage operations microservice. It manages:
- **Table Management** -- CRUD, status tracking, QR code ordering, floor plan positioning
- **Session Management** -- Dining sessions (open/close) linked to tables
- **Kitchen Display System (KDS)** -- Kitchen tickets with station routing and status workflow
- **Barista Queue** -- Drink preparation queue for cafe/bar operations with priority, assignment, and stats
- **Recipe Management** -- Product recipes with ingredients, COGS tracking, and inventory item linking
- **Reservation System** -- Table reservations with guest info, status management
- **Inventory Integration** -- Auto-deduction of inventory when kitchen tickets are served (via inventory-service HTTP client)
- **Multi-Tenant Isolation** -- Row-level security via EF Core global query filters + PostgreSQL session variables
---
## API Endpoints
### Tables (`/api/v1/tables`)
| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/api/v1/tables?shopId={id}` | Get tables by shop | Query: `shopId` (Guid) | `ApiResponse<IEnumerable<TableDto>>` |
| POST | `/api/v1/tables` | Create table | Body: `CreateTableRequest` | `ApiResponse<CreateTableResult>` (201) |
| PUT | `/api/v1/tables/{id}` | Update table | Body: `UpdateTableRequest` | `ApiResponse<bool>` |
| PATCH | `/api/v1/tables/{id}/status` | Change table status | Body: `ChangeStatusRequest` | `ApiResponse<bool>` |
| POST | `/api/v1/tables/{id}/generate-qr` | Generate QR token | - | `ApiResponse<{qrToken}>` |
| GET | `/api/v1/tables/by-token/{token}` | Get table by QR token (public) | Path: `token` | `ApiResponse<{Id,ShopId,TableNumber,Capacity,Zone}>` |
### Sessions (`/api/v1/sessions`)
| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| POST | `/api/v1/sessions` | Open session | Body: `OpenSessionRequest` | `ApiResponse<OpenSessionResult>` (201) |
| GET | `/api/v1/sessions/{id}` | Get session by ID | Path: `id` | `ApiResponse<SessionDto>` |
| POST | `/api/v1/sessions/{id}/close` | Close session | Path: `id` | `ApiResponse<bool>` |
### Kitchen (`/api/v1/kitchen`)
| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/api/v1/kitchen/tickets?shopId={id}&status={s}` | Get tickets by shop+status | Query: `shopId`, `status` | `ApiResponse<IEnumerable<KitchenTicketDto>>` |
| GET | `/api/v1/kitchen/tickets?station={s}` | Get pending tickets by station | Query: `station` | `ApiResponse<IEnumerable<KitchenTicketDto>>` |
| POST | `/api/v1/kitchen/tickets` | Create kitchen ticket | Body: `CreateTicketRequest` | `CreateTicketResponse` |
| PATCH | `/api/v1/kitchen/tickets/{id}/status` | Update ticket status | Body: `UpdateStatusRequest` | `ApiResponse<bool>` |
| GET | `/api/v1/kitchen/recipes?shopId={id}` | Get recipes by shop | Query: `shopId` | `ApiResponse<IEnumerable<RecipeDto>>` |
| GET | `/api/v1/kitchen/recipes/by-product?productId={id}&shopId={id}` | Get recipe by product | Query: `productId`, `shopId` | `ApiResponse<RecipeDto>` |
| POST | `/api/v1/kitchen/recipes` | Create recipe | Body: `CreateRecipeCommand` | `ApiResponse<Guid>` |
| PUT | `/api/v1/kitchen/recipes/{id}` | Update recipe | Body: `UpdateRecipeCommand` | `ApiResponse<bool>` |
| DELETE | `/api/v1/kitchen/recipes/{id}` | Delete (soft) recipe | Path: `id` | `ApiResponse<bool>` |
### Barista (`/api/v1/fnb/barista`)
| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/api/v1/fnb/barista/queue?shopId={id}` | Get active barista queue | Query: `shopId` | `ApiResponse<IEnumerable<BaristaQueueItemDto>>` |
| GET | `/api/v1/fnb/barista/stats?shopId={id}` | Get queue statistics | Query: `shopId` | `ApiResponse<QueueStatsDto>` |
| POST | `/api/v1/fnb/barista/queue` | Queue a drink | Body: `QueueDrinkCommand` | `ApiResponse<QueueDrinkResult>` |
| PUT | `/api/v1/fnb/barista/queue/{id}/start` | Start preparing drink | Body: `StartPreparingRequest` | `ApiResponse<bool>` |
| PUT | `/api/v1/fnb/barista/queue/{id}/ready` | Mark drink ready | - | `ApiResponse<bool>` |
| PUT | `/api/v1/fnb/barista/queue/{id}/delivered` | Mark drink delivered | - | `ApiResponse<bool>` |
| DELETE | `/api/v1/fnb/barista/queue/{id}` | Cancel queue item | Path: `id` | `ApiResponse<bool>` |
### Reservations (`/api/v1/reservations`)
| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/api/v1/reservations?shopId={id}&date={d}` | Get reservations | Query: `shopId`, `date?` | `ApiResponse<IEnumerable<ReservationDto>>` |
| POST | `/api/v1/reservations` | Create reservation | Body: `CreateReservationRequest` | `ApiResponse<CreateReservationResult>` (201) |
| PATCH | `/api/v1/reservations/{id}/status` | Update reservation status | Body: `UpdateReservationStatusRequest` | `ApiResponse<bool>` |
### Health Checks
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Full health check (PostgreSQL) |
| GET | `/health/live` | Liveness probe |
| GET | `/health/ready` | Readiness probe |
---
## Commands
### CreateTableCommand
- **Input**: `ShopId` (Guid), `TableNumber` (string), `Capacity` (int), `Zone?` (string), `HourlyRate?` (decimal)
- **Logic**: Checks duplicate table number per shop, creates Table entity, optionally sets hourly rate
- **Validator**: `CreateTableValidator` -- ShopId required, TableNumber required (max 20), Capacity > 0, Zone max 100
### UpdateTableCommand
- **Input**: `TableId` (Guid), `Capacity?` (int), `Zone?` (string), `PositionX?` (int), `PositionY?` (int), `HourlyRate?` (decimal)
- **Logic**: Loads table, sets position and/or hourly rate
- **Validator**: `UpdateTableCommandValidator` -- TableId required, Capacity > 0, Zone max 100, HourlyRate >= 0
### ChangeTableStatusCommand
- **Input**: `TableId` (Guid), `Status` (string: "Available"/"Occupied"/"Cleaning")
- **Logic**: Loads table, calls domain methods (MarkAsAvailable/MarkAsOccupied/MarkAsCleaning)
- **Validator**: `ChangeTableStatusCommandValidator` -- Valid statuses: Available, Occupied, Cleaning, Reserved, OutOfService
### OpenSessionCommand
- **Input**: `TableId` (Guid), `ShopId` (Guid), `GuestCount` (int, default 1)
- **Logic**: Validates table exists, checks no active session on table, creates Session, marks table as Occupied
- **Validator**: `OpenSessionCommandValidator` -- TableId/ShopId required, GuestCount 1-100
### CloseSessionCommand
- **Input**: `SessionId` (Guid)
- **Logic**: Loads session, calls `session.Close()`, marks table as Available
- **Validator**: `CloseSessionCommandValidator` -- SessionId required
### CreateKitchenTicketCommand
- **Input**: `SessionId` (Guid), `OrderItemId` (Guid), `ItemName` (string), `Station?` (string), `Priority` (int, default 0), `ProductId?` (Guid), `Quantity` (int, default 1)
- **Logic**: Creates KitchenTicket entity with product ID for recipe lookup
- **Validator**: `CreateKitchenTicketCommandValidator` -- SessionId/OrderItemId/ItemName required, Priority 0-10, Quantity > 0
### UpdateTicketStatusCommand
- **Input**: `TicketId` (Guid), `Status` (string: "InProgress"/"Ready"/"Served")
- **Logic**: Loads ticket, calls domain methods. When "Served", raises KitchenTicketServedDomainEvent for inventory deduction
- **Validator**: `UpdateTicketStatusCommandValidator` -- Valid statuses: Pending, InProgress, Ready, Served, Cancelled
### QueueDrinkCommand
- **Input**: `ShopId` (Guid), `OrderId` (Guid), `OrderItemId` (Guid), `DrinkName` (string), `Customizations?` (string), `Priority` (int, default 0), `EstimatedMinutes` (int, default 5)
- **Logic**: Creates BaristaQueueItem, raises DrinkQueuedDomainEvent
- **Validator**: `QueueDrinkCommandValidator` -- All IDs required, DrinkName required (max 200), Customizations max 2000, Priority 0-10, EstimatedMinutes 1-60
### StartPreparingCommand
- **Input**: `QueueItemId` (Guid), `BaristaName` (string)
- **Logic**: Loads queue item, calls `StartPreparing(baristaName)` (status Queued -> Preparing)
- **Validator**: `StartPreparingCommandValidator` -- QueueItemId required, BaristaName required (max 100)
### MarkDrinkReadyCommand
- **Input**: `QueueItemId` (Guid)
- **Logic**: Loads queue item, calls `MarkReady()` (Preparing -> Ready), raises DrinkReadyDomainEvent
- **Validator**: `MarkDrinkReadyCommandValidator` -- QueueItemId required
### MarkDrinkDeliveredCommand
- **Input**: `QueueItemId` (Guid)
- **Logic**: Loads queue item, calls `MarkDelivered()` (Ready -> Delivered)
- **Validator**: `MarkDrinkDeliveredCommandValidator` -- QueueItemId required
### CancelQueueItemCommand
- **Input**: `QueueItemId` (Guid)
- **Logic**: Loads queue item, calls `Cancel()` (any status except Delivered/Cancelled -> Cancelled)
- **Validator**: `CancelQueueItemCommandValidator` -- QueueItemId required
### CreateRecipeCommand
- **Input**: `ShopId` (Guid), `ProductId` (Guid), `Name` (string), `Instructions?` (string), `PrepTimeMinutes` (int), `Ingredients?` (List of IngredientItem)
- **Logic**: Creates Recipe entity, adds ingredients with optional inventory item links
- **Validator**: None (no dedicated validator)
### UpdateRecipeCommand
- **Input**: `RecipeId` (Guid), `ShopId` (Guid), `ProductId` (Guid), `Name` (string), `Instructions?` (string), `PrepTimeMinutes` (int), `Ingredients?` (List of IngredientItem)
- **Logic**: Loads recipe, updates fields, clears and re-adds ingredients
- **Validator**: None
### DeleteRecipeCommand
- **Input**: `RecipeId` (Guid)
- **Logic**: Loads recipe, calls `Deactivate()` (soft delete via IsActive=false)
- **Validator**: None
---
## Queries
### GetTablesQuery
- **Input**: `ShopId` (Guid)
- **Logic**: Gets all tables for shop, maps StatusId to status name via Enumeration pattern
- **Output**: `IEnumerable<TableDto>` (Id, ShopId, TableNumber, Capacity, Zone, Status, HourlyRate, PositionX, PositionY, QrToken)
### GetSessionQuery
- **Input**: `SessionId` (Guid)
- **Logic**: Gets session by ID, returns null if not found
- **Output**: `SessionDto?` (Id, TableId, ShopId, GuestCount, StartedAt, ClosedAt, Status)
### GetPendingTicketsQuery
- **Input**: `Station?` (string)
- **Logic**: Gets tickets with status Pending or InProgress, optionally filtered by station, sorted by priority desc then createdAt asc
- **Output**: `IEnumerable<KitchenTicketDto>` (Id, SessionId, OrderItemId, ItemName, Station, Priority, Status, CreatedAt)
### GetTicketsByShopQuery
- **Input**: `ShopId` (Guid), `Status?` (string)
- **Logic**: Finds sessions by shop, then gets tickets by session IDs with optional status filter
- **Output**: `IEnumerable<KitchenTicketDto>`
### GetBaristaQueueQuery
- **Input**: `ShopId` (Guid)
- **Logic**: Gets active queue items (Queued/Preparing/Ready) for shop, sorted by priority desc then createdAt asc
- **Output**: `IEnumerable<BaristaQueueItemDto>` (Id, ShopId, OrderId, OrderItemId, DrinkName, Customizations, Priority, StatusId, StatusName, AssignedTo, EstimatedMinutes, CreatedAt, StartedAt, CompletedAt)
### GetQueueStatsQuery
- **Input**: `ShopId` (Guid)
- **Logic**: Aggregates counts by status and calculates average prep time from completed items
- **Output**: `QueueStatsDto` (TotalQueued, TotalPreparing, TotalReady, TotalDelivered, TotalCancelled, AveragePrepTimeMinutes)
### GetReservationsQuery
- **Input**: `ShopId` (Guid), `Date?` (DateTime)
- **Logic**: Gets reservations by shop, optionally filtered to a single day, sorted by reservation time
- **Output**: `IEnumerable<ReservationDto>` (Id, ShopId, TableId, GuestName, Phone, PartySize, ReservationTime, Status, Note, CreatedAt)
### GetRecipesByShopQuery
- **Input**: `ShopId` (Guid)
- **Logic**: Gets active recipes for shop with ingredients, ordered by name
- **Output**: `IEnumerable<RecipeDto>` (Id, ProductId, ShopId, Name, Instructions, PrepTimeMinutes, IsActive, CreatedAt, Ingredients[])
### GetRecipeByProductQuery
- **Input**: `ProductId` (Guid), `ShopId` (Guid)
- **Logic**: Gets active recipe by product ID and shop ID (for inventory deduction lookup)
- **Output**: `RecipeDto?`
---
## Domain Model
### Table (Aggregate Root)
- **Fields**: `_shopId`, `_tableNumber`, `_capacity`, `_zone`, `_status` (TableStatus), `StatusId`, `_positionX`, `_positionY`, `_qrToken`, `_hourlyRate`, `_createdAt`, `_updatedAt`
- **Methods**:
- `MarkAsOccupied()` -- Only from Available/Reserved
- `MarkAsAvailable()` -- From any status
- `MarkAsCleaning()` -- From any status
- `SetHourlyRate(decimal rate)`
- `SetPosition(int x, int y)`
- `GenerateQrToken()` -- Returns 16-char hex token
- `ClearQrToken()`
- **Validation**: ShopId not empty, TableNumber not blank, Capacity > 0
- **Events**: None
### TableStatus (Enumeration)
- 1 = Available, 2 = Occupied, 3 = Reserved, 4 = Cleaning
### Session (Aggregate Root)
- **Fields**: `_tableId`, `_shopId`, `_guestCount`, `_startedAt`, `_closedAt`, `_status`
- **Methods**:
- `Close()` -- Sets status to "Closed", records ClosedAt. Throws if already closed.
- **Status Values**: "Active", "Closed"
- **Validation**: TableId/ShopId not empty, GuestCount > 0
- **Events**: None
### KitchenTicket (Aggregate Root)
- **Fields**: `_sessionId`, `_orderItemId`, `_productId`, `_itemName`, `_station`, `_priority`, `_quantity`, `_status`, `_createdAt`, `_completedAt`
- **Methods**:
- `MarkAsInProgress()` -- Sets status to "InProgress"
- `MarkAsReady()` -- Sets status to "Ready", records CompletedAt
- `MarkAsServed()` -- Sets status to "Served", raises `KitchenTicketServedDomainEvent`
- **Status Values**: "Pending" -> "InProgress" -> "Ready" -> "Served"
- **Events**: `KitchenTicketServedDomainEvent` (on Served)
### BaristaQueueItem (Aggregate Root)
- **Fields**: `_shopId`, `_orderId`, `_orderItemId`, `_drinkName`, `_customizations` (jsonb), `_priority`, `_statusId`, `_assignedTo`, `_estimatedMinutes`, `_createdAt`, `_startedAt`, `_completedAt`
- **Methods**:
- `StartPreparing(string baristaName)` -- Queued(1) -> Preparing(2), assigns barista
- `MarkReady()` -- Preparing(2) -> Ready(3), raises `DrinkReadyDomainEvent`
- `MarkDelivered()` -- Ready(3) -> Delivered(4)
- `Cancel()` -- Any except Delivered/Cancelled -> Cancelled(5)
- **Status IDs**: 1=Queued, 2=Preparing, 3=Ready, 4=Delivered, 5=Cancelled
- **Events**: `DrinkQueuedDomainEvent` (on creation), `DrinkReadyDomainEvent` (on ready)
### Recipe (Aggregate Root)
- **Fields**: `_shopId`, `_productId`, `_name`, `_instructions`, `_prepTimeMinutes`, `_isActive`, `_createdAt`, `_updatedAt`, `_ingredients` (List<RecipeIngredient>)
- **Methods**:
- `Update(productId, name, instructions, prepTimeMinutes)`
- `AddIngredient(ingredientName, quantity, unit, costPerUnit, inventoryItemId?, quantityPerServing?)`
- `ClearIngredients()`
- `Deactivate()` -- Soft delete
- **Events**: None
### RecipeIngredient (Entity, child of Recipe)
- **Fields**: `_recipeId`, `_ingredientName`, `_quantity`, `_unit`, `_costPerUnit`, `_inventoryItemId` (optional link to inventory-service), `_quantityPerServing` (for COGS)
### Reservation (Aggregate Root)
- **Fields**: `_shopId`, `_tableId` (optional), `_guestName`, `_phone`, `_partySize`, `_reservationTime`, `_status`, `_note`, `_createdAt`
- **Methods**:
- `Confirm()`, `Seat()`, `Cancel()`, `NoShow()`
- `AssignTable(Guid tableId)`
- `UpdateStatus(string status)` -- Validates against allowed statuses
- **Status Values**: "pending", "confirmed", "seated", "cancelled", "no_show"
- **Validation**: ShopId not empty, GuestName not blank, PartySize > 0
### Domain Events
| Event | Trigger | Handler |
|-------|---------|---------|
| `DrinkQueuedDomainEvent` | BaristaQueueItem constructor | No handler (available for SignalR notifications) |
| `DrinkReadyDomainEvent` | BaristaQueueItem.MarkReady() | No handler (available for SignalR notifications) |
| `KitchenTicketServedDomainEvent` | KitchenTicket.MarkAsServed() | `KitchenTicketServedDomainEventHandler` -- Looks up recipe, calls inventory-service for auto-deduction |
### Integration Events
| Event | Description |
|-------|-------------|
| `KitchenTicketServedIntegrationEvent` | Defined but not currently published (available for RabbitMQ) |
---
## Database Schema
### Table: `tables`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK |
| `shop_id` | uuid | NOT NULL, indexed |
| `table_number` | varchar(20) | NOT NULL |
| `capacity` | int | NOT NULL |
| `zone` | varchar(100) | nullable |
| `status_id` | int | NOT NULL |
| `position_x` | int | nullable |
| `position_y` | int | nullable |
| `qr_token` | varchar(64) | nullable, unique (filtered) |
| `hourly_rate` | decimal(18,2) | default 0 |
| `created_at` | timestamp | NOT NULL |
| `updated_at` | timestamp | nullable |
**Indexes**:
- `ix_tables_shop_id` on (`shop_id`)
- `ix_tables_shop_table_number` on (`shop_id`, `table_number`) UNIQUE
- `ix_tables_qr_token` on (`qr_token`) UNIQUE WHERE qr_token IS NOT NULL
### Table: `table_statuses` (seed data)
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | int | PK (no auto-increment) |
| `name` | varchar(50) | NOT NULL |
**Seed Data**: 1=Available, 2=Occupied, 3=Reserved, 4=Cleaning
### Table: `sessions`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK |
| `table_id` | uuid | NOT NULL |
| `shop_id` | uuid | NOT NULL |
| `guest_count` | int | NOT NULL |
| `started_at` | timestamp | NOT NULL |
| `closed_at` | timestamp | nullable |
| `status` | varchar(50) | NOT NULL |
**Indexes**:
- `ix_sessions_table_status` on (`table_id`, `status`)
- `ix_sessions_shop` on (`shop_id`)
### Table: `kitchen_tickets`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK |
| `session_id` | uuid | NOT NULL |
| `order_item_id` | uuid | NOT NULL |
| `product_id` | uuid | NOT NULL |
| `item_name` | varchar(200) | NOT NULL |
| `station` | varchar(100) | nullable |
| `priority` | int | NOT NULL |
| `quantity` | int | NOT NULL, default 1 |
| `status` | varchar(50) | NOT NULL |
| `created_at` | timestamp | NOT NULL |
| `completed_at` | timestamp | nullable |
**Indexes**:
- `ix_kitchen_tickets_session` on (`session_id`)
- `ix_kitchen_tickets_station_status_priority` on (`station`, `status`, `priority`)
### Table: `barista_queue_items`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK |
| `shop_id` | uuid | NOT NULL |
| `order_id` | uuid | NOT NULL |
| `order_item_id` | uuid | NOT NULL |
| `drink_name` | varchar(200) | NOT NULL |
| `customizations` | jsonb | nullable |
| `priority` | int | NOT NULL, default 0 |
| `status_id` | int | NOT NULL, default 1 |
| `assigned_to` | varchar(100) | nullable |
| `estimated_minutes` | int | NOT NULL, default 5 |
| `created_at` | timestamp | NOT NULL |
| `started_at` | timestamp | nullable |
| `completed_at` | timestamp | nullable |
**Indexes**:
- `ix_barista_queue_shop_status_priority` on (`shop_id`, `status_id`, `priority`)
- `ix_barista_queue_order_id` on (`order_id`)
- `ix_barista_queue_created_at` on (`created_at`)
### Table: `recipes`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK (no auto-increment) |
| `shop_id` | uuid | NOT NULL |
| `product_id` | uuid | NOT NULL |
| `name` | varchar(255) | NOT NULL |
| `instructions` | varchar(2000) | nullable |
| `prep_time_minutes` | int | |
| `is_active` | bool | default true |
| `created_at` | timestamp | NOT NULL |
| `updated_at` | timestamp | nullable |
**Indexes**:
- `ix_recipes_shop_id` on (`shop_id`)
- `ix_recipes_product_id` on (`product_id`)
### Table: `recipe_ingredients`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK (no auto-increment) |
| `recipe_id` | uuid | NOT NULL, FK -> recipes (cascade delete) |
| `ingredient_name` | varchar(200) | NOT NULL |
| `quantity` | decimal(18,4) | NOT NULL |
| `unit` | varchar(50) | NOT NULL |
| `cost_per_unit` | decimal(18,2) | |
| `inventory_item_id` | uuid | nullable (link to inventory-service) |
| `quantity_per_serving` | decimal(18,4) | |
### Table: `reservations`
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK |
| `shop_id` | uuid | NOT NULL |
| `table_id` | uuid | nullable |
| `guest_name` | varchar(200) | NOT NULL |
| `phone` | varchar(20) | nullable |
| `party_size` | int | NOT NULL |
| `reservation_time` | timestamp | NOT NULL |
| `status` | varchar(20) | NOT NULL |
| `note` | varchar(500) | nullable |
| `created_at` | timestamp | NOT NULL |
**Indexes**:
- `ix_reservations_shop_id` on (`shop_id`)
- `ix_reservations_shop_time` on (`shop_id`, `reservation_time`)
### Tenant Global Query Filters
EF Core global query filters applied on: `Table` (shop_id), `Session` (shop_id), `Reservation` (shop_id), `BaristaQueueItem` (shop_id).
Bypassed for service-to-service calls and admin users.
---
## Migrations
| Migration | Date | Description |
|-----------|------|-------------|
| `20260117194222_InitialCreate` | 2026-01-17 | Initial schema (tables, sessions, kitchen_tickets, table_statuses) |
| `20260304221850_AddReservations` | 2026-03-04 | Add reservations table |
| `20260305003518_AddTablePositionFields` | 2026-03-05 | Add position_x, position_y to tables |
| `20260305011822_AddQrTokenToTable` | 2026-03-05 | Add qr_token to tables |
| `20260305095701_AddInventoryLinkToRecipeIngredient` | 2026-03-05 | Add inventory_item_id to recipe_ingredients |
| `20260305100324_AddInventoryItemIdToRecipeIngredient` | 2026-03-05 | Refine inventory item ID column |
| `20260306175525_PhaseTwo` | 2026-03-06 | Barista queue, recipe enhancements, quantity on tickets |
---
## Dependencies
### NuGet Packages (API Layer)
- MediatR 12.4.1
- FluentValidation 11.11.0
- FluentValidation.DependencyInjectionExtensions 11.11.0
- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1
- Swashbuckle.AspNetCore 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
- Microsoft.EntityFrameworkCore.Design 10.0.0
### NuGet Packages (Domain Layer)
- MediatR.Contracts 2.0.1
### NuGet Packages (Infrastructure Layer)
- 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
- Polly 8.5.0
- Microsoft.Extensions.Http.Polly 9.0.0
- StackExchange.Redis 2.8.16
### External Service Calls
- **inventory-service**: HTTP client with Polly retry (3 retries, exponential backoff) + circuit breaker (5 failures / 30s).
Endpoint: `POST /api/v1/inventory/deduct`. Called from `KitchenTicketServedDomainEventHandler` when a ticket is served.
Default URL: `http://inventory-service:8080` (configurable via `Services:InventoryService:BaseUrl` or `INVENTORY_SERVICE_URL`).
---
## 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
| Variable | Purpose | Default |
|----------|---------|---------|
| `DATABASE_URL` | Fallback DB connection string | - |
| `INVENTORY_SERVICE_URL` | Inventory service base URL | `http://inventory-service:8080` |
| `Jwt:Authority` | OIDC discovery authority | `http://localhost:5001` |
### Middleware Pipeline
1. Serilog Request Logging
2. ProblemDetails (RFC 7807)
3. Swagger (Development only)
4. CORS (AllowAny)
5. Routing
6. Authentication (JWT Bearer via IAM IdentityServer OIDC)
7. Authorization
8. TenantMiddleware (sets PostgreSQL RLS session variables)
9. Health Checks (/health, /health/live, /health/ready)
10. Controllers
### MediatR Pipeline Behaviors
1. `LoggingBehavior` -- Logs request name + elapsed time (Stopwatch)
2. `ValidatorBehavior` -- Runs FluentValidation validators, throws ValidationException on failure
3. `TransactionBehavior` -- Wraps Commands in DB transaction (skips Queries by name convention), uses ExecutionStrategy
### Multi-Tenant Architecture
- **API Layer**: `ITenantProvider` extracts shop_id/merchant_id from JWT claims or `X-Shop-Id` header
- **Infrastructure Layer**: `IFnbTenantProvider` provides shop_id to DbContext global query filters
- **Middleware**: `TenantMiddleware` sets PostgreSQL session variables (`app.current_shop_id`, `app.current_merchant_id`) for RLS policies
- **Bypass**: Service-to-service calls (header `X-Service-Call: internal`) and admin/system roles bypass tenant filtering
---
## Tests
### Unit Tests (`tests/FnbEngine.UnitTests/`)
- `CreateTableCommandHandlerTests`
- `ChangeTableStatusCommandHandlerTests`
- `OpenSessionCommandHandlerTests`
- `CloseSessionCommandHandlerTests`
- `CreateKitchenTicketCommandHandlerTests`
- `UpdateTicketStatusCommandHandlerTests`
- `CreateReservationCommandHandlerTests`
- `RecipeCommandHandlersTests`
- `KitchenTicketServedDomainEventHandlerTests`
- `TableAggregateTests`
- `SessionTests`
- `KitchenTicketTests`
- `ReservationTests`
- `RecipeTests`
### Functional Tests (`tests/FnbEngine.FunctionalTests/`)
- `TablesControllerTests` (with `CustomWebApplicationFactory`)
---
## Key Design Decisions
1. **Kitchen tickets are linked to sessions, not directly to shops**. Shop-level filtering for tickets goes through sessions (indirect isolation).
2. **Recipes are NOT tenant-filtered** at the DbContext level. They are filtered by shopId in repository queries since recipes could theoretically be shared.
3. **Inventory deduction is fire-and-forget**. Errors in inventory-service calls are logged but do not block the kitchen workflow (non-throwing handler).
4. **Barista queue uses integer status IDs** (1-5) rather than string status like kitchen tickets, for more efficient querying and indexing.
5. **QR tokens are 16-char hex strings** generated from GUIDs, with a unique filtered index.
6. **Auto-migration on startup** -- EF Core migrations are applied automatically when the service starts (with error suppression for PendingModelChangesWarning).