feat(kitchen-service): expand kitchen workflow contracts
This commit is contained in:
parent
02cf18a0a6
commit
119f23ca66
@ -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>
|
||||
|
||||
27
docs/api/internal-kitchen-workflows.md
Normal file
27
docs/api/internal-kitchen-workflows.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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."));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -0,0 +1,5 @@
|
||||
namespace Kitchen.Service.Contracts.Contracts;
|
||||
|
||||
public sealed record KitchenBoardLaneContract(
|
||||
string Lane,
|
||||
IReadOnlyCollection<KitchenBoardItemContract> Items);
|
||||
@ -0,0 +1,6 @@
|
||||
namespace Kitchen.Service.Contracts.Requests;
|
||||
|
||||
public sealed record ClaimKitchenWorkItemRequest(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
string ClaimedBy);
|
||||
@ -0,0 +1,3 @@
|
||||
namespace Kitchen.Service.Contracts.Requests;
|
||||
|
||||
public sealed record GetKitchenBoardRequest(string ContextId);
|
||||
@ -0,0 +1,7 @@
|
||||
namespace Kitchen.Service.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateKitchenPriorityRequest(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
int Priority,
|
||||
string RequestedBy);
|
||||
@ -0,0 +1,8 @@
|
||||
namespace Kitchen.Service.Contracts.Responses;
|
||||
|
||||
public sealed record ClaimKitchenWorkItemResponse(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
bool Claimed,
|
||||
string ClaimedBy,
|
||||
string Message);
|
||||
@ -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);
|
||||
@ -0,0 +1,7 @@
|
||||
namespace Kitchen.Service.Contracts.Responses;
|
||||
|
||||
public sealed record UpdateKitchenPriorityResponse(
|
||||
string ContextId,
|
||||
string WorkItemId,
|
||||
int Priority,
|
||||
string Message);
|
||||
@ -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" }));
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user