From da7294045fe1f800ba38c58be0630c8e1bfdab05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 18:06:29 -0600 Subject: [PATCH] feat(operations-dal): add restaurant lifecycle store --- Operations.DAL.slnx | 3 + .../restaurant-lifecycle-store.md | 44 ++++++++++++ .../restaurant-lifecycle-store.puml | 30 ++++++++ docs/roadmap/feature-epics.md | 6 +- docs/runbooks/containerization.md | 4 +- src/Operations.DAL.Host/Program.cs | 63 ++++++++++++++++ .../RestaurantLifecycleEventRecord.cs | 9 +++ .../Contracts/RestaurantLifecycleRecord.cs | 16 +++++ .../IRestaurantLifecycleRepository.cs | 13 ++++ .../InMemoryRestaurantLifecycleRepository.cs | 71 ++++++++++++++++++ ...emoryRestaurantLifecycleRepositoryTests.cs | 72 +++++++++++++++++++ .../Operations.DAL.UnitTests.csproj | 19 +++++ 12 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/restaurant-lifecycle-store.md create mode 100644 docs/architecture/restaurant-lifecycle-store.puml create mode 100644 src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs create mode 100644 src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs create mode 100644 src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs create mode 100644 src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs create mode 100644 tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs create mode 100644 tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj diff --git a/Operations.DAL.slnx b/Operations.DAL.slnx index 7ab4a27..3ed8793 100644 --- a/Operations.DAL.slnx +++ b/Operations.DAL.slnx @@ -3,4 +3,7 @@ + + + diff --git a/docs/architecture/restaurant-lifecycle-store.md b/docs/architecture/restaurant-lifecycle-store.md new file mode 100644 index 0000000..c14ef2b --- /dev/null +++ b/docs/architecture/restaurant-lifecycle-store.md @@ -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. diff --git a/docs/architecture/restaurant-lifecycle-store.puml b/docs/architecture/restaurant-lifecycle-store.puml new file mode 100644 index 0000000..d92f7ab --- /dev/null +++ b/docs/architecture/restaurant-lifecycle-store.puml @@ -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 diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index df129b2..57820c7 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -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. diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 812ecf5..766597d 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -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. diff --git a/src/Operations.DAL.Host/Program.cs b/src/Operations.DAL.Host/Program.cs index 76ae583..0e63cc1 100644 --- a/src/Operations.DAL.Host/Program.cs +++ b/src/Operations.DAL.Host/Program.cs @@ -4,6 +4,7 @@ using Operations.DAL.Repositories; var builder = WebApplication.CreateBuilder(args); builder.Services.AddHealthChecks(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); 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"); diff --git a/src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs b/src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs new file mode 100644 index 0000000..bb4a3c4 --- /dev/null +++ b/src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs @@ -0,0 +1,9 @@ +namespace Operations.DAL.Contracts; + +public sealed record RestaurantLifecycleEventRecord( + string ContextId, + string OrderId, + string EventId, + string EventType, + string Description, + DateTime OccurredAtUtc); diff --git a/src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs b/src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs new file mode 100644 index 0000000..f38b448 --- /dev/null +++ b/src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs @@ -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 ItemIds, + DateTime UpdatedAtUtc); diff --git a/src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs b/src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs new file mode 100644 index 0000000..cb88bfd --- /dev/null +++ b/src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs @@ -0,0 +1,13 @@ +using Operations.DAL.Contracts; + +namespace Operations.DAL.Repositories; + +public interface IRestaurantLifecycleRepository +{ + Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken); + Task> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken); + Task UpsertOrderAsync(RestaurantLifecycleRecord record, CancellationToken cancellationToken); + Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task AppendEventAsync(RestaurantLifecycleEventRecord record, CancellationToken cancellationToken); +} diff --git a/src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs b/src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs new file mode 100644 index 0000000..97a6eba --- /dev/null +++ b/src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using Operations.DAL.Contracts; + +namespace Operations.DAL.Repositories; + +public sealed class InMemoryRestaurantLifecycleRepository : IRestaurantLifecycleRepository +{ + private readonly ConcurrentDictionary orders = new(); + private readonly ConcurrentDictionary> events = new(); + + public Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + orders.TryGetValue(BuildOrderKey(contextId, orderId), out var record); + return Task.FromResult(record); + } + + public Task> 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>(records); + } + + public Task> 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>(records); + } + + public Task UpsertOrderAsync(RestaurantLifecycleRecord record, CancellationToken cancellationToken) + { + orders[BuildOrderKey(record.ContextId, record.OrderId)] = record; + return Task.CompletedTask; + } + + public Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + if (!events.TryGetValue(BuildOrderKey(contextId, orderId), out var queue)) + { + return Task.FromResult>(Array.Empty()); + } + + var records = queue + .OrderByDescending(record => record.OccurredAtUtc) + .ToArray(); + + return Task.FromResult>(records); + } + + public Task AppendEventAsync(RestaurantLifecycleEventRecord record, CancellationToken cancellationToken) + { + var queue = events.GetOrAdd( + BuildOrderKey(record.ContextId, record.OrderId), + static _ => new ConcurrentQueue()); + + queue.Enqueue(record); + return Task.CompletedTask; + } + + private static string BuildOrderKey(string contextId, string orderId) => $"{contextId}::{orderId}"; +} diff --git a/tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs b/tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs new file mode 100644 index 0000000..2e29a7b --- /dev/null +++ b/tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs @@ -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); + } +} diff --git a/tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj b/tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj new file mode 100644 index 0000000..5a414aa --- /dev/null +++ b/tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + +