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