574 lines
26 KiB
Markdown
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).
|