feat(customer-orders-bff): align lifecycle detail routes
This commit is contained in:
parent
59554a217f
commit
1e5e082061
@ -21,10 +21,13 @@ This BFF exposes customer-facing order submission, status, detail, and history w
|
|||||||
- Default runtime target: `http://operations-service:8080`
|
- Default runtime target: `http://operations-service:8080`
|
||||||
- Internal upstream routes:
|
- Internal upstream routes:
|
||||||
- `GET /internal/operations/customer/status`
|
- `GET /internal/operations/customer/status`
|
||||||
|
- `GET /internal/operations/customer/orders/{orderId}`
|
||||||
|
- `GET /internal/operations/customer/history`
|
||||||
- `POST /internal/operations/orders`
|
- `POST /internal/operations/orders`
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Customer order submission currently maps `ItemIds.Count` into the upstream restaurant order workflow because the internal service contract still accepts aggregate item counts.
|
- 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.
|
- 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.
|
- Correlation IDs are preserved through Thalos session checks and operations-service calls.
|
||||||
|
|||||||
@ -20,22 +20,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
|
|||||||
|
|
||||||
public async Task<GetCustomerOrderDetailResponse> FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken)
|
public async Task<GetCustomerOrderDetailResponse> FetchDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken);
|
var payload = await GetDetailPayloadAsync(request.ContextId, request.OrderId, cancellationToken);
|
||||||
var orders = MapOrders(payload.Orders);
|
|
||||||
var matchingOrder = orders.FirstOrDefault(summary => summary.OrderId == request.OrderId);
|
|
||||||
|
|
||||||
return new GetCustomerOrderDetailResponse(
|
return new GetCustomerOrderDetailResponse(
|
||||||
payload.ContextId,
|
payload.ContextId,
|
||||||
matchingOrder is null
|
payload.Summary,
|
||||||
? $"Order {request.OrderId} is not visible in the current customer context."
|
payload.Order is null ? null : MapOrder(payload.Order),
|
||||||
: $"Order {request.OrderId} is currently {matchingOrder.Status}.",
|
|
||||||
matchingOrder,
|
|
||||||
payload.RecentEvents);
|
payload.RecentEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GetCustomerOrderHistoryResponse> FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
|
public async Task<GetCustomerOrderHistoryResponse> FetchHistoryAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken);
|
var payload = await GetHistoryPayloadAsync(request.ContextId, cancellationToken);
|
||||||
|
|
||||||
return new GetCustomerOrderHistoryResponse(
|
return new GetCustomerOrderHistoryResponse(
|
||||||
payload.ContextId,
|
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.");
|
return payload ?? throw new InvalidOperationException("Operations service returned an empty customer order status payload.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<GetCustomerOrderDetailPayload> GetDetailPayloadAsync(
|
||||||
|
string contextId,
|
||||||
|
string orderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderDetailPayload>(
|
||||||
|
$"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<GetCustomerOrderHistoryPayload> GetHistoryPayloadAsync(string contextId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var payload = await httpClient.GetFromJsonAsync<GetCustomerOrderHistoryPayload>(
|
||||||
|
$"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<SubmitRestaurantOrderResponsePayload> SubmitOrderPayloadAsync(
|
private async Task<SubmitRestaurantOrderResponsePayload> SubmitOrderPayloadAsync(
|
||||||
SubmitRestaurantOrderPayload request,
|
SubmitRestaurantOrderPayload request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@ -83,13 +100,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
|
|||||||
private static IReadOnlyCollection<CustomerOrderSummaryContract> MapOrders(IReadOnlyCollection<CustomerOrderPayload> orders)
|
private static IReadOnlyCollection<CustomerOrderSummaryContract> MapOrders(IReadOnlyCollection<CustomerOrderPayload> orders)
|
||||||
{
|
{
|
||||||
return orders
|
return orders
|
||||||
.Select(static order => new CustomerOrderSummaryContract(
|
.Select(MapOrder)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomerOrderSummaryContract MapOrder(CustomerOrderPayload order)
|
||||||
|
{
|
||||||
|
return new CustomerOrderSummaryContract(
|
||||||
order.OrderId,
|
order.OrderId,
|
||||||
order.TableId,
|
order.TableId,
|
||||||
order.Status,
|
order.Status,
|
||||||
order.GuestCount,
|
order.GuestCount,
|
||||||
order.ItemIds))
|
order.ItemIds);
|
||||||
.ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record GetCustomerOrderStatusPayload(
|
private sealed record GetCustomerOrderStatusPayload(
|
||||||
@ -105,6 +127,18 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
|
|||||||
int GuestCount,
|
int GuestCount,
|
||||||
IReadOnlyCollection<string> ItemIds);
|
IReadOnlyCollection<string> ItemIds);
|
||||||
|
|
||||||
|
private sealed record GetCustomerOrderDetailPayload(
|
||||||
|
string ContextId,
|
||||||
|
string Summary,
|
||||||
|
CustomerOrderPayload? Order,
|
||||||
|
IReadOnlyCollection<string> RecentEvents);
|
||||||
|
|
||||||
|
private sealed record GetCustomerOrderHistoryPayload(
|
||||||
|
string ContextId,
|
||||||
|
string Summary,
|
||||||
|
IReadOnlyCollection<CustomerOrderPayload> Orders,
|
||||||
|
IReadOnlyCollection<string> RecentEvents);
|
||||||
|
|
||||||
private sealed record SubmitRestaurantOrderPayload(
|
private sealed record SubmitRestaurantOrderPayload(
|
||||||
string ContextId,
|
string ContextId,
|
||||||
string OrderId,
|
string OrderId,
|
||||||
|
|||||||
@ -10,7 +10,10 @@ public sealed class OperationsCustomerOrdersServiceClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task FetchStatusAsync_MapsOrdersAndEvents()
|
public async Task FetchStatusAsync_MapsOrdersAndEvents()
|
||||||
{
|
{
|
||||||
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload));
|
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["/internal/operations/customer/status"] = StatusPayload
|
||||||
|
}));
|
||||||
|
|
||||||
var response = await adapter.FetchStatusAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
|
var response = await adapter.FetchStatusAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
|
||||||
|
|
||||||
@ -22,7 +25,10 @@ public sealed class OperationsCustomerOrdersServiceClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent()
|
public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent()
|
||||||
{
|
{
|
||||||
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload));
|
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["/internal/operations/customer/orders/ORD-1001"] = DetailPayload
|
||||||
|
}));
|
||||||
|
|
||||||
var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None);
|
var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None);
|
||||||
|
|
||||||
@ -32,20 +38,26 @@ public sealed class OperationsCustomerOrdersServiceClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FetchHistoryAsync_ReturnsProjectedHistorySnapshot()
|
public async Task FetchHistoryAsync_ReturnsLifecycleBackedHistorySnapshot()
|
||||||
{
|
{
|
||||||
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload));
|
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["/internal/operations/customer/history"] = HistoryPayload
|
||||||
|
}));
|
||||||
|
|
||||||
var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
|
var response = await adapter.FetchHistoryAsync(new GetCustomerOrderStatusRequest("demo-context"), CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(2, response.Orders.Count);
|
Assert.Equal(2, response.Orders.Count);
|
||||||
Assert.Equal(2, response.RecentEvents.Count);
|
Assert.Equal(2, response.RecentEvents.Count);
|
||||||
|
Assert.Contains("lifecycle-backed", response.Summary, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubmitOrderAsync_MapsSharedLifecycleAcceptanceUsingItemCount()
|
public async Task SubmitOrderAsync_MapsSharedLifecycleAcceptanceUsingItemCount()
|
||||||
{
|
{
|
||||||
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient("""
|
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["/internal/operations/orders"] = """
|
||||||
{
|
{
|
||||||
"contextId": "demo-context",
|
"contextId": "demo-context",
|
||||||
"orderId": "ORD-1009",
|
"orderId": "ORD-1009",
|
||||||
@ -54,7 +66,8 @@ public sealed class OperationsCustomerOrdersServiceClientTests
|
|||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"submittedAtUtc": "2026-03-31T12:30:00Z"
|
"submittedAtUtc": "2026-03-31T12:30:00Z"
|
||||||
}
|
}
|
||||||
"""));
|
"""
|
||||||
|
}));
|
||||||
|
|
||||||
var response = await adapter.SubmitOrderAsync(
|
var response = await adapter.SubmitOrderAsync(
|
||||||
new SubmitCustomerOrderRequest("demo-context", "ORD-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]),
|
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);
|
Assert.Contains("kitchen dispatch", response.Summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpClient CreateClient(string json)
|
private static HttpClient CreateClient(IReadOnlyDictionary<string, string> routes)
|
||||||
{
|
{
|
||||||
return new HttpClient(new StubHttpMessageHandler(json))
|
return new HttpClient(new StubHttpMessageHandler(routes))
|
||||||
{
|
{
|
||||||
BaseAddress = new Uri("http://operations-service:8080/")
|
BaseAddress = new Uri("http://operations-service:8080/")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler
|
private sealed class StubHttpMessageHandler(IReadOnlyDictionary<string, string> routes) : HttpMessageHandler
|
||||||
{
|
{
|
||||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
protected override Task<HttpResponseMessage> 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)
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||||
@ -87,7 +106,52 @@ public sealed class OperationsCustomerOrdersServiceClientTests
|
|||||||
private const string StatusPayload = """
|
private const string StatusPayload = """
|
||||||
{
|
{
|
||||||
"contextId": "demo-context",
|
"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": [
|
"orders": [
|
||||||
{
|
{
|
||||||
"orderId": "ORD-1001",
|
"orderId": "ORD-1001",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user