From 3687cb83efe896e404d6eef1344457b6d0e62862 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:08:53 -0600 Subject: [PATCH] feat(kitchen-dal): add linked ticket store --- Kitchen.DAL.slnx | 3 + docs/architecture/kitchen-ticket-store.md | 42 ++++++++++++ docs/architecture/kitchen-ticket-store.puml | 28 ++++++++ docs/roadmap/feature-epics.md | 6 +- docs/runbooks/containerization.md | 2 + src/Kitchen.DAL.Host/Program.cs | 50 +++++++++++++- .../Contracts/KitchenWorkItemEventRecord.cs | 9 +++ .../Contracts/KitchenWorkItemRecord.cs | 8 ++- .../IKitchenWorkItemRepository.cs | 6 +- .../InMemoryKitchenWorkItemRepository.cs | 56 +++++++++++++++- .../InMemoryKitchenWorkItemRepositoryTests.cs | 66 +++++++++++++++++++ .../Kitchen.DAL.UnitTests.csproj | 19 ++++++ 12 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/kitchen-ticket-store.md create mode 100644 docs/architecture/kitchen-ticket-store.puml create mode 100644 src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs create mode 100644 tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs create mode 100644 tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj diff --git a/Kitchen.DAL.slnx b/Kitchen.DAL.slnx index 9603303..d13c3c8 100644 --- a/Kitchen.DAL.slnx +++ b/Kitchen.DAL.slnx @@ -3,4 +3,7 @@ + + + diff --git a/docs/architecture/kitchen-ticket-store.md b/docs/architecture/kitchen-ticket-store.md new file mode 100644 index 0000000..4fc0f26 --- /dev/null +++ b/docs/architecture/kitchen-ticket-store.md @@ -0,0 +1,42 @@ +# Kitchen Ticket Store + +Related Master Plan: `plan/2026-03-31-s44-master-shared-restaurant-lifecycle.md` + +## Purpose + +`kitchen-dal` persists kitchen work items and ticket history while preserving the restaurant identifiers that link kitchen execution back to the shared order/check lifecycle. + +## Persisted Shapes + +### Kitchen Work Item + +Each stored work item keeps: +- `ContextId` +- `WorkItemId` +- `OrderId` +- `CheckId` +- `TableId` +- `WorkType` +- `Station` +- `Priority` +- `RequestedAtUtc` +- `State` +- `ClaimedBy` + +### Work Item Event History + +The DAL also stores event history per work item so kitchen and restaurant-facing projections can show how a ticket progressed over time. + +## Query Shape + +The DAL exposes internal access patterns for: +- load one kitchen work item by context and work item id +- list queued work items by context +- list work items for a restaurant order +- append and list work-item events + +## Ownership Boundary + +- `kitchen-domain` owns the ticket lifecycle rules and order-link integrity. +- `kitchen-dal` owns durable storage for ticket progression and ticket event history. +- `operations-dal` remains the source of truth for the restaurant order/check record itself. diff --git a/docs/architecture/kitchen-ticket-store.puml b/docs/architecture/kitchen-ticket-store.puml new file mode 100644 index 0000000..95188e1 --- /dev/null +++ b/docs/architecture/kitchen-ticket-store.puml @@ -0,0 +1,28 @@ +@startuml +hide empty members + +class KitchenWorkItemRecord { + ContextId + WorkItemId + OrderId + CheckId + TableId + WorkType + Station + Priority + RequestedAtUtc + State + ClaimedBy +} + +class KitchenWorkItemEventRecord { + ContextId + WorkItemId + EventId + EventType + Description + OccurredAtUtc +} + +KitchenWorkItemRecord "1" --> "many" KitchenWorkItemEventRecord : history +@enduml diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index db941a6..50f4f1e 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -9,10 +9,10 @@ kitchen-dal - Epic 3: Improve observability and operational readiness for demo compose environments. ## Domain-Specific Candidate Features -- Order lifecycle consistency and state transitions. +- Linked kitchen ticket persistence for shared restaurant identities. - Kitchen queue and dispatch optimization hooks. -- Operations control-plane policies (flags, service windows, overrides). -- POS closeout and settlement summary alignment. +- Station execution and work-item event history. +- Projection support for restaurant lifecycle feedback. ## 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 063581e..f617778 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -21,6 +21,7 @@ docker run --rm -p 8080:8080 --name kitchen-dal agilewebs/kitchen-dal:dev ## Runtime Notes - Exposes internal DAL probe endpoints for kitchen work item reads/writes. +- Exposes internal ticket history endpoints for linked kitchen work progression. ## Health Endpoint Consistency @@ -32,6 +33,7 @@ docker run --rm -p 8080:8080 --name kitchen-dal agilewebs/kitchen-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. diff --git a/src/Kitchen.DAL.Host/Program.cs b/src/Kitchen.DAL.Host/Program.cs index bda0536..68bcd62 100644 --- a/src/Kitchen.DAL.Host/Program.cs +++ b/src/Kitchen.DAL.Host/Program.cs @@ -8,10 +8,31 @@ builder.Services.AddSingleton { - var items = await repository.ListQueuedAsync(ct); + var items = await repository.ListQueuedAsync(contextId, ct); + return Results.Ok(items); +}); + +app.MapGet("/internal/kitchen-dal/work-items/{workItemId}", async ( + string contextId, + string workItemId, + IKitchenWorkItemRepository repository, + CancellationToken ct) => +{ + var record = await repository.GetAsync(contextId, workItemId, ct); + return record is null ? Results.NotFound() : Results.Ok(record); +}); + +app.MapGet("/internal/kitchen-dal/orders/{orderId}/work-items", async ( + string contextId, + string orderId, + IKitchenWorkItemRepository repository, + CancellationToken ct) => +{ + var items = await repository.ListByOrderAsync(contextId, orderId, ct); return Results.Ok(items); }); @@ -21,7 +42,32 @@ app.MapPost("/internal/kitchen-dal/work-items", async ( CancellationToken ct) => { await repository.UpsertAsync(record, ct); - return Results.Accepted($"/internal/kitchen-dal/work-items/{record.WorkItemId}", record); + return Results.Accepted($"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(record.WorkItemId)}?contextId={Uri.EscapeDataString(record.ContextId)}", record); +}); + +app.MapGet("/internal/kitchen-dal/work-items/{workItemId}/events", async ( + string contextId, + string workItemId, + IKitchenWorkItemRepository repository, + CancellationToken ct) => +{ + var events = await repository.ListEventsAsync(contextId, workItemId, ct); + return Results.Ok(events); +}); + +app.MapPost("/internal/kitchen-dal/work-items/{workItemId}/events", async ( + string workItemId, + KitchenWorkItemEventRecord record, + IKitchenWorkItemRepository repository, + CancellationToken ct) => +{ + if (!string.Equals(workItemId, record.WorkItemId, StringComparison.Ordinal)) + { + return Results.BadRequest(new { message = "Route workItemId must match event payload workItemId." }); + } + + await repository.AppendEventAsync(record, ct); + return Results.Accepted($"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(workItemId)}/events?contextId={Uri.EscapeDataString(record.ContextId)}", record); }); app.MapHealthChecks("/health"); diff --git a/src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs b/src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs new file mode 100644 index 0000000..f1ef7cd --- /dev/null +++ b/src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs @@ -0,0 +1,9 @@ +namespace Kitchen.DAL.Contracts; + +public sealed record KitchenWorkItemEventRecord( + string ContextId, + string WorkItemId, + string EventId, + string EventType, + string Description, + DateTime OccurredAtUtc); diff --git a/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs b/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs index c7cac02..2c283cd 100644 --- a/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs +++ b/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs @@ -1,8 +1,14 @@ namespace Kitchen.DAL.Contracts; public sealed record KitchenWorkItemRecord( + string ContextId, string WorkItemId, + string OrderId, + string CheckId, + string TableId, string WorkType, + string Station, int Priority, DateTime RequestedAtUtc, - string State); + string State, + string? ClaimedBy); diff --git a/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs b/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs index 8711984..1b99fb3 100644 --- a/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs +++ b/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs @@ -4,6 +4,10 @@ namespace Kitchen.DAL.Repositories; public interface IKitchenWorkItemRepository { - Task> ListQueuedAsync(CancellationToken cancellationToken); + Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken); + Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken); + Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken); + Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken); + Task AppendEventAsync(KitchenWorkItemEventRecord record, CancellationToken cancellationToken); } diff --git a/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs b/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs index 3836043..348970c 100644 --- a/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs +++ b/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs @@ -6,16 +6,66 @@ namespace Kitchen.DAL.Repositories; public sealed class InMemoryKitchenWorkItemRepository : IKitchenWorkItemRepository { private readonly ConcurrentDictionary store = new(); + private readonly ConcurrentDictionary> events = new(); - public Task> ListQueuedAsync(CancellationToken cancellationToken) + public Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken) { - var result = store.Values.Where(x => x.State == "Queued").OrderByDescending(x => x.Priority).ToArray(); + store.TryGetValue(BuildKey(contextId, workItemId), out var record); + return Task.FromResult(record); + } + + public Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken) + { + var result = store.Values + .Where(x => string.Equals(x.ContextId, contextId, StringComparison.Ordinal)) + .Where(x => string.Equals(x.State, "Queued", StringComparison.Ordinal)) + .OrderByDescending(x => x.Priority) + .ThenBy(x => x.RequestedAtUtc) + .ToArray(); + + return Task.FromResult>(result); + } + + public Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + var result = store.Values + .Where(x => string.Equals(x.ContextId, contextId, StringComparison.Ordinal)) + .Where(x => string.Equals(x.OrderId, orderId, StringComparison.Ordinal)) + .OrderByDescending(x => x.RequestedAtUtc) + .ToArray(); + return Task.FromResult>(result); } public Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken) { - store[record.WorkItemId] = record; + store[BuildKey(record.ContextId, record.WorkItemId)] = record; return Task.CompletedTask; } + + public Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken) + { + if (!events.TryGetValue(BuildKey(contextId, workItemId), out var queue)) + { + return Task.FromResult>(Array.Empty()); + } + + var result = queue + .OrderByDescending(x => x.OccurredAtUtc) + .ToArray(); + + return Task.FromResult>(result); + } + + public Task AppendEventAsync(KitchenWorkItemEventRecord record, CancellationToken cancellationToken) + { + var queue = events.GetOrAdd( + BuildKey(record.ContextId, record.WorkItemId), + static _ => new ConcurrentQueue()); + + queue.Enqueue(record); + return Task.CompletedTask; + } + + private static string BuildKey(string contextId, string workItemId) => $"{contextId}::{workItemId}"; } diff --git a/tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs b/tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs new file mode 100644 index 0000000..fbac33c --- /dev/null +++ b/tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs @@ -0,0 +1,66 @@ +using Kitchen.DAL.Contracts; +using Kitchen.DAL.Repositories; + +namespace Kitchen.DAL.UnitTests; + +public sealed class InMemoryKitchenWorkItemRepositoryTests +{ + private readonly InMemoryKitchenWorkItemRepository repository = new(); + + [Fact] + public async Task UpsertAsync_PersistsLinkedKitchenWorkItem() + { + var record = CreateRecord(workItemId: "WK-4001", state: "Queued"); + + await repository.UpsertAsync(record, CancellationToken.None); + var persisted = await repository.GetAsync(record.ContextId, record.WorkItemId, CancellationToken.None); + + Assert.NotNull(persisted); + Assert.Equal("ORD-4001", persisted!.OrderId); + Assert.Equal("CHK-4001", persisted.CheckId); + } + + [Fact] + public async Task ListQueuedAsync_ReturnsOnlyQueuedItemsForContext() + { + await repository.UpsertAsync(CreateRecord(workItemId: "WK-4001", state: "Queued"), CancellationToken.None); + await repository.UpsertAsync(CreateRecord(workItemId: "WK-4002", state: "Preparing"), CancellationToken.None); + await repository.UpsertAsync(CreateRecord(workItemId: "WK-4003", state: "Queued", contextId: "other-context"), CancellationToken.None); + + var queued = await repository.ListQueuedAsync("demo-context", CancellationToken.None); + + Assert.Single(queued); + Assert.Equal("WK-4001", queued.Single().WorkItemId); + } + + [Fact] + public async Task AppendEventAsync_StoresHistoryForLinkedWorkItem() + { + var first = new KitchenWorkItemEventRecord("demo-context", "WK-4001", "EVT-1", "Queued", "Ticket was queued.", DateTime.UtcNow.AddMinutes(-3)); + var second = new KitchenWorkItemEventRecord("demo-context", "WK-4001", "EVT-2", "Preparing", "Chef started work.", DateTime.UtcNow.AddMinutes(-1)); + + await repository.AppendEventAsync(first, CancellationToken.None); + await repository.AppendEventAsync(second, CancellationToken.None); + + var events = await repository.ListEventsAsync("demo-context", "WK-4001", CancellationToken.None); + + Assert.Equal(2, events.Count); + Assert.Equal("EVT-2", events.First().EventId); + } + + private static KitchenWorkItemRecord CreateRecord(string workItemId, string state, string contextId = "demo-context") + { + return new KitchenWorkItemRecord( + ContextId: contextId, + WorkItemId: workItemId, + OrderId: "ORD-4001", + CheckId: "CHK-4001", + TableId: "T-09", + WorkType: "PrepareOrder", + Station: "grill", + Priority: 3, + RequestedAtUtc: DateTime.UtcNow.AddMinutes(-4), + State: state, + ClaimedBy: null); + } +} diff --git a/tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj b/tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj new file mode 100644 index 0000000..a5c8c7e --- /dev/null +++ b/tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + +