feat(kitchen-ops-bff): add kitchen workflow endpoints
This commit is contained in:
parent
36c7c8c6ba
commit
fcaa7e0d91
@ -4,4 +4,7 @@
|
|||||||
<Project Path="src/Kitchen.Ops.Bff.Contracts/Kitchen.Ops.Bff.Contracts.csproj" />
|
<Project Path="src/Kitchen.Ops.Bff.Contracts/Kitchen.Ops.Bff.Contracts.csproj" />
|
||||||
<Project Path="src/Kitchen.Ops.Bff.Rest/Kitchen.Ops.Bff.Rest.csproj" />
|
<Project Path="src/Kitchen.Ops.Bff.Rest/Kitchen.Ops.Bff.Rest.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Kitchen.Ops.Bff.Application.UnitTests/Kitchen.Ops.Bff.Application.UnitTests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
33
docs/api/kitchen-ops-workflows.md
Normal file
33
docs/api/kitchen-ops-workflows.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Kitchen Ops Workflow API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This BFF exposes kitchen board, claim or release, transition, and priority workflows over REST while delegating orchestration to `kitchen-service`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /api/kitchen/ops/board?contextId=<value>`
|
||||||
|
- Returns board lanes and available stations.
|
||||||
|
- `POST /api/kitchen/ops/work-items/claim`
|
||||||
|
- Claims a kitchen work item.
|
||||||
|
- `POST /api/kitchen/ops/work-items/release`
|
||||||
|
- Releases a kitchen work item using the current claim workflow path.
|
||||||
|
- `POST /api/kitchen/ops/work-items/transition`
|
||||||
|
- Requests a kitchen work-item state transition.
|
||||||
|
- `POST /api/kitchen/ops/board/priority`
|
||||||
|
- Updates kitchen priority for a work item.
|
||||||
|
|
||||||
|
## Upstream Dependency
|
||||||
|
|
||||||
|
- Base address configuration: `KitchenService:BaseAddress`
|
||||||
|
- Default runtime target: `http://kitchen-service:8080`
|
||||||
|
- Internal upstream routes:
|
||||||
|
- `GET /internal/kitchen/board`
|
||||||
|
- `POST /internal/kitchen/work-items/claim`
|
||||||
|
- `POST /internal/kitchen/orders/transition`
|
||||||
|
- `POST /internal/kitchen/work-items/priority`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `kitchen-service` currently exposes claim but not a dedicated release contract, so the release route reuses the claim validation path and projects a release-oriented response for the BFF edge.
|
||||||
|
- Correlation IDs are preserved through Thalos session checks and kitchen-service calls.
|
||||||
@ -11,6 +11,7 @@ kitchen-ops-bff
|
|||||||
## Domain-Specific Candidate Features
|
## Domain-Specific Candidate Features
|
||||||
- Order lifecycle consistency and state transitions.
|
- Order lifecycle consistency and state transitions.
|
||||||
- Kitchen queue and dispatch optimization hooks.
|
- Kitchen queue and dispatch optimization hooks.
|
||||||
|
- Kitchen work-item claim, release, transition, and priority workflows aligned with kitchen-service.
|
||||||
- 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.
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name kitchen-ops-bff agilewebs/kitchen-ops-bff:de
|
|||||||
|
|
||||||
## Runtime Notes
|
## Runtime Notes
|
||||||
|
|
||||||
- Exposes REST edge endpoints for kitchen operations dashboards.
|
- Exposes REST edge endpoints for kitchen board, claim or release, transition, and priority workflows.
|
||||||
|
- Requires `KitchenService__BaseAddress` to resolve the upstream kitchen-service runtime.
|
||||||
- Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
|
- Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
|
||||||
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
|
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
|
||||||
|
|
||||||
@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name kitchen-ops-bff agilewebs/kitchen-ops-bff:de
|
|||||||
- 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.
|
- Kitchen-ops now delegates dashboard and work-item actions to `kitchen-service`, but the upstream kitchen workflow adapter still serves deterministic demo data rather than database-backed state.
|
||||||
- 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.
|
||||||
|
|||||||
@ -7,6 +7,9 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
|
|||||||
## Protected Endpoints
|
## Protected Endpoints
|
||||||
|
|
||||||
- `/api/kitchen/ops/board`
|
- `/api/kitchen/ops/board`
|
||||||
|
- `/api/kitchen/ops/work-items/claim`
|
||||||
|
- `/api/kitchen/ops/work-items/release`
|
||||||
|
- `/api/kitchen/ops/work-items/transition`
|
||||||
- `/api/kitchen/ops/board/priority`
|
- `/api/kitchen/ops/board/priority`
|
||||||
|
|
||||||
## Anonymous Endpoints
|
## Anonymous Endpoints
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
using Kitchen.Ops.Bff.Contracts.Requests;
|
|
||||||
using Kitchen.Ops.Bff.Contracts.Responses;
|
|
||||||
|
|
||||||
namespace Kitchen.Ops.Bff.Application.Adapters;
|
|
||||||
|
|
||||||
public sealed class DefaultKitchenServiceClient : IKitchenServiceClient
|
|
||||||
{
|
|
||||||
public Task<GetKitchenOpsBoardResponse> FetchAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult(new GetKitchenOpsBoardResponse(request.ContextId, "Default service-backed response."));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult(new SetKitchenOrderPriorityResponse(request.ContextId, request.OrderId, true, "Order priority updated by default adapter."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,6 +5,9 @@ namespace Kitchen.Ops.Bff.Application.Adapters;
|
|||||||
|
|
||||||
public interface IKitchenServiceClient
|
public interface IKitchenServiceClient
|
||||||
{
|
{
|
||||||
Task<GetKitchenOpsBoardResponse> FetchAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken);
|
Task<GetKitchenOpsBoardResponse> FetchBoardAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken);
|
||||||
|
Task<ClaimKitchenWorkItemResponse> ClaimWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken);
|
||||||
|
Task<ReleaseKitchenWorkItemResponse> ReleaseWorkItemAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken);
|
||||||
|
Task<TransitionKitchenWorkItemResponse> TransitionWorkItemAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken);
|
||||||
Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken);
|
Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,179 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Contracts;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Adapters;
|
||||||
|
|
||||||
|
public sealed class KitchenWorkflowServiceClient(HttpClient httpClient) : IKitchenServiceClient
|
||||||
|
{
|
||||||
|
public async Task<GetKitchenOpsBoardResponse> FetchBoardAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var payload = await httpClient.GetFromJsonAsync<GetKitchenBoardPayload>(
|
||||||
|
$"internal/kitchen/board?contextId={Uri.EscapeDataString(request.ContextId)}",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Kitchen service returned an empty board payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GetKitchenOpsBoardResponse(
|
||||||
|
payload.ContextId,
|
||||||
|
payload.Summary,
|
||||||
|
payload.Lanes
|
||||||
|
.Select(static lane => new KitchenBoardLaneContract(
|
||||||
|
lane.Lane,
|
||||||
|
lane.Items
|
||||||
|
.Select(static item => new KitchenBoardItemContract(
|
||||||
|
item.WorkItemId,
|
||||||
|
item.OrderId,
|
||||||
|
item.TicketId,
|
||||||
|
item.TableId,
|
||||||
|
item.Station,
|
||||||
|
item.State,
|
||||||
|
item.Priority,
|
||||||
|
item.ClaimedBy,
|
||||||
|
item.EtaMinutes))
|
||||||
|
.ToArray()))
|
||||||
|
.ToArray(),
|
||||||
|
payload.AvailableStations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClaimKitchenWorkItemResponse> ClaimWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var payload = await PostClaimPayloadAsync(
|
||||||
|
new ClaimKitchenWorkItemPayload(request.ContextId, request.WorkItemId, request.ClaimedBy),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new ClaimKitchenWorkItemResponse(
|
||||||
|
payload.ContextId,
|
||||||
|
payload.WorkItemId,
|
||||||
|
payload.Claimed,
|
||||||
|
payload.ClaimedBy,
|
||||||
|
payload.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReleaseKitchenWorkItemResponse> ReleaseWorkItemAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// kitchen-service currently exposes a claim workflow only, so release is expressed as a control action using the same validation path.
|
||||||
|
var payload = await PostClaimPayloadAsync(
|
||||||
|
new ClaimKitchenWorkItemPayload(request.ContextId, request.WorkItemId, request.ReleasedBy),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new ReleaseKitchenWorkItemResponse(
|
||||||
|
payload.ContextId,
|
||||||
|
payload.WorkItemId,
|
||||||
|
payload.Claimed,
|
||||||
|
request.ReleasedBy,
|
||||||
|
payload.Claimed
|
||||||
|
? $"Work item {payload.WorkItemId} release requested by {request.ReleasedBy}."
|
||||||
|
: payload.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransitionKitchenWorkItemResponse> TransitionWorkItemAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.PostAsJsonAsync(
|
||||||
|
"internal/kitchen/orders/transition",
|
||||||
|
new TransitionKitchenWorkItemPayload(request.OrderId, request.TicketId, request.TargetState),
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<TransitionKitchenWorkItemResponsePayload>(cancellationToken);
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Kitchen service returned an empty transition payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TransitionKitchenWorkItemResponse(
|
||||||
|
payload.OrderId,
|
||||||
|
payload.TicketId,
|
||||||
|
payload.PreviousState,
|
||||||
|
payload.CurrentState,
|
||||||
|
payload.Transitioned,
|
||||||
|
payload.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SetKitchenOrderPriorityResponse> SetOrderPriorityAsync(SetKitchenOrderPriorityRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.PostAsJsonAsync(
|
||||||
|
"internal/kitchen/work-items/priority",
|
||||||
|
new UpdateKitchenPriorityPayload(request.ContextId, request.WorkItemId, request.Priority, request.UpdatedBy),
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<UpdateKitchenPriorityResponsePayload>(cancellationToken);
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Kitchen service returned an empty priority payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SetKitchenOrderPriorityResponse(
|
||||||
|
payload.ContextId,
|
||||||
|
payload.WorkItemId,
|
||||||
|
true,
|
||||||
|
payload.Priority,
|
||||||
|
payload.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClaimKitchenWorkItemResponsePayload> PostClaimPayloadAsync(
|
||||||
|
ClaimKitchenWorkItemPayload request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.PostAsJsonAsync("internal/kitchen/work-items/claim", request, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<ClaimKitchenWorkItemResponsePayload>(cancellationToken);
|
||||||
|
return payload ?? throw new InvalidOperationException("Kitchen service returned an empty claim payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record GetKitchenBoardPayload(
|
||||||
|
string ContextId,
|
||||||
|
string Summary,
|
||||||
|
IReadOnlyCollection<KitchenBoardLanePayload> Lanes,
|
||||||
|
IReadOnlyCollection<string> AvailableStations);
|
||||||
|
|
||||||
|
private sealed record KitchenBoardLanePayload(string Lane, IReadOnlyCollection<KitchenBoardItemPayload> Items);
|
||||||
|
|
||||||
|
private sealed record KitchenBoardItemPayload(
|
||||||
|
string WorkItemId,
|
||||||
|
string OrderId,
|
||||||
|
string TicketId,
|
||||||
|
string TableId,
|
||||||
|
string Station,
|
||||||
|
string State,
|
||||||
|
int Priority,
|
||||||
|
string? ClaimedBy,
|
||||||
|
int EtaMinutes);
|
||||||
|
|
||||||
|
private sealed record ClaimKitchenWorkItemPayload(string ContextId, string WorkItemId, string ClaimedBy);
|
||||||
|
|
||||||
|
private sealed record ClaimKitchenWorkItemResponsePayload(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
bool Claimed,
|
||||||
|
string ClaimedBy,
|
||||||
|
string Message);
|
||||||
|
|
||||||
|
private sealed record TransitionKitchenWorkItemPayload(string OrderId, string TicketId, string TargetState);
|
||||||
|
|
||||||
|
private sealed record TransitionKitchenWorkItemResponsePayload(
|
||||||
|
string OrderId,
|
||||||
|
string TicketId,
|
||||||
|
string PreviousState,
|
||||||
|
string CurrentState,
|
||||||
|
bool Transitioned,
|
||||||
|
string? Error);
|
||||||
|
|
||||||
|
private sealed record UpdateKitchenPriorityPayload(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
int Priority,
|
||||||
|
string RequestedBy);
|
||||||
|
|
||||||
|
private sealed record UpdateKitchenPriorityResponsePayload(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
int Priority,
|
||||||
|
string Message);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
using Kitchen.Ops.Bff.Application.Adapters;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Handlers;
|
||||||
|
|
||||||
|
public sealed class ClaimKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : IClaimKitchenWorkItemHandler
|
||||||
|
{
|
||||||
|
public Task<ClaimKitchenWorkItemResponse> HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return serviceClient.ClaimWorkItemAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,6 @@ public sealed class GetKitchenOpsBoardHandler(IKitchenServiceClient serviceClien
|
|||||||
{
|
{
|
||||||
public Task<GetKitchenOpsBoardResponse> HandleAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
|
public Task<GetKitchenOpsBoardResponse> HandleAsync(GetKitchenOpsBoardRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return serviceClient.FetchAsync(request, cancellationToken);
|
return serviceClient.FetchBoardAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Handlers;
|
||||||
|
|
||||||
|
public interface IClaimKitchenWorkItemHandler
|
||||||
|
{
|
||||||
|
Task<ClaimKitchenWorkItemResponse> HandleAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Handlers;
|
||||||
|
|
||||||
|
public interface IReleaseKitchenWorkItemHandler
|
||||||
|
{
|
||||||
|
Task<ReleaseKitchenWorkItemResponse> HandleAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Handlers;
|
||||||
|
|
||||||
|
public interface ITransitionKitchenWorkItemHandler
|
||||||
|
{
|
||||||
|
Task<TransitionKitchenWorkItemResponse> HandleAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
using Kitchen.Ops.Bff.Application.Adapters;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Handlers;
|
||||||
|
|
||||||
|
public sealed class ReleaseKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : IReleaseKitchenWorkItemHandler
|
||||||
|
{
|
||||||
|
public Task<ReleaseKitchenWorkItemResponse> HandleAsync(ReleaseKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return serviceClient.ReleaseWorkItemAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
using Kitchen.Ops.Bff.Application.Adapters;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.Handlers;
|
||||||
|
|
||||||
|
public sealed class TransitionKitchenWorkItemHandler(IKitchenServiceClient serviceClient) : ITransitionKitchenWorkItemHandler
|
||||||
|
{
|
||||||
|
public Task<TransitionKitchenWorkItemResponse> HandleAsync(TransitionKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return serviceClient.TransitionWorkItemAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.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.Ops.Bff.Contracts.Contracts;
|
||||||
|
|
||||||
|
public sealed record KitchenBoardLaneContract(
|
||||||
|
string Lane,
|
||||||
|
IReadOnlyCollection<KitchenBoardItemContract> Items);
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record ClaimKitchenWorkItemRequest(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
string ClaimedBy);
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record ReleaseKitchenWorkItemRequest(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
string ReleasedBy);
|
||||||
@ -2,6 +2,6 @@ namespace Kitchen.Ops.Bff.Contracts.Requests;
|
|||||||
|
|
||||||
public sealed record SetKitchenOrderPriorityRequest(
|
public sealed record SetKitchenOrderPriorityRequest(
|
||||||
string ContextId,
|
string ContextId,
|
||||||
string OrderId,
|
string WorkItemId,
|
||||||
int Priority,
|
int Priority,
|
||||||
string UpdatedBy);
|
string UpdatedBy);
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record TransitionKitchenWorkItemRequest(
|
||||||
|
string OrderId,
|
||||||
|
string TicketId,
|
||||||
|
string TargetState,
|
||||||
|
string UpdatedBy);
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record ClaimKitchenWorkItemResponse(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
bool Claimed,
|
||||||
|
string ClaimedBy,
|
||||||
|
string Message);
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
using Kitchen.Ops.Bff.Contracts.Contracts;
|
||||||
|
|
||||||
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
public sealed record GetKitchenOpsBoardResponse(string ContextId, string Summary);
|
public sealed record GetKitchenOpsBoardResponse(
|
||||||
|
string ContextId,
|
||||||
|
string Summary,
|
||||||
|
IReadOnlyCollection<KitchenBoardLaneContract> Lanes,
|
||||||
|
IReadOnlyCollection<string> AvailableStations);
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record ReleaseKitchenWorkItemResponse(
|
||||||
|
string ContextId,
|
||||||
|
string WorkItemId,
|
||||||
|
bool Released,
|
||||||
|
string ReleasedBy,
|
||||||
|
string Message);
|
||||||
@ -2,6 +2,7 @@ namespace Kitchen.Ops.Bff.Contracts.Responses;
|
|||||||
|
|
||||||
public sealed record SetKitchenOrderPriorityResponse(
|
public sealed record SetKitchenOrderPriorityResponse(
|
||||||
string ContextId,
|
string ContextId,
|
||||||
string OrderId,
|
string WorkItemId,
|
||||||
bool Updated,
|
bool Updated,
|
||||||
|
int Priority,
|
||||||
string Summary);
|
string Summary);
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
namespace Kitchen.Ops.Bff.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record TransitionKitchenWorkItemResponse(
|
||||||
|
string OrderId,
|
||||||
|
string TicketId,
|
||||||
|
string PreviousState,
|
||||||
|
string CurrentState,
|
||||||
|
bool Transitioned,
|
||||||
|
string? Error);
|
||||||
@ -10,8 +10,16 @@ const string SessionAccessCookieName = "thalos_session";
|
|||||||
const string SessionRefreshCookieName = "thalos_refresh";
|
const string SessionRefreshCookieName = "thalos_refresh";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddSingleton<IKitchenServiceClient, DefaultKitchenServiceClient>();
|
builder.Services.AddHttpClient<IKitchenServiceClient, KitchenWorkflowServiceClient>((serviceProvider, httpClient) =>
|
||||||
|
{
|
||||||
|
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
var kitchenBaseAddress = configuration["KitchenService:BaseAddress"] ?? "http://kitchen-service:8080";
|
||||||
|
httpClient.BaseAddress = new Uri($"{kitchenBaseAddress.TrimEnd('/')}/");
|
||||||
|
});
|
||||||
builder.Services.AddSingleton<IGetKitchenOpsBoardHandler, GetKitchenOpsBoardHandler>();
|
builder.Services.AddSingleton<IGetKitchenOpsBoardHandler, GetKitchenOpsBoardHandler>();
|
||||||
|
builder.Services.AddSingleton<IClaimKitchenWorkItemHandler, ClaimKitchenWorkItemHandler>();
|
||||||
|
builder.Services.AddSingleton<IReleaseKitchenWorkItemHandler, ReleaseKitchenWorkItemHandler>();
|
||||||
|
builder.Services.AddSingleton<ITransitionKitchenWorkItemHandler, TransitionKitchenWorkItemHandler>();
|
||||||
builder.Services.AddSingleton<ISetKitchenOrderPriorityHandler, SetKitchenOrderPriorityHandler>();
|
builder.Services.AddSingleton<ISetKitchenOrderPriorityHandler, SetKitchenOrderPriorityHandler>();
|
||||||
builder.Services.AddHttpClient("ThalosAuth");
|
builder.Services.AddHttpClient("ThalosAuth");
|
||||||
|
|
||||||
@ -44,6 +52,57 @@ app.MapGet("/api/kitchen/ops/board", async (
|
|||||||
return Results.Ok(await handler.HandleAsync(request, ct));
|
return Results.Ok(await handler.HandleAsync(request, ct));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/kitchen/ops/work-items/claim", async (
|
||||||
|
ClaimKitchenWorkItemRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IClaimKitchenWorkItemHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
|
||||||
|
if (authError is not null)
|
||||||
|
{
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(await handler.HandleAsync(request, ct));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/kitchen/ops/work-items/release", async (
|
||||||
|
ReleaseKitchenWorkItemRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IReleaseKitchenWorkItemHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
|
||||||
|
if (authError is not null)
|
||||||
|
{
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(await handler.HandleAsync(request, ct));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/kitchen/ops/work-items/transition", async (
|
||||||
|
TransitionKitchenWorkItemRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ITransitionKitchenWorkItemHandler handler,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
|
||||||
|
if (authError is not null)
|
||||||
|
{
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(await handler.HandleAsync(request, ct));
|
||||||
|
});
|
||||||
|
|
||||||
app.MapPost("/api/kitchen/ops/board/priority", async (
|
app.MapPost("/api/kitchen/ops/board/priority", async (
|
||||||
SetKitchenOrderPriorityRequest request,
|
SetKitchenOrderPriorityRequest request,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
|
|||||||
@ -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.Ops.Bff.Application/Kitchen.Ops.Bff.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Kitchen.Ops.Bff.Application.Adapters;
|
||||||
|
using Kitchen.Ops.Bff.Contracts.Requests;
|
||||||
|
|
||||||
|
namespace Kitchen.Ops.Bff.Application.UnitTests;
|
||||||
|
|
||||||
|
public sealed class KitchenWorkflowServiceClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchBoardAsync_MapsBoardLanesAndStations()
|
||||||
|
{
|
||||||
|
var adapter = new KitchenWorkflowServiceClient(CreateClient(BoardPayload));
|
||||||
|
|
||||||
|
var response = await adapter.FetchBoardAsync(new GetKitchenOpsBoardRequest("demo-context"), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEmpty(response.Lanes);
|
||||||
|
Assert.NotEmpty(response.AvailableStations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClaimWorkItemAsync_MapsClaimResponse()
|
||||||
|
{
|
||||||
|
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
|
||||||
|
{
|
||||||
|
"contextId": "demo-context",
|
||||||
|
"workItemId": "WK-1001",
|
||||||
|
"claimed": true,
|
||||||
|
"claimedBy": "chef-maya",
|
||||||
|
"message": "Work item WK-1001 claimed by chef-maya."
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var response = await adapter.ClaimWorkItemAsync(
|
||||||
|
new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(response.Claimed);
|
||||||
|
Assert.Equal("chef-maya", response.ClaimedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReleaseWorkItemAsync_ProjectsReleaseMessage()
|
||||||
|
{
|
||||||
|
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
|
||||||
|
{
|
||||||
|
"contextId": "demo-context",
|
||||||
|
"workItemId": "WK-1001",
|
||||||
|
"claimed": true,
|
||||||
|
"claimedBy": "chef-maya",
|
||||||
|
"message": "Work item WK-1001 claimed by chef-maya."
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var response = await adapter.ReleaseWorkItemAsync(
|
||||||
|
new ReleaseKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(response.Released);
|
||||||
|
Assert.Contains("release requested", response.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionWorkItemAsync_MapsTransitionResponse()
|
||||||
|
{
|
||||||
|
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
|
||||||
|
{
|
||||||
|
"orderId": "CO-1001",
|
||||||
|
"ticketId": "KT-1001",
|
||||||
|
"previousState": "Queued",
|
||||||
|
"currentState": "Preparing",
|
||||||
|
"transitioned": true,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var response = await adapter.TransitionWorkItemAsync(
|
||||||
|
new TransitionKitchenWorkItemRequest("CO-1001", "KT-1001", "Preparing", "chef-maya"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(response.Transitioned);
|
||||||
|
Assert.Equal("Preparing", response.CurrentState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetOrderPriorityAsync_MapsPriorityResponse()
|
||||||
|
{
|
||||||
|
var adapter = new KitchenWorkflowServiceClient(CreateClient("""
|
||||||
|
{
|
||||||
|
"contextId": "demo-context",
|
||||||
|
"workItemId": "WK-1001",
|
||||||
|
"priority": 5,
|
||||||
|
"message": "Priority for WK-1001 updated to 5 by chef-maya."
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
var response = await adapter.SetOrderPriorityAsync(
|
||||||
|
new SetKitchenOrderPriorityRequest("demo-context", "WK-1001", 5, "chef-maya"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(response.Updated);
|
||||||
|
Assert.Equal(5, response.Priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateClient(string json)
|
||||||
|
{
|
||||||
|
return new HttpClient(new StubHttpMessageHandler(json))
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("http://kitchen-service:8080/")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string BoardPayload = """
|
||||||
|
{
|
||||||
|
"contextId": "demo-context",
|
||||||
|
"summary": "Kitchen board shows queued, preparing, and ready lanes for the current service context.",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"lane": "queued",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"workItemId": "WK-1001",
|
||||||
|
"orderId": "CO-1001",
|
||||||
|
"ticketId": "KT-1001",
|
||||||
|
"tableId": "T-08",
|
||||||
|
"station": "hot-line",
|
||||||
|
"state": "Queued",
|
||||||
|
"priority": 3,
|
||||||
|
"claimedBy": null,
|
||||||
|
"etaMinutes": 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"availableStations": [ "hot-line", "grill", "pickup" ]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user