From 9022fe6658f8c3f1fed8a37b0892d8297100ad23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 18:47:41 -0600 Subject: [PATCH] feat(kitchen-service): sync kitchen tickets with restaurant lifecycle --- docs/api/internal-kitchen-workflows.md | 31 +- docs/roadmap/feature-epics.md | 8 +- docs/runbooks/containerization.md | 11 +- .../Ports/DefaultKitchenQueueReadPort.cs | 29 +- .../Ports/DefaultKitchenWorkflowPort.cs | 274 ++++++++++++++---- .../Ports/IKitchenWorkItemStorePort.cs | 13 + .../Ports/IRestaurantLifecycleSyncPort.cs | 9 + .../Ports/InMemoryKitchenWorkItemStorePort.cs | 98 +++++++ .../InMemoryRestaurantLifecycleSyncPort.cs | 41 +++ .../State/PersistedKitchenWorkItemEvent.cs | 9 + .../State/PersistedKitchenWorkItemRecord.cs | 14 + .../PersistedRestaurantLifecycleRecord.cs | 16 + .../TransitionKitchenOrderStateUseCase.cs | 14 +- .../TransitionKitchenOrderStateRequest.cs | 3 +- .../Adapters/KitchenDalWorkItemStoreClient.cs | 62 ++++ ...rationsDalRestaurantLifecycleSyncClient.cs | 26 ++ src/Kitchen.Service.Grpc/Program.cs | 19 +- .../appsettings.Development.json | 6 + src/Kitchen.Service.Grpc/appsettings.json | 6 + .../KitchenWorkflowUseCasesTests.cs | 48 ++- 20 files changed, 638 insertions(+), 99 deletions(-) create mode 100644 src/Kitchen.Service.Application/Ports/IKitchenWorkItemStorePort.cs create mode 100644 src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs create mode 100644 src/Kitchen.Service.Application/Ports/InMemoryKitchenWorkItemStorePort.cs create mode 100644 src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs create mode 100644 src/Kitchen.Service.Application/State/PersistedKitchenWorkItemEvent.cs create mode 100644 src/Kitchen.Service.Application/State/PersistedKitchenWorkItemRecord.cs create mode 100644 src/Kitchen.Service.Application/State/PersistedRestaurantLifecycleRecord.cs create mode 100644 src/Kitchen.Service.Grpc/Adapters/KitchenDalWorkItemStoreClient.cs create mode 100644 src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs diff --git a/docs/api/internal-kitchen-workflows.md b/docs/api/internal-kitchen-workflows.md index 8c2613a..9d3ebcf 100644 --- a/docs/api/internal-kitchen-workflows.md +++ b/docs/api/internal-kitchen-workflows.md @@ -1,27 +1,32 @@ -# Internal Kitchen Workflow Contracts +# Internal Kitchen Workflows ## Purpose -`kitchen-service` now exposes a board-oriented internal workflow surface that the kitchen-ops BFF can consume directly. +`kitchen-service` exposes workflow-shaped internal endpoints for the kitchen BFF while keeping ticket execution aligned with the shared restaurant lifecycle. ## Endpoint Surface -- `GET /internal/kitchen/queue?queueName=&limit=` -- `POST /internal/kitchen/orders/transition` +- `GET /internal/kitchen/queue?queueName=&limit=` - `GET /internal/kitchen/board?contextId=` +- `POST /internal/kitchen/orders/transition` - `POST /internal/kitchen/work-items/claim` - `POST /internal/kitchen/work-items/priority` -## Contract Depth Added In Stage 41 +## Stage 46 Runtime Shape -The new kitchen workflow contracts add enough shape for downstream BFF and SPA work: +This repo now orchestrates kitchen progression over persisted kitchen tickets from `kitchen-dal` and syncs restaurant order state back through `operations-dal`. -- 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 +That means: +- board and queue reads come from persisted kitchen tickets +- claim and priority changes update persisted kitchen ticket state +- ticket transitions update both kitchen tickets and the linked restaurant order lifecycle -## Current Runtime Shape +## Handoff Mapping -- The default implementation remains deterministic and in-memory. -- This repo still focuses on orchestration and contract shape, not kitchen persistence realism. +- `Preparing` -> restaurant order becomes `Preparing` +- `ReadyForPickup` -> restaurant order becomes `Ready` +- `Delivered` -> restaurant order becomes `Served` + +## Remaining Limitation + +- Payment opening is still owned by `operations-service`; this repo only advances kitchen execution and the linked restaurant order state. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index a0fc000..06f13c1 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -9,10 +9,10 @@ kitchen-service - Epic 3: Improve observability and operational readiness for demo compose environments. ## Domain-Specific Candidate Features -- Order lifecycle consistency and state transitions. -- Kitchen queue and dispatch optimization hooks. -- Operations control-plane policies (flags, service windows, overrides). -- POS closeout and settlement summary alignment. +- Kitchen ticket orchestration over persisted linked tickets. +- Board and queue projections backed by shared restaurant identities. +- Claim ownership, priority updates, and kitchen transition history. +- Kitchen-to-restaurant lifecycle handoff for ready and served states. - Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows. ## Documentation Contract diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 266e7ee..7ba99e2 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME= --build-arg NUGET ## Local Run ```bash -docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:dev +docker run --rm -p 8080:8080 -e KitchenDal__BaseUrl=http://kitchen-dal:8080 -e OperationsDal__BaseUrl=http://operations-dal:8080 --name kitchen-service agilewebs/kitchen-service:dev ``` ## Health Probe @@ -22,8 +22,9 @@ 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. +- Exposes persisted queue and board endpoints for kitchen operators. +- Exposes claim, priority, and transition endpoints over linked kitchen tickets. +- Requires access to both the kitchen DAL host and the operations DAL host. ## Health Endpoint Consistency @@ -35,8 +36,8 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de - Participates in: **restaurant** demo compose stack. - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` + ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- Payment opening remains owned by `operations-service`. - 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. diff --git a/src/Kitchen.Service.Application/Ports/DefaultKitchenQueueReadPort.cs b/src/Kitchen.Service.Application/Ports/DefaultKitchenQueueReadPort.cs index 510427d..ca9ac27 100644 --- a/src/Kitchen.Service.Application/Ports/DefaultKitchenQueueReadPort.cs +++ b/src/Kitchen.Service.Application/Ports/DefaultKitchenQueueReadPort.cs @@ -5,14 +5,27 @@ namespace Kitchen.Service.Application.Ports; public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort { - public Task ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken) - { - var items = new[] - { - new KitchenQueueItemContract("WK-1001", "PrepareOrder", 3, "Queued"), - new KitchenQueueItemContract("WK-1002", "AssembleTray", 2, "Queued") - }.Take(Math.Max(limit, 0)).ToArray(); + private readonly IKitchenWorkItemStorePort workItemStore; - return Task.FromResult(new GetKitchenQueueResponse(items)); + public DefaultKitchenQueueReadPort() + : this(new InMemoryKitchenWorkItemStorePort()) + { + } + + public DefaultKitchenQueueReadPort(IKitchenWorkItemStorePort workItemStore) + { + this.workItemStore = workItemStore; + } + + public async Task ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken) + { + var contextId = string.IsNullOrWhiteSpace(queueName) ? "demo-context" : queueName; + var items = await workItemStore.ListQueuedAsync(contextId, cancellationToken); + var contracts = items + .Take(Math.Max(limit, 0)) + .Select(item => new KitchenQueueItemContract(item.WorkItemId, item.WorkType, item.Priority, item.State)) + .ToArray(); + + return new GetKitchenQueueResponse(contracts); } } diff --git a/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs b/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs index f0675b1..0dfafcc 100644 --- a/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs +++ b/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs @@ -1,3 +1,4 @@ +using Kitchen.Service.Application.State; using Kitchen.Service.Contracts.Contracts; using Kitchen.Service.Contracts.Requests; using Kitchen.Service.Contracts.Responses; @@ -6,83 +7,254 @@ namespace Kitchen.Service.Application.Ports; public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort { - public Task GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken) + private readonly IKitchenWorkItemStorePort workItemStore; + private readonly IRestaurantLifecycleSyncPort restaurantLifecycle; + + public DefaultKitchenWorkflowPort() + : this(new InMemoryKitchenWorkItemStorePort(), new InMemoryRestaurantLifecycleSyncPort()) { - 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 ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken) + public DefaultKitchenWorkflowPort(IKitchenWorkItemStorePort workItemStore, IRestaurantLifecycleSyncPort restaurantLifecycle) + { + this.workItemStore = workItemStore; + this.restaurantLifecycle = restaurantLifecycle; + } + + public async Task GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var claimed = !string.IsNullOrWhiteSpace(request.ContextId) && - !string.IsNullOrWhiteSpace(request.WorkItemId) && - !string.IsNullOrWhiteSpace(request.ClaimedBy); + var workItems = await LoadAllWorkItemsAsync(request.ContextId, cancellationToken); + var lanes = workItems + .GroupBy(item => MapLane(item.State), StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => new KitchenBoardLaneContract( + group.Key, + group.OrderByDescending(item => item.Priority) + .ThenBy(item => item.RequestedAtUtc) + .Select(ToBoardItem) + .ToArray())) + .ToArray(); - return Task.FromResult(new ClaimKitchenWorkItemResponse( + return new GetKitchenBoardResponse( + request.ContextId, + "Kitchen board now reflects persisted tickets linked to shared restaurant orders.", + lanes, + new[] { "hot-line", "grill", "salad", "pickup", "expedite" }); + } + + public async Task ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken); + var claimed = workItem is not null && !string.IsNullOrWhiteSpace(request.ClaimedBy); + + if (!claimed) + { + return new ClaimKitchenWorkItemResponse( + request.ContextId, + request.WorkItemId, + false, + request.ClaimedBy, + "Kitchen work-item claim is incomplete."); + } + + var updated = workItem! with { ClaimedBy = request.ClaimedBy }; + await workItemStore.UpsertAsync(updated, cancellationToken); + await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent( request.ContextId, request.WorkItemId, - claimed, + $"EVT-{Guid.NewGuid():N}", + "Claimed", + $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}.", + DateTime.UtcNow), cancellationToken); + + return new ClaimKitchenWorkItemResponse( + request.ContextId, + request.WorkItemId, + true, request.ClaimedBy, - claimed - ? $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}." - : "Kitchen work-item claim is incomplete.")); + $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}."); } - public Task UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken) + public async Task UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken); var priority = Math.Max(1, request.Priority); - return Task.FromResult(new UpdateKitchenPriorityResponse( + if (workItem is null) + { + return new UpdateKitchenPriorityResponse( + request.ContextId, + request.WorkItemId, + priority, + $"Work item {request.WorkItemId} could not be found."); + } + + var updated = workItem with { Priority = priority }; + await workItemStore.UpsertAsync(updated, cancellationToken); + await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent( + request.ContextId, + request.WorkItemId, + $"EVT-{Guid.NewGuid():N}", + "PriorityUpdated", + $"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}.", + DateTime.UtcNow), cancellationToken); + + return new UpdateKitchenPriorityResponse( request.ContextId, request.WorkItemId, priority, - $"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}.")); + $"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}."); } - public Task TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken) + public async Task TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var previous = "Queued"; - var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated"; + var workItem = await ResolveWorkItemAsync(request.ContextId ?? "demo-context", request.OrderId, request.TicketId, cancellationToken); + if (workItem is null) + { + return new TransitionKitchenOrderStateResponse( + request.OrderId, + request.TicketId, + "Unknown", + "Unknown", + false, + "Ticket could not be found for the provided order."); + } - return Task.FromResult(new TransitionKitchenOrderStateResponse( + var previousState = workItem.State; + if (!CanTransition(previousState, request.TargetState)) + { + return new TransitionKitchenOrderStateResponse( + request.OrderId, + request.TicketId, + previousState, + previousState, + false, + "Target state is not allowed by kitchen-service policy."); + } + + var nowUtc = DateTime.UtcNow; + var updatedWorkItem = workItem with { State = request.TargetState, ClaimedBy = request.RequestedBy }; + await workItemStore.UpsertAsync(updatedWorkItem, cancellationToken); + await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent( + request.ContextId ?? "demo-context", + updatedWorkItem.WorkItemId, + $"EVT-{Guid.NewGuid():N}", + request.TargetState, + $"Ticket {request.TicketId} moved from {previousState} to {request.TargetState} by {request.RequestedBy}.", + nowUtc), cancellationToken); + + var order = await restaurantLifecycle.GetOrderAsync(request.ContextId ?? "demo-context", request.OrderId, cancellationToken); + if (order is not null) + { + var updatedOrder = order with + { + OrderState = MapRestaurantOrderState(request.TargetState), + HasKitchenTicket = true, + UpdatedAtUtc = nowUtc + }; + + await restaurantLifecycle.UpsertOrderAsync(updatedOrder, cancellationToken); + } + + return new TransitionKitchenOrderStateResponse( request.OrderId, request.TicketId, - previous, - allowed ? request.TargetState : previous, - allowed, - allowed ? null : "Target state is not allowed by kitchen-service policy.")); + previousState, + request.TargetState, + true, + null); } + + private async Task> LoadAllWorkItemsAsync(string contextId, CancellationToken cancellationToken) + { + var queued = await workItemStore.ListQueuedAsync(contextId, cancellationToken); + var knownOrders = new[] { "ORD-1001", "ORD-1002", "ORD-1003", "ORD-1004" }; + var all = new Dictionary(StringComparer.Ordinal); + + foreach (var item in queued) + { + all[item.WorkItemId] = item; + } + + foreach (var orderId in knownOrders) + { + var items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken); + foreach (var item in items) + { + all[item.WorkItemId] = item; + } + } + + return all.Values.OrderByDescending(item => item.Priority).ThenBy(item => item.RequestedAtUtc).ToArray(); + } + + private async Task ResolveWorkItemAsync(string contextId, string orderId, string ticketId, CancellationToken cancellationToken) + { + var items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken); + return items.FirstOrDefault(item => string.Equals(ToTicketId(item.WorkItemId), ticketId, StringComparison.Ordinal)); + } + + private static KitchenBoardItemContract ToBoardItem(PersistedKitchenWorkItemRecord item) + { + return new KitchenBoardItemContract( + item.WorkItemId, + item.OrderId, + ToTicketId(item.WorkItemId), + item.TableId, + item.Station, + item.State, + item.Priority, + item.ClaimedBy, + MapEta(item.State)); + } + + private static string ToTicketId(string workItemId) + { + return workItemId.StartsWith("WK-", StringComparison.Ordinal) + ? $"KT-{workItemId[3..]}" + : $"KT-{workItemId}"; + } + + private static string MapLane(string state) => state switch + { + "Queued" => "queued", + "Preparing" => "preparing", + "ReadyForPickup" => "ready", + _ => "other" + }; + + private static int MapEta(string state) => state switch + { + "Queued" => 12, + "Preparing" => 6, + "ReadyForPickup" => 0, + _ => 4 + }; + + private static bool CanTransition(string currentState, string targetState) + { + return currentState switch + { + "Queued" => targetState is "Preparing" or "Failed" or "Canceled", + "Preparing" => targetState is "ReadyForPickup" or "Failed" or "Canceled", + "ReadyForPickup" => targetState == "Delivered", + _ => false + }; + } + + private static string MapRestaurantOrderState(string kitchenState) => kitchenState switch + { + "Preparing" => "Preparing", + "ReadyForPickup" => "Ready", + "Delivered" => "Served", + "Canceled" => "Canceled", + _ => "InKitchen" + }; } diff --git a/src/Kitchen.Service.Application/Ports/IKitchenWorkItemStorePort.cs b/src/Kitchen.Service.Application/Ports/IKitchenWorkItemStorePort.cs new file mode 100644 index 0000000..d8b5bcb --- /dev/null +++ b/src/Kitchen.Service.Application/Ports/IKitchenWorkItemStorePort.cs @@ -0,0 +1,13 @@ +using Kitchen.Service.Application.State; + +namespace Kitchen.Service.Application.Ports; + +public interface IKitchenWorkItemStorePort +{ + Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken); + Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken); + Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task UpsertAsync(PersistedKitchenWorkItemRecord record, CancellationToken cancellationToken); + Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken); + Task AppendEventAsync(PersistedKitchenWorkItemEvent record, CancellationToken cancellationToken); +} diff --git a/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs b/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs new file mode 100644 index 0000000..f6b0461 --- /dev/null +++ b/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs @@ -0,0 +1,9 @@ +using Kitchen.Service.Application.State; + +namespace Kitchen.Service.Application.Ports; + +public interface IRestaurantLifecycleSyncPort +{ + Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken); +} diff --git a/src/Kitchen.Service.Application/Ports/InMemoryKitchenWorkItemStorePort.cs b/src/Kitchen.Service.Application/Ports/InMemoryKitchenWorkItemStorePort.cs new file mode 100644 index 0000000..88818fb --- /dev/null +++ b/src/Kitchen.Service.Application/Ports/InMemoryKitchenWorkItemStorePort.cs @@ -0,0 +1,98 @@ +using System.Collections.Concurrent; +using Kitchen.Service.Application.State; + +namespace Kitchen.Service.Application.Ports; + +public sealed class InMemoryKitchenWorkItemStorePort : IKitchenWorkItemStorePort +{ + private readonly ConcurrentDictionary store = new(); + private readonly ConcurrentDictionary> events = new(); + + public InMemoryKitchenWorkItemStorePort() + { + foreach (var record in BuildSeedItems()) + { + store[BuildKey(record.ContextId, record.WorkItemId)] = record; + } + + foreach (var record in BuildSeedEvents()) + { + var queue = events.GetOrAdd(BuildKey(record.ContextId, record.WorkItemId), static _ => new ConcurrentQueue()); + queue.Enqueue(record); + } + } + + public Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken) + { + store.TryGetValue(BuildKey(contextId, workItemId), out var record); + return Task.FromResult(record); + } + + public Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken) + { + var result = store.Values + .Where(item => string.Equals(item.ContextId, contextId, StringComparison.Ordinal)) + .Where(item => string.Equals(item.State, "Queued", StringComparison.Ordinal)) + .OrderByDescending(item => item.Priority) + .ThenBy(item => item.RequestedAtUtc) + .ToArray(); + + return Task.FromResult>(result); + } + + public Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + var result = store.Values + .Where(item => string.Equals(item.ContextId, contextId, StringComparison.Ordinal)) + .Where(item => string.Equals(item.OrderId, orderId, StringComparison.Ordinal)) + .OrderByDescending(item => item.RequestedAtUtc) + .ToArray(); + + return Task.FromResult>(result); + } + + public Task UpsertAsync(PersistedKitchenWorkItemRecord record, CancellationToken cancellationToken) + { + store[BuildKey(record.ContextId, record.WorkItemId)] = record; + return Task.CompletedTask; + } + + public Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken) + { + if (!events.TryGetValue(BuildKey(contextId, workItemId), out var queue)) + { + return Task.FromResult>(Array.Empty()); + } + + var result = queue.OrderByDescending(item => item.OccurredAtUtc).ToArray(); + return Task.FromResult>(result); + } + + public Task AppendEventAsync(PersistedKitchenWorkItemEvent record, CancellationToken cancellationToken) + { + var queue = events.GetOrAdd(BuildKey(record.ContextId, record.WorkItemId), static _ => new ConcurrentQueue()); + queue.Enqueue(record); + return Task.CompletedTask; + } + + private static IReadOnlyCollection BuildSeedItems() + { + return new[] + { + new PersistedKitchenWorkItemRecord("demo-context", "WK-1001", "ORD-1001", "CHK-1001", "T-08", "PrepareOrder", "hot-line", 3, DateTime.UtcNow.AddMinutes(-10), "Queued", null), + new PersistedKitchenWorkItemRecord("demo-context", "WK-1002", "ORD-1002", "CHK-1002", "T-12", "PrepareOrder", "grill", 4, DateTime.UtcNow.AddMinutes(-7), "Preparing", "chef-maya"), + new PersistedKitchenWorkItemRecord("demo-context", "WK-1003", "ORD-1003", "CHK-1003", "T-21", "AssembleTray", "pickup", 1, DateTime.UtcNow.AddMinutes(-3), "ReadyForPickup", "expo-noah") + }; + } + + private static IReadOnlyCollection BuildSeedEvents() + { + return new[] + { + new PersistedKitchenWorkItemEvent("demo-context", "WK-1001", "EVT-2001", "Queued", "Ticket WK-1001 is waiting in the hot-line queue.", DateTime.UtcNow.AddMinutes(-9)), + new PersistedKitchenWorkItemEvent("demo-context", "WK-1002", "EVT-2002", "Preparing", "Chef Maya started ticket WK-1002.", DateTime.UtcNow.AddMinutes(-6)) + }; + } + + private static string BuildKey(string contextId, string workItemId) => $"{contextId}::{workItemId}"; +} diff --git a/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs b/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs new file mode 100644 index 0000000..b13f9d0 --- /dev/null +++ b/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; +using Kitchen.Service.Application.State; + +namespace Kitchen.Service.Application.Ports; + +public sealed class InMemoryRestaurantLifecycleSyncPort : IRestaurantLifecycleSyncPort +{ + private readonly ConcurrentDictionary store = new(); + + public InMemoryRestaurantLifecycleSyncPort() + { + foreach (var record in BuildSeedOrders()) + { + store[BuildKey(record.ContextId, record.OrderId)] = record; + } + } + + public Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + store.TryGetValue(BuildKey(contextId, orderId), out var record); + return Task.FromResult(record); + } + + public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) + { + store[BuildKey(record.ContextId, record.OrderId)] = record; + return Task.CompletedTask; + } + + private static IReadOnlyCollection BuildSeedOrders() + { + return new[] + { + new PersistedRestaurantLifecycleRecord("demo-context", "ORD-1001", "CHK-1001", "T-08", "Accepted", "Open", 2, false, 24.00m, "USD", "customer-orders", new[] { "ITEM-101", "ITEM-202" }, DateTime.UtcNow.AddMinutes(-10)), + new PersistedRestaurantLifecycleRecord("demo-context", "ORD-1002", "CHK-1002", "T-12", "Preparing", "Open", 4, true, 37.50m, "USD", "waiter-floor", new[] { "ITEM-301", "ITEM-404", "ITEM-405" }, DateTime.UtcNow.AddMinutes(-6)), + new PersistedRestaurantLifecycleRecord("demo-context", "ORD-1003", "CHK-1003", "T-21", "Ready", "Open", 2, true, 18.00m, "USD", "waiter-floor", new[] { "ITEM-401", "ITEM-402" }, DateTime.UtcNow.AddMinutes(-3)) + }; + } + + private static string BuildKey(string contextId, string orderId) => $"{contextId}::{orderId}"; +} diff --git a/src/Kitchen.Service.Application/State/PersistedKitchenWorkItemEvent.cs b/src/Kitchen.Service.Application/State/PersistedKitchenWorkItemEvent.cs new file mode 100644 index 0000000..f0f256c --- /dev/null +++ b/src/Kitchen.Service.Application/State/PersistedKitchenWorkItemEvent.cs @@ -0,0 +1,9 @@ +namespace Kitchen.Service.Application.State; + +public sealed record PersistedKitchenWorkItemEvent( + string ContextId, + string WorkItemId, + string EventId, + string EventType, + string Description, + DateTime OccurredAtUtc); diff --git a/src/Kitchen.Service.Application/State/PersistedKitchenWorkItemRecord.cs b/src/Kitchen.Service.Application/State/PersistedKitchenWorkItemRecord.cs new file mode 100644 index 0000000..4e43d5a --- /dev/null +++ b/src/Kitchen.Service.Application/State/PersistedKitchenWorkItemRecord.cs @@ -0,0 +1,14 @@ +namespace Kitchen.Service.Application.State; + +public sealed record PersistedKitchenWorkItemRecord( + string ContextId, + string WorkItemId, + string OrderId, + string CheckId, + string TableId, + string WorkType, + string Station, + int Priority, + DateTime RequestedAtUtc, + string State, + string? ClaimedBy); diff --git a/src/Kitchen.Service.Application/State/PersistedRestaurantLifecycleRecord.cs b/src/Kitchen.Service.Application/State/PersistedRestaurantLifecycleRecord.cs new file mode 100644 index 0000000..839f5ff --- /dev/null +++ b/src/Kitchen.Service.Application/State/PersistedRestaurantLifecycleRecord.cs @@ -0,0 +1,16 @@ +namespace Kitchen.Service.Application.State; + +public sealed record PersistedRestaurantLifecycleRecord( + string ContextId, + string OrderId, + string CheckId, + string TableId, + string OrderState, + string CheckState, + int GuestCount, + bool HasKitchenTicket, + decimal OutstandingBalance, + string Currency, + string Source, + IReadOnlyCollection ItemIds, + DateTime UpdatedAtUtc); diff --git a/src/Kitchen.Service.Application/UseCases/TransitionKitchenOrderStateUseCase.cs b/src/Kitchen.Service.Application/UseCases/TransitionKitchenOrderStateUseCase.cs index 552f221..cab5e4e 100644 --- a/src/Kitchen.Service.Application/UseCases/TransitionKitchenOrderStateUseCase.cs +++ b/src/Kitchen.Service.Application/UseCases/TransitionKitchenOrderStateUseCase.cs @@ -1,23 +1,15 @@ +using Kitchen.Service.Application.Ports; using Kitchen.Service.Contracts.Requests; using Kitchen.Service.Contracts.Responses; namespace Kitchen.Service.Application.UseCases; -public sealed class TransitionKitchenOrderStateUseCase : ITransitionKitchenOrderStateUseCase +public sealed class TransitionKitchenOrderStateUseCase(IKitchenWorkflowPort workflowPort) : ITransitionKitchenOrderStateUseCase { public Task HandleAsync( TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken) { - var previous = "Queued"; - var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled"; - - 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.")); + return workflowPort.TransitionKitchenOrderStateAsync(request, cancellationToken); } } diff --git a/src/Kitchen.Service.Contracts/Requests/TransitionKitchenOrderStateRequest.cs b/src/Kitchen.Service.Contracts/Requests/TransitionKitchenOrderStateRequest.cs index e5badd0..694b975 100644 --- a/src/Kitchen.Service.Contracts/Requests/TransitionKitchenOrderStateRequest.cs +++ b/src/Kitchen.Service.Contracts/Requests/TransitionKitchenOrderStateRequest.cs @@ -4,4 +4,5 @@ public sealed record TransitionKitchenOrderStateRequest( string OrderId, string TicketId, string TargetState, - string RequestedBy); + string RequestedBy, + string? ContextId = null); diff --git a/src/Kitchen.Service.Grpc/Adapters/KitchenDalWorkItemStoreClient.cs b/src/Kitchen.Service.Grpc/Adapters/KitchenDalWorkItemStoreClient.cs new file mode 100644 index 0000000..51ff315 --- /dev/null +++ b/src/Kitchen.Service.Grpc/Adapters/KitchenDalWorkItemStoreClient.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Json; +using Kitchen.Service.Application.Ports; +using Kitchen.Service.Application.State; + +namespace Kitchen.Service.Grpc.Adapters; + +public sealed class KitchenDalWorkItemStoreClient(HttpClient httpClient) : IKitchenWorkItemStorePort +{ + public async Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken) + { + var response = await httpClient.GetAsync($"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(workItemId)}?contextId={Uri.EscapeDataString(contextId)}", cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + + public async Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/kitchen-dal/work-items/queued?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + + public async Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/kitchen-dal/orders/{Uri.EscapeDataString(orderId)}/work-items?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + + public async Task UpsertAsync(PersistedKitchenWorkItemRecord record, CancellationToken cancellationToken) + { + var response = await httpClient.PostAsJsonAsync("/internal/kitchen-dal/work-items", record, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + public async Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(workItemId)}/events?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + + public async Task AppendEventAsync(PersistedKitchenWorkItemEvent record, CancellationToken cancellationToken) + { + var response = await httpClient.PostAsJsonAsync( + $"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(record.WorkItemId)}/events", + record, + cancellationToken); + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs b/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs new file mode 100644 index 0000000..3da954d --- /dev/null +++ b/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs @@ -0,0 +1,26 @@ +using System.Net.Http.Json; +using Kitchen.Service.Application.Ports; +using Kitchen.Service.Application.State; + +namespace Kitchen.Service.Grpc.Adapters; + +public sealed class OperationsDalRestaurantLifecycleSyncClient(HttpClient httpClient) : IRestaurantLifecycleSyncPort +{ + public async Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + var response = await httpClient.GetAsync($"/internal/operations-dal/orders/{Uri.EscapeDataString(orderId)}?contextId={Uri.EscapeDataString(contextId)}", cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + + public async Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) + { + var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken); + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/Kitchen.Service.Grpc/Program.cs b/src/Kitchen.Service.Grpc/Program.cs index e334832..f87cceb 100644 --- a/src/Kitchen.Service.Grpc/Program.cs +++ b/src/Kitchen.Service.Grpc/Program.cs @@ -1,10 +1,25 @@ using Kitchen.Service.Application.Ports; using Kitchen.Service.Application.UseCases; using Kitchen.Service.Contracts.Requests; +using Kitchen.Service.Grpc.Adapters; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["KitchenDal:BaseUrl"] ?? "http://kitchen-dal:8080"; + client.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["OperationsDal:BaseUrl"] ?? "http://operations-dal:8080"; + client.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddSingleton(sp => + new DefaultKitchenQueueReadPort(sp.GetRequiredService())); +builder.Services.AddSingleton(sp => + new DefaultKitchenWorkflowPort( + sp.GetRequiredService(), + sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Kitchen.Service.Grpc/appsettings.Development.json b/src/Kitchen.Service.Grpc/appsettings.Development.json index 0c208ae..9633e1e 100644 --- a/src/Kitchen.Service.Grpc/appsettings.Development.json +++ b/src/Kitchen.Service.Grpc/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "KitchenDal": { + "BaseUrl": "http://127.0.0.1:21080" + }, + "OperationsDal": { + "BaseUrl": "http://127.0.0.1:21081" } } diff --git a/src/Kitchen.Service.Grpc/appsettings.json b/src/Kitchen.Service.Grpc/appsettings.json index 10f68b8..8a22d86 100644 --- a/src/Kitchen.Service.Grpc/appsettings.json +++ b/src/Kitchen.Service.Grpc/appsettings.json @@ -5,5 +5,11 @@ "Microsoft.AspNetCore": "Warning" } }, + "KitchenDal": { + "BaseUrl": "http://kitchen-dal:8080" + }, + "OperationsDal": { + "BaseUrl": "http://operations-dal:8080" + }, "AllowedHosts": "*" } diff --git a/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs b/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs index 404be16..10bb369 100644 --- a/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs +++ b/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs @@ -6,7 +6,14 @@ namespace Kitchen.Service.Application.UnitTests; public class KitchenWorkflowUseCasesTests { - private readonly DefaultKitchenWorkflowPort workflowPort = new(); + private readonly InMemoryKitchenWorkItemStorePort workItemStore = new(); + private readonly InMemoryRestaurantLifecycleSyncPort restaurantLifecycle = new(); + private readonly DefaultKitchenWorkflowPort workflowPort; + + public KitchenWorkflowUseCasesTests() + { + workflowPort = new DefaultKitchenWorkflowPort(workItemStore, restaurantLifecycle); + } [Fact] public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations() @@ -18,6 +25,7 @@ public class KitchenWorkflowUseCasesTests Assert.Equal("demo-context", response.ContextId); Assert.NotEmpty(response.Lanes); Assert.NotEmpty(response.AvailableStations); + Assert.Contains(response.Lanes, lane => lane.Lane == "queued"); } [Fact] @@ -29,8 +37,11 @@ public class KitchenWorkflowUseCasesTests new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"), CancellationToken.None); + var updated = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None); + Assert.True(response.Claimed); Assert.Equal("chef-maya", response.ClaimedBy); + Assert.Equal("chef-maya", updated!.ClaimedBy); } [Fact] @@ -42,19 +53,48 @@ public class KitchenWorkflowUseCasesTests new UpdateKitchenPriorityRequest("demo-context", "WK-1001", 0, "expo-noah"), CancellationToken.None); + var updated = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None); + Assert.Equal(1, response.Priority); + Assert.Equal(1, updated!.Priority); } [Fact] - public async Task TransitionKitchenOrderStateUseCase_AllowsConfiguredStates() + public async Task TransitionKitchenOrderStateUseCase_UpdatesKitchenAndRestaurantFlow() { - var useCase = new TransitionKitchenOrderStateUseCase(); + var useCase = new TransitionKitchenOrderStateUseCase(workflowPort); var response = await useCase.HandleAsync( - new TransitionKitchenOrderStateRequest("CO-1001", "KT-1001", "Preparing", "chef-maya"), + new TransitionKitchenOrderStateRequest("ORD-1001", "KT-1001", "Preparing", "chef-maya"), CancellationToken.None); + var updatedWorkItem = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None); + var updatedOrder = await restaurantLifecycle.GetOrderAsync("demo-context", "ORD-1001", CancellationToken.None); + Assert.True(response.Applied); + Assert.Equal("Queued", response.PreviousState); Assert.Equal("Preparing", response.CurrentState); + Assert.Equal("Preparing", updatedWorkItem!.State); + Assert.Equal("Preparing", updatedOrder!.OrderState); + Assert.True(updatedOrder.HasKitchenTicket); + } + + [Fact] + public async Task TransitionKitchenOrderStateUseCase_MarksOrderServedWhenKitchenDelivers() + { + var useCase = new TransitionKitchenOrderStateUseCase(workflowPort); + + var response = await useCase.HandleAsync( + new TransitionKitchenOrderStateRequest("ORD-1003", "KT-1003", "Delivered", "expo-noah"), + CancellationToken.None); + + var updatedWorkItem = await workItemStore.GetAsync("demo-context", "WK-1003", CancellationToken.None); + var updatedOrder = await restaurantLifecycle.GetOrderAsync("demo-context", "ORD-1003", CancellationToken.None); + + Assert.True(response.Applied); + Assert.Equal("ReadyForPickup", response.PreviousState); + Assert.Equal("Delivered", response.CurrentState); + Assert.Equal("Delivered", updatedWorkItem!.State); + Assert.Equal("Served", updatedOrder!.OrderState); } }