Compare commits

...

2 Commits

Author SHA1 Message Date
José René White Enciso
0dc1f39dd2 feat(kitchen-service): derive kitchen tickets from lifecycle orders 2026-03-31 19:55:18 -06:00
José René White Enciso
9022fe6658 feat(kitchen-service): sync kitchen tickets with restaurant lifecycle 2026-03-31 18:47:41 -06:00
20 changed files with 788 additions and 99 deletions

View File

@ -1,27 +1,35 @@
# Internal Kitchen Workflow Contracts # Internal Kitchen Workflows
## Purpose ## 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 ## Endpoint Surface
- `GET /internal/kitchen/queue?queueName=<name>&limit=<n>` - `GET /internal/kitchen/queue?queueName=<contextId>&limit=<n>`
- `POST /internal/kitchen/orders/transition`
- `GET /internal/kitchen/board?contextId=<id>` - `GET /internal/kitchen/board?contextId=<id>`
- `POST /internal/kitchen/orders/transition`
- `POST /internal/kitchen/work-items/claim` - `POST /internal/kitchen/work-items/claim`
- `POST /internal/kitchen/work-items/priority` - `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 That means:
- explicit claim/release-ready work-item ownership responses - board and queue reads come from persisted kitchen tickets plus lifecycle-derived ticket materialization for newly accepted restaurant orders
- dedicated priority update responses separate from generic state transitions - claim and priority changes update persisted kitchen ticket state
- existing transition contract kept in place for order-state changes - 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
## Current Runtime Shape ## Handoff Mapping
- The default implementation remains deterministic and in-memory. - `Accepted` or `Submitted` restaurant order -> derived kitchen ticket starts in `Queued`
- 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.
- Downstream waiter, customer, and POS visibility still depends on the Stage 51 BFF alignment tasks consuming the updated runtime behavior.

View File

@ -9,10 +9,10 @@ kitchen-service
- Epic 3: Improve observability and operational readiness for demo compose environments. - Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features ## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions. - Kitchen ticket orchestration over persisted linked tickets.
- Kitchen queue and dispatch optimization hooks. - Board and queue projections backed by shared restaurant identities.
- Operations control-plane policies (flags, service windows, overrides). - Claim ownership, priority updates, and kitchen transition history.
- POS closeout and settlement summary alignment. - Kitchen-to-restaurant lifecycle handoff for ready and served states.
- Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows. - Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows.
## Documentation Contract ## Documentation Contract

View File

@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
## Local Run ## Local Run
```bash ```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 ## Health Probe
@ -22,8 +22,9 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de
## Runtime Notes ## Runtime Notes
- Exposes internal queue and order state transition endpoints. - Exposes persisted queue and board endpoints for kitchen operators.
- Also exposes board, work-item claim, and priority update endpoints for the kitchen-ops BFF flow. - 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 ## 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. - Participates in: **restaurant** demo compose stack.
- 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. - 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. - 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.

View File

@ -5,14 +5,27 @@ namespace Kitchen.Service.Application.Ports;
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
{ {
public Task<GetKitchenQueueResponse> ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken) private readonly IKitchenWorkItemStorePort workItemStore;
{
var items = new[]
{
new KitchenQueueItemContract("WK-1001", "PrepareOrder", 3, "Queued"),
new KitchenQueueItemContract("WK-1002", "AssembleTray", 2, "Queued")
}.Take(Math.Max(limit, 0)).ToArray();
return Task.FromResult(new GetKitchenQueueResponse(items)); public DefaultKitchenQueueReadPort()
: this(new InMemoryKitchenWorkItemStorePort())
{
}
public DefaultKitchenQueueReadPort(IKitchenWorkItemStorePort workItemStore)
{
this.workItemStore = workItemStore;
}
public async Task<GetKitchenQueueResponse> 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);
} }
} }

View File

@ -1,3 +1,4 @@
using Kitchen.Service.Application.State;
using Kitchen.Service.Contracts.Contracts; using Kitchen.Service.Contracts.Contracts;
using Kitchen.Service.Contracts.Requests; using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses; using Kitchen.Service.Contracts.Responses;
@ -6,83 +7,351 @@ namespace Kitchen.Service.Application.Ports;
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
{ {
public Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken) private readonly IKitchenWorkItemStorePort workItemStore;
{ private readonly IRestaurantLifecycleSyncPort restaurantLifecycle;
cancellationToken.ThrowIfCancellationRequested();
var lanes = new[] public DefaultKitchenWorkflowPort()
: this(new InMemoryKitchenWorkItemStorePort(), new InMemoryRestaurantLifecycleSyncPort())
{ {
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<ClaimKitchenWorkItemResponse> ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken) public DefaultKitchenWorkflowPort(IKitchenWorkItemStorePort workItemStore, IRestaurantLifecycleSyncPort restaurantLifecycle)
{
this.workItemStore = workItemStore;
this.restaurantLifecycle = restaurantLifecycle;
}
public async Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var claimed = !string.IsNullOrWhiteSpace(request.ContextId) && var workItems = await LoadAllWorkItemsAsync(request.ContextId, cancellationToken);
!string.IsNullOrWhiteSpace(request.WorkItemId) && var lanes = workItems
!string.IsNullOrWhiteSpace(request.ClaimedBy); .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<ClaimKitchenWorkItemResponse> 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.ContextId,
request.WorkItemId, request.WorkItemId,
claimed, false,
request.ClaimedBy, request.ClaimedBy,
claimed "Kitchen work-item claim is incomplete.");
? $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}."
: "Kitchen work-item claim is incomplete."));
} }
public Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken) var updated = workItem! with { ClaimedBy = request.ClaimedBy };
await workItemStore.UpsertAsync(updated, cancellationToken);
await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent(
request.ContextId,
request.WorkItemId,
$"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,
$"Work item {request.WorkItemId} claimed by {request.ClaimedBy}.");
}
public async Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken);
var priority = Math.Max(1, request.Priority); var priority = Math.Max(1, request.Priority);
return Task.FromResult(new UpdateKitchenPriorityResponse( if (workItem is null)
{
return new UpdateKitchenPriorityResponse(
request.ContextId, request.ContextId,
request.WorkItemId, request.WorkItemId,
priority, priority,
$"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}.")); $"Work item {request.WorkItemId} could not be found.");
} }
public Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken) 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}.");
}
public async Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var previous = "Queued"; var workItem = await ResolveWorkItemAsync(request.ContextId ?? "demo-context", request.OrderId, request.TicketId, cancellationToken);
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated"; if (workItem is null)
{
return Task.FromResult(new TransitionKitchenOrderStateResponse( return new TransitionKitchenOrderStateResponse(
request.OrderId, request.OrderId,
request.TicketId, request.TicketId,
previous, "Unknown",
allowed ? request.TargetState : previous, "Unknown",
allowed, false,
allowed ? null : "Target state is not allowed by kitchen-service policy.")); "Ticket could not be found for the provided order.");
}
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,
previousState,
request.TargetState,
true,
null);
}
private async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> LoadAllWorkItemsAsync(string contextId, CancellationToken cancellationToken)
{
var orders = await restaurantLifecycle.ListOrdersAsync(contextId, cancellationToken);
var all = new Dictionary<string, PersistedKitchenWorkItemRecord>(StringComparer.Ordinal);
foreach (var order in orders)
{
var items = await LoadOrDeriveWorkItemsAsync(order, cancellationToken);
foreach (var item in items)
{
if (ShouldAppearOnBoard(item))
{
all[item.WorkItemId] = item;
} }
} }
}
return all.Values.OrderByDescending(item => item.Priority).ThenBy(item => item.RequestedAtUtc).ToArray();
}
private async Task<PersistedKitchenWorkItemRecord?> ResolveWorkItemAsync(string contextId, string orderId, string ticketId, CancellationToken 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(
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 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
{
"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"
};
}

View File

@ -0,0 +1,13 @@
using Kitchen.Service.Application.State;
namespace Kitchen.Service.Application.Ports;
public interface IKitchenWorkItemStorePort
{
Task<PersistedKitchenWorkItemRecord?> GetAsync(string contextId, string workItemId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> ListQueuedAsync(string contextId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task UpsertAsync(PersistedKitchenWorkItemRecord record, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedKitchenWorkItemEvent>> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken);
Task AppendEventAsync(PersistedKitchenWorkItemEvent record, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,10 @@
using Kitchen.Service.Application.State;
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);
}

View File

@ -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<string, PersistedKitchenWorkItemRecord> store = new();
private readonly ConcurrentDictionary<string, ConcurrentQueue<PersistedKitchenWorkItemEvent>> 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<PersistedKitchenWorkItemEvent>());
queue.Enqueue(record);
}
}
public Task<PersistedKitchenWorkItemRecord?> GetAsync(string contextId, string workItemId, CancellationToken cancellationToken)
{
store.TryGetValue(BuildKey(contextId, workItemId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> 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<IReadOnlyCollection<PersistedKitchenWorkItemRecord>>(result);
}
public Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> 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<IReadOnlyCollection<PersistedKitchenWorkItemRecord>>(result);
}
public Task UpsertAsync(PersistedKitchenWorkItemRecord record, CancellationToken cancellationToken)
{
store[BuildKey(record.ContextId, record.WorkItemId)] = record;
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<PersistedKitchenWorkItemEvent>> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken)
{
if (!events.TryGetValue(BuildKey(contextId, workItemId), out var queue))
{
return Task.FromResult<IReadOnlyCollection<PersistedKitchenWorkItemEvent>>(Array.Empty<PersistedKitchenWorkItemEvent>());
}
var result = queue.OrderByDescending(item => item.OccurredAtUtc).ToArray();
return Task.FromResult<IReadOnlyCollection<PersistedKitchenWorkItemEvent>>(result);
}
public Task AppendEventAsync(PersistedKitchenWorkItemEvent record, CancellationToken cancellationToken)
{
var queue = events.GetOrAdd(BuildKey(record.ContextId, record.WorkItemId), static _ => new ConcurrentQueue<PersistedKitchenWorkItemEvent>());
queue.Enqueue(record);
return Task.CompletedTask;
}
private static IReadOnlyCollection<PersistedKitchenWorkItemRecord> 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<PersistedKitchenWorkItemEvent> 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}";
}

View File

@ -0,0 +1,51 @@
using System.Collections.Concurrent;
using Kitchen.Service.Application.State;
namespace Kitchen.Service.Application.Ports;
public sealed class InMemoryRestaurantLifecycleSyncPort : IRestaurantLifecycleSyncPort
{
private readonly ConcurrentDictionary<string, PersistedRestaurantLifecycleRecord> store = new();
public InMemoryRestaurantLifecycleSyncPort()
{
foreach (var record in BuildSeedOrders())
{
store[BuildKey(record.ContextId, record.OrderId)] = record;
}
}
public Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
store.TryGetValue(BuildKey(contextId, orderId), out var 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)
{
store[BuildKey(record.ContextId, record.OrderId)] = record;
return Task.CompletedTask;
}
private static IReadOnlyCollection<PersistedRestaurantLifecycleRecord> 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}";
}

View File

@ -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);

View File

@ -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);

View File

@ -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<string> ItemIds,
DateTime UpdatedAtUtc);

View File

@ -1,23 +1,15 @@
using Kitchen.Service.Application.Ports;
using Kitchen.Service.Contracts.Requests; using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses; using Kitchen.Service.Contracts.Responses;
namespace Kitchen.Service.Application.UseCases; namespace Kitchen.Service.Application.UseCases;
public sealed class TransitionKitchenOrderStateUseCase : ITransitionKitchenOrderStateUseCase public sealed class TransitionKitchenOrderStateUseCase(IKitchenWorkflowPort workflowPort) : ITransitionKitchenOrderStateUseCase
{ {
public Task<TransitionKitchenOrderStateResponse> HandleAsync( public Task<TransitionKitchenOrderStateResponse> HandleAsync(
TransitionKitchenOrderStateRequest request, TransitionKitchenOrderStateRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var previous = "Queued"; return workflowPort.TransitionKitchenOrderStateAsync(request, cancellationToken);
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."));
} }
} }

View File

@ -4,4 +4,5 @@ public sealed record TransitionKitchenOrderStateRequest(
string OrderId, string OrderId,
string TicketId, string TicketId,
string TargetState, string TargetState,
string RequestedBy); string RequestedBy,
string? ContextId = null);

View File

@ -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<PersistedKitchenWorkItemRecord?> 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<PersistedKitchenWorkItemRecord>(cancellationToken: cancellationToken);
}
public async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> ListQueuedAsync(string contextId, CancellationToken cancellationToken)
{
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedKitchenWorkItemRecord>>(
$"/internal/kitchen-dal/work-items/queued?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return result ?? Array.Empty<PersistedKitchenWorkItemRecord>();
}
public async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedKitchenWorkItemRecord>>(
$"/internal/kitchen-dal/orders/{Uri.EscapeDataString(orderId)}/work-items?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return result ?? Array.Empty<PersistedKitchenWorkItemRecord>();
}
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<IReadOnlyCollection<PersistedKitchenWorkItemEvent>> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken)
{
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedKitchenWorkItemEvent>>(
$"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(workItemId)}/events?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return result ?? Array.Empty<PersistedKitchenWorkItemEvent>();
}
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();
}
}

View File

@ -0,0 +1,35 @@
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<PersistedRestaurantLifecycleRecord?> 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<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);
response.EnsureSuccessStatusCode();
}
}

View File

@ -1,10 +1,25 @@
using Kitchen.Service.Application.Ports; using Kitchen.Service.Application.Ports;
using Kitchen.Service.Application.UseCases; using Kitchen.Service.Application.UseCases;
using Kitchen.Service.Contracts.Requests; using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Grpc.Adapters;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>(); builder.Services.AddHttpClient<IKitchenWorkItemStorePort, KitchenDalWorkItemStoreClient>(client =>
builder.Services.AddSingleton<IKitchenWorkflowPort, DefaultKitchenWorkflowPort>(); {
var baseUrl = builder.Configuration["KitchenDal:BaseUrl"] ?? "http://kitchen-dal:8080";
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddHttpClient<IRestaurantLifecycleSyncPort, OperationsDalRestaurantLifecycleSyncClient>(client =>
{
var baseUrl = builder.Configuration["OperationsDal:BaseUrl"] ?? "http://operations-dal:8080";
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddSingleton<IKitchenQueueReadPort>(sp =>
new DefaultKitchenQueueReadPort(sp.GetRequiredService<IKitchenWorkItemStorePort>()));
builder.Services.AddSingleton<IKitchenWorkflowPort>(sp =>
new DefaultKitchenWorkflowPort(
sp.GetRequiredService<IKitchenWorkItemStorePort>(),
sp.GetRequiredService<IRestaurantLifecycleSyncPort>()));
builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>(); builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>(); builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>(); builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();

View File

@ -4,5 +4,11 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"KitchenDal": {
"BaseUrl": "http://127.0.0.1:21080"
},
"OperationsDal": {
"BaseUrl": "http://127.0.0.1:21081"
} }
} }

View File

@ -5,5 +5,11 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"KitchenDal": {
"BaseUrl": "http://kitchen-dal:8080"
},
"OperationsDal": {
"BaseUrl": "http://operations-dal:8080"
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@ -6,7 +6,14 @@ namespace Kitchen.Service.Application.UnitTests;
public class KitchenWorkflowUseCasesTests 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] [Fact]
public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations() public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations()
@ -18,6 +25,37 @@ public class KitchenWorkflowUseCasesTests
Assert.Equal("demo-context", response.ContextId); Assert.Equal("demo-context", response.ContextId);
Assert.NotEmpty(response.Lanes); Assert.NotEmpty(response.Lanes);
Assert.NotEmpty(response.AvailableStations); Assert.NotEmpty(response.AvailableStations);
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]
@ -29,8 +67,11 @@ public class KitchenWorkflowUseCasesTests
new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"), new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
CancellationToken.None); CancellationToken.None);
var updated = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None);
Assert.True(response.Claimed); Assert.True(response.Claimed);
Assert.Equal("chef-maya", response.ClaimedBy); Assert.Equal("chef-maya", response.ClaimedBy);
Assert.Equal("chef-maya", updated!.ClaimedBy);
} }
[Fact] [Fact]
@ -42,19 +83,48 @@ public class KitchenWorkflowUseCasesTests
new UpdateKitchenPriorityRequest("demo-context", "WK-1001", 0, "expo-noah"), new UpdateKitchenPriorityRequest("demo-context", "WK-1001", 0, "expo-noah"),
CancellationToken.None); CancellationToken.None);
var updated = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None);
Assert.Equal(1, response.Priority); Assert.Equal(1, response.Priority);
Assert.Equal(1, updated!.Priority);
} }
[Fact] [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( 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); 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.True(response.Applied);
Assert.Equal("Queued", response.PreviousState);
Assert.Equal("Preparing", response.CurrentState); 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);
} }
} }