feat(kitchen-service): derive kitchen tickets from lifecycle orders

This commit is contained in:
José René White Enciso 2026-03-31 19:55:18 -06:00
parent 9022fe6658
commit 0dc1f39dd2
6 changed files with 162 additions and 12 deletions

View File

@ -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`. This repo now orchestrates kitchen progression over persisted kitchen tickets from `kitchen-dal` and syncs restaurant order state back through `operations-dal`.
That means: 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 - claim and priority changes update persisted kitchen ticket state
- ticket transitions update both kitchen tickets and the linked restaurant order lifecycle - 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 ## Handoff Mapping
- `Accepted` or `Submitted` restaurant order -> derived kitchen ticket starts in `Queued`
- `Preparing` -> restaurant order becomes `Preparing` - `Preparing` -> restaurant order becomes `Preparing`
- `ReadyForPickup` -> restaurant order becomes `Ready` - `ReadyForPickup` -> restaurant order becomes `Ready`
- `Delivered` -> restaurant order becomes `Served` - `Delivered` -> restaurant order becomes `Served`
@ -30,3 +32,4 @@ That means:
## Remaining Limitation ## Remaining Limitation
- Payment opening is still owned by `operations-service`; this repo only advances kitchen execution and the linked restaurant order state. - 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.

View File

@ -174,21 +174,18 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
private async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> LoadAllWorkItemsAsync(string contextId, CancellationToken cancellationToken) private async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> LoadAllWorkItemsAsync(string contextId, CancellationToken cancellationToken)
{ {
var queued = await workItemStore.ListQueuedAsync(contextId, cancellationToken); var orders = await restaurantLifecycle.ListOrdersAsync(contextId, cancellationToken);
var knownOrders = new[] { "ORD-1001", "ORD-1002", "ORD-1003", "ORD-1004" };
var all = new Dictionary<string, PersistedKitchenWorkItemRecord>(StringComparer.Ordinal); var all = new Dictionary<string, PersistedKitchenWorkItemRecord>(StringComparer.Ordinal);
foreach (var item in queued) foreach (var order in orders)
{ {
all[item.WorkItemId] = item; var items = await LoadOrDeriveWorkItemsAsync(order, cancellationToken);
}
foreach (var orderId in knownOrders)
{
var items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken);
foreach (var item in items) 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<PersistedKitchenWorkItemRecord?> ResolveWorkItemAsync(string contextId, string orderId, string ticketId, CancellationToken cancellationToken) private async Task<PersistedKitchenWorkItemRecord?> 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)); return items.FirstOrDefault(item => string.Equals(ToTicketId(item.WorkItemId), ticketId, StringComparison.Ordinal));
} }
private async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> 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<PersistedKitchenWorkItemRecord>();
}
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) private static KitchenBoardItemContract ToBoardItem(PersistedKitchenWorkItemRecord item)
{ {
return new KitchenBoardItemContract( return new KitchenBoardItemContract(
@ -238,6 +276,65 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
_ => 4 _ => 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) private static bool CanTransition(string currentState, string targetState)
{ {
return currentState switch return currentState switch

View File

@ -5,5 +5,6 @@ namespace Kitchen.Service.Application.Ports;
public interface IRestaurantLifecycleSyncPort public interface IRestaurantLifecycleSyncPort
{ {
Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken);
Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken); Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken);
} }

View File

@ -21,6 +21,16 @@ public sealed class InMemoryRestaurantLifecycleSyncPort : IRestaurantLifecycleSy
return Task.FromResult(record); return Task.FromResult(record);
} }
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> 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<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(records);
}
public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken)
{ {
store[BuildKey(record.ContextId, record.OrderId)] = record; store[BuildKey(record.ContextId, record.OrderId)] = record;

View File

@ -18,6 +18,15 @@ public sealed class OperationsDalRestaurantLifecycleSyncClient(HttpClient httpCl
return await response.Content.ReadFromJsonAsync<PersistedRestaurantLifecycleRecord>(cancellationToken: cancellationToken); return await response.Content.ReadFromJsonAsync<PersistedRestaurantLifecycleRecord>(cancellationToken: cancellationToken);
} }
public async Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken)
{
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(
$"/internal/operations-dal/orders?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return result ?? Array.Empty<PersistedRestaurantLifecycleRecord>();
}
public async Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) public async Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken)
{ {
var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken); var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken);

View File

@ -28,6 +28,36 @@ public class KitchenWorkflowUseCasesTests
Assert.Contains(response.Lanes, lane => lane.Lane == "queued"); 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] [Fact]
public async Task ClaimKitchenWorkItemUseCase_ReturnsClaimedResponse() public async Task ClaimKitchenWorkItemUseCase_ReturnsClaimedResponse()
{ {