feat(kitchen-dal): add linked ticket store
This commit is contained in:
parent
583d345222
commit
3687cb83ef
@ -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>
|
||||
|
||||
42
docs/architecture/kitchen-ticket-store.md
Normal file
42
docs/architecture/kitchen-ticket-store.md
Normal 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.
|
||||
28
docs/architecture/kitchen-ticket-store.puml
Normal file
28
docs/architecture/kitchen-ticket-store.puml
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
9
src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs
Normal file
9
src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs
Normal 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);
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}";
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
19
tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj
Normal file
19
tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user