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.Host/Kitchen.DAL.Host.csproj" />
<Project Path="src/Kitchen.DAL/Kitchen.DAL.csproj" /> <Project Path="src/Kitchen.DAL/Kitchen.DAL.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj" />
</Folder>
</Solution> </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. - 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. - Linked kitchen ticket persistence for shared restaurant identities.
- Kitchen queue and dispatch optimization hooks. - Kitchen queue and dispatch optimization hooks.
- Operations control-plane policies (flags, service windows, overrides). - Station execution and work-item event history.
- POS closeout and settlement summary alignment. - Projection support for restaurant lifecycle feedback.
## 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.

View File

@ -21,6 +21,7 @@ docker run --rm -p 8080:8080 --name kitchen-dal agilewebs/kitchen-dal:dev
## Runtime Notes ## Runtime Notes
- Exposes internal DAL probe endpoints for kitchen work item reads/writes. - Exposes internal DAL probe endpoints for kitchen work item reads/writes.
- Exposes internal ticket history endpoints for linked kitchen work progression.
## Health Endpoint Consistency ## 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. - 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 predominantly in-memory for deterministic local/demo behavior.

View File

@ -8,10 +8,31 @@ builder.Services.AddSingleton<IKitchenWorkItemRepository, InMemoryKitchenWorkIte
var app = builder.Build(); var app = builder.Build();
app.MapGet("/internal/kitchen-dal/work-items/queued", async ( app.MapGet("/internal/kitchen-dal/work-items/queued", async (
string contextId,
IKitchenWorkItemRepository repository, IKitchenWorkItemRepository repository,
CancellationToken ct) => 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); return Results.Ok(items);
}); });
@ -21,7 +42,32 @@ app.MapPost("/internal/kitchen-dal/work-items", async (
CancellationToken ct) => CancellationToken ct) =>
{ {
await repository.UpsertAsync(record, 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"); 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; namespace Kitchen.DAL.Contracts;
public sealed record KitchenWorkItemRecord( public sealed record KitchenWorkItemRecord(
string ContextId,
string WorkItemId, string WorkItemId,
string OrderId,
string CheckId,
string TableId,
string WorkType, string WorkType,
string Station,
int Priority, int Priority,
DateTime RequestedAtUtc, DateTime RequestedAtUtc,
string State); string State,
string? ClaimedBy);

View File

@ -4,6 +4,10 @@ namespace Kitchen.DAL.Repositories;
public interface IKitchenWorkItemRepository 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 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 public sealed class InMemoryKitchenWorkItemRepository : IKitchenWorkItemRepository
{ {
private readonly ConcurrentDictionary<string, KitchenWorkItemRecord> store = new(); 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); return Task.FromResult<IReadOnlyCollection<KitchenWorkItemRecord>>(result);
} }
public Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken) public Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken)
{ {
store[record.WorkItemId] = record; store[BuildKey(record.ContextId, record.WorkItemId)] = record;
return Task.CompletedTask; 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>