feat(kitchen-service): expand kitchen workflow contracts

This commit is contained in:
José René White Enciso 2026-03-31 16:18:04 -06:00
parent 02cf18a0a6
commit 119f23ca66
23 changed files with 363 additions and 0 deletions

View File

@ -4,4 +4,7 @@
<Project Path="src/Kitchen.Service.Contracts/Kitchen.Service.Contracts.csproj" /> <Project Path="src/Kitchen.Service.Contracts/Kitchen.Service.Contracts.csproj" />
<Project Path="src/Kitchen.Service.Grpc/Kitchen.Service.Grpc.csproj" /> <Project Path="src/Kitchen.Service.Grpc/Kitchen.Service.Grpc.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/Kitchen.Service.Application.UnitTests/Kitchen.Service.Application.UnitTests.csproj" />
</Folder>
</Solution> </Solution>

View File

@ -0,0 +1,27 @@
# Internal Kitchen Workflow Contracts
## Purpose
`kitchen-service` now exposes a board-oriented internal workflow surface that the kitchen-ops BFF can consume directly.
## Endpoint Surface
- `GET /internal/kitchen/queue?queueName=<name>&limit=<n>`
- `POST /internal/kitchen/orders/transition`
- `GET /internal/kitchen/board?contextId=<id>`
- `POST /internal/kitchen/work-items/claim`
- `POST /internal/kitchen/work-items/priority`
## Contract Depth Added In Stage 41
The new kitchen workflow contracts add enough shape for downstream BFF and SPA work:
- board lanes with per-item station, claim, ETA, and priority details
- explicit claim/release-ready work-item ownership responses
- dedicated priority update responses separate from generic state transitions
- existing transition contract kept in place for order-state changes
## Current Runtime Shape
- The default implementation remains deterministic and in-memory.
- This repo still focuses on orchestration and contract shape, not kitchen persistence realism.

View File

@ -13,6 +13,7 @@ kitchen-service
- Kitchen queue and dispatch optimization hooks. - Kitchen queue and dispatch optimization hooks.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment. - POS closeout and settlement summary alignment.
- Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows.
## 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

@ -23,6 +23,7 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de
## Runtime Notes ## Runtime Notes
- Exposes internal queue and order state transition endpoints. - Exposes internal queue and order state transition endpoints.
- Also exposes board, work-item claim, and priority update endpoints for the kitchen-ops BFF flow.
## Health Endpoint Consistency ## Health Endpoint Consistency
@ -38,3 +39,4 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de
- 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.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity. - Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.
- Stage 41 adds kitchen workflow contract depth first; downstream BFFs still need to adopt these endpoints before richer board behavior reaches the web app.

View File

@ -0,0 +1,88 @@
using Kitchen.Service.Contracts.Contracts;
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.Ports;
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
{
public Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var lanes = new[]
{
new KitchenBoardLaneContract(
"queued",
new[]
{
new KitchenBoardItemContract("WK-1001", "CO-1001", "KT-1001", "T-08", "hot-line", "Queued", 3, null, 12),
new KitchenBoardItemContract("WK-1002", "CO-1003", "KT-1002", "T-12", "expedite", "Queued", 2, null, 8)
}),
new KitchenBoardLaneContract(
"preparing",
new[]
{
new KitchenBoardItemContract("WK-1003", "CO-1002", "KT-1003", "T-15", "grill", "Preparing", 4, "chef-maya", 5)
}),
new KitchenBoardLaneContract(
"ready",
new[]
{
new KitchenBoardItemContract("WK-1004", "CO-0999", "KT-0999", "T-21", "pickup", "ReadyForPickup", 1, "expo-noah", 0)
})
};
return Task.FromResult(new GetKitchenBoardResponse(
request.ContextId,
"Kitchen board shows queued, preparing, and ready lanes for the current service context.",
lanes,
new[] { "hot-line", "grill", "salad", "pickup", "expedite" }));
}
public Task<ClaimKitchenWorkItemResponse> ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var claimed = !string.IsNullOrWhiteSpace(request.ContextId) &&
!string.IsNullOrWhiteSpace(request.WorkItemId) &&
!string.IsNullOrWhiteSpace(request.ClaimedBy);
return Task.FromResult(new ClaimKitchenWorkItemResponse(
request.ContextId,
request.WorkItemId,
claimed,
request.ClaimedBy,
claimed
? $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}."
: "Kitchen work-item claim is incomplete."));
}
public Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var priority = Math.Max(1, request.Priority);
return Task.FromResult(new UpdateKitchenPriorityResponse(
request.ContextId,
request.WorkItemId,
priority,
$"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}."));
}
public Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var previous = "Queued";
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated";
return Task.FromResult(new TransitionKitchenOrderStateResponse(
request.OrderId,
request.TicketId,
previous,
allowed ? request.TargetState : previous,
allowed,
allowed ? null : "Target state is not allowed by kitchen-service policy."));
}
}

View File

@ -0,0 +1,12 @@
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.Ports;
public interface IKitchenWorkflowPort
{
Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken);
Task<ClaimKitchenWorkItemResponse> ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken);
Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken);
Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,13 @@
using Kitchen.Service.Application.Ports;
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases;
public sealed class ClaimKitchenWorkItemUseCase(IKitchenWorkflowPort workflowPort) : IClaimKitchenWorkItemUseCase
{
public Task<ClaimKitchenWorkItemResponse> HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
{
return workflowPort.ClaimKitchenWorkItemAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,13 @@
using Kitchen.Service.Application.Ports;
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases;
public sealed class GetKitchenBoardUseCase(IKitchenWorkflowPort workflowPort) : IGetKitchenBoardUseCase
{
public Task<GetKitchenBoardResponse> HandleAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
{
return workflowPort.GetKitchenBoardAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,9 @@
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases;
public interface IClaimKitchenWorkItemUseCase
{
Task<ClaimKitchenWorkItemResponse> HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases;
public interface IGetKitchenBoardUseCase
{
Task<GetKitchenBoardResponse> HandleAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases;
public interface IUpdateKitchenPriorityUseCase
{
Task<UpdateKitchenPriorityResponse> HandleAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,13 @@
using Kitchen.Service.Application.Ports;
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases;
public sealed class UpdateKitchenPriorityUseCase(IKitchenWorkflowPort workflowPort) : IUpdateKitchenPriorityUseCase
{
public Task<UpdateKitchenPriorityResponse> HandleAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
{
return workflowPort.UpdateKitchenPriorityAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,12 @@
namespace Kitchen.Service.Contracts.Contracts;
public sealed record KitchenBoardItemContract(
string WorkItemId,
string OrderId,
string TicketId,
string TableId,
string Station,
string State,
int Priority,
string? ClaimedBy,
int EtaMinutes);

View File

@ -0,0 +1,5 @@
namespace Kitchen.Service.Contracts.Contracts;
public sealed record KitchenBoardLaneContract(
string Lane,
IReadOnlyCollection<KitchenBoardItemContract> Items);

View File

@ -0,0 +1,6 @@
namespace Kitchen.Service.Contracts.Requests;
public sealed record ClaimKitchenWorkItemRequest(
string ContextId,
string WorkItemId,
string ClaimedBy);

View File

@ -0,0 +1,3 @@
namespace Kitchen.Service.Contracts.Requests;
public sealed record GetKitchenBoardRequest(string ContextId);

View File

@ -0,0 +1,7 @@
namespace Kitchen.Service.Contracts.Requests;
public sealed record UpdateKitchenPriorityRequest(
string ContextId,
string WorkItemId,
int Priority,
string RequestedBy);

View File

@ -0,0 +1,8 @@
namespace Kitchen.Service.Contracts.Responses;
public sealed record ClaimKitchenWorkItemResponse(
string ContextId,
string WorkItemId,
bool Claimed,
string ClaimedBy,
string Message);

View File

@ -0,0 +1,9 @@
using Kitchen.Service.Contracts.Contracts;
namespace Kitchen.Service.Contracts.Responses;
public sealed record GetKitchenBoardResponse(
string ContextId,
string Summary,
IReadOnlyCollection<KitchenBoardLaneContract> Lanes,
IReadOnlyCollection<string> AvailableStations);

View File

@ -0,0 +1,7 @@
namespace Kitchen.Service.Contracts.Responses;
public sealed record UpdateKitchenPriorityResponse(
string ContextId,
string WorkItemId,
int Priority,
string Message);

View File

@ -4,8 +4,12 @@ using Kitchen.Service.Contracts.Requests;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>(); builder.Services.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>();
builder.Services.AddSingleton<IKitchenWorkflowPort, DefaultKitchenWorkflowPort>();
builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>(); builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>(); builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();
builder.Services.AddSingleton<IClaimKitchenWorkItemUseCase, ClaimKitchenWorkItemUseCase>();
builder.Services.AddSingleton<IUpdateKitchenPriorityUseCase, UpdateKitchenPriorityUseCase>();
var app = builder.Build(); var app = builder.Build();
@ -23,6 +27,30 @@ app.MapPost("/internal/kitchen/orders/transition", async (
return Results.Ok(await useCase.HandleAsync(request, ct)); return Results.Ok(await useCase.HandleAsync(request, ct));
}); });
app.MapGet("/internal/kitchen/board", async (
string contextId,
IGetKitchenBoardUseCase useCase,
CancellationToken ct) =>
{
return Results.Ok(await useCase.HandleAsync(new GetKitchenBoardRequest(contextId), ct));
});
app.MapPost("/internal/kitchen/work-items/claim", async (
ClaimKitchenWorkItemRequest request,
IClaimKitchenWorkItemUseCase useCase,
CancellationToken ct) =>
{
return Results.Ok(await useCase.HandleAsync(request, ct));
});
app.MapPost("/internal/kitchen/work-items/priority", async (
UpdateKitchenPriorityRequest request,
IUpdateKitchenPriorityUseCase useCase,
CancellationToken ct) =>
{
return Results.Ok(await useCase.HandleAsync(request, ct));
});
app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "kitchen-service" })); app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "kitchen-service" }));
app.MapGet("/healthz", () => Results.Ok(new { status = "ok", service = "kitchen-service" })); app.MapGet("/healthz", () => Results.Ok(new { status = "ok", service = "kitchen-service" }));

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

View File

@ -0,0 +1,60 @@
using Kitchen.Service.Application.Ports;
using Kitchen.Service.Application.UseCases;
using Kitchen.Service.Contracts.Requests;
namespace Kitchen.Service.Application.UnitTests;
public class KitchenWorkflowUseCasesTests
{
private readonly DefaultKitchenWorkflowPort workflowPort = new();
[Fact]
public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations()
{
var useCase = new GetKitchenBoardUseCase(workflowPort);
var response = await useCase.HandleAsync(new GetKitchenBoardRequest("demo-context"), CancellationToken.None);
Assert.Equal("demo-context", response.ContextId);
Assert.NotEmpty(response.Lanes);
Assert.NotEmpty(response.AvailableStations);
}
[Fact]
public async Task ClaimKitchenWorkItemUseCase_ReturnsClaimedResponse()
{
var useCase = new ClaimKitchenWorkItemUseCase(workflowPort);
var response = await useCase.HandleAsync(
new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
CancellationToken.None);
Assert.True(response.Claimed);
Assert.Equal("chef-maya", response.ClaimedBy);
}
[Fact]
public async Task UpdateKitchenPriorityUseCase_NormalizesPriority()
{
var useCase = new UpdateKitchenPriorityUseCase(workflowPort);
var response = await useCase.HandleAsync(
new UpdateKitchenPriorityRequest("demo-context", "WK-1001", 0, "expo-noah"),
CancellationToken.None);
Assert.Equal(1, response.Priority);
}
[Fact]
public async Task TransitionKitchenOrderStateUseCase_AllowsConfiguredStates()
{
var useCase = new TransitionKitchenOrderStateUseCase();
var response = await useCase.HandleAsync(
new TransitionKitchenOrderStateRequest("CO-1001", "KT-1001", "Preparing", "chef-maya"),
CancellationToken.None);
Assert.True(response.Applied);
Assert.Equal("Preparing", response.CurrentState);
}
}