diff --git a/docs/api/customer-order-workflows.md b/docs/api/customer-order-workflows.md index 00ccbd7..1ad22aa 100644 --- a/docs/api/customer-order-workflows.md +++ b/docs/api/customer-order-workflows.md @@ -21,10 +21,13 @@ This BFF exposes customer-facing order submission, status, detail, and history w - Default runtime target: `http://operations-service:8080` - Internal upstream routes: - `GET /internal/operations/customer/status` + - `GET /internal/operations/customer/orders/{orderId}` + - `GET /internal/operations/customer/history` - `POST /internal/operations/orders` ## Notes - Customer order submission currently maps `ItemIds.Count` into the upstream restaurant order workflow because the internal service contract still accepts aggregate item counts. - Successful submission writes into the same shared restaurant lifecycle later observed by waiter-floor, kitchen-ops, and POS projections. +- Customer detail and history now come from dedicated lifecycle-backed upstream routes instead of projecting everything from the status snapshot. - Correlation IDs are preserved through Thalos session checks and operations-service calls. diff --git a/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs b/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs index 4654885..6c776cd 100644 --- a/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs +++ b/src/Customer.Orders.Bff.Application/Adapters/OperationsCustomerOrdersServiceClient.cs @@ -20,22 +20,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient) public async Task FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken) { - var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); - var orders = MapOrders(payload.Orders); - var matchingOrder = orders.FirstOrDefault(summary => summary.OrderId == request.OrderId); + var payload = await GetDetailPayloadAsync(request.ContextId, request.OrderId, cancellationToken); return new GetCustomerOrderDetailResponse( payload.ContextId, - matchingOrder is null - ? $"Order {request.OrderId} is not visible in the current customer context." - : $"Order {request.OrderId} is currently {matchingOrder.Status}.", - matchingOrder, + payload.Summary, + payload.Order is null ? null : MapOrder(payload.Order), payload.RecentEvents); } public async Task FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken) { - var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken); + var payload = await GetHistoryPayloadAsync(request.ContextId, cancellationToken); return new GetCustomerOrderHistoryResponse( payload.ContextId, @@ -69,6 +65,27 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient) return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order status payload."); } + private async Task GetDetailPayloadAsync( + string contextId, + string orderId, + CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/customer/orders/{Uri.EscapeDataString(orderId)}?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order detail payload."); + } + + private async Task GetHistoryPayloadAsync(string contextId, CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/customer/history?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order history payload."); + } + private async Task SubmitOrderPayloadAsync( SubmitRestaurantOrderPayload request, CancellationToken cancellationToken) @@ -83,15 +100,20 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient) private static IReadOnlyCollection MapOrders(IReadOnlyCollection orders) { return orders - .Select(static order => new CustomerOrderSummaryContract( - order.OrderId, - order.TableId, - order.Status, - order.GuestCount, - order.ItemIds)) + .Select(MapOrder) .ToArray(); } + private static CustomerOrderSummaryContract MapOrder(CustomerOrderPayload order) + { + return new CustomerOrderSummaryContract( + order.OrderId, + order.TableId, + order.Status, + order.GuestCount, + order.ItemIds); + } + private sealed record GetCustomerOrderStatusPayload( string ContextId, string Summary, @@ -105,6 +127,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient) int GuestCount, IReadOnlyCollection ItemIds); + private sealed record GetCustomerOrderDetailPayload( + string ContextId, + string Summary, + CustomerOrderPayload? Order, + IReadOnlyCollection RecentEvents); + + private sealed record GetCustomerOrderHistoryPayload( + string ContextId, + string Summary, + IReadOnlyCollection Orders, + IReadOnlyCollection RecentEvents); + private sealed record SubmitRestaurantOrderPayload( string ContextId, string OrderId, diff --git a/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs b/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs index ea33acc..2f36148 100644 --- a/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs +++ b/tests/Customer.Orders.Bff.Application.UnitTests/OperationsCustomerOrdersServiceClientTests.cs @@ -10,7 +10,10 @@ public sealed class OperationsCustomerOrdersServiceClientTests [Fact] public async Task FetchStatusAsync_MapsOrdersAndEvents() { - var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/customer/status"] = StatusPayload + })); var response = await adapter.FetchStatusAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None); @@ -22,7 +25,10 @@ public sealed class OperationsCustomerOrdersServiceClientTests [Fact] public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent() { - var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/customer/orders/ORD-1001"] = DetailPayload + })); var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None); @@ -32,20 +38,26 @@ public sealed class OperationsCustomerOrdersServiceClientTests } [Fact] - public async Task FetchHistoryAsync_ReturnsProjectedHistorySnapshot() + public async Task FetchHistoryAsync_ReturnsLifecycleBackedHistorySnapshot() { - var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload)); + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/customer/history"] = HistoryPayload + })); var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None); Assert.Equal(2, response.Orders.Count); Assert.Equal(2, response.RecentEvents.Count); + Assert.Contains("lifecycle-backed", response.Summary, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task SubmitOrderAsync_MapsSharedLifecycleAcceptanceUsingItemCount() { - var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(""" + var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/orders"] = """ { "contextId": "demo-context", "orderId": "ORD-1009", @@ -54,7 +66,8 @@ public sealed class OperationsCustomerOrdersServiceClientTests "status": "accepted", "submittedAtUtc": "2026-03-31T12:30:00Z" } - """)); + """ + })); var response = await adapter.SubmitOrderAsync( new SubmitCustomerOrderRequest("demo-context", "ORD-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]), @@ -65,18 +78,24 @@ public sealed class OperationsCustomerOrdersServiceClientTests Assert.Contains("kitchen dispatch", response.Summary); } - private static HttpClient CreateClient(string json) + private static HttpClient CreateClient(IReadOnlyDictionary routes) { - return new HttpClient(new StubHttpMessageHandler(json)) + return new HttpClient(new StubHttpMessageHandler(routes)) { BaseAddress = new Uri("http://operations-service:8080/") }; } - private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler + private sealed class StubHttpMessageHandler(IReadOnlyDictionary routes) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + if (!routes.TryGetValue(path, out var json)) + { + throw new InvalidOperationException($"No stub payload configured for '{path}'."); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") @@ -87,7 +106,52 @@ public sealed class OperationsCustomerOrdersServiceClientTests private const string StatusPayload = """ { "contextId": "demo-context", - "summary": "2 recent customer orders are visible for the active context.", + "summary": "2 customer orders are currently tracked in the shared restaurant lifecycle.", + "orders": [ + { + "orderId": "ORD-1001", + "tableId": "T-08", + "status": "preparing", + "guestCount": 2, + "itemIds": [ "ITEM-101", "ITEM-202" ] + }, + { + "orderId": "ORD-1002", + "tableId": "T-15", + "status": "ready", + "guestCount": 4, + "itemIds": [ "ITEM-301", "ITEM-404", "ITEM-405" ] + } + ], + "recentEvents": [ + "Order ORD-1001 moved to preparing at the kitchen hot-line station.", + "Order ORD-1002 is ready for table pickup." + ] + } + """; + + private const string DetailPayload = """ + { + "contextId": "demo-context", + "summary": "Order ORD-1001 is currently preparing in the shared restaurant lifecycle.", + "order": { + "orderId": "ORD-1001", + "tableId": "T-08", + "status": "preparing", + "guestCount": 2, + "itemIds": [ "ITEM-101", "ITEM-202" ] + }, + "recentEvents": [ + "Order ORD-1001 was accepted into the shared restaurant lifecycle.", + "Order ORD-1001 moved to preparing at the kitchen hot-line station." + ] + } + """; + + private const string HistoryPayload = """ + { + "contextId": "demo-context", + "summary": "2 customer orders are currently available through lifecycle-backed history.", "orders": [ { "orderId": "ORD-1001",