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.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>
|
||||||
|
|||||||
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.
|
- 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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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