Compare commits
2 Commits
119f23ca66
...
0dc1f39dd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc1f39dd2 | ||
|
|
9022fe6658 |
@ -1,27 +1,35 @@
|
||||
# Internal Kitchen Workflow Contracts
|
||||
# Internal Kitchen Workflows
|
||||
|
||||
## 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
|
||||
|
||||
- `GET /internal/kitchen/queue?queueName=<name>&limit=<n>`
|
||||
- `POST /internal/kitchen/orders/transition`
|
||||
- `GET /internal/kitchen/queue?queueName=<contextId>&limit=<n>`
|
||||
- `GET /internal/kitchen/board?contextId=<id>`
|
||||
- `POST /internal/kitchen/orders/transition`
|
||||
- `POST /internal/kitchen/work-items/claim`
|
||||
- `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
|
||||
- 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
|
||||
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
|
||||
|
||||
## Current Runtime Shape
|
||||
## Handoff Mapping
|
||||
|
||||
- The default implementation remains deterministic and in-memory.
|
||||
- This repo still focuses on orchestration and contract shape, not kitchen persistence realism.
|
||||
- `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.
|
||||
|
||||
@ -9,10 +9,10 @@ kitchen-service
|
||||
- Epic 3: Improve observability and operational readiness for demo compose environments.
|
||||
|
||||
## Domain-Specific Candidate Features
|
||||
- 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 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.
|
||||
- Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows.
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
@ -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 --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
|
||||
@ -22,8 +22,9 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
- Exposes internal queue and order state transition endpoints.
|
||||
- Also exposes board, work-item claim, and priority update endpoints for the kitchen-ops BFF flow.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
|
||||
|
||||
## 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.
|
||||
- Stage 41 adds kitchen workflow contract depth first; downstream BFFs still need to adopt these endpoints before richer board behavior reaches the web app.
|
||||
|
||||
@ -5,14 +5,27 @@ namespace Kitchen.Service.Application.Ports;
|
||||
|
||||
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
|
||||
{
|
||||
public Task<GetKitchenQueueResponse> ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
new KitchenQueueItemContract("WK-1001", "PrepareOrder", 3, "Queued"),
|
||||
new KitchenQueueItemContract("WK-1002", "AssembleTray", 2, "Queued")
|
||||
}.Take(Math.Max(limit, 0)).ToArray();
|
||||
private readonly IKitchenWorkItemStorePort workItemStore;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using Kitchen.Service.Application.State;
|
||||
using Kitchen.Service.Contracts.Contracts;
|
||||
using Kitchen.Service.Contracts.Requests;
|
||||
using Kitchen.Service.Contracts.Responses;
|
||||
@ -6,83 +7,351 @@ namespace Kitchen.Service.Application.Ports;
|
||||
|
||||
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
||||
{
|
||||
public Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
private readonly IKitchenWorkItemStorePort workItemStore;
|
||||
private readonly IRestaurantLifecycleSyncPort restaurantLifecycle;
|
||||
|
||||
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();
|
||||
|
||||
var claimed = !string.IsNullOrWhiteSpace(request.ContextId) &&
|
||||
!string.IsNullOrWhiteSpace(request.WorkItemId) &&
|
||||
!string.IsNullOrWhiteSpace(request.ClaimedBy);
|
||||
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();
|
||||
|
||||
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.WorkItemId,
|
||||
claimed,
|
||||
false,
|
||||
request.ClaimedBy,
|
||||
claimed
|
||||
? $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}."
|
||||
: "Kitchen work-item claim is incomplete."));
|
||||
"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();
|
||||
|
||||
var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken);
|
||||
var priority = Math.Max(1, request.Priority);
|
||||
return Task.FromResult(new UpdateKitchenPriorityResponse(
|
||||
if (workItem is null)
|
||||
{
|
||||
return new UpdateKitchenPriorityResponse(
|
||||
request.ContextId,
|
||||
request.WorkItemId,
|
||||
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();
|
||||
|
||||
var previous = "Queued";
|
||||
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated";
|
||||
|
||||
return Task.FromResult(new TransitionKitchenOrderStateResponse(
|
||||
var workItem = await ResolveWorkItemAsync(request.ContextId ?? "demo-context", request.OrderId, request.TicketId, cancellationToken);
|
||||
if (workItem is null)
|
||||
{
|
||||
return new TransitionKitchenOrderStateResponse(
|
||||
request.OrderId,
|
||||
request.TicketId,
|
||||
previous,
|
||||
allowed ? request.TargetState : previous,
|
||||
allowed,
|
||||
allowed ? null : "Target state is not allowed by kitchen-service policy."));
|
||||
"Unknown",
|
||||
"Unknown",
|
||||
false,
|
||||
"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"
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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}";
|
||||
}
|
||||
@ -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}";
|
||||
}
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -1,23 +1,15 @@
|
||||
using Kitchen.Service.Application.Ports;
|
||||
using Kitchen.Service.Contracts.Requests;
|
||||
using Kitchen.Service.Contracts.Responses;
|
||||
|
||||
namespace Kitchen.Service.Application.UseCases;
|
||||
|
||||
public sealed class TransitionKitchenOrderStateUseCase : ITransitionKitchenOrderStateUseCase
|
||||
public sealed class TransitionKitchenOrderStateUseCase(IKitchenWorkflowPort workflowPort) : ITransitionKitchenOrderStateUseCase
|
||||
{
|
||||
public Task<TransitionKitchenOrderStateResponse> HandleAsync(
|
||||
TransitionKitchenOrderStateRequest request,
|
||||
CancellationToken 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."));
|
||||
return workflowPort.TransitionKitchenOrderStateAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,4 +4,5 @@ public sealed record TransitionKitchenOrderStateRequest(
|
||||
string OrderId,
|
||||
string TicketId,
|
||||
string TargetState,
|
||||
string RequestedBy);
|
||||
string RequestedBy,
|
||||
string? ContextId = null);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,25 @@
|
||||
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.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>();
|
||||
builder.Services.AddSingleton<IKitchenWorkflowPort, DefaultKitchenWorkflowPort>();
|
||||
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<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
|
||||
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
|
||||
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();
|
||||
|
||||
@ -4,5 +4,11 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"KitchenDal": {
|
||||
"BaseUrl": "http://127.0.0.1:21080"
|
||||
},
|
||||
"OperationsDal": {
|
||||
"BaseUrl": "http://127.0.0.1:21081"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,5 +5,11 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"KitchenDal": {
|
||||
"BaseUrl": "http://kitchen-dal:8080"
|
||||
},
|
||||
"OperationsDal": {
|
||||
"BaseUrl": "http://operations-dal:8080"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@ -6,7 +6,14 @@ namespace Kitchen.Service.Application.UnitTests;
|
||||
|
||||
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]
|
||||
public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations()
|
||||
@ -18,6 +25,37 @@ 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]
|
||||
@ -29,8 +67,11 @@ 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]
|
||||
@ -42,19 +83,48 @@ 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_AllowsConfiguredStates()
|
||||
public async Task TransitionKitchenOrderStateUseCase_UpdatesKitchenAndRestaurantFlow()
|
||||
{
|
||||
var useCase = new TransitionKitchenOrderStateUseCase();
|
||||
var useCase = new TransitionKitchenOrderStateUseCase(workflowPort);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user