diff --git a/docs/api/internal-kitchen-workflows.md b/docs/api/internal-kitchen-workflows.md index 9d3ebcf..5849421 100644 --- a/docs/api/internal-kitchen-workflows.md +++ b/docs/api/internal-kitchen-workflows.md @@ -17,12 +17,14 @@ This repo now orchestrates kitchen progression over persisted kitchen tickets from `kitchen-dal` and syncs restaurant order state back through `operations-dal`. That means: -- board and queue reads come from persisted kitchen tickets +- board and queue reads come from persisted kitchen tickets plus lifecycle-derived ticket materialization for newly accepted restaurant orders - claim and priority changes update persisted kitchen ticket state - ticket transitions update both kitchen tickets and the linked restaurant order lifecycle +- accepted orders can become kitchen-visible without waiting for a stack reset or a second write path ## Handoff Mapping +- `Accepted` or `Submitted` restaurant order -> derived kitchen ticket starts in `Queued` - `Preparing` -> restaurant order becomes `Preparing` - `ReadyForPickup` -> restaurant order becomes `Ready` - `Delivered` -> restaurant order becomes `Served` @@ -30,3 +32,4 @@ That means: ## Remaining Limitation - Payment opening is still owned by `operations-service`; this repo only advances kitchen execution and the linked restaurant order state. +- Downstream waiter, customer, and POS visibility still depends on the Stage 51 BFF alignment tasks consuming the updated runtime behavior. diff --git a/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs b/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs index 0dfafcc..84a3cba 100644 --- a/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs +++ b/src/Kitchen.Service.Application/Ports/DefaultKitchenWorkflowPort.cs @@ -174,21 +174,18 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort 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 orders = await restaurantLifecycle.ListOrdersAsync(contextId, cancellationToken); var all = new Dictionary(StringComparer.Ordinal); - foreach (var item in queued) + foreach (var order in orders) { - all[item.WorkItemId] = item; - } - - foreach (var orderId in knownOrders) - { - var items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken); + var items = await LoadOrDeriveWorkItemsAsync(order, cancellationToken); foreach (var item in items) { - all[item.WorkItemId] = item; + if (ShouldAppearOnBoard(item)) + { + all[item.WorkItemId] = item; + } } } @@ -197,10 +194,51 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort private async Task ResolveWorkItemAsync(string contextId, string orderId, string ticketId, CancellationToken cancellationToken) { - var items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken); + var order = await restaurantLifecycle.GetOrderAsync(contextId, orderId, cancellationToken); + if (order is null) + { + return null; + } + + var items = await LoadOrDeriveWorkItemsAsync(order, cancellationToken); return items.FirstOrDefault(item => string.Equals(ToTicketId(item.WorkItemId), ticketId, StringComparison.Ordinal)); } + private async Task> LoadOrDeriveWorkItemsAsync( + PersistedRestaurantLifecycleRecord order, + CancellationToken cancellationToken) + { + var items = await workItemStore.ListByOrderAsync(order.ContextId, order.OrderId, cancellationToken); + if (items.Count > 0) + { + return items; + } + + if (!ShouldDeriveKitchenTicket(order)) + { + return Array.Empty(); + } + + var derived = BuildDerivedWorkItem(order); + await workItemStore.UpsertAsync(derived, cancellationToken); + await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent( + order.ContextId, + derived.WorkItemId, + $"EVT-{Guid.NewGuid():N}", + "DerivedFromLifecycle", + $"Derived kitchen ticket {derived.WorkItemId} from restaurant order {order.OrderId}.", + DateTime.UtcNow), cancellationToken); + + // A newly accepted order must become kitchen-visible through the shared runtime path. + // Marking the order as ticket-backed keeps restaurant and kitchen projections in sync. + if (!order.HasKitchenTicket) + { + await restaurantLifecycle.UpsertOrderAsync(order with { HasKitchenTicket = true }, cancellationToken); + } + + return new[] { derived }; + } + private static KitchenBoardItemContract ToBoardItem(PersistedKitchenWorkItemRecord item) { return new KitchenBoardItemContract( @@ -238,6 +276,65 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort _ => 4 }; + private static int MapPriority(string orderState) => orderState switch + { + "Ready" => 1, + "Preparing" or "InKitchen" => 2, + _ => 3 + }; + + private static string MapKitchenState(string orderState) => orderState switch + { + "Ready" => "ReadyForPickup", + "Preparing" or "InKitchen" => "Preparing", + _ => "Queued" + }; + + private static string MapStation(string source) => source switch + { + "waiter-floor" => "grill", + "customer-orders" => "hot-line", + _ => "expedite" + }; + + private static bool ShouldDeriveKitchenTicket(PersistedRestaurantLifecycleRecord order) + { + if (string.Equals(order.OrderState, "Canceled", StringComparison.Ordinal) + || string.Equals(order.OrderState, "Rejected", StringComparison.Ordinal) + || string.Equals(order.CheckState, "Paid", StringComparison.Ordinal)) + { + return false; + } + + return order.OrderState is "Submitted" or "Accepted" or "InKitchen" or "Preparing" or "Ready"; + } + + private static bool ShouldAppearOnBoard(PersistedKitchenWorkItemRecord item) + { + return item.State is "Queued" or "Preparing" or "ReadyForPickup"; + } + + private static PersistedKitchenWorkItemRecord BuildDerivedWorkItem(PersistedRestaurantLifecycleRecord order) + { + var numericSuffix = new string(order.OrderId.Where(char.IsDigit).ToArray()); + var workItemId = string.IsNullOrWhiteSpace(numericSuffix) + ? $"WK-{order.OrderId}" + : $"WK-{numericSuffix}"; + + return new PersistedKitchenWorkItemRecord( + order.ContextId, + workItemId, + order.OrderId, + order.CheckId, + order.TableId, + string.Equals(order.OrderState, "Ready", StringComparison.Ordinal) ? "AssembleTray" : "PrepareOrder", + MapStation(order.Source), + MapPriority(order.OrderState), + order.UpdatedAtUtc, + MapKitchenState(order.OrderState), + null); + } + private static bool CanTransition(string currentState, string targetState) { return currentState switch diff --git a/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs b/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs index f6b0461..d00d0d6 100644 --- a/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs +++ b/src/Kitchen.Service.Application/Ports/IRestaurantLifecycleSyncPort.cs @@ -5,5 +5,6 @@ namespace Kitchen.Service.Application.Ports; public interface IRestaurantLifecycleSyncPort { Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken); Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken); } diff --git a/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs b/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs index b13f9d0..fb149a8 100644 --- a/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs +++ b/src/Kitchen.Service.Application/Ports/InMemoryRestaurantLifecycleSyncPort.cs @@ -21,6 +21,16 @@ public sealed class InMemoryRestaurantLifecycleSyncPort : IRestaurantLifecycleSy return Task.FromResult(record); } + public Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken) + { + var records = store.Values + .Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal)) + .OrderByDescending(record => record.UpdatedAtUtc) + .ToArray(); + + return Task.FromResult>(records); + } + public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) { store[BuildKey(record.ContextId, record.OrderId)] = record; diff --git a/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs b/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs index 3da954d..918c4b9 100644 --- a/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs +++ b/src/Kitchen.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleSyncClient.cs @@ -18,6 +18,15 @@ public sealed class OperationsDalRestaurantLifecycleSyncClient(HttpClient httpCl return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); } + public async Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/operations-dal/orders?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + public async Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) { var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken); diff --git a/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs b/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs index 10bb369..9584598 100644 --- a/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs +++ b/tests/Kitchen.Service.Application.UnitTests/KitchenWorkflowUseCasesTests.cs @@ -28,6 +28,36 @@ public class KitchenWorkflowUseCasesTests Assert.Contains(response.Lanes, lane => lane.Lane == "queued"); } + [Fact] + public async Task GetKitchenBoardUseCase_DerivesTicketForAcceptedRestaurantOrder() + { + await restaurantLifecycle.UpsertOrderAsync( + new Kitchen.Service.Application.State.PersistedRestaurantLifecycleRecord( + "demo-context", + "ORD-1099", + "CHK-1099", + "T-31", + "Accepted", + "Open", + 3, + false, + 42.00m, + "USD", + "customer-orders", + new[] { "ITEM-701", "ITEM-702" }, + DateTime.UtcNow), + CancellationToken.None); + + var useCase = new GetKitchenBoardUseCase(workflowPort); + var response = await useCase.HandleAsync(new GetKitchenBoardRequest("demo-context"), CancellationToken.None); + + var queuedLane = Assert.Single(response.Lanes, lane => lane.Lane == "queued"); + var derivedItem = Assert.Single(queuedLane.Items, item => item.OrderId == "ORD-1099"); + + Assert.Equal("hot-line", derivedItem.Station); + Assert.Equal("Queued", derivedItem.State); + } + [Fact] public async Task ClaimKitchenWorkItemUseCase_ReturnsClaimedResponse() {