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/Operations.DAL.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj" />
</Folder>
</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.
## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions.
- Kitchen queue and dispatch optimization hooks.
- Shared restaurant order/check lifecycle persistence.
- Kitchen dispatch and ticket-link projection support.
- 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
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
- 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
@ -32,7 +33,8 @@ docker run --rm -p 8080:8080 --name operations-dal agilewebs/operations-dal:dev
- Participates in: **restaurant** demo compose stack.
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## 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.

View File

@ -4,6 +4,7 @@ using Operations.DAL.Repositories;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
builder.Services.AddSingleton<IOperationsConfigRepository, InMemoryOperationsConfigRepository>();
builder.Services.AddSingleton<IRestaurantLifecycleRepository, InMemoryRestaurantLifecycleRepository>();
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);
});
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("/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>