feat(operations-service): orchestrate shared restaurant lifecycle

This commit is contained in:
José René White Enciso 2026-03-31 18:38:30 -06:00
parent a24ccc12d9
commit d19167e3ea
13 changed files with 555 additions and 95 deletions

View File

@ -2,7 +2,7 @@
## Purpose ## Purpose
`operations-service` now exposes workflow-shaped internal endpoints that the restaurant BFFs can consume without inventing their own orchestration payloads. `operations-service` exposes workflow-shaped internal endpoints that restaurant BFFs can consume without inventing their own orchestration payloads.
## Endpoint Surface ## Endpoint Surface
@ -16,18 +16,23 @@
- `GET /internal/operations/admin/config?contextId=<id>` - `GET /internal/operations/admin/config?contextId=<id>`
- `POST /internal/operations/admin/service-window` - `POST /internal/operations/admin/service-window`
## Contract Depth Added In Stage 41 ## Stage 46 Runtime Shape
The new workflow contracts add enough shape for the next BFF layer tasks to expose richer responses: This repo now orchestrates restaurant workflow over the shared lifecycle store exposed by `operations-dal`.
- waiter assignments plus recent activity That means:
- customer order status plus recent status events - submitted orders are persisted as shared order/check records
- POS summary plus recent payment activity - customer status reads come from persisted restaurant state rather than static arrays
- restaurant admin snapshot plus service windows and recent config changes - POS summary reads only served checks that remain payable
- workflow write responses that include status/message detail instead of only a boolean summary - payment capture updates persisted check state and appends lifecycle events
## Current Runtime Shape ## Contract Intent
- The default implementation is still in-memory and deterministic. - waiter assignments surface floor-facing table attention derived from shared order/check state
- This repo remains orchestration-only; no DAL redesign is introduced by this task. - customer order status reflects the same lifecycle that waiter and POS flows observe
- Demo realism can deepen later without forcing BFF or SPA contract churn. - POS payment only opens for served orders with outstanding balance
- restaurant-admin configuration remains control-plane oriented and intentionally separate from order persistence
## Remaining Limitation
- Kitchen ticket creation and kitchen state feedback are not owned by this repo; they will be linked in the Stage 46 kitchen-service task.

View File

@ -9,10 +9,10 @@ operations-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. - Shared restaurant order/check orchestration over persisted lifecycle state.
- Kitchen queue and dispatch optimization hooks. - Kitchen dispatch readiness and order/check progression handoff.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment. - POS payment eligibility and capture over shared restaurant checks.
- Waiter/customer/POS/admin workflow contracts that can be reused by multiple restaurant BFFs. - Waiter/customer/POS/admin workflow contracts that can be reused by multiple restaurant BFFs.
## Documentation Contract ## Documentation Contract

View File

@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
## Local Run ## Local Run
```bash ```bash
docker run --rm -p 8080:8080 --name operations-service agilewebs/operations-service:dev docker run --rm -p 8080:8080 -e OperationsDal__BaseUrl=http://operations-dal:8080 --name operations-service agilewebs/operations-service:dev
``` ```
## Health Probe ## Health Probe
@ -23,7 +23,8 @@ docker run --rm -p 8080:8080 --name operations-service agilewebs/operations-serv
## Runtime Notes ## Runtime Notes
- Exposes internal control-plane evaluation/config endpoints. - Exposes internal control-plane evaluation/config endpoints.
- Also exposes workflow-shaped internal endpoints for waiter assignments, customer order status, POS summaries/payment capture, and restaurant-admin service-window updates. - Exposes shared lifecycle-backed endpoints for waiter assignments, customer order status, POS summaries/payment capture, and restaurant-admin service-window updates.
- Requires access to the operations DAL host through `OperationsDal__BaseUrl`.
## Health Endpoint Consistency ## Health Endpoint Consistency
@ -35,8 +36,8 @@ docker run --rm -p 8080:8080 --name operations-service agilewebs/operations-serv
- 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. - Kitchen ticket creation and state feedback are completed in the separate `kitchen-service` orchestration task.
- 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 contract depth first; downstream BFFs still need to adopt the new internal endpoints before the richer workflow data reaches the web apps.

View File

@ -1,3 +1,4 @@
using Operations.Service.Application.State;
using Operations.Service.Contracts.Contracts; using Operations.Service.Contracts.Contracts;
using Operations.Service.Contracts.Requests; using Operations.Service.Contracts.Requests;
using Operations.Service.Contracts.Responses; using Operations.Service.Contracts.Responses;
@ -6,116 +7,247 @@ namespace Operations.Service.Application.Ports;
public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort
{ {
public Task<GetWaiterAssignmentsResponse> GetWaiterAssignmentsAsync( private const decimal DemoItemPrice = 12.50m;
private readonly IRestaurantLifecycleStorePort lifecycleStore;
public DefaultOperationsWorkflowPort()
: this(new InMemoryRestaurantLifecycleStorePort())
{
}
public DefaultOperationsWorkflowPort(IRestaurantLifecycleStorePort lifecycleStore)
{
this.lifecycleStore = lifecycleStore;
}
public async Task<GetWaiterAssignmentsResponse> GetWaiterAssignmentsAsync(
GetWaiterAssignmentsRequest request, GetWaiterAssignmentsRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var assignments = new[] var orders = await lifecycleStore.ListOrdersAsync(request.ContextId, cancellationToken);
{ var activeOrders = orders
new WaiterAssignmentContract("waiter-01", "T-12", "serving", 2), .Where(order => !string.Equals(order.CheckState, "Paid", StringComparison.Ordinal))
new WaiterAssignmentContract("waiter-07", "T-08", "ready-for-check", 1) .Where(order => !string.Equals(order.OrderState, "Canceled", StringComparison.Ordinal))
}; .Where(order => !string.Equals(order.OrderState, "Rejected", StringComparison.Ordinal))
var activity = new[] .ToArray();
{
$"{request.ContextId}: table T-12 requested dessert menus",
$"{request.ContextId}: table T-08 is waiting for payment capture"
};
return Task.FromResult(new GetWaiterAssignmentsResponse( var assignments = activeOrders
.GroupBy(order => order.TableId, StringComparer.Ordinal)
.Select(group => new WaiterAssignmentContract(
"service-pool",
group.Key,
MapWaiterStatus(group.OrderByDescending(order => order.UpdatedAtUtc).First()),
group.Count()))
.OrderBy(assignment => assignment.TableId, StringComparer.Ordinal)
.ToArray();
var recentActivity = await BuildRecentActivityAsync(activeOrders, cancellationToken);
return new GetWaiterAssignmentsResponse(
request.ContextId, request.ContextId,
"restaurant-demo", "restaurant-demo",
$"{assignments.Length} active waiter assignments are currently visible.", $"{assignments.Length} tables currently require floor attention.",
assignments, assignments,
activity)); recentActivity);
} }
public Task<SubmitRestaurantOrderResponse> SubmitRestaurantOrderAsync( public async Task<SubmitRestaurantOrderResponse> SubmitRestaurantOrderAsync(
SubmitRestaurantOrderRequest request, SubmitRestaurantOrderRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var accepted = request.ItemCount > 0 && var accepted = request.ItemCount > 0
!string.IsNullOrWhiteSpace(request.ContextId) && && !string.IsNullOrWhiteSpace(request.ContextId)
!string.IsNullOrWhiteSpace(request.OrderId) && && !string.IsNullOrWhiteSpace(request.OrderId)
!string.IsNullOrWhiteSpace(request.TableId); && !string.IsNullOrWhiteSpace(request.TableId);
return Task.FromResult(new SubmitRestaurantOrderResponse( if (!accepted)
{
return new SubmitRestaurantOrderResponse(
request.ContextId,
request.OrderId,
false,
"Order payload is incomplete.",
"rejected",
DateTime.UtcNow);
}
var existing = await lifecycleStore.GetOrderAsync(request.ContextId, request.OrderId, cancellationToken);
var nowUtc = DateTime.UtcNow;
var outstandingBalance = existing?.OutstandingBalance ?? decimal.Round(request.ItemCount * DemoItemPrice, 2, MidpointRounding.AwayFromZero);
var itemIds = existing?.ItemIds ?? Enumerable.Range(1, request.ItemCount).Select(index => $"ITEM-{index:000}").ToArray();
var checkId = existing?.CheckId ?? $"CHK-{request.OrderId}";
var record = new PersistedRestaurantLifecycleRecord(
request.ContextId, request.ContextId,
request.OrderId, request.OrderId,
accepted, checkId,
accepted request.TableId,
? $"Order {request.OrderId} for table {request.TableId} was accepted with {request.ItemCount} items." "Accepted",
: "Order payload is incomplete.", existing?.CheckState ?? "Open",
accepted ? "queued" : "rejected", Math.Max(existing?.GuestCount ?? 0, request.ItemCount),
DateTime.UtcNow)); existing?.HasKitchenTicket ?? false,
outstandingBalance,
existing?.Currency ?? "USD",
existing?.Source ?? "shared-entry",
itemIds,
nowUtc);
await lifecycleStore.UpsertOrderAsync(record, cancellationToken);
await lifecycleStore.AppendEventAsync(new PersistedRestaurantLifecycleEvent(
request.ContextId,
request.OrderId,
$"EVT-{Guid.NewGuid():N}",
"Submitted",
$"Order {request.OrderId} was submitted for table {request.TableId} with {request.ItemCount} items.",
nowUtc), cancellationToken);
await lifecycleStore.AppendEventAsync(new PersistedRestaurantLifecycleEvent(
request.ContextId,
request.OrderId,
$"EVT-{Guid.NewGuid():N}",
"Accepted",
$"Order {request.OrderId} was accepted into the shared restaurant lifecycle.",
nowUtc.AddMilliseconds(1)), cancellationToken);
return new SubmitRestaurantOrderResponse(
request.ContextId,
request.OrderId,
true,
$"Order {request.OrderId} was accepted and is ready for kitchen dispatch.",
"accepted",
nowUtc);
} }
public Task<GetCustomerOrderStatusResponse> GetCustomerOrderStatusAsync( public async Task<GetCustomerOrderStatusResponse> GetCustomerOrderStatusAsync(
GetCustomerOrderStatusRequest request, GetCustomerOrderStatusRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var orders = new[] var orders = await lifecycleStore.ListOrdersAsync(request.ContextId, cancellationToken);
{ var contracts = orders
new CustomerOrderStatusContract("CO-1001", "T-08", "preparing", 2, new[] { "ITEM-101", "ITEM-202" }), .Select(order => new CustomerOrderStatusContract(
new CustomerOrderStatusContract("CO-1002", "T-15", "ready", 4, new[] { "ITEM-301", "ITEM-404", "ITEM-405" }) order.OrderId,
}; order.TableId,
var events = new[] MapCustomerStatus(order.OrderState),
{ order.GuestCount,
"CO-1001 moved to preparing at kitchen hot-line station.", order.ItemIds))
"CO-1002 is ready for table pickup." .ToArray();
};
return Task.FromResult(new GetCustomerOrderStatusResponse( var events = await BuildRecentActivityAsync(orders, cancellationToken);
return new GetCustomerOrderStatusResponse(
request.ContextId, request.ContextId,
$"{orders.Length} recent customer orders are visible for the active context.", contracts.Length == 0
orders, ? "No customer orders are currently visible for the active context."
events)); : $"{contracts.Length} customer orders are currently tracked in the shared restaurant lifecycle.",
contracts,
events);
} }
public Task<GetPosTransactionSummaryResponse> GetPosTransactionSummaryAsync( public async Task<GetPosTransactionSummaryResponse> GetPosTransactionSummaryAsync(
GetPosTransactionSummaryRequest request, GetPosTransactionSummaryRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var payments = new[] var payableOrders = await lifecycleStore.ListPayableOrdersAsync(request.ContextId, cancellationToken);
{ var openBalance = payableOrders.Sum(order => order.OutstandingBalance);
new PosPaymentActivityContract("POS-9001", "card", 25.50m, "USD", "captured", DateTime.UtcNow.AddMinutes(-18)), var payments = payableOrders
new PosPaymentActivityContract("POS-9002", "wallet", 12.00m, "USD", "pending", DateTime.UtcNow.AddMinutes(-6)) .Select(order => new PosPaymentActivityContract(
}; order.CheckId,
"check",
order.OutstandingBalance,
order.Currency,
string.Equals(order.CheckState, "AwaitingPayment", StringComparison.Ordinal) ? "partial-payment" : "awaiting-payment",
order.UpdatedAtUtc))
.ToArray();
return Task.FromResult(new GetPosTransactionSummaryResponse( return new GetPosTransactionSummaryResponse(
request.ContextId, request.ContextId,
"Open POS balance reflects one captured payment and one pending settlement.", payments.Length == 0
37.50m, ? "No served checks are currently payable."
"USD", : $"{payments.Length} payable checks are waiting for POS capture.",
payments)); openBalance,
payableOrders.FirstOrDefault()?.Currency ?? "USD",
payments);
} }
public Task<CapturePosPaymentResponse> CapturePosPaymentAsync( public async Task<CapturePosPaymentResponse> CapturePosPaymentAsync(
CapturePosPaymentRequest request, CapturePosPaymentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var succeeded = request.Amount > 0 && if (request.Amount <= 0
!string.IsNullOrWhiteSpace(request.TransactionId) && || string.IsNullOrWhiteSpace(request.TransactionId)
!string.IsNullOrWhiteSpace(request.PaymentMethod); || string.IsNullOrWhiteSpace(request.PaymentMethod))
{
return new CapturePosPaymentResponse(
request.ContextId,
request.TransactionId,
false,
"Payment capture request is incomplete.",
"failed",
DateTime.UtcNow);
}
return Task.FromResult(new CapturePosPaymentResponse( var record = await ResolveTransactionAsync(request.ContextId, request.TransactionId, cancellationToken);
if (record is null)
{
return new CapturePosPaymentResponse(
request.ContextId,
request.TransactionId,
false,
"No payable restaurant check matched the requested transaction.",
"failed",
DateTime.UtcNow);
}
if (!IsPayable(record))
{
return new CapturePosPaymentResponse(
request.ContextId,
request.TransactionId,
false,
$"Check {record.CheckId} is not yet payable because order {record.OrderId} is {record.OrderState}.",
"blocked",
DateTime.UtcNow);
}
var nowUtc = DateTime.UtcNow;
var remainingBalance = decimal.Max(record.OutstandingBalance - request.Amount, 0m);
var nextCheckState = remainingBalance == 0m ? "Paid" : "AwaitingPayment";
// Partial captures keep the check open for the remaining balance; full captures close the check cleanly.
var updated = record with
{
CheckState = nextCheckState,
OutstandingBalance = remainingBalance,
UpdatedAtUtc = nowUtc
};
await lifecycleStore.UpsertOrderAsync(updated, cancellationToken);
await lifecycleStore.AppendEventAsync(new PersistedRestaurantLifecycleEvent(
request.ContextId,
record.OrderId,
$"EVT-{Guid.NewGuid():N}",
"PaymentCaptured",
$"Captured {request.Amount:0.00} {record.Currency} on check {record.CheckId} using {request.PaymentMethod}.",
nowUtc), cancellationToken);
return new CapturePosPaymentResponse(
request.ContextId, request.ContextId,
request.TransactionId, request.TransactionId,
succeeded, true,
succeeded remainingBalance == 0m
? $"Captured {request.Amount:0.00} {request.Currency} using {request.PaymentMethod}." ? $"Captured {request.Amount:0.00} {record.Currency}; check {record.CheckId} is now paid in full."
: "Payment capture request is incomplete.", : $"Captured {request.Amount:0.00} {record.Currency}; {remainingBalance:0.00} remains open on check {record.CheckId}.",
succeeded ? "captured" : "failed", remainingBalance == 0m ? "captured" : "partial",
DateTime.UtcNow)); nowUtc);
} }
public Task<GetRestaurantAdminConfigResponse> GetRestaurantAdminConfigAsync( public Task<GetRestaurantAdminConfigResponse> GetRestaurantAdminConfigAsync(
@ -152,11 +284,11 @@ public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var applied = !string.IsNullOrWhiteSpace(request.ContextId) && var applied = !string.IsNullOrWhiteSpace(request.ContextId)
request.DayOfWeek is >= 0 and <= 6 && && request.DayOfWeek is >= 0 and <= 6
!string.IsNullOrWhiteSpace(request.OpenAt) && && !string.IsNullOrWhiteSpace(request.OpenAt)
!string.IsNullOrWhiteSpace(request.CloseAt) && && !string.IsNullOrWhiteSpace(request.CloseAt)
!string.IsNullOrWhiteSpace(request.UpdatedBy); && !string.IsNullOrWhiteSpace(request.UpdatedBy);
var serviceWindow = new ServiceWindowSnapshotContract( var serviceWindow = new ServiceWindowSnapshotContract(
request.DayOfWeek, request.DayOfWeek,
@ -173,6 +305,61 @@ public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort
serviceWindow)); serviceWindow));
} }
private async Task<PersistedRestaurantLifecycleRecord?> ResolveTransactionAsync(string contextId, string transactionId, CancellationToken cancellationToken)
{
var directMatch = await lifecycleStore.GetOrderAsync(contextId, transactionId, cancellationToken);
if (directMatch is not null)
{
return directMatch;
}
var orders = await lifecycleStore.ListOrdersAsync(contextId, cancellationToken);
return orders.FirstOrDefault(order => string.Equals(order.CheckId, transactionId, StringComparison.Ordinal));
}
private async Task<IReadOnlyCollection<string>> BuildRecentActivityAsync(IEnumerable<PersistedRestaurantLifecycleRecord> orders, CancellationToken cancellationToken)
{
var activity = new List<PersistedRestaurantLifecycleEvent>();
foreach (var order in orders)
{
var events = await lifecycleStore.ListEventsAsync(order.ContextId, order.OrderId, cancellationToken);
activity.AddRange(events.Take(2));
}
return activity
.OrderByDescending(record => record.OccurredAtUtc)
.Select(record => record.Description)
.Take(6)
.ToArray();
}
private static bool IsPayable(PersistedRestaurantLifecycleRecord record)
{
return string.Equals(record.OrderState, "Served", StringComparison.Ordinal)
&& (string.Equals(record.CheckState, "Open", StringComparison.Ordinal)
|| string.Equals(record.CheckState, "AwaitingPayment", StringComparison.Ordinal))
&& record.OutstandingBalance > 0m;
}
private static string MapCustomerStatus(string orderState) => orderState.ToLowerInvariant();
private static string MapWaiterStatus(PersistedRestaurantLifecycleRecord record)
{
if (string.Equals(record.OrderState, "Served", StringComparison.Ordinal)
&& !string.Equals(record.CheckState, "Paid", StringComparison.Ordinal))
{
return "ready-for-check";
}
return record.OrderState switch
{
"Ready" => "ready-for-pickup",
"Preparing" or "InKitchen" => "preparing",
"Accepted" or "Submitted" => "waiting-on-kitchen",
_ => record.OrderState.ToLowerInvariant()
};
}
private static IReadOnlyCollection<ServiceWindowSnapshotContract> BuildServiceWindows() private static IReadOnlyCollection<ServiceWindowSnapshotContract> BuildServiceWindows()
{ {
return new[] return new[]

View File

@ -0,0 +1,13 @@
using Operations.Service.Application.State;
namespace Operations.Service.Application.Ports;
public interface IRestaurantLifecycleStorePort
{
Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken);
Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken);
Task<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,123 @@
using System.Collections.Concurrent;
using Operations.Service.Application.State;
namespace Operations.Service.Application.Ports;
public sealed class InMemoryRestaurantLifecycleStorePort : IRestaurantLifecycleStorePort
{
private readonly ConcurrentDictionary<string, PersistedRestaurantLifecycleRecord> orders = new();
private readonly ConcurrentDictionary<string, ConcurrentQueue<PersistedRestaurantLifecycleEvent>> events = new();
public InMemoryRestaurantLifecycleStorePort()
{
foreach (var record in BuildSeedOrders())
{
orders[BuildKey(record.ContextId, record.OrderId)] = record;
}
foreach (var record in BuildSeedEvents())
{
var queue = events.GetOrAdd(BuildKey(record.ContextId, record.OrderId), static _ => new ConcurrentQueue<PersistedRestaurantLifecycleEvent>());
queue.Enqueue(record);
}
}
public Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
orders.TryGetValue(BuildKey(contextId, orderId), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken)
{
var records = orders.Values
.Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal))
.OrderByDescending(record => record.UpdatedAtUtc)
.ToArray();
return Task.FromResult<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(records);
}
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken)
{
var records = orders.Values
.Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal))
.Where(record => string.Equals(record.OrderState, "Served", StringComparison.Ordinal))
.Where(record => string.Equals(record.CheckState, "Open", StringComparison.Ordinal) || string.Equals(record.CheckState, "AwaitingPayment", StringComparison.Ordinal))
.Where(record => record.OutstandingBalance > 0m)
.OrderByDescending(record => record.UpdatedAtUtc)
.ToArray();
return Task.FromResult<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(records);
}
public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken)
{
orders[BuildKey(record.ContextId, record.OrderId)] = record;
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
if (!events.TryGetValue(BuildKey(contextId, orderId), out var queue))
{
return Task.FromResult<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>>(Array.Empty<PersistedRestaurantLifecycleEvent>());
}
var records = queue.OrderByDescending(record => record.OccurredAtUtc).ToArray();
return Task.FromResult<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>>(records);
}
public Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken)
{
var queue = events.GetOrAdd(BuildKey(record.ContextId, record.OrderId), static _ => new ConcurrentQueue<PersistedRestaurantLifecycleEvent>());
queue.Enqueue(record);
return Task.CompletedTask;
}
private static IReadOnlyCollection<PersistedRestaurantLifecycleRecord> BuildSeedOrders()
{
return new[]
{
new PersistedRestaurantLifecycleRecord(
"demo-context",
"ORD-1001",
"CHK-1001",
"T-08",
"Preparing",
"Open",
2,
true,
24.00m,
"USD",
"customer-orders",
new[] { "ITEM-101", "ITEM-202" },
DateTime.UtcNow.AddMinutes(-9)),
new PersistedRestaurantLifecycleRecord(
"demo-context",
"ORD-1002",
"CHK-1002",
"T-12",
"Served",
"Open",
4,
true,
37.50m,
"USD",
"waiter-floor",
new[] { "ITEM-301", "ITEM-404", "ITEM-405" },
DateTime.UtcNow.AddMinutes(-4))
};
}
private static IReadOnlyCollection<PersistedRestaurantLifecycleEvent> BuildSeedEvents()
{
return new[]
{
new PersistedRestaurantLifecycleEvent("demo-context", "ORD-1001", "EVT-1001", "Preparing", "Order ORD-1001 is being prepared at the hot-line station.", DateTime.UtcNow.AddMinutes(-8)),
new PersistedRestaurantLifecycleEvent("demo-context", "ORD-1002", "EVT-1002", "Served", "Order ORD-1002 was served and is ready for payment.", DateTime.UtcNow.AddMinutes(-3))
};
}
private static string BuildKey(string contextId, string orderId) => $"{contextId}::{orderId}";
}

View File

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

View File

@ -0,0 +1,16 @@
namespace Operations.Service.Application.State;
public sealed record PersistedRestaurantLifecycleRecord(
string ContextId,
string OrderId,
string CheckId,
string TableId,
string OrderState,
string CheckState,
int GuestCount,
bool HasKitchenTicket,
decimal OutstandingBalance,
string Currency,
string Source,
IReadOnlyCollection<string> ItemIds,
DateTime UpdatedAtUtc);

View File

@ -0,0 +1,62 @@
using System.Net.Http.Json;
using Operations.Service.Application.Ports;
using Operations.Service.Application.State;
namespace Operations.Service.Grpc.Adapters;
public sealed class OperationsDalRestaurantLifecycleStoreClient(HttpClient httpClient) : IRestaurantLifecycleStorePort
{
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<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken)
{
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(
$"/internal/operations-dal/orders/payable?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();
}
public async Task<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken)
{
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>>(
$"/internal/operations-dal/orders/{Uri.EscapeDataString(orderId)}/events?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return result ?? Array.Empty<PersistedRestaurantLifecycleEvent>();
}
public async Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken)
{
var response = await httpClient.PostAsJsonAsync(
$"/internal/operations-dal/orders/{Uri.EscapeDataString(record.OrderId)}/events",
record,
cancellationToken);
response.EnsureSuccessStatusCode();
}
}

View File

@ -1,10 +1,17 @@
using Operations.Service.Application.Ports; using Operations.Service.Application.Ports;
using Operations.Service.Application.UseCases; using Operations.Service.Application.UseCases;
using Operations.Service.Contracts.Requests; using Operations.Service.Contracts.Requests;
using Operations.Service.Grpc.Adapters;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IOperationsConfigReadPort, DefaultOperationsConfigReadPort>(); builder.Services.AddSingleton<IOperationsConfigReadPort, DefaultOperationsConfigReadPort>();
builder.Services.AddSingleton<IOperationsWorkflowPort, DefaultOperationsWorkflowPort>(); builder.Services.AddHttpClient<IRestaurantLifecycleStorePort, OperationsDalRestaurantLifecycleStoreClient>(client =>
{
var baseUrl = builder.Configuration["OperationsDal:BaseUrl"] ?? "http://operations-dal:8080";
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddSingleton<IOperationsWorkflowPort>(sp =>
new DefaultOperationsWorkflowPort(sp.GetRequiredService<IRestaurantLifecycleStorePort>()));
builder.Services.AddSingleton<IGetOperationsConfigUseCase, GetOperationsConfigUseCase>(); builder.Services.AddSingleton<IGetOperationsConfigUseCase, GetOperationsConfigUseCase>();
builder.Services.AddSingleton<IEvaluateOperationalDecisionUseCase, EvaluateOperationalDecisionUseCase>(); builder.Services.AddSingleton<IEvaluateOperationalDecisionUseCase, EvaluateOperationalDecisionUseCase>();
builder.Services.AddSingleton<IGetWaiterAssignmentsUseCase, GetWaiterAssignmentsUseCase>(); builder.Services.AddSingleton<IGetWaiterAssignmentsUseCase, GetWaiterAssignmentsUseCase>();

View File

@ -4,5 +4,8 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"OperationsDal": {
"BaseUrl": "http://127.0.0.1:21180"
} }
} }

View File

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

View File

@ -6,7 +6,13 @@ namespace Operations.Service.Application.UnitTests;
public class OperationsWorkflowUseCasesTests public class OperationsWorkflowUseCasesTests
{ {
private readonly DefaultOperationsWorkflowPort workflowPort = new(); private readonly InMemoryRestaurantLifecycleStorePort lifecycleStore = new();
private readonly DefaultOperationsWorkflowPort workflowPort;
public OperationsWorkflowUseCasesTests()
{
workflowPort = new DefaultOperationsWorkflowPort(lifecycleStore);
}
[Fact] [Fact]
public async Task GetWaiterAssignmentsUseCase_ReturnsAssignmentsAndActivity() public async Task GetWaiterAssignmentsUseCase_ReturnsAssignmentsAndActivity()
@ -18,10 +24,11 @@ public class OperationsWorkflowUseCasesTests
Assert.Equal("demo-context", response.ContextId); Assert.Equal("demo-context", response.ContextId);
Assert.NotEmpty(response.Assignments); Assert.NotEmpty(response.Assignments);
Assert.NotEmpty(response.RecentActivity); Assert.NotEmpty(response.RecentActivity);
Assert.Contains(response.Assignments, assignment => assignment.Status == "ready-for-check");
} }
[Fact] [Fact]
public async Task SubmitRestaurantOrderUseCase_WhenRequestValid_AcceptsOrder() public async Task SubmitRestaurantOrderUseCase_WhenRequestValid_PersistsAcceptedOrder()
{ {
var useCase = new SubmitRestaurantOrderUseCase(workflowPort); var useCase = new SubmitRestaurantOrderUseCase(workflowPort);
@ -29,12 +36,16 @@ public class OperationsWorkflowUseCasesTests
new SubmitRestaurantOrderRequest("demo-context", "ORD-101", "T-12", 3), new SubmitRestaurantOrderRequest("demo-context", "ORD-101", "T-12", 3),
CancellationToken.None); CancellationToken.None);
var persisted = await lifecycleStore.GetOrderAsync("demo-context", "ORD-101", CancellationToken.None);
Assert.True(response.Accepted); Assert.True(response.Accepted);
Assert.Equal("queued", response.Status); Assert.Equal("accepted", response.Status);
Assert.NotNull(persisted);
Assert.Equal("Accepted", persisted!.OrderState);
} }
[Fact] [Fact]
public async Task GetCustomerOrderStatusUseCase_ReturnsOrders() public async Task GetCustomerOrderStatusUseCase_ReturnsOrdersFromSharedLifecycleStore()
{ {
var useCase = new GetCustomerOrderStatusUseCase(workflowPort); var useCase = new GetCustomerOrderStatusUseCase(workflowPort);
@ -43,10 +54,11 @@ public class OperationsWorkflowUseCasesTests
Assert.Equal("demo-context", response.ContextId); Assert.Equal("demo-context", response.ContextId);
Assert.NotEmpty(response.Orders); Assert.NotEmpty(response.Orders);
Assert.NotEmpty(response.RecentEvents); Assert.NotEmpty(response.RecentEvents);
Assert.Contains(response.Orders, order => order.OrderId == "ORD-1001");
} }
[Fact] [Fact]
public async Task GetPosTransactionSummaryUseCase_ReturnsPaymentActivity() public async Task GetPosTransactionSummaryUseCase_ReturnsOnlyPayableChecks()
{ {
var useCase = new GetPosTransactionSummaryUseCase(workflowPort); var useCase = new GetPosTransactionSummaryUseCase(workflowPort);
@ -54,19 +66,38 @@ public class OperationsWorkflowUseCasesTests
Assert.Equal("USD", response.Currency); Assert.Equal("USD", response.Currency);
Assert.NotEmpty(response.RecentPayments); Assert.NotEmpty(response.RecentPayments);
Assert.All(response.RecentPayments, payment => Assert.Equal("check", payment.PaymentMethod));
} }
[Fact] [Fact]
public async Task CapturePosPaymentUseCase_WhenAmountPositive_ReturnsCapturedStatus() public async Task CapturePosPaymentUseCase_WhenOrderServed_ReturnsCapturedStatus()
{ {
var useCase = new CapturePosPaymentUseCase(workflowPort); var useCase = new CapturePosPaymentUseCase(workflowPort);
var response = await useCase.HandleAsync( var response = await useCase.HandleAsync(
new CapturePosPaymentRequest("demo-context", "POS-9001", 25.50m, "USD", "card"), new CapturePosPaymentRequest("demo-context", "CHK-1002", 37.50m, "USD", "card"),
CancellationToken.None); CancellationToken.None);
var updated = await lifecycleStore.GetOrderAsync("demo-context", "ORD-1002", CancellationToken.None);
Assert.True(response.Succeeded); Assert.True(response.Succeeded);
Assert.Equal("captured", response.Status); Assert.Equal("captured", response.Status);
Assert.NotNull(updated);
Assert.Equal("Paid", updated!.CheckState);
Assert.Equal(0m, updated.OutstandingBalance);
}
[Fact]
public async Task CapturePosPaymentUseCase_WhenOrderNotServed_ReturnsBlockedStatus()
{
var useCase = new CapturePosPaymentUseCase(workflowPort);
var response = await useCase.HandleAsync(
new CapturePosPaymentRequest("demo-context", "ORD-1001", 12m, "USD", "card"),
CancellationToken.None);
Assert.False(response.Succeeded);
Assert.Equal("blocked", response.Status);
} }
[Fact] [Fact]