feat(kitchen-service): sync kitchen tickets with restaurant lifecycle
This commit is contained in:
parent
119f23ca66
commit
9022fe6658
@ -1,27 +1,32 @@
|
|||||||
# Internal Kitchen Workflow Contracts
|
# Internal Kitchen Workflows
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`kitchen-service` now exposes a board-oriented internal workflow surface that the kitchen-ops BFF can consume directly.
|
`kitchen-service` exposes workflow-shaped internal endpoints for the kitchen BFF while keeping ticket execution aligned with the shared restaurant lifecycle.
|
||||||
|
|
||||||
## Endpoint Surface
|
## Endpoint Surface
|
||||||
|
|
||||||
- `GET /internal/kitchen/queue?queueName=<name>&limit=<n>`
|
- `GET /internal/kitchen/queue?queueName=<contextId>&limit=<n>`
|
||||||
- `POST /internal/kitchen/orders/transition`
|
|
||||||
- `GET /internal/kitchen/board?contextId=<id>`
|
- `GET /internal/kitchen/board?contextId=<id>`
|
||||||
|
- `POST /internal/kitchen/orders/transition`
|
||||||
- `POST /internal/kitchen/work-items/claim`
|
- `POST /internal/kitchen/work-items/claim`
|
||||||
- `POST /internal/kitchen/work-items/priority`
|
- `POST /internal/kitchen/work-items/priority`
|
||||||
|
|
||||||
## Contract Depth Added In Stage 41
|
## Stage 46 Runtime Shape
|
||||||
|
|
||||||
The new kitchen workflow contracts add enough shape for downstream BFF and SPA work:
|
This repo now orchestrates kitchen progression over persisted kitchen tickets from `kitchen-dal` and syncs restaurant order state back through `operations-dal`.
|
||||||
|
|
||||||
- board lanes with per-item station, claim, ETA, and priority details
|
That means:
|
||||||
- explicit claim/release-ready work-item ownership responses
|
- board and queue reads come from persisted kitchen tickets
|
||||||
- dedicated priority update responses separate from generic state transitions
|
- claim and priority changes update persisted kitchen ticket state
|
||||||
- existing transition contract kept in place for order-state changes
|
- ticket transitions update both kitchen tickets and the linked restaurant order lifecycle
|
||||||
|
|
||||||
## Current Runtime Shape
|
## Handoff Mapping
|
||||||
|
|
||||||
- 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.
|
||||||
|
|||||||
@ -9,10 +9,10 @@ kitchen-service
|
|||||||
- Epic 3: Improve observability and operational readiness for demo compose environments.
|
- Epic 3: Improve observability and operational readiness for demo compose environments.
|
||||||
|
|
||||||
## Domain-Specific Candidate Features
|
## Domain-Specific Candidate Features
|
||||||
- Order lifecycle consistency and state transitions.
|
- Kitchen ticket orchestration over persisted linked tickets.
|
||||||
- Kitchen queue and dispatch optimization hooks.
|
- Board and queue projections backed by shared restaurant identities.
|
||||||
- Operations control-plane policies (flags, service windows, overrides).
|
- Claim ownership, priority updates, and kitchen transition history.
|
||||||
- POS closeout and settlement summary alignment.
|
- Kitchen-to-restaurant lifecycle handoff for ready and served states.
|
||||||
- Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows.
|
- Kitchen board lanes, claim ownership, and priority updates aligned to operator workflows.
|
||||||
|
|
||||||
## Documentation Contract
|
## Documentation Contract
|
||||||
|
|||||||
@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
|
|||||||
## Local Run
|
## Local Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:dev
|
docker run --rm -p 8080:8080 -e KitchenDal__BaseUrl=http://kitchen-dal:8080 -e OperationsDal__BaseUrl=http://operations-dal:8080 --name kitchen-service agilewebs/kitchen-service:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health Probe
|
## Health Probe
|
||||||
@ -22,8 +22,9 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de
|
|||||||
|
|
||||||
## Runtime Notes
|
## Runtime Notes
|
||||||
|
|
||||||
- Exposes internal queue and order state transition endpoints.
|
- Exposes persisted queue and board endpoints for kitchen operators.
|
||||||
- Also exposes board, work-item claim, and priority update endpoints for the kitchen-ops BFF flow.
|
- Exposes claim, priority, and transition endpoints over linked kitchen tickets.
|
||||||
|
- Requires access to both the kitchen DAL host and the operations DAL host.
|
||||||
|
|
||||||
## Health Endpoint Consistency
|
## Health Endpoint Consistency
|
||||||
|
|
||||||
@ -35,8 +36,8 @@ docker run --rm -p 8080:8080 --name kitchen-service agilewebs/kitchen-service:de
|
|||||||
|
|
||||||
- Participates in: **restaurant** demo compose stack.
|
- Participates in: **restaurant** demo compose stack.
|
||||||
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
|
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior.
|
- Payment opening remains owned by `operations-service`.
|
||||||
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.
|
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.
|
||||||
- Stage 41 adds kitchen workflow contract depth first; downstream BFFs still need to adopt these endpoints before richer board behavior reaches the web app.
|
|
||||||
|
|||||||
@ -5,14 +5,27 @@ namespace Kitchen.Service.Application.Ports;
|
|||||||
|
|
||||||
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
|
public sealed class DefaultKitchenQueueReadPort : IKitchenQueueReadPort
|
||||||
{
|
{
|
||||||
public Task<GetKitchenQueueResponse> ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken)
|
private readonly IKitchenWorkItemStorePort workItemStore;
|
||||||
{
|
|
||||||
var items = new[]
|
|
||||||
{
|
|
||||||
new KitchenQueueItemContract("WK-1001", "PrepareOrder", 3, "Queued"),
|
|
||||||
new KitchenQueueItemContract("WK-1002", "AssembleTray", 2, "Queued")
|
|
||||||
}.Take(Math.Max(limit, 0)).ToArray();
|
|
||||||
|
|
||||||
return Task.FromResult(new GetKitchenQueueResponse(items));
|
public DefaultKitchenQueueReadPort()
|
||||||
|
: this(new InMemoryKitchenWorkItemStorePort())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DefaultKitchenQueueReadPort(IKitchenWorkItemStorePort workItemStore)
|
||||||
|
{
|
||||||
|
this.workItemStore = workItemStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetKitchenQueueResponse> ReadQueueAsync(string queueName, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contextId = string.IsNullOrWhiteSpace(queueName) ? "demo-context" : queueName;
|
||||||
|
var items = await workItemStore.ListQueuedAsync(contextId, cancellationToken);
|
||||||
|
var contracts = items
|
||||||
|
.Take(Math.Max(limit, 0))
|
||||||
|
.Select(item => new KitchenQueueItemContract(item.WorkItemId, item.WorkType, item.Priority, item.State))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new GetKitchenQueueResponse(contracts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Kitchen.Service.Application.State;
|
||||||
using Kitchen.Service.Contracts.Contracts;
|
using Kitchen.Service.Contracts.Contracts;
|
||||||
using Kitchen.Service.Contracts.Requests;
|
using Kitchen.Service.Contracts.Requests;
|
||||||
using Kitchen.Service.Contracts.Responses;
|
using Kitchen.Service.Contracts.Responses;
|
||||||
@ -6,83 +7,254 @@ namespace Kitchen.Service.Application.Ports;
|
|||||||
|
|
||||||
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
public sealed class DefaultKitchenWorkflowPort : IKitchenWorkflowPort
|
||||||
{
|
{
|
||||||
public Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
|
private readonly IKitchenWorkItemStorePort workItemStore;
|
||||||
{
|
private readonly IRestaurantLifecycleSyncPort restaurantLifecycle;
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var lanes = new[]
|
public DefaultKitchenWorkflowPort()
|
||||||
|
: this(new InMemoryKitchenWorkItemStorePort(), new InMemoryRestaurantLifecycleSyncPort())
|
||||||
{
|
{
|
||||||
new KitchenBoardLaneContract(
|
|
||||||
"queued",
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
new KitchenBoardItemContract("WK-1001", "CO-1001", "KT-1001", "T-08", "hot-line", "Queued", 3, null, 12),
|
|
||||||
new KitchenBoardItemContract("WK-1002", "CO-1003", "KT-1002", "T-12", "expedite", "Queued", 2, null, 8)
|
|
||||||
}),
|
|
||||||
new KitchenBoardLaneContract(
|
|
||||||
"preparing",
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
new KitchenBoardItemContract("WK-1003", "CO-1002", "KT-1003", "T-15", "grill", "Preparing", 4, "chef-maya", 5)
|
|
||||||
}),
|
|
||||||
new KitchenBoardLaneContract(
|
|
||||||
"ready",
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
new KitchenBoardItemContract("WK-1004", "CO-0999", "KT-0999", "T-21", "pickup", "ReadyForPickup", 1, "expo-noah", 0)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
return Task.FromResult(new GetKitchenBoardResponse(
|
|
||||||
request.ContextId,
|
|
||||||
"Kitchen board shows queued, preparing, and ready lanes for the current service context.",
|
|
||||||
lanes,
|
|
||||||
new[] { "hot-line", "grill", "salad", "pickup", "expedite" }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ClaimKitchenWorkItemResponse> ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
public DefaultKitchenWorkflowPort(IKitchenWorkItemStorePort workItemStore, IRestaurantLifecycleSyncPort restaurantLifecycle)
|
||||||
|
{
|
||||||
|
this.workItemStore = workItemStore;
|
||||||
|
this.restaurantLifecycle = restaurantLifecycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetKitchenBoardResponse> GetKitchenBoardAsync(GetKitchenBoardRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var claimed = !string.IsNullOrWhiteSpace(request.ContextId) &&
|
var workItems = await LoadAllWorkItemsAsync(request.ContextId, cancellationToken);
|
||||||
!string.IsNullOrWhiteSpace(request.WorkItemId) &&
|
var lanes = workItems
|
||||||
!string.IsNullOrWhiteSpace(request.ClaimedBy);
|
.GroupBy(item => MapLane(item.State), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => new KitchenBoardLaneContract(
|
||||||
|
group.Key,
|
||||||
|
group.OrderByDescending(item => item.Priority)
|
||||||
|
.ThenBy(item => item.RequestedAtUtc)
|
||||||
|
.Select(ToBoardItem)
|
||||||
|
.ToArray()))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return Task.FromResult(new ClaimKitchenWorkItemResponse(
|
return new GetKitchenBoardResponse(
|
||||||
|
request.ContextId,
|
||||||
|
"Kitchen board now reflects persisted tickets linked to shared restaurant orders.",
|
||||||
|
lanes,
|
||||||
|
new[] { "hot-line", "grill", "salad", "pickup", "expedite" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClaimKitchenWorkItemResponse> ClaimKitchenWorkItemAsync(ClaimKitchenWorkItemRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken);
|
||||||
|
var claimed = workItem is not null && !string.IsNullOrWhiteSpace(request.ClaimedBy);
|
||||||
|
|
||||||
|
if (!claimed)
|
||||||
|
{
|
||||||
|
return new ClaimKitchenWorkItemResponse(
|
||||||
request.ContextId,
|
request.ContextId,
|
||||||
request.WorkItemId,
|
request.WorkItemId,
|
||||||
claimed,
|
false,
|
||||||
request.ClaimedBy,
|
request.ClaimedBy,
|
||||||
claimed
|
"Kitchen work-item claim is incomplete.");
|
||||||
? $"Work item {request.WorkItemId} claimed by {request.ClaimedBy}."
|
|
||||||
: "Kitchen work-item claim is incomplete."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
|
var updated = workItem! with { ClaimedBy = request.ClaimedBy };
|
||||||
|
await workItemStore.UpsertAsync(updated, cancellationToken);
|
||||||
|
await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent(
|
||||||
|
request.ContextId,
|
||||||
|
request.WorkItemId,
|
||||||
|
$"EVT-{Guid.NewGuid():N}",
|
||||||
|
"Claimed",
|
||||||
|
$"Work item {request.WorkItemId} claimed by {request.ClaimedBy}.",
|
||||||
|
DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return new ClaimKitchenWorkItemResponse(
|
||||||
|
request.ContextId,
|
||||||
|
request.WorkItemId,
|
||||||
|
true,
|
||||||
|
request.ClaimedBy,
|
||||||
|
$"Work item {request.WorkItemId} claimed by {request.ClaimedBy}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateKitchenPriorityResponse> UpdateKitchenPriorityAsync(UpdateKitchenPriorityRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var workItem = await workItemStore.GetAsync(request.ContextId, request.WorkItemId, cancellationToken);
|
||||||
var priority = Math.Max(1, request.Priority);
|
var priority = Math.Max(1, request.Priority);
|
||||||
return Task.FromResult(new UpdateKitchenPriorityResponse(
|
if (workItem is null)
|
||||||
|
{
|
||||||
|
return new UpdateKitchenPriorityResponse(
|
||||||
request.ContextId,
|
request.ContextId,
|
||||||
request.WorkItemId,
|
request.WorkItemId,
|
||||||
priority,
|
priority,
|
||||||
$"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}."));
|
$"Work item {request.WorkItemId} could not be found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken)
|
var updated = workItem with { Priority = priority };
|
||||||
|
await workItemStore.UpsertAsync(updated, cancellationToken);
|
||||||
|
await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent(
|
||||||
|
request.ContextId,
|
||||||
|
request.WorkItemId,
|
||||||
|
$"EVT-{Guid.NewGuid():N}",
|
||||||
|
"PriorityUpdated",
|
||||||
|
$"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}.",
|
||||||
|
DateTime.UtcNow), cancellationToken);
|
||||||
|
|
||||||
|
return new UpdateKitchenPriorityResponse(
|
||||||
|
request.ContextId,
|
||||||
|
request.WorkItemId,
|
||||||
|
priority,
|
||||||
|
$"Priority for {request.WorkItemId} updated to {priority} by {request.RequestedBy}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransitionKitchenOrderStateResponse> TransitionKitchenOrderStateAsync(TransitionKitchenOrderStateRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var previous = "Queued";
|
var workItem = await ResolveWorkItemAsync(request.ContextId ?? "demo-context", request.OrderId, request.TicketId, cancellationToken);
|
||||||
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled" or "Plated";
|
if (workItem is null)
|
||||||
|
{
|
||||||
return Task.FromResult(new TransitionKitchenOrderStateResponse(
|
return new TransitionKitchenOrderStateResponse(
|
||||||
request.OrderId,
|
request.OrderId,
|
||||||
request.TicketId,
|
request.TicketId,
|
||||||
previous,
|
"Unknown",
|
||||||
allowed ? request.TargetState : previous,
|
"Unknown",
|
||||||
allowed,
|
false,
|
||||||
allowed ? null : "Target state is not allowed by kitchen-service policy."));
|
"Ticket could not be found for the provided order.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var previousState = workItem.State;
|
||||||
|
if (!CanTransition(previousState, request.TargetState))
|
||||||
|
{
|
||||||
|
return new TransitionKitchenOrderStateResponse(
|
||||||
|
request.OrderId,
|
||||||
|
request.TicketId,
|
||||||
|
previousState,
|
||||||
|
previousState,
|
||||||
|
false,
|
||||||
|
"Target state is not allowed by kitchen-service policy.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var updatedWorkItem = workItem with { State = request.TargetState, ClaimedBy = request.RequestedBy };
|
||||||
|
await workItemStore.UpsertAsync(updatedWorkItem, cancellationToken);
|
||||||
|
await workItemStore.AppendEventAsync(new PersistedKitchenWorkItemEvent(
|
||||||
|
request.ContextId ?? "demo-context",
|
||||||
|
updatedWorkItem.WorkItemId,
|
||||||
|
$"EVT-{Guid.NewGuid():N}",
|
||||||
|
request.TargetState,
|
||||||
|
$"Ticket {request.TicketId} moved from {previousState} to {request.TargetState} by {request.RequestedBy}.",
|
||||||
|
nowUtc), cancellationToken);
|
||||||
|
|
||||||
|
var order = await restaurantLifecycle.GetOrderAsync(request.ContextId ?? "demo-context", request.OrderId, cancellationToken);
|
||||||
|
if (order is not null)
|
||||||
|
{
|
||||||
|
var updatedOrder = order with
|
||||||
|
{
|
||||||
|
OrderState = MapRestaurantOrderState(request.TargetState),
|
||||||
|
HasKitchenTicket = true,
|
||||||
|
UpdatedAtUtc = nowUtc
|
||||||
|
};
|
||||||
|
|
||||||
|
await restaurantLifecycle.UpsertOrderAsync(updatedOrder, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TransitionKitchenOrderStateResponse(
|
||||||
|
request.OrderId,
|
||||||
|
request.TicketId,
|
||||||
|
previousState,
|
||||||
|
request.TargetState,
|
||||||
|
true,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<PersistedKitchenWorkItemRecord>> LoadAllWorkItemsAsync(string contextId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var queued = await workItemStore.ListQueuedAsync(contextId, cancellationToken);
|
||||||
|
var knownOrders = new[] { "ORD-1001", "ORD-1002", "ORD-1003", "ORD-1004" };
|
||||||
|
var all = new Dictionary<string, PersistedKitchenWorkItemRecord>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var item in queued)
|
||||||
|
{
|
||||||
|
all[item.WorkItemId] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var orderId in knownOrders)
|
||||||
|
{
|
||||||
|
var items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
all[item.WorkItemId] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 items = await workItemStore.ListByOrderAsync(contextId, orderId, cancellationToken);
|
||||||
|
return items.FirstOrDefault(item => string.Equals(ToTicketId(item.WorkItemId), ticketId, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,9 @@
|
|||||||
|
using Kitchen.Service.Application.State;
|
||||||
|
|
||||||
|
namespace Kitchen.Service.Application.Ports;
|
||||||
|
|
||||||
|
public interface IRestaurantLifecycleSyncPort
|
||||||
|
{
|
||||||
|
Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, 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,41 @@
|
|||||||
|
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 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.Requests;
|
||||||
using Kitchen.Service.Contracts.Responses;
|
using Kitchen.Service.Contracts.Responses;
|
||||||
|
|
||||||
namespace Kitchen.Service.Application.UseCases;
|
namespace Kitchen.Service.Application.UseCases;
|
||||||
|
|
||||||
public sealed class TransitionKitchenOrderStateUseCase : ITransitionKitchenOrderStateUseCase
|
public sealed class TransitionKitchenOrderStateUseCase(IKitchenWorkflowPort workflowPort) : ITransitionKitchenOrderStateUseCase
|
||||||
{
|
{
|
||||||
public Task<TransitionKitchenOrderStateResponse> HandleAsync(
|
public Task<TransitionKitchenOrderStateResponse> HandleAsync(
|
||||||
TransitionKitchenOrderStateRequest request,
|
TransitionKitchenOrderStateRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var previous = "Queued";
|
return workflowPort.TransitionKitchenOrderStateAsync(request, cancellationToken);
|
||||||
var allowed = request.TargetState is "Preparing" or "ReadyForPickup" or "Canceled";
|
|
||||||
|
|
||||||
return Task.FromResult(new TransitionKitchenOrderStateResponse(
|
|
||||||
request.OrderId,
|
|
||||||
request.TicketId,
|
|
||||||
previous,
|
|
||||||
allowed ? request.TargetState : previous,
|
|
||||||
allowed,
|
|
||||||
allowed ? null : "Target state is not allowed by kitchen-service policy."));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,4 +4,5 @@ public sealed record TransitionKitchenOrderStateRequest(
|
|||||||
string OrderId,
|
string OrderId,
|
||||||
string TicketId,
|
string TicketId,
|
||||||
string TargetState,
|
string TargetState,
|
||||||
string RequestedBy);
|
string RequestedBy,
|
||||||
|
string? ContextId = null);
|
||||||
|
|||||||
@ -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,26 @@
|
|||||||
|
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 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.Ports;
|
||||||
using Kitchen.Service.Application.UseCases;
|
using Kitchen.Service.Application.UseCases;
|
||||||
using Kitchen.Service.Contracts.Requests;
|
using Kitchen.Service.Contracts.Requests;
|
||||||
|
using Kitchen.Service.Grpc.Adapters;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddSingleton<IKitchenQueueReadPort, DefaultKitchenQueueReadPort>();
|
builder.Services.AddHttpClient<IKitchenWorkItemStorePort, KitchenDalWorkItemStoreClient>(client =>
|
||||||
builder.Services.AddSingleton<IKitchenWorkflowPort, DefaultKitchenWorkflowPort>();
|
{
|
||||||
|
var baseUrl = builder.Configuration["KitchenDal:BaseUrl"] ?? "http://kitchen-dal:8080";
|
||||||
|
client.BaseAddress = new Uri(baseUrl);
|
||||||
|
});
|
||||||
|
builder.Services.AddHttpClient<IRestaurantLifecycleSyncPort, OperationsDalRestaurantLifecycleSyncClient>(client =>
|
||||||
|
{
|
||||||
|
var baseUrl = builder.Configuration["OperationsDal:BaseUrl"] ?? "http://operations-dal:8080";
|
||||||
|
client.BaseAddress = new Uri(baseUrl);
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<IKitchenQueueReadPort>(sp =>
|
||||||
|
new DefaultKitchenQueueReadPort(sp.GetRequiredService<IKitchenWorkItemStorePort>()));
|
||||||
|
builder.Services.AddSingleton<IKitchenWorkflowPort>(sp =>
|
||||||
|
new DefaultKitchenWorkflowPort(
|
||||||
|
sp.GetRequiredService<IKitchenWorkItemStorePort>(),
|
||||||
|
sp.GetRequiredService<IRestaurantLifecycleSyncPort>()));
|
||||||
builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
|
builder.Services.AddSingleton<IGetKitchenQueueUseCase, GetKitchenQueueUseCase>();
|
||||||
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
|
builder.Services.AddSingleton<ITransitionKitchenOrderStateUseCase, TransitionKitchenOrderStateUseCase>();
|
||||||
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();
|
builder.Services.AddSingleton<IGetKitchenBoardUseCase, GetKitchenBoardUseCase>();
|
||||||
|
|||||||
@ -4,5 +4,11 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"KitchenDal": {
|
||||||
|
"BaseUrl": "http://127.0.0.1:21080"
|
||||||
|
},
|
||||||
|
"OperationsDal": {
|
||||||
|
"BaseUrl": "http://127.0.0.1:21081"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,5 +5,11 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"KitchenDal": {
|
||||||
|
"BaseUrl": "http://kitchen-dal:8080"
|
||||||
|
},
|
||||||
|
"OperationsDal": {
|
||||||
|
"BaseUrl": "http://operations-dal:8080"
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,14 @@ namespace Kitchen.Service.Application.UnitTests;
|
|||||||
|
|
||||||
public class KitchenWorkflowUseCasesTests
|
public class KitchenWorkflowUseCasesTests
|
||||||
{
|
{
|
||||||
private readonly DefaultKitchenWorkflowPort workflowPort = new();
|
private readonly InMemoryKitchenWorkItemStorePort workItemStore = new();
|
||||||
|
private readonly InMemoryRestaurantLifecycleSyncPort restaurantLifecycle = new();
|
||||||
|
private readonly DefaultKitchenWorkflowPort workflowPort;
|
||||||
|
|
||||||
|
public KitchenWorkflowUseCasesTests()
|
||||||
|
{
|
||||||
|
workflowPort = new DefaultKitchenWorkflowPort(workItemStore, restaurantLifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations()
|
public async Task GetKitchenBoardUseCase_ReturnsLanesAndStations()
|
||||||
@ -18,6 +25,7 @@ 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]
|
[Fact]
|
||||||
@ -29,8 +37,11 @@ public class KitchenWorkflowUseCasesTests
|
|||||||
new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
|
new ClaimKitchenWorkItemRequest("demo-context", "WK-1001", "chef-maya"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(response.Claimed);
|
Assert.True(response.Claimed);
|
||||||
Assert.Equal("chef-maya", response.ClaimedBy);
|
Assert.Equal("chef-maya", response.ClaimedBy);
|
||||||
|
Assert.Equal("chef-maya", updated!.ClaimedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -42,19 +53,48 @@ public class KitchenWorkflowUseCasesTests
|
|||||||
new UpdateKitchenPriorityRequest("demo-context", "WK-1001", 0, "expo-noah"),
|
new UpdateKitchenPriorityRequest("demo-context", "WK-1001", 0, "expo-noah"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(1, response.Priority);
|
Assert.Equal(1, response.Priority);
|
||||||
|
Assert.Equal(1, updated!.Priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TransitionKitchenOrderStateUseCase_AllowsConfiguredStates()
|
public async Task TransitionKitchenOrderStateUseCase_UpdatesKitchenAndRestaurantFlow()
|
||||||
{
|
{
|
||||||
var useCase = new TransitionKitchenOrderStateUseCase();
|
var useCase = new TransitionKitchenOrderStateUseCase(workflowPort);
|
||||||
|
|
||||||
var response = await useCase.HandleAsync(
|
var response = await useCase.HandleAsync(
|
||||||
new TransitionKitchenOrderStateRequest("CO-1001", "KT-1001", "Preparing", "chef-maya"),
|
new TransitionKitchenOrderStateRequest("ORD-1001", "KT-1001", "Preparing", "chef-maya"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var updatedWorkItem = await workItemStore.GetAsync("demo-context", "WK-1001", CancellationToken.None);
|
||||||
|
var updatedOrder = await restaurantLifecycle.GetOrderAsync("demo-context", "ORD-1001", CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(response.Applied);
|
Assert.True(response.Applied);
|
||||||
|
Assert.Equal("Queued", response.PreviousState);
|
||||||
Assert.Equal("Preparing", response.CurrentState);
|
Assert.Equal("Preparing", response.CurrentState);
|
||||||
|
Assert.Equal("Preparing", updatedWorkItem!.State);
|
||||||
|
Assert.Equal("Preparing", updatedOrder!.OrderState);
|
||||||
|
Assert.True(updatedOrder.HasKitchenTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionKitchenOrderStateUseCase_MarksOrderServedWhenKitchenDelivers()
|
||||||
|
{
|
||||||
|
var useCase = new TransitionKitchenOrderStateUseCase(workflowPort);
|
||||||
|
|
||||||
|
var response = await useCase.HandleAsync(
|
||||||
|
new TransitionKitchenOrderStateRequest("ORD-1003", "KT-1003", "Delivered", "expo-noah"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var updatedWorkItem = await workItemStore.GetAsync("demo-context", "WK-1003", CancellationToken.None);
|
||||||
|
var updatedOrder = await restaurantLifecycle.GetOrderAsync("demo-context", "ORD-1003", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(response.Applied);
|
||||||
|
Assert.Equal("ReadyForPickup", response.PreviousState);
|
||||||
|
Assert.Equal("Delivered", response.CurrentState);
|
||||||
|
Assert.Equal("Delivered", updatedWorkItem!.State);
|
||||||
|
Assert.Equal("Served", updatedOrder!.OrderState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user