From d19167e3eadc7039810adee4c72f1ad00e28d0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 18:38:30 -0600 Subject: [PATCH] feat(operations-service): orchestrate shared restaurant lifecycle --- docs/api/internal-workflow-contracts.md | 29 +- docs/roadmap/feature-epics.md | 6 +- docs/runbooks/containerization.md | 9 +- .../Ports/DefaultOperationsWorkflowPort.cs | 323 ++++++++++++++---- .../Ports/IRestaurantLifecycleStorePort.cs | 13 + .../InMemoryRestaurantLifecycleStorePort.cs | 123 +++++++ .../PersistedRestaurantLifecycleEvent.cs | 9 + .../PersistedRestaurantLifecycleRecord.cs | 16 + ...ationsDalRestaurantLifecycleStoreClient.cs | 62 ++++ src/Operations.Service.Grpc/Program.cs | 9 +- .../appsettings.Development.json | 3 + src/Operations.Service.Grpc/appsettings.json | 3 + .../OperationsWorkflowUseCasesTests.cs | 45 ++- 13 files changed, 555 insertions(+), 95 deletions(-) create mode 100644 src/Operations.Service.Application/Ports/IRestaurantLifecycleStorePort.cs create mode 100644 src/Operations.Service.Application/Ports/InMemoryRestaurantLifecycleStorePort.cs create mode 100644 src/Operations.Service.Application/State/PersistedRestaurantLifecycleEvent.cs create mode 100644 src/Operations.Service.Application/State/PersistedRestaurantLifecycleRecord.cs create mode 100644 src/Operations.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleStoreClient.cs diff --git a/docs/api/internal-workflow-contracts.md b/docs/api/internal-workflow-contracts.md index 279722e..f3fd508 100644 --- a/docs/api/internal-workflow-contracts.md +++ b/docs/api/internal-workflow-contracts.md @@ -2,7 +2,7 @@ ## 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 @@ -16,18 +16,23 @@ - `GET /internal/operations/admin/config?contextId=` - `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 -- customer order status plus recent status events -- POS summary plus recent payment activity -- restaurant admin snapshot plus service windows and recent config changes -- workflow write responses that include status/message detail instead of only a boolean summary +That means: +- submitted orders are persisted as shared order/check records +- customer status reads come from persisted restaurant state rather than static arrays +- POS summary reads only served checks that remain payable +- payment capture updates persisted check state and appends lifecycle events -## Current Runtime Shape +## Contract Intent -- The default implementation is still in-memory and deterministic. -- This repo remains orchestration-only; no DAL redesign is introduced by this task. -- Demo realism can deepen later without forcing BFF or SPA contract churn. +- waiter assignments surface floor-facing table attention derived from shared order/check state +- customer order status reflects the same lifecycle that waiter and POS flows observe +- 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. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index 51326a2..13d79c3 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -9,10 +9,10 @@ operations-service - Epic 3: Improve observability and operational readiness for demo compose environments. ## Domain-Specific Candidate Features -- Order lifecycle consistency and state transitions. -- Kitchen queue and dispatch optimization hooks. +- Shared restaurant order/check orchestration over persisted lifecycle state. +- Kitchen dispatch readiness and order/check progression handoff. - 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. ## Documentation Contract diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 8d15ad9..a337084 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME= --build-arg NUGET ## Local Run ```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 @@ -23,7 +23,8 @@ docker run --rm -p 8080:8080 --name operations-service agilewebs/operations-serv ## Runtime Notes - 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 @@ -35,8 +36,8 @@ docker run --rm -p 8080:8080 --name operations-service agilewebs/operations-serv - Participates in: **restaurant** demo compose stack. - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` + ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- 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. -- 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. diff --git a/src/Operations.Service.Application/Ports/DefaultOperationsWorkflowPort.cs b/src/Operations.Service.Application/Ports/DefaultOperationsWorkflowPort.cs index f406027..ded9dcb 100644 --- a/src/Operations.Service.Application/Ports/DefaultOperationsWorkflowPort.cs +++ b/src/Operations.Service.Application/Ports/DefaultOperationsWorkflowPort.cs @@ -1,3 +1,4 @@ +using Operations.Service.Application.State; using Operations.Service.Contracts.Contracts; using Operations.Service.Contracts.Requests; using Operations.Service.Contracts.Responses; @@ -6,116 +7,247 @@ namespace Operations.Service.Application.Ports; public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort { - public Task 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 GetWaiterAssignmentsAsync( GetWaiterAssignmentsRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var assignments = new[] - { - new WaiterAssignmentContract("waiter-01", "T-12", "serving", 2), - new WaiterAssignmentContract("waiter-07", "T-08", "ready-for-check", 1) - }; - var activity = new[] - { - $"{request.ContextId}: table T-12 requested dessert menus", - $"{request.ContextId}: table T-08 is waiting for payment capture" - }; + var orders = await lifecycleStore.ListOrdersAsync(request.ContextId, cancellationToken); + var activeOrders = orders + .Where(order => !string.Equals(order.CheckState, "Paid", StringComparison.Ordinal)) + .Where(order => !string.Equals(order.OrderState, "Canceled", StringComparison.Ordinal)) + .Where(order => !string.Equals(order.OrderState, "Rejected", StringComparison.Ordinal)) + .ToArray(); - 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, "restaurant-demo", - $"{assignments.Length} active waiter assignments are currently visible.", + $"{assignments.Length} tables currently require floor attention.", assignments, - activity)); + recentActivity); } - public Task SubmitRestaurantOrderAsync( + public async Task SubmitRestaurantOrderAsync( SubmitRestaurantOrderRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var accepted = request.ItemCount > 0 && - !string.IsNullOrWhiteSpace(request.ContextId) && - !string.IsNullOrWhiteSpace(request.OrderId) && - !string.IsNullOrWhiteSpace(request.TableId); + var accepted = request.ItemCount > 0 + && !string.IsNullOrWhiteSpace(request.ContextId) + && !string.IsNullOrWhiteSpace(request.OrderId) + && !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.OrderId, - accepted, - accepted - ? $"Order {request.OrderId} for table {request.TableId} was accepted with {request.ItemCount} items." - : "Order payload is incomplete.", - accepted ? "queued" : "rejected", - DateTime.UtcNow)); + checkId, + request.TableId, + "Accepted", + existing?.CheckState ?? "Open", + Math.Max(existing?.GuestCount ?? 0, request.ItemCount), + 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 GetCustomerOrderStatusAsync( + public async Task GetCustomerOrderStatusAsync( GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var orders = new[] - { - new CustomerOrderStatusContract("CO-1001", "T-08", "preparing", 2, new[] { "ITEM-101", "ITEM-202" }), - new CustomerOrderStatusContract("CO-1002", "T-15", "ready", 4, new[] { "ITEM-301", "ITEM-404", "ITEM-405" }) - }; - var events = new[] - { - "CO-1001 moved to preparing at kitchen hot-line station.", - "CO-1002 is ready for table pickup." - }; + var orders = await lifecycleStore.ListOrdersAsync(request.ContextId, cancellationToken); + var contracts = orders + .Select(order => new CustomerOrderStatusContract( + order.OrderId, + order.TableId, + MapCustomerStatus(order.OrderState), + order.GuestCount, + order.ItemIds)) + .ToArray(); - return Task.FromResult(new GetCustomerOrderStatusResponse( + var events = await BuildRecentActivityAsync(orders, cancellationToken); + + return new GetCustomerOrderStatusResponse( request.ContextId, - $"{orders.Length} recent customer orders are visible for the active context.", - orders, - events)); + contracts.Length == 0 + ? "No customer orders are currently visible for the active context." + : $"{contracts.Length} customer orders are currently tracked in the shared restaurant lifecycle.", + contracts, + events); } - public Task GetPosTransactionSummaryAsync( + public async Task GetPosTransactionSummaryAsync( GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var payments = new[] - { - new PosPaymentActivityContract("POS-9001", "card", 25.50m, "USD", "captured", DateTime.UtcNow.AddMinutes(-18)), - new PosPaymentActivityContract("POS-9002", "wallet", 12.00m, "USD", "pending", DateTime.UtcNow.AddMinutes(-6)) - }; + var payableOrders = await lifecycleStore.ListPayableOrdersAsync(request.ContextId, cancellationToken); + var openBalance = payableOrders.Sum(order => order.OutstandingBalance); + var payments = payableOrders + .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, - "Open POS balance reflects one captured payment and one pending settlement.", - 37.50m, - "USD", - payments)); + payments.Length == 0 + ? "No served checks are currently payable." + : $"{payments.Length} payable checks are waiting for POS capture.", + openBalance, + payableOrders.FirstOrDefault()?.Currency ?? "USD", + payments); } - public Task CapturePosPaymentAsync( + public async Task CapturePosPaymentAsync( CapturePosPaymentRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var succeeded = request.Amount > 0 && - !string.IsNullOrWhiteSpace(request.TransactionId) && - !string.IsNullOrWhiteSpace(request.PaymentMethod); + if (request.Amount <= 0 + || string.IsNullOrWhiteSpace(request.TransactionId) + || 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.TransactionId, - succeeded, - succeeded - ? $"Captured {request.Amount:0.00} {request.Currency} using {request.PaymentMethod}." - : "Payment capture request is incomplete.", - succeeded ? "captured" : "failed", - DateTime.UtcNow)); + true, + remainingBalance == 0m + ? $"Captured {request.Amount:0.00} {record.Currency}; check {record.CheckId} is now paid in full." + : $"Captured {request.Amount:0.00} {record.Currency}; {remainingBalance:0.00} remains open on check {record.CheckId}.", + remainingBalance == 0m ? "captured" : "partial", + nowUtc); } public Task GetRestaurantAdminConfigAsync( @@ -152,11 +284,11 @@ public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort { cancellationToken.ThrowIfCancellationRequested(); - var applied = !string.IsNullOrWhiteSpace(request.ContextId) && - request.DayOfWeek is >= 0 and <= 6 && - !string.IsNullOrWhiteSpace(request.OpenAt) && - !string.IsNullOrWhiteSpace(request.CloseAt) && - !string.IsNullOrWhiteSpace(request.UpdatedBy); + var applied = !string.IsNullOrWhiteSpace(request.ContextId) + && request.DayOfWeek is >= 0 and <= 6 + && !string.IsNullOrWhiteSpace(request.OpenAt) + && !string.IsNullOrWhiteSpace(request.CloseAt) + && !string.IsNullOrWhiteSpace(request.UpdatedBy); var serviceWindow = new ServiceWindowSnapshotContract( request.DayOfWeek, @@ -173,6 +305,61 @@ public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort serviceWindow)); } + private async Task 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> BuildRecentActivityAsync(IEnumerable orders, CancellationToken cancellationToken) + { + var activity = new List(); + 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 BuildServiceWindows() { return new[] diff --git a/src/Operations.Service.Application/Ports/IRestaurantLifecycleStorePort.cs b/src/Operations.Service.Application/Ports/IRestaurantLifecycleStorePort.cs new file mode 100644 index 0000000..e23f179 --- /dev/null +++ b/src/Operations.Service.Application/Ports/IRestaurantLifecycleStorePort.cs @@ -0,0 +1,13 @@ +using Operations.Service.Application.State; + +namespace Operations.Service.Application.Ports; + +public interface IRestaurantLifecycleStorePort +{ + Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken); + Task> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken); + Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken); + Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken); + Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken); +} diff --git a/src/Operations.Service.Application/Ports/InMemoryRestaurantLifecycleStorePort.cs b/src/Operations.Service.Application/Ports/InMemoryRestaurantLifecycleStorePort.cs new file mode 100644 index 0000000..7f120ca --- /dev/null +++ b/src/Operations.Service.Application/Ports/InMemoryRestaurantLifecycleStorePort.cs @@ -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 orders = new(); + private readonly ConcurrentDictionary> 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()); + queue.Enqueue(record); + } + } + + public Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + orders.TryGetValue(BuildKey(contextId, orderId), out var record); + return Task.FromResult(record); + } + + public Task> 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>(records); + } + + public Task> 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>(records); + } + + public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) + { + orders[BuildKey(record.ContextId, record.OrderId)] = record; + return Task.CompletedTask; + } + + public Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + if (!events.TryGetValue(BuildKey(contextId, orderId), out var queue)) + { + return Task.FromResult>(Array.Empty()); + } + + var records = queue.OrderByDescending(record => record.OccurredAtUtc).ToArray(); + return Task.FromResult>(records); + } + + public Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken) + { + var queue = events.GetOrAdd(BuildKey(record.ContextId, record.OrderId), static _ => new ConcurrentQueue()); + queue.Enqueue(record); + return Task.CompletedTask; + } + + private static IReadOnlyCollection 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 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}"; +} diff --git a/src/Operations.Service.Application/State/PersistedRestaurantLifecycleEvent.cs b/src/Operations.Service.Application/State/PersistedRestaurantLifecycleEvent.cs new file mode 100644 index 0000000..966304c --- /dev/null +++ b/src/Operations.Service.Application/State/PersistedRestaurantLifecycleEvent.cs @@ -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); diff --git a/src/Operations.Service.Application/State/PersistedRestaurantLifecycleRecord.cs b/src/Operations.Service.Application/State/PersistedRestaurantLifecycleRecord.cs new file mode 100644 index 0000000..48891e3 --- /dev/null +++ b/src/Operations.Service.Application/State/PersistedRestaurantLifecycleRecord.cs @@ -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 ItemIds, + DateTime UpdatedAtUtc); diff --git a/src/Operations.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleStoreClient.cs b/src/Operations.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleStoreClient.cs new file mode 100644 index 0000000..7d37851 --- /dev/null +++ b/src/Operations.Service.Grpc/Adapters/OperationsDalRestaurantLifecycleStoreClient.cs @@ -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 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(cancellationToken: cancellationToken); + } + + public async Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/operations-dal/orders?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + + public async Task> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/operations-dal/orders/payable?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + + public async Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken) + { + var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + public async Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken) + { + var result = await httpClient.GetFromJsonAsync>( + $"/internal/operations-dal/orders/{Uri.EscapeDataString(orderId)}/events?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return result ?? Array.Empty(); + } + + 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(); + } +} diff --git a/src/Operations.Service.Grpc/Program.cs b/src/Operations.Service.Grpc/Program.cs index 718514a..1340dc9 100644 --- a/src/Operations.Service.Grpc/Program.cs +++ b/src/Operations.Service.Grpc/Program.cs @@ -1,10 +1,17 @@ using Operations.Service.Application.Ports; using Operations.Service.Application.UseCases; using Operations.Service.Contracts.Requests; +using Operations.Service.Grpc.Adapters; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["OperationsDal:BaseUrl"] ?? "http://operations-dal:8080"; + client.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddSingleton(sp => + new DefaultOperationsWorkflowPort(sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Operations.Service.Grpc/appsettings.Development.json b/src/Operations.Service.Grpc/appsettings.Development.json index 0c208ae..407e827 100644 --- a/src/Operations.Service.Grpc/appsettings.Development.json +++ b/src/Operations.Service.Grpc/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "OperationsDal": { + "BaseUrl": "http://127.0.0.1:21180" } } diff --git a/src/Operations.Service.Grpc/appsettings.json b/src/Operations.Service.Grpc/appsettings.json index 10f68b8..23e1c22 100644 --- a/src/Operations.Service.Grpc/appsettings.json +++ b/src/Operations.Service.Grpc/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, + "OperationsDal": { + "BaseUrl": "http://operations-dal:8080" + }, "AllowedHosts": "*" } diff --git a/tests/Operations.Service.Application.UnitTests/OperationsWorkflowUseCasesTests.cs b/tests/Operations.Service.Application.UnitTests/OperationsWorkflowUseCasesTests.cs index a0b1b3e..83a414a 100644 --- a/tests/Operations.Service.Application.UnitTests/OperationsWorkflowUseCasesTests.cs +++ b/tests/Operations.Service.Application.UnitTests/OperationsWorkflowUseCasesTests.cs @@ -6,7 +6,13 @@ namespace Operations.Service.Application.UnitTests; public class OperationsWorkflowUseCasesTests { - private readonly DefaultOperationsWorkflowPort workflowPort = new(); + private readonly InMemoryRestaurantLifecycleStorePort lifecycleStore = new(); + private readonly DefaultOperationsWorkflowPort workflowPort; + + public OperationsWorkflowUseCasesTests() + { + workflowPort = new DefaultOperationsWorkflowPort(lifecycleStore); + } [Fact] public async Task GetWaiterAssignmentsUseCase_ReturnsAssignmentsAndActivity() @@ -18,10 +24,11 @@ public class OperationsWorkflowUseCasesTests Assert.Equal("demo-context", response.ContextId); Assert.NotEmpty(response.Assignments); Assert.NotEmpty(response.RecentActivity); + Assert.Contains(response.Assignments, assignment => assignment.Status == "ready-for-check"); } [Fact] - public async Task SubmitRestaurantOrderUseCase_WhenRequestValid_AcceptsOrder() + public async Task SubmitRestaurantOrderUseCase_WhenRequestValid_PersistsAcceptedOrder() { var useCase = new SubmitRestaurantOrderUseCase(workflowPort); @@ -29,12 +36,16 @@ public class OperationsWorkflowUseCasesTests new SubmitRestaurantOrderRequest("demo-context", "ORD-101", "T-12", 3), CancellationToken.None); + var persisted = await lifecycleStore.GetOrderAsync("demo-context", "ORD-101", CancellationToken.None); + Assert.True(response.Accepted); - Assert.Equal("queued", response.Status); + Assert.Equal("accepted", response.Status); + Assert.NotNull(persisted); + Assert.Equal("Accepted", persisted!.OrderState); } [Fact] - public async Task GetCustomerOrderStatusUseCase_ReturnsOrders() + public async Task GetCustomerOrderStatusUseCase_ReturnsOrdersFromSharedLifecycleStore() { var useCase = new GetCustomerOrderStatusUseCase(workflowPort); @@ -43,10 +54,11 @@ public class OperationsWorkflowUseCasesTests Assert.Equal("demo-context", response.ContextId); Assert.NotEmpty(response.Orders); Assert.NotEmpty(response.RecentEvents); + Assert.Contains(response.Orders, order => order.OrderId == "ORD-1001"); } [Fact] - public async Task GetPosTransactionSummaryUseCase_ReturnsPaymentActivity() + public async Task GetPosTransactionSummaryUseCase_ReturnsOnlyPayableChecks() { var useCase = new GetPosTransactionSummaryUseCase(workflowPort); @@ -54,19 +66,38 @@ public class OperationsWorkflowUseCasesTests Assert.Equal("USD", response.Currency); Assert.NotEmpty(response.RecentPayments); + Assert.All(response.RecentPayments, payment => Assert.Equal("check", payment.PaymentMethod)); } [Fact] - public async Task CapturePosPaymentUseCase_WhenAmountPositive_ReturnsCapturedStatus() + public async Task CapturePosPaymentUseCase_WhenOrderServed_ReturnsCapturedStatus() { var useCase = new CapturePosPaymentUseCase(workflowPort); 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); + var updated = await lifecycleStore.GetOrderAsync("demo-context", "ORD-1002", CancellationToken.None); + Assert.True(response.Succeeded); 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]