Compare commits
No commits in common. "0dc1f39dd23648277b2805be6096f40a9c392240" and "119f23ca66b9b754b9a31f4ed4bc1990a3c1cad3" have entirely different histories.
0dc1f39dd2
...
119f23ca66
@ -1,35 +1,27 @@
|
|||||||
# Internal Kitchen Workflows
|
# Internal Kitchen Workflow Contracts
|
||||||
|
|
||||||
## Purpose
|
## 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
|
## Endpoint Surface
|
||||||
|
|
||||||
- `GET /internal/kitchen/queue?queueName=<contextId>&limit=<n>`
|
- `GET /internal/kitchen/queue?queueName=<name>&limit=<n>`
|
||||||
- `GET /internal/kitchen/board?contextId=<id>`
|
|
||||||
- `POST /internal/kitchen/orders/transition`
|
- `POST /internal/kitchen/orders/transition`
|
||||||
|
- `GET /internal/kitchen/board?contextId=<id>`
|
||||||
- `POST /internal/kitchen/work-items/claim`
|
- `POST /internal/kitchen/work-items/claim`
|
||||||
- `POST /internal/kitchen/work-items/priority`
|
- `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 lanes with per-item station, claim, ETA, and priority details
|
||||||
- board and queue reads come from persisted kitchen tickets plus lifecycle-derived ticket materialization for newly accepted restaurant orders
|
- explicit claim/release-ready work-item ownership responses
|
||||||
- claim and priority changes update persisted kitchen ticket state
|
- dedicated priority update responses separate from generic state transitions
|
||||||
- ticket transitions update both kitchen tickets and the linked restaurant order lifecycle
|
- existing transition contract kept in place for order-state changes
|
||||||
- accepted orders can become kitchen-visible without waiting for a stack reset or a second write path
|
|
||||||
|
|
||||||
## Handoff Mapping
|
## Current Runtime Shape
|
||||||
|
|
||||||
- `Accepted` or `Submitted` restaurant order -> derived kitchen ticket starts in `Queued`
|
- The default implementation remains deterministic and in-memory.
|
||||||
- `Preparing` -> restaurant order becomes `Preparing`
|
- This repo still focuses on orchestration and contract shape, not kitchen persistence realism.
|
||||||
- `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.
|
- Epic 3: Improve observability and operational readiness for demo compose environments.
|
||||||
|
|
||||||
## Domain-Specific Candidate Features
|
## Domain-Specific Candidate Features
|
||||||
- Kitchen ticket orchestration over persisted linked tickets.
|
- Order lifecycle consistency and state transitions.
|
||||||
- Board and queue projections backed by shared restaurant identities.
|
- Kitchen queue and dispatch optimization hooks.
|
||||||
- Claim ownership, priority updates, and kitchen transition history.
|
- Operations control-plane policies (flags, service windows, overrides).
|
||||||
- Kitchen-to-restaurant lifecycle handoff for ready and served states.
|
- POS closeout and settlement summary alignment.
|
||||||
- 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
|
||||||
|
|||||||
@ -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 -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
|
## Health Probe
|
||||||
@ -22,9 +22,8 @@ docker run --rm -p 8080:8080 -e KitchenDal__BaseUrl=http://kitchen-dal:8080 -e O
|
|||||||
|
|
||||||
## Runtime Notes
|
## Runtime Notes
|
||||||
|
|
||||||
- Exposes persisted queue and board endpoints for kitchen operators.
|
- Exposes internal queue and order state transition endpoints.
|
||||||
- Exposes claim, priority, and transition endpoints over linked kitchen tickets.
|
- Also exposes board, work-item claim, and priority update endpoints for the kitchen-ops BFF flow.
|
||||||
- Requires access to both the kitchen DAL host and the operations DAL host.
|
|
||||||
|
|
||||||
## Health Endpoint Consistency
|
## 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.
|
- 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
|
||||||
|
|
||||||
- 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.
|
- 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,27 +5,14 @@ namespace Kitchen.Service.Application.Ports;
|
|||||||
|
|
||||||
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
|
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
|
||||||
{
|
{
|
||||||
private readonly IKitchenWorkItemStorePort workItemStore;
|
public Task<GetKitchenQueueResponse> ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken)
|
||||||
|
|
||||||
public DefaultKitchenQueueReadPort()
|
|
||||||
: this(new InMemoryKitchenWorkItemStorePort())
|
|
||||||
{
|
{
|
||||||
}
|
var items = new[]
|
||||||
|
|
||||||
public DefaultKitchenQueueReadPort(IKitchenWorkItemStorePort workItemStore)
|
|
||||||
{
|
{
|
||||||
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)
|
return Task.FromResult(new GetKitchenQueueResponse(items));
|
||||||
{
|
|
||||||
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,4 +1,3 @@
|
|||||||
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;
|
||||||
@ -7,351 +6,83 @@ namespace Kitchen.Service.Application.Ports;
|
|||||||
|
|
||||||
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
||||||
{
|
{
|
||||||
private readonly IKitchenWorkItemStorePort workItemStore;
|
public Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var workItems = await LoadAllWorkItemsAsync(request.ContextId, cancellationToken);
|
var lanes = new[]
|
||||||
var lanes = workItems
|
{
|
||||||
.GroupBy(item => MapLane(item.State), StringComparer.OrdinalIgnoreCase)
|
new KitchenBoardLaneContract(
|
||||||
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
|
"queued",
|
||||||
.Select(group => new KitchenBoardLaneContract(
|
new[]
|
||||||
group.Key,
|
{
|
||||||
group.OrderByDescending(item => item.Priority)
|
new KitchenBoardItemContract("WK-1001", "CO-1001", "KT-1001", "T-08", "hot-line", "Queued", 3, null, 12),
|
||||||
.ThenBy(item => item.RequestedAtUtc)
|
new KitchenBoardItemContract("WK-1002", "CO-1003", "KT-1002", "T-12", "expedite", "Queued", 2, null, 8)
|
||||||
.Select(ToBoardItem)
|
}),
|
||||||
.ToArray()))
|
new KitchenBoardLaneContract(
|
||||||
.ToArray();
|
"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,
|
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,
|
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();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken);
|
var claimed = !string.IsNullOrWhiteSpace(request.ContextId) &&
|
||||||
var claimed = workItem is not null && !string.IsNullOrWhiteSpace(request.ClaimedBy);
|
!string.IsNullOrWhiteSpace(request.WorkItemId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(request.ClaimedBy);
|
||||||
|
|
||||||
if (!claimed)
|
return Task.FromResult(new ClaimKitchenWorkItemResponse(
|
||||||
{
|
|
||||||
return new ClaimKitchenWorkItemResponse(
|
|
||||||
request.ContextId,
|
request.ContextId,
|
||||||
request.WorkItemId,
|
request.WorkItemId,
|
||||||
false,
|
claimed,
|
||||||
request.ClaimedBy,
|
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 };
|
public Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
|
||||||
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);
|
||||||
if (workItem is null)
|
return Task.FromResult(new UpdateKitchenPriorityResponse(
|
||||||
{
|
|
||||||
return new UpdateKitchenPriorityResponse(
|
|
||||||
request.ContextId,
|
request.ContextId,
|
||||||
request.WorkItemId,
|
request.WorkItemId,
|
||||||
priority,
|
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 };
|
public Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken)
|
||||||
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 workItem = await ResolveWorkItemAsync(request.ContextId ?? "demo-context", request.OrderId, request.TicketId, cancellationToken);
|
var previous = "Queued";
|
||||||
if (workItem is null)
|
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated";
|
||||||
{
|
|
||||||
return new TransitionKitchenOrderStateResponse(
|
return Task.FromResult(new TransitionKitchenOrderStateResponse(
|
||||||
request.OrderId,
|
request.OrderId,
|
||||||
request.TicketId,
|
request.TicketId,
|
||||||
"Unknown",
|
previous,
|
||||||
"Unknown",
|
allowed ? request.TargetState : previous,
|
||||||
false,
|
allowed,
|
||||||
"Ticket could not be found for the provided order.");
|
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"
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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}";
|
|
||||||
}
|
|
||||||
@ -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}";
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -1,15 +1,23 @@
|
|||||||
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(IKitchenWorkflowPort workflowPort) : ITransitionKitchenOrderStateUseCase
|
public sealed class TransitionKitchenOrderStateUseCase : ITransitionKitchenOrderStateUseCase
|
||||||
{
|
{
|
||||||
public Task<TransitionKitchenOrderStateResponse> HandleAsync(
|
public Task<TransitionKitchenOrderStateResponse> HandleAsync(
|
||||||
TransitionKitchenOrderStateRequest request,
|
TransitionKitchenOrderStateRequest request,
|
||||||
CancellationToken cancellationToken)
|
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."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,4 @@ public sealed record TransitionKitchenOrderStateRequest(
|
|||||||
string OrderId,
|
string OrderId,
|
||||||
string TicketId,
|
string TicketId,
|
||||||
string TargetState,
|
string TargetState,
|
||||||
string RequestedBy,
|
string RequestedBy);
|
||||||
string? ContextId = null);
|
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +1,10 @@
|
|||||||
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.AddHttpClient<IKitchenWorkItemStorePort, KitchenDalWorkItemStoreClient>(client =>
|
builder.Services.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>();
|
||||||
{
|
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>();
|
||||||
|
|||||||
@ -4,11 +4,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,5 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"KitchenDal": {
|
|
||||||
"BaseUrl": "http://kitchen-dal:8080"
|
|
||||||
},
|
|
||||||
"OperationsDal": {
|
|
||||||
"BaseUrl": "http://operations-dal:8080"
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,7 @@ namespace Kitchen.Service.Application.UnitTests;
|
|||||||
|
|
||||||
public class KitchenWorkflowUseCasesTests
|
public class KitchenWorkflowUseCasesTests
|
||||||
{
|
{
|
||||||
private readonly InMemoryKitchenWorkItemStorePort workItemStore = new();
|
private readonly DefaultKitchenWorkflowPort workflowPort = 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()
|
||||||
@ -25,37 +18,6 @@ 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]
|
||||||
@ -67,11 +29,8 @@ 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]
|
||||||
@ -83,48 +42,19 @@ 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_UpdatesKitchenAndRestaurantFlow()
|
public async Task TransitionKitchenOrderStateUseCase_AllowsConfiguredStates()
|
||||||
{
|
{
|
||||||
var useCase = new TransitionKitchenOrderStateUseCase(workflowPort);
|
var useCase = new TransitionKitchenOrderStateUseCase();
|
||||||
|
|
||||||
var response = await useCase.HandleAsync(
|
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);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user