feat(operations-dal): add restaurant lifecycle store

This commit is contained in:
José René White Enciso 2026-03-31 18:06:29 -06:00
parent 10d30fbc16
commit da7294045f
12 changed files with 346 additions and 4 deletions

View File

@ -3,4 +3,7 @@
<Project Path="src/Operations.DAL.Host/Operations.DAL.Host.csproj" /> <Project Path="src/Operations.DAL.Host/Operations.DAL.Host.csproj" />
<Project Path="src/Operations.DAL/Operations.DAL.csproj" /> <Project Path="src/Operations.DAL/Operations.DAL.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj" />
</Folder>
</Solution> </Solution>

View File

@ -0,0 +1,44 @@
# Restaurant Lifecycle Store
Related Master Plan: `plan/2026-03-31-s44-master-shared-restaurant-lifecycle.md`
## Purpose
`operations-dal` persists the shared restaurant order and check state that waiter, customer, kitchen, and POS flows will operate on together.
## Persisted Shapes
### Order and Check Record
The lifecycle store keeps one shared record per restaurant order containing:
- `ContextId`
- `OrderId`
- `CheckId`
- `TableId`
- `OrderState`
- `CheckState`
- `GuestCount`
- `HasKitchenTicket`
- `OutstandingBalance`
- `Currency`
- `Source`
- `ItemIds`
- `UpdatedAtUtc`
### Event History
The store also keeps an append-only event stream per order so services and BFFs can expose recent lifecycle activity without synthesizing it from isolated arrays.
## Query Shape
The DAL exposes internal access patterns for:
- load one order by context and order id
- list orders by context
- list payable orders by context
- append and list lifecycle events by order
## Ownership Boundary
- `operations-domain` owns the lifecycle rules.
- `operations-dal` owns durable storage for the shared restaurant order/check state.
- `kitchen-dal` will persist kitchen ticket execution separately while sharing the restaurant identities.

View File

@ -0,0 +1,30 @@
@startuml
hide empty members
class RestaurantLifecycleRecord {
ContextId
OrderId
CheckId
TableId
OrderState
CheckState
GuestCount
HasKitchenTicket
OutstandingBalance
Currency
Source
ItemIds
UpdatedAtUtc
}
class RestaurantLifecycleEventRecord {
ContextId
OrderId
EventId
EventType
Description
OccurredAtUtc
}
RestaurantLifecycleRecord "1" --> "many" RestaurantLifecycleEventRecord : history
@enduml

View File

@ -9,10 +9,10 @@ operations-dal
- Epic 3: Improve observability and operational readiness for demo compose environments. - Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features ## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions. - Shared restaurant order/check lifecycle persistence.
- Kitchen queue and dispatch optimization hooks. - Kitchen dispatch and ticket-link projection support.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment. - POS payment eligibility query support for payable restaurant checks.
## Documentation Contract ## Documentation Contract
Any code change in this repository must include docs updates in the same branch. Any code change in this repository must include docs updates in the same branch.

View File

@ -21,6 +21,7 @@ docker run --rm -p 8080:8080 --name operations-dal agilewebs/operations-dal:dev
## Runtime Notes ## Runtime Notes
- Exposes internal DAL probe endpoints for operations configuration reads/writes. - Exposes internal DAL probe endpoints for operations configuration reads/writes.
- Exposes internal restaurant lifecycle endpoints for shared order/check and event history access.
## Health Endpoint Consistency ## Health Endpoint Consistency
@ -32,7 +33,8 @@ docker run --rm -p 8080:8080 --name operations-dal agilewebs/operations-dal:dev
- Participates in: **restaurant** demo compose stack. - Participates in: **restaurant** demo compose stack.
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations ## Known Limitations
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. - Current runtime adapters are still in-memory for deterministic local/demo behavior.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity. - Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.

View File

@ -4,6 +4,7 @@ using Operations.DAL.Repositories;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
builder.Services.AddSingleton<IOperationsConfigRepository, InMemoryOperationsConfigRepository>(); builder.Services.AddSingleton<IOperationsConfigRepository, InMemoryOperationsConfigRepository>();
builder.Services.AddSingleton<IRestaurantLifecycleRepository, InMemoryRestaurantLifecycleRepository>();
var app = builder.Build(); var app = builder.Build();
@ -27,6 +28,68 @@ app.MapPost("/internal/operations-dal/config", async (
return Results.Accepted($"/internal/operations-dal/config?locationId={Uri.EscapeDataString(record.LocationId)}", record); return Results.Accepted($"/internal/operations-dal/config?locationId={Uri.EscapeDataString(record.LocationId)}", record);
}); });
app.MapGet("/internal/operations-dal/orders", async (
string contextId,
IRestaurantLifecycleRepository repository,
CancellationToken ct) =>
{
var records = await repository.ListOrdersAsync(contextId, ct);
return Results.Ok(records);
});
app.MapGet("/internal/operations-dal/orders/payable", async (
string contextId,
IRestaurantLifecycleRepository repository,
CancellationToken ct) =>
{
var records = await repository.ListPayableOrdersAsync(contextId, ct);
return Results.Ok(records);
});
app.MapGet("/internal/operations-dal/orders/{orderId}", async (
string contextId,
string orderId,
IRestaurantLifecycleRepository repository,
CancellationToken ct) =>
{
var record = await repository.GetOrderAsync(contextId, orderId, ct);
return record is null ? Results.NotFound() : Results.Ok(record);
});
app.MapPost("/internal/operations-dal/orders", async (
RestaurantLifecycleRecord record,
IRestaurantLifecycleRepository repository,
CancellationToken ct) =>
{
await repository.UpsertOrderAsync(record, ct);
return Results.Accepted($"/internal/operations-dal/orders/{Uri.EscapeDataString(record.OrderId)}?contextId={Uri.EscapeDataString(record.ContextId)}", record);
});
app.MapGet("/internal/operations-dal/orders/{orderId}/events", async (
string contextId,
string orderId,
IRestaurantLifecycleRepository repository,
CancellationToken ct) =>
{
var records = await repository.ListEventsAsync(contextId, orderId, ct);
return Results.Ok(records);
});
app.MapPost("/internal/operations-dal/orders/{orderId}/events", async (
string orderId,
RestaurantLifecycleEventRecord record,
IRestaurantLifecycleRepository repository,
CancellationToken ct) =>
{
if (!string.Equals(orderId, record.OrderId, StringComparison.Ordinal))
{
return Results.BadRequest(new { message = "Route orderId must match event payload orderId." });
}
await repository.AppendEventAsync(record, ct);
return Results.Accepted($"/internal/operations-dal/orders/{Uri.EscapeDataString(orderId)}/events?contextId={Uri.EscapeDataString(record.ContextId)}", record);
});
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
app.MapHealthChecks("/healthz"); app.MapHealthChecks("/healthz");

View File

@ -0,0 +1,9 @@
namespace Operations.DAL.Contracts;
public sealed record RestaurantLifecycleEventRecord(
string ContextId,
string OrderId,
string EventId,
string EventType,
string Description,
DateTime OccurredAtUtc);

View File

@ -0,0 +1,16 @@
namespace Operations.DAL.Contracts;
public sealed record RestaurantLifecycleRecord(
string ContextId,
string OrderId,
string CheckId,
string TableId,
string OrderState,
string CheckState,
int GuestCount,
bool HasKitchenTicket,
decimal OutstandingBalance,
string Currency,
string Source,
IReadOnlyCollection<string> ItemIds,
DateTime UpdatedAtUtc);

View File

@ -0,0 +1,13 @@
using Operations.DAL.Contracts;
namespace Operations.DAL.Repositories;
public interface IRestaurantLifecycleRepository
{
Task<RestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<RestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<RestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken);
Task UpsertOrderAsync(RestaurantLifecycleRecord record, CancellationToken cancellationToken);
Task<IReadOnlyCollection<RestaurantLifecycleEventRecord>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task AppendEventAsync(RestaurantLifecycleEventRecord record, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,71 @@
using System.Collections.Concurrent;
using Operations.DAL.Contracts;
namespace Operations.DAL.Repositories;
public sealed class InMemoryRestaurantLifecycleRepository : IRestaurantLifecycleRepository
{
private readonly ConcurrentDictionary<string, RestaurantLifecycleRecord> orders = new();
private readonly ConcurrentDictionary<string, ConcurrentQueue<RestaurantLifecycleEventRecord>> events = new();
public Task<RestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
orders.TryGetValue(BuildOrderKey(contextId, orderId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<RestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken)
{
var records = orders.Values
.Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal))
.OrderByDescending(record => record.UpdatedAtUtc)
.ToArray();
return Task.FromResult<IReadOnlyCollection<RestaurantLifecycleRecord>>(records);
}
public Task<IReadOnlyCollection<RestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken)
{
var records = orders.Values
.Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal))
.Where(record => string.Equals(record.OrderState, "Served", StringComparison.Ordinal)
&& string.Equals(record.CheckState, "Open", StringComparison.Ordinal)
&& record.OutstandingBalance > 0m)
.OrderByDescending(record => record.UpdatedAtUtc)
.ToArray();
return Task.FromResult<IReadOnlyCollection<RestaurantLifecycleRecord>>(records);
}
public Task UpsertOrderAsync(RestaurantLifecycleRecord record, CancellationToken cancellationToken)
{
orders[BuildOrderKey(record.ContextId, record.OrderId)] = record;
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<RestaurantLifecycleEventRecord>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
if (!events.TryGetValue(BuildOrderKey(contextId, orderId), out var queue))
{
return Task.FromResult<IReadOnlyCollection<RestaurantLifecycleEventRecord>>(Array.Empty<RestaurantLifecycleEventRecord>());
}
var records = queue
.OrderByDescending(record => record.OccurredAtUtc)
.ToArray();
return Task.FromResult<IReadOnlyCollection<RestaurantLifecycleEventRecord>>(records);
}
public Task AppendEventAsync(RestaurantLifecycleEventRecord record, CancellationToken cancellationToken)
{
var queue = events.GetOrAdd(
BuildOrderKey(record.ContextId, record.OrderId),
static _ => new ConcurrentQueue<RestaurantLifecycleEventRecord>());
queue.Enqueue(record);
return Task.CompletedTask;
}
private static string BuildOrderKey(string contextId, string orderId) => $"{contextId}::{orderId}";
}

View File

@ -0,0 +1,72 @@
using Operations.DAL.Contracts;
using Operations.DAL.Repositories;
namespace Operations.DAL.UnitTests;
public sealed class InMemoryRestaurantLifecycleRepositoryTests
{
private readonly InMemoryRestaurantLifecycleRepository repository = new();
[Fact]
public async Task UpsertOrderAsync_PersistsAndReturnsSharedRecord()
{
var record = CreateRecord(orderId: "ORD-3001", orderState: "Accepted");
await repository.UpsertOrderAsync(record, CancellationToken.None);
var persisted = await repository.GetOrderAsync(record.ContextId, record.OrderId, CancellationToken.None);
Assert.NotNull(persisted);
Assert.Equal("CHK-3001", persisted!.CheckId);
Assert.Equal("Accepted", persisted.OrderState);
}
[Fact]
public async Task ListPayableOrdersAsync_ReturnsOnlyServedOpenBalanceOrders()
{
await repository.UpsertOrderAsync(CreateRecord(orderId: "ORD-3001", orderState: "Served", outstandingBalance: 18m), CancellationToken.None);
await repository.UpsertOrderAsync(CreateRecord(orderId: "ORD-3002", orderState: "Preparing", outstandingBalance: 18m), CancellationToken.None);
await repository.UpsertOrderAsync(CreateRecord(orderId: "ORD-3003", orderState: "Served", checkState: "Paid", outstandingBalance: 18m), CancellationToken.None);
var payable = await repository.ListPayableOrdersAsync("demo-context", CancellationToken.None);
Assert.Single(payable);
Assert.Equal("ORD-3001", payable.Single().OrderId);
}
[Fact]
public async Task AppendEventAsync_StoresOrderedHistoryForSharedOrder()
{
var first = new RestaurantLifecycleEventRecord("demo-context", "ORD-3001", "EVT-1", "Submitted", "Customer submitted the order.", DateTime.UtcNow.AddMinutes(-3));
var second = new RestaurantLifecycleEventRecord("demo-context", "ORD-3001", "EVT-2", "Accepted", "Operations accepted the order.", DateTime.UtcNow.AddMinutes(-1));
await repository.AppendEventAsync(first, CancellationToken.None);
await repository.AppendEventAsync(second, CancellationToken.None);
var events = await repository.ListEventsAsync("demo-context", "ORD-3001", CancellationToken.None);
Assert.Equal(2, events.Count);
Assert.Equal("EVT-2", events.First().EventId);
}
private static RestaurantLifecycleRecord CreateRecord(
string orderId,
string orderState,
string checkState = "Open",
decimal outstandingBalance = 24m)
{
return new RestaurantLifecycleRecord(
ContextId: "demo-context",
OrderId: orderId,
CheckId: "CHK-3001",
TableId: "T-11",
OrderState: orderState,
CheckState: checkState,
GuestCount: 4,
HasKitchenTicket: orderState is "InKitchen" or "Preparing" or "Ready" or "Served",
OutstandingBalance: outstandingBalance,
Currency: "USD",
Source: "customer-orders",
ItemIds: new[] { "ITEM-100", "ITEM-200" },
UpdatedAtUtc: DateTime.UtcNow);
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/Operations.DAL/Operations.DAL.csproj" />
</ItemGroup>
</Project>