feat(operations-dal): add restaurant lifecycle store
This commit is contained in:
parent
10d30fbc16
commit
da7294045f
@ -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>
|
||||
|
||||
44
docs/architecture/restaurant-lifecycle-store.md
Normal file
44
docs/architecture/restaurant-lifecycle-store.md
Normal 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.
|
||||
30
docs/architecture/restaurant-lifecycle-store.puml
Normal file
30
docs/architecture/restaurant-lifecycle-store.puml
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
namespace Operations.DAL.Contracts;
|
||||
|
||||
public sealed record RestaurantLifecycleEventRecord(
|
||||
string ContextId,
|
||||
string OrderId,
|
||||
string EventId,
|
||||
string EventType,
|
||||
string Description,
|
||||
DateTime OccurredAtUtc);
|
||||
16
src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs
Normal file
16
src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs
Normal 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);
|
||||
@ -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);
|
||||
}
|
||||
@ -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}";
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user