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.Grpc/Kitchen.Service.Grpc.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Kitchen.Service.Application.UnitTests/Kitchen.Service.Application.UnitTests.csproj" />
</Folder>
</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.
- Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment.
- Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows.
## Documentation Contract
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
- 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
@ -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.
- 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);
builder.Services.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>();
builder.Services.AddSingleton<IKitchenWorkflowPort, DefaultKitchenWorkflowPort>();
builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();
builder.Services.AddSingleton<IClaimKitchenWorkItemUseCase, ClaimKitchenWorkItemUseCase>();
builder.Services.AddSingleton<IUpdateKitchenPriorityUseCase, UpdateKitchenPriorityUseCase>();
var app = builder.Build();
@ -23,6 +27,30 @@ app.MapPost("/internal/kitchen/orders/transition", async (
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("/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);
}
}