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

26 KiB

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)
  • 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

{
  "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).