Compare commits

..

No commits in common. "1e5e082061a44e5b76d2989a1e98bc17cae5e7fc" and "40933edb84fa92ead50cd959718d175bea65a888" have entirely different histories.

5 changed files with 42 additions and 147 deletions

View File

@ -21,13 +21,9 @@ 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.
- 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.

View File

@ -9,10 +9,11 @@ customer-orders-bff
- 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
- Customer-facing projection over the shared restaurant lifecycle. - Order lifecycle consistency and state transitions.
- Customer order detail and history views that stay aligned with waiter, kitchen, and POS state changes. - Customer order detail and history views that stay aligned with operations-service workflows.
- Cross-app order continuity from customer submission to kitchen preparation and POS payment. - Kitchen queue and dispatch optimization hooks.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment.
## Documentation Contract ## Documentation Contract
Any code change in this repository must include docs updates in the same branch. Any code change in this repository must include docs updates in the same branch.

View File

@ -39,6 +39,5 @@ docker run --rm -p 8080:8080 --name customer-orders-bff agilewebs/customer-order
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations ## Known Limitations
- Customer-orders now delegates workflow snapshots to `operations-service`, which projects persisted shared lifecycle state from `operations-dal`. - Customer-orders now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state.
- Kitchen and POS visibility still depend on the remaining Stage 46-48 restaurant flow tasks being wired end-to-end.
- 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.

View File

@ -20,18 +20,22 @@ 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 GetDetailPayloadAsync(request.ContextId, request.OrderId, cancellationToken); var payload = await GetStatusPayloadAsync(request.ContextId, 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,
payload.Summary, matchingOrder is null
payload.Order is null ? null : MapOrder(payload.Order), ? $"Order {request.OrderId} is not visible in the current customer context."
: $"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 GetHistoryPayloadAsync(request.ContextId, cancellationToken); var payload = await GetStatusPayloadAsync(request.ContextId, cancellationToken);
return new GetCustomerOrderHistoryResponse( return new GetCustomerOrderHistoryResponse(
payload.ContextId, payload.ContextId,
@ -42,8 +46,7 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
public async Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken) public async Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken)
{ {
// The customer BFF writes into the same shared restaurant lifecycle used by waiter, kitchen, and POS. // operations-service still accepts aggregate item counts for restaurant order submission.
// The upstream contract still accepts aggregate counts, so the edge contract projects item IDs into count.
var payload = await SubmitOrderPayloadAsync( var payload = await SubmitOrderPayloadAsync(
new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemIds.Count), new SubmitRestaurantOrderPayload(request.ContextId, request.OrderId, request.TableId, request.ItemIds.Count),
cancellationToken); cancellationToken);
@ -65,27 +68,6 @@ 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)
@ -100,20 +82,15 @@ 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(MapOrder) .Select(static order => new CustomerOrderSummaryContract(
order.OrderId,
order.TableId,
order.Status,
order.GuestCount,
order.ItemIds))
.ToArray(); .ToArray();
} }
private static CustomerOrderSummaryContract MapOrder(CustomerOrderPayload order)
{
return new CustomerOrderSummaryContract(
order.OrderId,
order.TableId,
order.Status,
order.GuestCount,
order.ItemIds);
}
private sealed record GetCustomerOrderStatusPayload( private sealed record GetCustomerOrderStatusPayload(
string ContextId, string ContextId,
string Summary, string Summary,
@ -127,18 +104,6 @@ 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,

View File

@ -10,10 +10,7 @@ public sealed class OperationsCustomerOrdersServiceClientTests
[Fact] [Fact]
public async Task FetchStatusAsync_MapsOrdersAndEvents() public async Task FetchStatusAsync_MapsOrdersAndEvents()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload));
{
["/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);
@ -25,77 +22,59 @@ public sealed class OperationsCustomerOrdersServiceClientTests
[Fact] [Fact]
public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent() public async Task FetchDetailAsync_ReturnsMatchingOrderWhenPresent()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload));
{
["/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", "CO-1001"), CancellationToken.None);
Assert.NotNull(response.Order); Assert.NotNull(response.Order);
Assert.Equal("ORD-1001", response.Order!.OrderId); Assert.Equal("CO-1001", response.Order!.OrderId);
Assert.Contains("preparing", response.Summary, StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
public async Task FetchHistoryAsync_ReturnsLifecycleBackedHistorySnapshot() public async Task FetchHistoryAsync_ReturnsProjectedHistorySnapshot()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(StatusPayload));
{
["/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_MapsSubmitPayloadUsingItemCount()
{ {
var adapter = new OperationsCustomerOrdersServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsCustomerOrdersServiceClient(CreateClient("""
{
["/internal/operations/orders"] = """
{ {
"contextId": "demo-context", "contextId": "demo-context",
"orderId": "ORD-1009", "orderId": "CO-1009",
"accepted": true, "accepted": true,
"summary": "Order ORD-1009 was accepted and is ready for kitchen dispatch.", "summary": "Order CO-1009 for table T-18 was accepted with 3 items.",
"status": "accepted", "status": "queued",
"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", "CO-1009", "T-18", 4, ["ITEM-101", "ITEM-202", "ITEM-303"]),
CancellationToken.None); CancellationToken.None);
Assert.True(response.Accepted); Assert.True(response.Accepted);
Assert.Equal("accepted", response.Status); Assert.Equal("queued", response.Status);
Assert.Contains("kitchen dispatch", response.Summary);
} }
private static HttpClient CreateClient(IReadOnlyDictionary<string, string> routes) private static HttpClient CreateClient(string json)
{ {
return new HttpClient(new StubHttpMessageHandler(routes)) return new HttpClient(new StubHttpMessageHandler(json))
{ {
BaseAddress = new Uri("http://operations-service:8080/") BaseAddress = new Uri("http://operations-service:8080/")
}; };
} }
private sealed class StubHttpMessageHandler(IReadOnlyDictionary<string, string> routes) : HttpMessageHandler private sealed class StubHttpMessageHandler(string json) : 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")
@ -106,17 +85,17 @@ public sealed class OperationsCustomerOrdersServiceClientTests
private const string StatusPayload = """ private const string StatusPayload = """
{ {
"contextId": "demo-context", "contextId": "demo-context",
"summary": "2 customer orders are currently tracked in the shared restaurant lifecycle.", "summary": "2 recent customer orders are visible for the active context.",
"orders": [ "orders": [
{ {
"orderId": "ORD-1001", "orderId": "CO-1001",
"tableId": "T-08", "tableId": "T-08",
"status": "preparing", "status": "preparing",
"guestCount": 2, "guestCount": 2,
"itemIds": [ "ITEM-101", "ITEM-202" ] "itemIds": [ "ITEM-101", "ITEM-202" ]
}, },
{ {
"orderId": "ORD-1002", "orderId": "CO-1002",
"tableId": "T-15", "tableId": "T-15",
"status": "ready", "status": "ready",
"guestCount": 4, "guestCount": 4,
@ -124,53 +103,8 @@ public sealed class OperationsCustomerOrdersServiceClientTests
} }
], ],
"recentEvents": [ "recentEvents": [
"Order ORD-1001 moved to preparing at the kitchen hot-line station.", "CO-1001 moved to preparing at kitchen hot-line station.",
"Order ORD-1002 is ready for table pickup." "CO-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",
"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."
] ]
} }
"""; """;