feat(kitchen-service): derive kitchen tickets from lifecycle orders
This commit is contained in:
parent
9022fe6658
commit
0dc1f39dd2
@ -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.
|
||||
|
||||
@ -174,21 +174,18 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
||||
|
||||
private async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> 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<string, PersistedKitchenWorkItemRecord>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var item in queued)
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var items = await LoadOrDeriveWorkItemsAsync(order, cancellationToken);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (ShouldAppearOnBoard(item))
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,10 +194,51 @@ public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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
|
||||
|
||||
@ -5,5 +5,6 @@ namespace Kitchen.Service.Application.Ports;
|
||||
public interface IRestaurantLifecycleSyncPort
|
||||
{
|
||||
Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken);
|
||||
Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@ -21,6 +21,16 @@ public sealed class InMemoryRestaurantLifecycleSyncPort : IRestaurantLifecycleSy
|
||||
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)
|
||||
{
|
||||
store[BuildKey(record.ContextId, record.OrderId)] = record;
|
||||
|
||||
@ -18,6 +18,15 @@ public sealed class OperationsDalRestaurantLifecycleSyncClient(HttpClient httpCl
|
||||
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)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken);
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user