feat(kitchen-dal): add linked ticket store

This commit is contained in:
José René White Enciso 2026-03-31 18:08:53 -06:00
parent 583d345222
commit 3687cb83ef
12 changed files with 285 additions and 10 deletions

View File

@ -3,4 +3,7 @@
<Project Path="src/Kitchen.DAL.Host/Kitchen.DAL.Host.csproj" />
<Project Path="src/Kitchen.DAL/Kitchen.DAL.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj" />
</Folder>
</Solution>

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -8,10 +8,31 @@ builder.Services.AddSingleton<IKitchenWorkItemRepository, InMemoryKitchenWorkIte
var app = builder.Build();
app.MapGet("/internal/kitchen-dal/work-items/queued", async (
string contextId,
IKitchenWorkItemRepository repository,
CancellationToken ct) =>
{
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");

View File

@ -0,0 +1,9 @@
namespace Kitchen.DAL.Contracts;
public sealed record KitchenWorkItemEventRecord(
string ContextId,
string WorkItemId,
string EventId,
string EventType,
string Description,
DateTime OccurredAtUtc);

View File

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

View File

@ -4,6 +4,10 @@ namespace Kitchen.DAL.Repositories;
public interface IKitchenWorkItemRepository
{
Task<IReadOnlyCollection<KitchenWorkItemRecord>> ListQueuedAsync(CancellationToken cancellationToken);
Task<KitchenWorkItemRecord?> GetAsync(string contextId, string workItemId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<KitchenWorkItemRecord>> ListQueuedAsync(string contextId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<KitchenWorkItemRecord>> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken);
Task<IReadOnlyCollection<KitchenWorkItemEventRecord>> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken);
Task AppendEventAsync(KitchenWorkItemEventRecord record, CancellationToken cancellationToken);
}

View File

@ -6,16 +6,66 @@ namespace Kitchen.DAL.Repositories;
public sealed class InMemoryKitchenWorkItemRepository : IKitchenWorkItemRepository
{
private readonly ConcurrentDictionary<string, KitchenWorkItemRecord> store = new();
private readonly ConcurrentDictionary<string, ConcurrentQueue<KitchenWorkItemEventRecord>> events = new();
public Task<IReadOnlyCollection<KitchenWorkItemRecord>> ListQueuedAsync(CancellationToken cancellationToken)
public Task<KitchenWorkItemRecord?> 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<IReadOnlyCollection<KitchenWorkItemRecord>> 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<IReadOnlyCollection<KitchenWorkItemRecord>>(result);
}
public Task<IReadOnlyCollection<KitchenWorkItemRecord>> 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<IReadOnlyCollection<KitchenWorkItemRecord>>(result);
}
public Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken)
{
store[record.WorkItemId] = record;
store[BuildKey(record.ContextId, record.WorkItemId)] = record;
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<KitchenWorkItemEventRecord>> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken)
{
if (!events.TryGetValue(BuildKey(contextId, workItemId), out var queue))
{
return Task.FromResult<IReadOnlyCollection<KitchenWorkItemEventRecord>>(Array.Empty<KitchenWorkItemEventRecord>());
}
var result = queue
.OrderByDescending(x => x.OccurredAtUtc)
.ToArray();
return Task.FromResult<IReadOnlyCollection<KitchenWorkItemEventRecord>>(result);
}
public Task AppendEventAsync(KitchenWorkItemEventRecord record, CancellationToken cancellationToken)
{
var queue = events.GetOrAdd(
BuildKey(record.ContextId, record.WorkItemId),
static _ => new ConcurrentQueue<KitchenWorkItemEventRecord>());
queue.Enqueue(record);
return Task.CompletedTask;
}
private static string BuildKey(string contextId, string workItemId) => $"{contextId}::{workItemId}";
}

View File

@ -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);
}
}

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/Kitchen.DAL/Kitchen.DAL.csproj" />
</ItemGroup>
</Project>