Compare commits
2 Commits
a24ccc12d9
...
1e86d2f05b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e86d2f05b | ||
|
|
d19167e3ea |
@ -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
|
||||
|
||||
@ -11,23 +11,34 @@
|
||||
- `GET /internal/operations/waiter/assignments?contextId=<id>`
|
||||
- `POST /internal/operations/orders`
|
||||
- `GET /internal/operations/customer/status?contextId=<id>`
|
||||
- `GET /internal/operations/customer/orders/<orderId>?contextId=<id>`
|
||||
- `GET /internal/operations/customer/history?contextId=<id>`
|
||||
- `GET /internal/operations/pos/summary?contextId=<id>`
|
||||
- `GET /internal/operations/pos/transactions/<transactionId>?contextId=<id>`
|
||||
- `POST /internal/operations/pos/payments`
|
||||
- `GET /internal/operations/admin/config?contextId=<id>`
|
||||
- `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
|
||||
- customer detail and history no longer need to be projected from the status summary payload
|
||||
- POS summary reads only served checks that remain payable
|
||||
- POS detail can resolve a specific check directly from the shared lifecycle store
|
||||
- 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 status, detail, and history all reflect the same lifecycle that waiter and POS flows observe
|
||||
- POS payment only opens for served orders with outstanding balance
|
||||
- POS detail stays lifecycle-backed even when the check is not yet payable, which keeps downstream error handling honest
|
||||
- restaurant-admin configuration remains control-plane oriented and intentionally separate from order persistence
|
||||
|
||||
## Remaining Limitation
|
||||
|
||||
- Kitchen ticket creation and kitchen state feedback still depend on `kitchen-service` propagation alignment in the Stage 50 companion task.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -11,7 +11,7 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --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.
|
||||
|
||||
@ -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,291 @@ namespace Operations.Service.Application.Ports;
|
||||
|
||||
public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort
|
||||
{
|
||||
public Task<GetWaiterAssignmentsResponse> 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<GetWaiterAssignmentsResponse> 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<SubmitRestaurantOrderResponse> SubmitRestaurantOrderAsync(
|
||||
public async Task<SubmitRestaurantOrderResponse> 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,
|
||||
accepted,
|
||||
accepted
|
||||
? $"Order {request.OrderId} for table {request.TableId} was accepted with {request.ItemCount} items."
|
||||
: "Order payload is incomplete.",
|
||||
accepted ? "queued" : "rejected",
|
||||
DateTime.UtcNow));
|
||||
false,
|
||||
"Order payload is incomplete.",
|
||||
"rejected",
|
||||
DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public Task<GetCustomerOrderStatusResponse> GetCustomerOrderStatusAsync(
|
||||
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,
|
||||
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 async Task<GetCustomerOrderStatusResponse> 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 = MapCustomerOrders(orders);
|
||||
|
||||
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<GetPosTransactionSummaryResponse> GetPosTransactionSummaryAsync(
|
||||
public async Task<GetCustomerOrderDetailResponse> GetCustomerOrderDetailAsync(
|
||||
GetCustomerOrderDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var order = await lifecycleStore.GetOrderAsync(request.ContextId, request.OrderId, cancellationToken);
|
||||
var recentEvents = order is null
|
||||
? Array.Empty<string>()
|
||||
: await BuildOrderActivityAsync(request.ContextId, request.OrderId, cancellationToken);
|
||||
|
||||
return new GetCustomerOrderDetailResponse(
|
||||
request.ContextId,
|
||||
order is null
|
||||
? $"Order {request.OrderId} is not visible in the shared restaurant lifecycle."
|
||||
: $"Order {request.OrderId} is currently {MapCustomerStatus(order.OrderState)} in the shared restaurant lifecycle.",
|
||||
order is null ? null : MapCustomerOrder(order),
|
||||
recentEvents);
|
||||
}
|
||||
|
||||
public async Task<GetCustomerOrderHistoryResponse> GetCustomerOrderHistoryAsync(
|
||||
GetCustomerOrderHistoryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var orders = await lifecycleStore.ListOrdersAsync(request.ContextId, cancellationToken);
|
||||
var contracts = MapCustomerOrders(orders);
|
||||
var recentEvents = await BuildRecentActivityAsync(orders, cancellationToken);
|
||||
|
||||
return new GetCustomerOrderHistoryResponse(
|
||||
request.ContextId,
|
||||
contracts.Length == 0
|
||||
? "No customer order history is currently visible for the active context."
|
||||
: $"{contracts.Length} customer orders are currently available through lifecycle-backed history.",
|
||||
contracts,
|
||||
recentEvents);
|
||||
}
|
||||
|
||||
public async Task<GetPosTransactionSummaryResponse> 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 = MapPayments(payableOrders);
|
||||
|
||||
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<CapturePosPaymentResponse> CapturePosPaymentAsync(
|
||||
public async Task<GetPosTransactionDetailResponse> GetPosTransactionDetailAsync(
|
||||
GetPosTransactionDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var record = await ResolveTransactionAsync(request.ContextId, request.TransactionId, cancellationToken);
|
||||
var payableOrders = await lifecycleStore.ListPayableOrdersAsync(request.ContextId, cancellationToken);
|
||||
var openBalance = payableOrders.Sum(order => order.OutstandingBalance);
|
||||
|
||||
return new GetPosTransactionDetailResponse(
|
||||
request.ContextId,
|
||||
record is null
|
||||
? $"Transaction {request.TransactionId} is not visible in the shared restaurant lifecycle."
|
||||
: $"Transaction {request.TransactionId} maps to check {record.CheckId} and is currently {MapPaymentStatus(record)}.",
|
||||
openBalance,
|
||||
record?.Currency ?? payableOrders.FirstOrDefault()?.Currency ?? "USD",
|
||||
record is null ? null : MapPayment(record));
|
||||
}
|
||||
|
||||
public async Task<CapturePosPaymentResponse> CapturePosPaymentAsync(
|
||||
CapturePosPaymentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var succeeded = request.Amount > 0 &&
|
||||
!string.IsNullOrWhiteSpace(request.TransactionId) &&
|
||||
!string.IsNullOrWhiteSpace(request.PaymentMethod);
|
||||
|
||||
return Task.FromResult(new CapturePosPaymentResponse(
|
||||
if (request.Amount <= 0
|
||||
|| string.IsNullOrWhiteSpace(request.TransactionId)
|
||||
|| string.IsNullOrWhiteSpace(request.PaymentMethod))
|
||||
{
|
||||
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));
|
||||
false,
|
||||
"Payment capture request is incomplete.",
|
||||
"failed",
|
||||
DateTime.UtcNow);
|
||||
}
|
||||
|
||||
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,
|
||||
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<GetRestaurantAdminConfigResponse> GetRestaurantAdminConfigAsync(
|
||||
@ -152,11 +328,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 +349,123 @@ public sealed class DefaultOperationsWorkflowPort : IOperationsWorkflowPort
|
||||
serviceWindow));
|
||||
}
|
||||
|
||||
private async Task<PersistedRestaurantLifecycleRecord?> 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<IReadOnlyCollection<string>> BuildOrderActivityAsync(string contextId, string orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var events = await lifecycleStore.ListEventsAsync(contextId, orderId, cancellationToken);
|
||||
return events
|
||||
.OrderByDescending(record => record.OccurredAtUtc)
|
||||
.Select(record => record.Description)
|
||||
.Take(6)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<string>> BuildRecentActivityAsync(IEnumerable<PersistedRestaurantLifecycleRecord> orders, CancellationToken cancellationToken)
|
||||
{
|
||||
var activity = new List<PersistedRestaurantLifecycleEvent>();
|
||||
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 CustomerOrderStatusContract[] MapCustomerOrders(IEnumerable<PersistedRestaurantLifecycleRecord> orders)
|
||||
{
|
||||
return orders
|
||||
.Select(MapCustomerOrder)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static CustomerOrderStatusContract MapCustomerOrder(PersistedRestaurantLifecycleRecord order)
|
||||
{
|
||||
return new CustomerOrderStatusContract(
|
||||
order.OrderId,
|
||||
order.TableId,
|
||||
MapCustomerStatus(order.OrderState),
|
||||
order.GuestCount,
|
||||
order.ItemIds);
|
||||
}
|
||||
|
||||
private static PosPaymentActivityContract[] MapPayments(IEnumerable<PersistedRestaurantLifecycleRecord> payableOrders)
|
||||
{
|
||||
return payableOrders
|
||||
.Select(MapPayment)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static PosPaymentActivityContract MapPayment(PersistedRestaurantLifecycleRecord order)
|
||||
{
|
||||
return new PosPaymentActivityContract(
|
||||
order.CheckId,
|
||||
"check",
|
||||
order.OutstandingBalance,
|
||||
order.Currency,
|
||||
MapPaymentStatus(order),
|
||||
order.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
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 MapPaymentStatus(PersistedRestaurantLifecycleRecord record)
|
||||
{
|
||||
if (string.Equals(record.CheckState, "Paid", StringComparison.Ordinal))
|
||||
{
|
||||
return "captured";
|
||||
}
|
||||
|
||||
if (!IsPayable(record))
|
||||
{
|
||||
return "not-payable";
|
||||
}
|
||||
|
||||
return string.Equals(record.CheckState, "AwaitingPayment", StringComparison.Ordinal)
|
||||
? "partial-payment"
|
||||
: "awaiting-payment";
|
||||
}
|
||||
|
||||
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<ServiceWindowSnapshotContract> BuildServiceWindows()
|
||||
{
|
||||
return new[]
|
||||
|
||||
@ -8,7 +8,10 @@ public interface IOperationsWorkflowPort
|
||||
Task<GetWaiterAssignmentsResponse> GetWaiterAssignmentsAsync(GetWaiterAssignmentsRequest request, CancellationToken cancellationToken);
|
||||
Task<SubmitRestaurantOrderResponse> SubmitRestaurantOrderAsync(SubmitRestaurantOrderRequest request, CancellationToken cancellationToken);
|
||||
Task<GetCustomerOrderStatusResponse> GetCustomerOrderStatusAsync(GetCustomerOrderStatusRequest request, CancellationToken cancellationToken);
|
||||
Task<GetCustomerOrderDetailResponse> GetCustomerOrderDetailAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken);
|
||||
Task<GetCustomerOrderHistoryResponse> GetCustomerOrderHistoryAsync(GetCustomerOrderHistoryRequest request, CancellationToken cancellationToken);
|
||||
Task<GetPosTransactionSummaryResponse> GetPosTransactionSummaryAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken);
|
||||
Task<GetPosTransactionDetailResponse> GetPosTransactionDetailAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken);
|
||||
Task<CapturePosPaymentResponse> CapturePosPaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken);
|
||||
Task<GetRestaurantAdminConfigResponse> GetRestaurantAdminConfigAsync(GetRestaurantAdminConfigRequest request, CancellationToken cancellationToken);
|
||||
Task<SetServiceWindowResponse> SetServiceWindowAsync(SetServiceWindowRequest request, CancellationToken cancellationToken);
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
using Operations.Service.Application.State;
|
||||
|
||||
namespace Operations.Service.Application.Ports;
|
||||
|
||||
public interface IRestaurantLifecycleStorePort
|
||||
{
|
||||
Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken);
|
||||
Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken);
|
||||
Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken);
|
||||
}
|
||||
@ -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<string, PersistedRestaurantLifecycleRecord> orders = new();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<PersistedRestaurantLifecycleEvent>> 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<PersistedRestaurantLifecycleEvent>());
|
||||
queue.Enqueue(record);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PersistedRestaurantLifecycleRecord?> GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
orders.TryGetValue(BuildKey(contextId, orderId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> 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<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(records);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> 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<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(records);
|
||||
}
|
||||
|
||||
public Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
orders[BuildKey(record.ContextId, record.OrderId)] = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!events.TryGetValue(BuildKey(contextId, orderId), out var queue))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>>(Array.Empty<PersistedRestaurantLifecycleEvent>());
|
||||
}
|
||||
|
||||
var records = queue.OrderByDescending(record => record.OccurredAtUtc).ToArray();
|
||||
return Task.FromResult<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>>(records);
|
||||
}
|
||||
|
||||
public Task AppendEventAsync(PersistedRestaurantLifecycleEvent record, CancellationToken cancellationToken)
|
||||
{
|
||||
var queue = events.GetOrAdd(BuildKey(record.ContextId, record.OrderId), static _ => new ConcurrentQueue<PersistedRestaurantLifecycleEvent>());
|
||||
queue.Enqueue(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<PersistedRestaurantLifecycleRecord> 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<PersistedRestaurantLifecycleEvent> 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}";
|
||||
}
|
||||
@ -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);
|
||||
@ -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<string> ItemIds,
|
||||
DateTime UpdatedAtUtc);
|
||||
@ -0,0 +1,13 @@
|
||||
using Operations.Service.Application.Ports;
|
||||
using Operations.Service.Contracts.Requests;
|
||||
using Operations.Service.Contracts.Responses;
|
||||
|
||||
namespace Operations.Service.Application.UseCases;
|
||||
|
||||
public sealed class GetCustomerOrderDetailUseCase(IOperationsWorkflowPort workflowPort) : IGetCustomerOrderDetailUseCase
|
||||
{
|
||||
public Task<GetCustomerOrderDetailResponse> HandleAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return workflowPort.GetCustomerOrderDetailAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
using Operations.Service.Application.Ports;
|
||||
using Operations.Service.Contracts.Requests;
|
||||
using Operations.Service.Contracts.Responses;
|
||||
|
||||
namespace Operations.Service.Application.UseCases;
|
||||
|
||||
public sealed class GetCustomerOrderHistoryUseCase(IOperationsWorkflowPort workflowPort) : IGetCustomerOrderHistoryUseCase
|
||||
{
|
||||
public Task<GetCustomerOrderHistoryResponse> HandleAsync(GetCustomerOrderHistoryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return workflowPort.GetCustomerOrderHistoryAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
using Operations.Service.Application.Ports;
|
||||
using Operations.Service.Contracts.Requests;
|
||||
using Operations.Service.Contracts.Responses;
|
||||
|
||||
namespace Operations.Service.Application.UseCases;
|
||||
|
||||
public sealed class GetPosTransactionDetailUseCase(IOperationsWorkflowPort workflowPort) : IGetPosTransactionDetailUseCase
|
||||
{
|
||||
public Task<GetPosTransactionDetailResponse> HandleAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return workflowPort.GetPosTransactionDetailAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Operations.Service.Contracts.Requests;
|
||||
using Operations.Service.Contracts.Responses;
|
||||
|
||||
namespace Operations.Service.Application.UseCases;
|
||||
|
||||
public interface IGetCustomerOrderDetailUseCase
|
||||
{
|
||||
Task<GetCustomerOrderDetailResponse> HandleAsync(GetCustomerOrderDetailRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Operations.Service.Contracts.Requests;
|
||||
using Operations.Service.Contracts.Responses;
|
||||
|
||||
namespace Operations.Service.Application.UseCases;
|
||||
|
||||
public interface IGetCustomerOrderHistoryUseCase
|
||||
{
|
||||
Task<GetCustomerOrderHistoryResponse> HandleAsync(GetCustomerOrderHistoryRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Operations.Service.Contracts.Requests;
|
||||
using Operations.Service.Contracts.Responses;
|
||||
|
||||
namespace Operations.Service.Application.UseCases;
|
||||
|
||||
public interface IGetPosTransactionDetailUseCase
|
||||
{
|
||||
Task<GetPosTransactionDetailResponse> HandleAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
namespace Operations.Service.Contracts.Requests;
|
||||
|
||||
public sealed record GetCustomerOrderDetailRequest(
|
||||
string ContextId,
|
||||
string OrderId);
|
||||
@ -0,0 +1,3 @@
|
||||
namespace Operations.Service.Contracts.Requests;
|
||||
|
||||
public sealed record GetCustomerOrderHistoryRequest(string ContextId);
|
||||
@ -0,0 +1,5 @@
|
||||
namespace Operations.Service.Contracts.Requests;
|
||||
|
||||
public sealed record GetPosTransactionDetailRequest(
|
||||
string ContextId,
|
||||
string TransactionId);
|
||||
@ -0,0 +1,9 @@
|
||||
using Operations.Service.Contracts.Contracts;
|
||||
|
||||
namespace Operations.Service.Contracts.Responses;
|
||||
|
||||
public sealed record GetCustomerOrderDetailResponse(
|
||||
string ContextId,
|
||||
string Summary,
|
||||
CustomerOrderStatusContract? Order,
|
||||
IReadOnlyCollection<string> RecentEvents);
|
||||
@ -0,0 +1,9 @@
|
||||
using Operations.Service.Contracts.Contracts;
|
||||
|
||||
namespace Operations.Service.Contracts.Responses;
|
||||
|
||||
public sealed record GetCustomerOrderHistoryResponse(
|
||||
string ContextId,
|
||||
string Summary,
|
||||
IReadOnlyCollection<CustomerOrderStatusContract> Orders,
|
||||
IReadOnlyCollection<string> RecentEvents);
|
||||
@ -0,0 +1,10 @@
|
||||
using Operations.Service.Contracts.Contracts;
|
||||
|
||||
namespace Operations.Service.Contracts.Responses;
|
||||
|
||||
public sealed record GetPosTransactionDetailResponse(
|
||||
string ContextId,
|
||||
string Summary,
|
||||
decimal OpenBalance,
|
||||
string Currency,
|
||||
PosPaymentActivityContract? Transaction);
|
||||
@ -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<PersistedRestaurantLifecycleRecord?> 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<PersistedRestaurantLifecycleRecord>(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListOrdersAsync(string contextId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(
|
||||
$"/internal/operations-dal/orders?contextId={Uri.EscapeDataString(contextId)}",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? Array.Empty<PersistedRestaurantLifecycleRecord>();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedRestaurantLifecycleRecord>>(
|
||||
$"/internal/operations-dal/orders/payable?contextId={Uri.EscapeDataString(contextId)}",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? Array.Empty<PersistedRestaurantLifecycleRecord>();
|
||||
}
|
||||
|
||||
public async Task UpsertOrderAsync(PersistedRestaurantLifecycleRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync("/internal/operations-dal/orders", record, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<IReadOnlyCollection<PersistedRestaurantLifecycleEvent>>(
|
||||
$"/internal/operations-dal/orders/{Uri.EscapeDataString(orderId)}/events?contextId={Uri.EscapeDataString(contextId)}",
|
||||
cancellationToken);
|
||||
|
||||
return result ?? Array.Empty<PersistedRestaurantLifecycleEvent>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,26 @@
|
||||
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<IOperationsConfigReadPort, DefaultOperationsConfigReadPort>();
|
||||
builder.Services.AddSingleton<IOperationsWorkflowPort, DefaultOperationsWorkflowPort>();
|
||||
builder.Services.AddHttpClient<IRestaurantLifecycleStorePort, OperationsDalRestaurantLifecycleStoreClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["OperationsDal:BaseUrl"] ?? "http://operations-dal:8080";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddSingleton<IOperationsWorkflowPort>(sp =>
|
||||
new DefaultOperationsWorkflowPort(sp.GetRequiredService<IRestaurantLifecycleStorePort>()));
|
||||
builder.Services.AddSingleton<IGetOperationsConfigUseCase, GetOperationsConfigUseCase>();
|
||||
builder.Services.AddSingleton<IEvaluateOperationalDecisionUseCase, EvaluateOperationalDecisionUseCase>();
|
||||
builder.Services.AddSingleton<IGetWaiterAssignmentsUseCase, GetWaiterAssignmentsUseCase>();
|
||||
builder.Services.AddSingleton<ISubmitRestaurantOrderUseCase, SubmitRestaurantOrderUseCase>();
|
||||
builder.Services.AddSingleton<IGetCustomerOrderStatusUseCase, GetCustomerOrderStatusUseCase>();
|
||||
builder.Services.AddSingleton<IGetCustomerOrderDetailUseCase, GetCustomerOrderDetailUseCase>();
|
||||
builder.Services.AddSingleton<IGetCustomerOrderHistoryUseCase, GetCustomerOrderHistoryUseCase>();
|
||||
builder.Services.AddSingleton<IGetPosTransactionSummaryUseCase, GetPosTransactionSummaryUseCase>();
|
||||
builder.Services.AddSingleton<IGetPosTransactionDetailUseCase, GetPosTransactionDetailUseCase>();
|
||||
builder.Services.AddSingleton<ICapturePosPaymentUseCase, CapturePosPaymentUseCase>();
|
||||
builder.Services.AddSingleton<IGetRestaurantAdminConfigUseCase, GetRestaurantAdminConfigUseCase>();
|
||||
builder.Services.AddSingleton<ISetServiceWindowUseCase, SetServiceWindowUseCase>();
|
||||
@ -55,6 +65,23 @@ app.MapGet("/internal/operations/customer/status", async (
|
||||
return Results.Ok(await useCase.HandleAsync(new GetCustomerOrderStatusRequest(contextId), ct));
|
||||
});
|
||||
|
||||
app.MapGet("/internal/operations/customer/orders/{orderId}", async (
|
||||
string contextId,
|
||||
string orderId,
|
||||
IGetCustomerOrderDetailUseCase useCase,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
return Results.Ok(await useCase.HandleAsync(new GetCustomerOrderDetailRequest(contextId, orderId), ct));
|
||||
});
|
||||
|
||||
app.MapGet("/internal/operations/customer/history", async (
|
||||
string contextId,
|
||||
IGetCustomerOrderHistoryUseCase useCase,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
return Results.Ok(await useCase.HandleAsync(new GetCustomerOrderHistoryRequest(contextId), ct));
|
||||
});
|
||||
|
||||
app.MapGet("/internal/operations/pos/summary", async (
|
||||
string contextId,
|
||||
IGetPosTransactionSummaryUseCase useCase,
|
||||
@ -63,6 +90,15 @@ app.MapGet("/internal/operations/pos/summary", async (
|
||||
return Results.Ok(await useCase.HandleAsync(new GetPosTransactionSummaryRequest(contextId), ct));
|
||||
});
|
||||
|
||||
app.MapGet("/internal/operations/pos/transactions/{transactionId}", async (
|
||||
string contextId,
|
||||
string transactionId,
|
||||
IGetPosTransactionDetailUseCase useCase,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
return Results.Ok(await useCase.HandleAsync(new GetPosTransactionDetailRequest(contextId, transactionId), ct));
|
||||
});
|
||||
|
||||
app.MapPost("/internal/operations/pos/payments", async (
|
||||
CapturePosPaymentRequest request,
|
||||
ICapturePosPaymentUseCase useCase,
|
||||
|
||||
@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"OperationsDal": {
|
||||
"BaseUrl": "http://127.0.0.1:21180"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,5 +5,8 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"OperationsDal": {
|
||||
"BaseUrl": "http://operations-dal:8080"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@ -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,41 @@ 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 GetCustomerOrderDetailUseCase_ReturnsSpecificLifecycleOrder()
|
||||
{
|
||||
var useCase = new GetCustomerOrderDetailUseCase(workflowPort);
|
||||
|
||||
var response = await useCase.HandleAsync(
|
||||
new GetCustomerOrderDetailRequest("demo-context", "ORD-1001"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("demo-context", response.ContextId);
|
||||
Assert.NotNull(response.Order);
|
||||
Assert.Equal("ORD-1001", response.Order!.OrderId);
|
||||
Assert.NotEmpty(response.RecentEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomerOrderHistoryUseCase_ReturnsLifecycleBackedHistory()
|
||||
{
|
||||
var useCase = new GetCustomerOrderHistoryUseCase(workflowPort);
|
||||
|
||||
var response = await useCase.HandleAsync(
|
||||
new GetCustomerOrderHistoryRequest("demo-context"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("demo-context", response.ContextId);
|
||||
Assert.NotEmpty(response.Orders);
|
||||
Assert.NotEmpty(response.RecentEvents);
|
||||
Assert.Contains("lifecycle-backed history", response.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPosTransactionSummaryUseCase_ReturnsOnlyPayableChecks()
|
||||
{
|
||||
var useCase = new GetPosTransactionSummaryUseCase(workflowPort);
|
||||
|
||||
@ -54,19 +96,53 @@ 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 GetPosTransactionDetailUseCase_ReturnsLifecycleBackedCheck()
|
||||
{
|
||||
var useCase = new GetPosTransactionDetailUseCase(workflowPort);
|
||||
|
||||
var response = await useCase.HandleAsync(
|
||||
new GetPosTransactionDetailRequest("demo-context", "CHK-1002"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("demo-context", response.ContextId);
|
||||
Assert.NotNull(response.Transaction);
|
||||
Assert.Equal("CHK-1002", response.Transaction!.TransactionId);
|
||||
Assert.Equal("awaiting-payment", response.Transaction.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user