Compare commits

...

2 Commits

Author SHA1 Message Date
José René White Enciso
1e5e082061 feat(customer-orders-bff): align lifecycle detail routes 2026-03-31 19:59:32 -06:00
José René White Enciso
59554a217f docs(customer-orders-bff): align customer workflows to shared lifecycle 2026-03-31 18:51:36 -06:00
5 changed files with 147 additions and 42 deletions

View File

@ -21,9 +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.
- 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,11 +9,10 @@ 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
- Order lifecycle consistency and state transitions. - Customer-facing projection over the shared restaurant lifecycle.
- Customer order detail and history views that stay aligned with operations-service workflows. - Customer order detail and history views that stay aligned with waiter, kitchen, and POS state changes.
- Kitchen queue and dispatch optimization hooks. - Cross-app order continuity from customer submission to kitchen preparation and POS payment.
- 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,5 +39,6 @@ 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`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state. - Customer-orders now delegates workflow snapshots to `operations-service`, which projects persisted shared lifecycle state from `operations-dal`.
- 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,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,
@ -46,7 +42,8 @@ public sealed class OperationsCustomerOrdersServiceClient(HttpClient httpClient)
public async Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken) public async Task<SubmitCustomerOrderResponse> SubmitOrderAsync(SubmitCustomerOrderRequest request, CancellationToken cancellationToken)
{ {
// operations-service still accepts aggregate item counts for restaurant order submission. // The customer BFF writes into the same shared restaurant lifecycle used by waiter, kitchen, and POS.
// 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);
@ -68,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)
@ -82,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(
@ -104,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,

View File

@ -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,59 +25,77 @@ 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", "CO-1001"), CancellationToken.None); var response = await adapter.FetchDetailAsync(new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"), CancellationToken.None);
Assert.NotNull(response.Order); Assert.NotNull(response.Order);
Assert.Equal("CO-1001", response.Order!.OrderId); Assert.Equal("ORD-1001", response.Order!.OrderId);
Assert.Contains("preparing", response.Summary, StringComparison.OrdinalIgnoreCase);
} }
[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_MapsSubmitPayloadUsingItemCount() 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": "CO-1009", "orderId": "ORD-1009",
"accepted": true, "accepted": true,
"summary": "Order CO-1009 for table T-18 was accepted with 3 items.", "summary": "Order ORD-1009 was accepted and is ready for kitchen dispatch.",
"status": "queued", "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", "CO-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"]),
CancellationToken.None); CancellationToken.None);
Assert.True(response.Accepted); Assert.True(response.Accepted);
Assert.Equal("queued", response.Status); Assert.Equal("accepted", response.Status);
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")
@ -85,17 +106,17 @@ 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": [ "orders": [
{ {
"orderId": "CO-1001", "orderId": "ORD-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": "CO-1002", "orderId": "ORD-1002",
"tableId": "T-15", "tableId": "T-15",
"status": "ready", "status": "ready",
"guestCount": 4, "guestCount": 4,
@ -103,8 +124,53 @@ public sealed class OperationsCustomerOrdersServiceClientTests
} }
], ],
"recentEvents": [ "recentEvents": [
"CO-1001 moved to preparing at kitchen hot-line station.", "Order ORD-1001 moved to preparing at the kitchen hot-line station.",
"CO-1002 is ready for table pickup." "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",
"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."
] ]
} }
"""; """;