Compare commits

..

No commits in common. "0dc1f39dd23648277b2805be6096f40a9c392240" and "119f23ca66b9b754b9a31f4ed4bc1990a3c1cad3" have entirely different histories.

20 changed files with 93 additions and 782 deletions

View File

@ -1,35 +1,27 @@
# Internal Kitchen Workflows
# Internal Kitchen Workflow Contracts
## Purpose
`kitchen-service` exposes workflow-shaped internal endpoints for the kitchen BFF while keeping ticket execution aligned with the shared restaurant lifecycle.
`kitchen-service` now exposes a board-oriented internal workflow surface that the kitchen-ops BFF can consume directly.
## Endpoint Surface
- `GET /internal/kitchen/queue?queueName=<contextId>&limit=<n>`
- `GET /internal/kitchen/board?contextId=<id>`
- `GET /internal/kitchen/queue?queueName=<name>&limit=<n>`
- `POST /internal/kitchen/orders/transition`
- `GET /internal/kitchen/board?contextId=<id>`
- `POST /internal/kitchen/work-items/claim`
- `POST /internal/kitchen/work-items/priority`
## Stage 46 Runtime Shape
## Contract Depth Added In Stage 41
This repo now orchestrates kitchen progression over persisted kitchen tickets from `kitchen-dal` and syncs restaurant order state back through `operations-dal`.
The new kitchen workflow contracts add enough shape for downstream BFF and SPA work:
That means:
- 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
- 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
## Handoff Mapping
## Current Runtime Shape
- `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`
## 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.
- The default implementation remains deterministic and in-memory.
- This repo still focuses on orchestration and contract shape, not kitchen persistence realism.

View File

@ -9,10 +9,10 @@ kitchen-service
- Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features
- 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.
- 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 board lanes, claim ownership, and priority updates aligned to operator workflows.
## Documentation Contract

View File

@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
## Local Run
```bash
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
docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:dev
```
## Health Probe
@ -22,9 +22,8 @@ docker run --rm -p 8080:8080 -e KitchenDal__BaseUrl=http://kitchen-dal:8080 -e O
## Runtime Notes
- 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.
- Exposes internal queue and order state transition endpoints.
- Also exposes board, work-item claim, and priority update endpoints for the kitchen-ops BFF flow.
## Health Endpoint Consistency
@ -36,8 +35,8 @@ docker run --rm -p 8080:8080 -e KitchenDal__BaseUrl=http://kitchen-dal:8080 -e O
- Participates in: **restaurant** demo compose stack.
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations
- Payment opening remains owned by `operations-service`.
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior.
- 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,27 +5,14 @@ namespace Kitchen.Service.Application.Ports;
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
{
private readonly IKitchenWorkItemStorePort workItemStore;
public DefaultKitchenQueueReadPort()
: this(new InMemoryKitchenWorkItemStorePort())
public Task<GetKitchenQueueResponse> ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken)
{
}
public DefaultKitchenQueueReadPort(IKitchenWorkItemStorePort workItemStore)
var items = new[]
{
this.workItemStore = workItemStore;
}
new KitchenQueueItemContract("WK-1001", "PrepareOrder", 3, "Queued"),
new KitchenQueueItemContract("WK-1002", "AssembleTray", 2, "Queued")
}.Take(Math.Max(limit, 0)).ToArray();
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);
return Task.FromResult(new GetKitchenQueueResponse(items));
}
}

View File

@ -1,4 +1,3 @@
using Kitchen.Service.Application.State;
using Kitchen.Service.Contracts.Contracts;
using Kitchen.Service.Contracts.Requests;
using Kitchen.Service.Contracts.Responses;
@ -7,351 +6,83 @@ namespace Kitchen.Service.Application.Ports;
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
{
private readonly IKitchenWorkItemStorePort workItemStore;
private readonly IRestaurantLifecycleSyncPort restaurantLifecycle;
public DefaultKitchenWorkflowPort()
: this(new InMemoryKitchenWorkItemStorePort(), new InMemoryRestaurantLifecycleSyncPort())
{
}
public DefaultKitchenWorkflowPort(IKitchenWorkItemStorePort workItemStore, IRestaurantLifecycleSyncPort restaurantLifecycle)
{
this.workItemStore = workItemStore;
this.restaurantLifecycle = restaurantLifecycle;
}
public async Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
public Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
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();
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 new GetKitchenBoardResponse(
return Task.FromResult(new GetKitchenBoardResponse(
request.ContextId,
"Kitchen board now reflects persisted tickets linked to shared restaurant orders.",
"Kitchen board shows queued, preparing, and ready lanes for the current service context.",
lanes,
new[] { "hot-line", "grill", "salad", "pickup", "expedite" });
new[] { "hot-line", "grill", "salad", "pickup", "expedite" }));
}
public async Task<ClaimKitchenWorkItemResponse> ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
public 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);
var claimed = !string.IsNullOrWhiteSpace(request.ContextId) &&
!string.IsNullOrWhiteSpace(request.WorkItemId) &&
!string.IsNullOrWhiteSpace(request.ClaimedBy);
if (!claimed)
{
return new ClaimKitchenWorkItemResponse(
return Task.FromResult(new ClaimKitchenWorkItemResponse(
request.ContextId,
request.WorkItemId,
false,
claimed,
request.ClaimedBy,
"Kitchen work-item claim is incomplete.");
claimed
? $"Work item {request.WorkItemId} claimed by {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,
$"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)
public Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken);
var priority = Math.Max(1, request.Priority);
if (workItem is null)
{
return new UpdateKitchenPriorityResponse(
return Task.FromResult(new UpdateKitchenPriorityResponse(
request.ContextId,
request.WorkItemId,
priority,
$"Work item {request.WorkItemId} could not be found.");
$"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}."));
}
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)
public Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var workItem = await ResolveWorkItemAsync(request.ContextId ?? "demo-context", request.OrderId, request.TicketId, cancellationToken);
if (workItem is null)
{
return new TransitionKitchenOrderStateResponse(
var previous = "Queued";
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated";
return Task.FromResult(new TransitionKitchenOrderStateResponse(
request.OrderId,
request.TicketId,
"Unknown",
"Unknown",
false,
"Ticket could not be found for the provided order.");
previous,
allowed ? request.TargetState : previous,
allowed,
allowed ? null : "Target state is not allowed by kitchen-service policy."));
}
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

@ -1,13 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,98 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,9 +0,0 @@
namespace Kitchen.Service.Application.State;
public sealed record PersistedKitchenWorkItemEvent(
string ContextId,
string WorkItemId,
string EventId,
string EventType,
string Description,
DateTime OccurredAtUtc);

View File

@ -1,14 +0,0 @@
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

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

View File

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

View File

@ -1,62 +0,0 @@
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

@ -1,35 +0,0 @@
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,25 +1,10 @@
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.AddHttpClient<IKitchenWorkItemStorePort, KitchenDalWorkItemStoreClient>(client =>
{
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<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>();
builder.Services.AddSingleton<IKitchenWorkflowPort, DefaultKitchenWorkflowPort>();
builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();

View File

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

View File

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

View File

@ -6,14 +6,7 @@ namespace Kitchen.Service.Application.UnitTests;
public class KitchenWorkflowUseCasesTests
{
private readonly InMemoryKitchenWorkItemStorePort workItemStore = new();
private readonly InMemoryRestaurantLifecycleSyncPort restaurantLifecycle = new();
private readonly DefaultKitchenWorkflowPort workflowPort;
public KitchenWorkflowUseCasesTests()
{
workflowPort = new DefaultKitchenWorkflowPort(workItemStore, restaurantLifecycle);
}
private readonly DefaultKitchenWorkflowPort workflowPort = new();
[Fact]
public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations()
@ -25,37 +18,6 @@ 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]
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]
@ -67,11 +29,8 @@ 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]
@ -83,48 +42,19 @@ 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_UpdatesKitchenAndRestaurantFlow()
public async Task TransitionKitchenOrderStateUseCase_AllowsConfiguredStates()
{
var useCase = new TransitionKitchenOrderStateUseCase(workflowPort);
var useCase = new TransitionKitchenOrderStateUseCase();
var response = await useCase.HandleAsync(
new TransitionKitchenOrderStateRequest("ORD-1001", "KT-1001", "Preparing", "chef-maya"),
new TransitionKitchenOrderStateRequest("CO-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);
}
}