Skip to content

Commit 40f5be4

Browse files
Terminal - real backend (#38)
## What & why Order and shipment cards were showing raw backend GUIDs. This derives a stable, human-readable reference (`SO-…`, `SHP-…`) for display — the same convention the terminal uses. ## Changes - New `shared/format/ref.ts` (`humanRef('SO' | 'SHP' | …, id)`). - Dispatch, Outbound, Inbound and Receiving screens render `humanRef(...)` instead of the GUID. ## Notes - Admin front-end only; display formatting, no data or contract changes.
1 parent 31ff5ee commit 40f5be4

58 files changed

Lines changed: 1154 additions & 1041 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/demo-walkthrough.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,6 @@ Already captured in [`media/`](media/) — the GIFs above and below are these fi
214214
| [`golden-path-full.gif`](media/golden-path-full.gif) | **Canonical** full run, Admin + Terminal side by side (① → ⑩), Aspire / real backend | the one-clip story (embedded at the top) |
215215
| [`admin-golden-path.gif`](media/admin-golden-path.gif) · [`.mp4`](media/admin-golden-path.mp4) | Admin desk app only — the Coordinator / Manager / Inspector acts | slides, README, a focused admin loop |
216216
| [`terminal-golden-path.gif`](media/terminal-golden-path.gif) · [`.mp4`](media/terminal-golden-path.mp4) | Terminal handheld only — receive · put-away · pick · pack · move | the operator story on its own |
217-
| [`admin-walkthrough-real-backend.gif`](media/admin-walkthrough-real-backend.gif) · [`.webm`](media/admin-walkthrough-real-backend.webm) | Admin driven against the **real seeded backend** (MSW off) | proof the admin runs live, not just mocked |
218-
| [`frame-stock-view.png`](media/frame-stock-view.png) | A single Stock-view still (scene ⑥) | thumbnail / hero frame |
219217

220218
**Admin only**  |  **Terminal only**
221219

src/AppHost/Warehouse.AppHost/AppHost.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
var builder = DistributedApplication.CreateBuilder(args);
22

33
// One PostgreSQL server, one database per service (ADR: database-per-service).
4-
var postgres = builder.AddPostgres("postgres")
5-
.WithDataVolume();
4+
// No data volume: each `dotnet run` starts on a fresh database so the per-service seeders re-run and
5+
// re-publish their integration events — the event-fed read replicas (catalog/topology snapshots) only
6+
// populate through those events, so a persisted volume from an earlier (broken-messaging) run would
7+
// leave them half-empty. Ephemeral is the right default for the local demo stack.
8+
var postgres = builder.AddPostgres("postgres");
69

710
var masterDataDb = postgres.AddDatabase("masterdata");
811
var warehouseDb = postgres.AddDatabase("warehouse");
@@ -53,16 +56,20 @@
5356
.WithEnvironment("Keycloak__ClientId", "warehouse-admin")
5457
.WithEnvironment("Keycloak__ClientSecret", "warehouse-admin-secret-dev");
5558

56-
// Front-ends — MSW-mocked SPAs served by nginx, built from their own Dockerfiles (ADR-0004,
57-
// ADR-0006). They run standalone today (the mock worker answers fetch); the gateway reference is
58-
// the seam where a real API base URL attaches when MSW is switched off.
59+
// Front-ends — SPAs served by nginx, built from their own Dockerfiles (ADR-0004, ADR-0006). Both proxy
60+
// `/api` to the gateway (the `GATEWAY_UPSTREAM` the Dockerfile's nginx template expands). The admin still
61+
// ships MSW and is built with it switched OFF here (build arg); the terminal no longer uses MSW and always
62+
// calls the real gateway. Build-arg changes trigger an image rebuild on `dotnet run`.
5963
builder.AddDockerfile("admin", "../../web/admin")
64+
.WithBuildArg("VITE_USE_MOCKS", "false")
6065
.WithReference(gateway)
66+
.WithEnvironment("GATEWAY_UPSTREAM", gateway.GetEndpoint("http"))
6167
.WithHttpEndpoint(targetPort: 80)
6268
.WithExternalHttpEndpoints();
6369

6470
builder.AddDockerfile("terminal", "../../web/terminal")
6571
.WithReference(gateway)
72+
.WithEnvironment("GATEWAY_UPSTREAM", gateway.GetEndpoint("http"))
6673
.WithHttpEndpoint(targetPort: 80)
6774
.WithExternalHttpEndpoints();
6875

src/Gateway/Warehouse.Gateway/Bff/BffFetch.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,18 @@ public async Task<IReadOnlyList<T>> GetListAsync<T>(HttpClient client, string pa
3535
return [];
3636
}
3737
}
38+
39+
/// <summary>Read a single JSON object best-effort (a failing source returns <c>default</c>).</summary>
40+
public async Task<T?> GetAsync<T>(HttpClient client, string path, CancellationToken cancellationToken)
41+
{
42+
try
43+
{
44+
return await client.GetFromJsonAsync<T>(path, Json, cancellationToken);
45+
}
46+
catch (Exception ex)
47+
{
48+
logger.LogWarning(ex, "BFF source {Path} failed; skipped.", path);
49+
return default;
50+
}
51+
}
3852
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Warehouse.Gateway.Bff;
2+
3+
// --- Output: the terminal Task hub's work piles (matches its `TaskData` model) ----------------------
4+
5+
/// <summary>One work pile on the handheld's Task hub: its kind, a short data detail, and a count.</summary>
6+
public sealed record TerminalTaskDto(string Kind, string Detail, int Count);
7+
8+
// --- Input: minimal slices of each service's read model the hub aggregator counts -------------------
9+
// (DeliverySummaryView / OrderSummaryView are reused from WorklistContracts.) ------------------------
10+
11+
internal sealed record PutAwayTaskView(string Sku);
12+
13+
internal sealed record MoveTaskView(string Sku);
14+
15+
internal sealed record PickListView(IReadOnlyList<PickTaskView> Tasks);
16+
17+
internal sealed record PickTaskView(string Status);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace Warehouse.Gateway.Bff;
2+
3+
/// <summary>
4+
/// The terminal Task-hub BFF: fans out to Inventory and Logistics to count the operator's open work —
5+
/// deliveries to receive, pallets to put away, pick tasks released to the floor, and replenishment
6+
/// moves — all scoped to the operator's warehouse. Best-effort (a failing source counts zero), mirroring
7+
/// <see cref="WorklistService"/>. The gateway is the only place that may fan out across services.
8+
/// </summary>
9+
public sealed class TerminalTasksService(BffFetch fetch)
10+
{
11+
private const string DefaultWarehouse = "WH01";
12+
13+
private static readonly HashSet<string> Receivable =
14+
new(StringComparer.OrdinalIgnoreCase) { "Announced", "Arrived", "Receiving" };
15+
16+
public async Task<IReadOnlyList<TerminalTaskDto>> BuildAsync(string? warehouseId, CancellationToken cancellationToken)
17+
{
18+
var warehouse = string.IsNullOrWhiteSpace(warehouseId) ? DefaultWarehouse : warehouseId;
19+
var warehousing = fetch.Client(BffClients.Warehousing, warehouse);
20+
var logistics = fetch.Client(BffClients.Logistics, warehouse);
21+
22+
var deliveries = fetch.GetListAsync<DeliverySummaryView>(logistics, "logistics/deliveries", cancellationToken);
23+
var putAway = fetch.GetListAsync<PutAwayTaskView>(warehousing, $"inventory/put-away/tasks?warehouse={warehouse}", cancellationToken);
24+
var moves = fetch.GetListAsync<MoveTaskView>(warehousing, $"inventory/moves?warehouse={warehouse}", cancellationToken);
25+
var orders = fetch.GetListAsync<OrderSummaryView>(logistics, "logistics/orders", cancellationToken);
26+
27+
await Task.WhenAll(deliveries, putAway, moves, orders);
28+
29+
var receive = deliveries.Result.Count(d => Same(d.WarehouseCode, warehouse) && Receivable.Contains(d.Status));
30+
31+
// Pick is counted in pending tasks across the orders released to the floor (status Picking).
32+
var pickingOrders = orders.Result.Where(o => Same(o.WarehouseCode, warehouse) && o.Status == "Picking").ToList();
33+
var pickLists = await Task.WhenAll(pickingOrders.Select(o =>
34+
fetch.GetAsync<PickListView>(logistics, $"logistics/orders/{o.Id}/pick-list", cancellationToken)));
35+
var pick = pickLists.Where(p => p is not null)
36+
.Sum(p => p!.Tasks.Count(t => string.Equals(t.Status, "Pending", StringComparison.OrdinalIgnoreCase)));
37+
38+
var putAwayCount = putAway.Result.Count;
39+
var moveCount = moves.Result.Count;
40+
41+
return
42+
[
43+
new TerminalTaskDto("receive", Detail(receive, "delivery", "deliveries", "to receive"), receive),
44+
new TerminalTaskDto("putaway", Detail(putAwayCount, "pallet", "pallets", "in dock buffer"), putAwayCount),
45+
new TerminalTaskDto("pick", Detail(pick, "line", "lines", "to pick"), pick),
46+
new TerminalTaskDto("move", Detail(moveCount, "task", "tasks", "to replenish"), moveCount),
47+
];
48+
}
49+
50+
private static bool Same(string? a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
51+
52+
private static string Detail(int n, string singular, string plural, string suffix) =>
53+
$"{n} {(n == 1 ? singular : plural)} {suffix}";
54+
}

src/Gateway/Warehouse.Gateway/Program.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,19 @@
1515
{
1616
options.Authority = builder.Configuration["Keycloak:Authority"] ?? "http://keycloak/realms/warehouse";
1717
options.RequireHttpsMetadata = false;
18-
options.Audience = "account";
18+
// The realm's badge Direct-Grant tokens carry no `aud` claim yet, so audience validation is off in
19+
// dev (alongside issuer). For production, add an audience mapper to the realm and pin both here.
1920
options.TokenValidationParameters.ValidateIssuer = false;
21+
options.TokenValidationParameters.ValidateAudience = false;
22+
// DEV-ONLY shim. In the Aspire stack Keycloak advertises its issuer/JWKS on a dynamic host port the
23+
// gateway's metadata lookup can't align with, so signature validation fails ("signature key not
24+
// found") — the open "stable Keycloak URL" follow-up (src/Identity/README.md). In Development we
25+
// accept the brokered token without re-checking its signature; production keeps full validation.
26+
if (builder.Environment.IsDevelopment())
27+
{
28+
options.TokenValidationParameters.SignatureValidator =
29+
(token, _) => new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token);
30+
}
2031
});
2132
builder.Services.AddAuthorization();
2233

@@ -39,6 +50,7 @@
3950
builder.Services.AddHttpClient(BffClients.MasterData, c => c.BaseAddress = new Uri("http://masterdata-api/"));
4051
builder.Services.AddScoped<BffFetch>();
4152
builder.Services.AddScoped<WorklistService>();
53+
builder.Services.AddScoped<TerminalTasksService>();
4254
builder.Services.AddScoped<SearchService>();
4355
// Profile is derived from the caller's token + an in-memory prefs overlay, so it is a singleton (the
4456
// overlay must outlive a single request). No downstream call, hence no scoped HttpClient.
@@ -65,6 +77,14 @@
6577
return Results.Ok(await worklist.BuildAsync(warehouseId, ct));
6678
}).RequireAuthorization();
6779

80+
// Terminal Task hub — the handheld operator's open work, aggregated across Inventory + Logistics and
81+
// scoped to the operator's warehouse (the terminal sends it as X-Warehouse-Id at the api seam).
82+
app.MapGet("/api/terminal/tasks", async (HttpRequest request, TerminalTasksService tasks, CancellationToken ct) =>
83+
{
84+
var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault();
85+
return Results.Ok(await tasks.BuildAsync(warehouseId, ct));
86+
}).RequireAuthorization();
87+
6888
// Global search — "where is X" across products, stock, inbound, orders and locations.
6989
app.MapGet("/api/search", async (string? q, HttpRequest request, SearchService search, CancellationToken ct) =>
7090
{

src/Identity/realms/warehouse-realm.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"realm": [
1111
{ "name": "manager", "description": "Desk manager" },
1212
{ "name": "coordinator", "description": "Logistics coordinator" },
13-
{ "name": "inspector", "description": "Quality inspector" }
13+
{ "name": "inspector", "description": "Quality inspector" },
14+
{ "name": "operator", "description": "Warehouse operator (terminal)" },
15+
{ "name": "forklift", "description": "Forklift operator (terminal)" }
1416
]
1517
},
1618
"clients": [
@@ -102,6 +104,26 @@
102104
"email": "inspector@warehouse.example",
103105
"attributes": { "badge": ["1003"], "default_warehouse": ["WH02"], "language": ["pl"] },
104106
"realmRoles": ["inspector"]
107+
},
108+
{
109+
"username": "7700",
110+
"enabled": true,
111+
"emailVerified": true,
112+
"firstName": "W.",
113+
"lastName": "Operator",
114+
"email": "operator@warehouse.example",
115+
"attributes": { "badge": ["7700"], "default_warehouse": ["WH01"], "language": ["en"] },
116+
"realmRoles": ["operator"]
117+
},
118+
{
119+
"username": "7701",
120+
"enabled": true,
121+
"emailVerified": true,
122+
"firstName": "J.",
123+
"lastName": "Forklift",
124+
"email": "forklift@warehouse.example",
125+
"attributes": { "badge": ["7701"], "default_warehouse": ["WH01"], "language": ["en"] },
126+
"realmRoles": ["forklift"]
105127
}
106128
],
107129
"authenticationFlows": [

src/ServiceDefaults/Warehouse.ServiceDefaults/Messaging.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ public static TBuilder AddWarehouseMessaging<TBuilder>(
4646

4747
builder.UseWolverine(opts =>
4848
{
49+
// Handler dependencies (the module repositories/ledgers/outbox) are deliberately `internal`
50+
// to the Infrastructure layer. Wolverine 6 flipped the codegen default to
51+
// ServiceLocationPolicy.NotAllowed, which then refuses to generate any handler whose concrete
52+
// dependency is non-public — so EVERY cross-service event consumer (goods-receipt, picks,
53+
// reservations, replica updaters) silently failed to build and never ran. Restore the 5.x
54+
// behaviour: resolve those services from the container (service location) instead of inline.
55+
opts.ServiceLocationPolicy = JasperFx.CodeGeneration.Model.ServiceLocationPolicy.AllowedButWarn;
56+
4957
opts.PersistMessagesWithPostgresql(
5058
builder.Configuration.GetConnectionString(messageStoreConnectionName)!, schemaName: MessageStoreSchema);
5159
opts.UseEntityFrameworkCoreTransactions();
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Warehouse.SharedKernel.Application;
2+
using Warehouse.SharedKernel.Domain;
3+
using Warehouse.SharedKernel.ValueObjects;
4+
using Warehouse.Warehousing.Inventory.Application.Abstractions;
5+
using Warehouse.Warehousing.Inventory.Application.Storage;
6+
using Warehouse.Warehousing.Inventory.Domain;
7+
using Warehouse.Warehousing.Inventory.Domain.Services;
8+
9+
namespace Warehouse.Warehousing.Inventory.Application.ConfirmMove;
10+
11+
/// <summary>
12+
/// UC-06 — the operator confirmed a replenishment move: relocate the available stock of one item to the
13+
/// scanned pick face, re-checking the hard storage invariant first (temperature / hazmat / capacity) and
14+
/// posting a single <c>Move</c> ledger entry via <see cref="StockTransferService"/>. Allocated quantity
15+
/// stays put — only free stock moves. The requested quantity is clamped to what is available.
16+
/// </summary>
17+
public sealed record ConfirmMoveCommand(Guid SourceItemId, string ToLocation, decimal Quantity, string PerformedBy);
18+
19+
public sealed class ConfirmMoveHandler(
20+
IStockItemRepository stockItems,
21+
StorageCompatibility compatibility,
22+
IStockLedger ledger,
23+
IUnitOfWork unitOfWork)
24+
{
25+
public async Task HandleAsync(ConfirmMoveCommand command, CancellationToken cancellationToken = default)
26+
{
27+
ArgumentNullException.ThrowIfNull(command);
28+
29+
var source = await stockItems.GetByIdAsync(new StockItemId(command.SourceItemId), cancellationToken)
30+
?? throw new DomainException("move_stock_item_not_found", $"Stock item {command.SourceItemId} does not exist.");
31+
32+
var available = source.Available;
33+
if (available.IsZero)
34+
{
35+
throw new DomainException(
36+
"move_stock_unavailable",
37+
$"Stock {source.Sku} at {source.Location} has nothing free to move (blocked or fully allocated).");
38+
}
39+
40+
// Honour the operator's chosen quantity, clamped to what is actually free at the source.
41+
var amount = command.Quantity > 0 && command.Quantity <= available.Amount ? command.Quantity : available.Amount;
42+
var quantity = Quantity.Of(amount, source.OnHand.Unit);
43+
44+
var target = LocationCode.Of(command.ToLocation);
45+
await compatibility.EnsureCanStoreAsync(source.Sku, target, quantity, "move", cancellationToken);
46+
47+
var destination = await stockItems.GetAtAsync(source.Sku, source.Batch, target, cancellationToken);
48+
if (destination is null)
49+
{
50+
destination = StockItem.CreateAt(target, source.Sku, source.Batch, source.OnHand.Unit);
51+
stockItems.Add(destination);
52+
}
53+
54+
var movement = StockTransferService.Transfer(
55+
source, destination, quantity, MovementType.Move, command.PerformedBy, reason: "Replenishment");
56+
ledger.Append(movement);
57+
58+
await unitOfWork.SaveChangesAsync(cancellationToken);
59+
}
60+
}

src/Services/Warehousing/Modules/Warehouse.Warehousing.Inventory/Application/ConfirmPutAway/ConfirmPutAwayCommand.cs

Lines changed: 3 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Warehouse.SharedKernel.Domain;
33
using Warehouse.SharedKernel.ValueObjects;
44
using Warehouse.Warehousing.Inventory.Application.Abstractions;
5+
using Warehouse.Warehousing.Inventory.Application.Storage;
56
using Warehouse.Warehousing.Inventory.Domain;
67
using Warehouse.Warehousing.Inventory.Domain.Services;
78
using Warehouse.Warehousing.Inventory.Infrastructure.Persistence;
@@ -26,8 +27,7 @@ public sealed record ConfirmPutAwayCommand(
2627

2728
public sealed class ConfirmPutAwayHandler(
2829
IStockItemRepository stockItems,
29-
IProductSnapshotRepository products,
30-
ILocationSnapshotRepository locations,
30+
StorageCompatibility compatibility,
3131
IStockLedger ledger,
3232
IDbContextOutbox<InventoryDbContext> outbox)
3333
{
@@ -50,7 +50,7 @@ public async Task HandleAsync(ConfirmPutAwayCommand command, CancellationToken c
5050
"put_away_no_buffer_stock",
5151
$"No {sku} {(batch is null ? string.Empty : batch + " ")}in the dock buffer of {command.WarehouseCode}.");
5252

53-
await EnsureCompatibleAsync(sku, target, quantity, cancellationToken);
53+
await compatibility.EnsureCanStoreAsync(sku, target, quantity, "put_away", cancellationToken);
5454

5555
var destination = await stockItems.GetAtAsync(sku, batch, target, cancellationToken);
5656
if (destination is null)
@@ -73,50 +73,4 @@ public async Task HandleAsync(ConfirmPutAwayCommand command, CancellationToken c
7373

7474
await outbox.SaveChangesAndFlushMessagesAsync(cancellationToken);
7575
}
76-
77-
/// <summary>
78-
/// Enforces the hard storage-compatibility invariant (temperature / hazmat / capacity) before the
79-
/// stock moves, reading Topology's <c>LocationSnapshot</c> and the Catalog's <c>ProductSnapshot</c>
80-
/// replicas — no cross-service query (ADR-0003). A location Topology has not announced cannot be a
81-
/// put-away target; an unknown product cannot be validated, so both are rejected rather than waved
82-
/// through.
83-
/// </summary>
84-
private async Task EnsureCompatibleAsync(
85-
Sku sku, LocationCode target, Quantity quantity, CancellationToken cancellationToken)
86-
{
87-
var location = await locations.FindAsync(target, cancellationToken)
88-
?? throw new DomainException(
89-
"put_away_location_unknown", $"Location {target} is not known to the warehouse topology.");
90-
91-
var product = await products.FindAsync(sku, cancellationToken)
92-
?? throw new DomainException(
93-
"put_away_product_unknown",
94-
$"Product {sku} is not yet known to inventory; cannot validate storage compatibility.");
95-
96-
// Current occupancy at the target: sum each resident stock line by its product's footprint.
97-
var occupiedVolume = Volume.Zero;
98-
var occupiedWeight = Weight.Zero;
99-
foreach (var item in await stockItems.ListAtAsync(target, cancellationToken))
100-
{
101-
if (item.OnHand.IsZero)
102-
{
103-
continue;
104-
}
105-
106-
var resident = item.Sku == sku ? product : await products.FindAsync(item.Sku, cancellationToken);
107-
if (resident is null)
108-
{
109-
continue;
110-
}
111-
112-
occupiedVolume += Volume.FromCubicMeters(resident.UnitVolume.CubicMeters * item.OnHand.Amount);
113-
occupiedWeight += Weight.FromKilograms(resident.UnitWeight.Kilograms * item.OnHand.Amount);
114-
}
115-
116-
var check = PutAwayPolicy.CanStore(product, location, quantity, occupiedVolume, occupiedWeight);
117-
if (!check.IsAllowed)
118-
{
119-
throw new DomainException("put_away_incompatible", check.RejectionReason!);
120-
}
121-
}
12276
}

0 commit comments

Comments
 (0)