[T03] Stock Return Workflow

Summary

Stock-return workflow. Operator scans / imports / pastes a list of returned numbers; system clears person_id and transitions each to IN_STOCK (or preserves UNFIT_FOR_SERVICE and surfaces "recommend disposal"). Treats pre-assigned and stock-held tags identically per the project feedback feedback_returns_stock_model.md.

The endpoint (POST /api/race-numbers/return) is idempotent by construction: re-submitting a number that’s already in IN_STOCK, MANUFACTURED, or DESTROYED produces a NO_OP outcome row, no state mutation, no person_id change. Operators can re-submit a partial list without fear (see Idempotency & double-scan below).

Actor & Context

Actor: stock operator, tenant admin. Frequency: post-event; periodic stock-takes; ad-hoc loan returns. Precondition: user has TENANT_ADMIN permission; physical numbers in hand or scanned data ready. Entry point:

  • Tenant-admin sidebar Inventory ▸ Return (ADO-541-sidebar-tenant-scope) — empty list.

  • T02 bulk-select toolbar Return action — list pre-loaded with the selected ids.

Layout

T03 renders inside C01’s shell — sidebar (tenant scope) + topbar are C01’s. T03 owns the main content area.

Region Content

Topbar

Breadcrumb Tenant ▸ Inventory ▸ Return + Jump-to (⌘K) + notifications bell. Provided by C01.

Stage 1 — Build returned list

Two-column layout: left = scan / paste / type input (focus-on-mount); right = the returned-list builder showing every entry the operator has added with its lookup status (Found, Not found, Already submitted in this session). Optional note input (free text) applies to every entry confirmed in this session — passes through as the note field on the bulk request.

Stage 2 — Confirmation

Pre-flight summary card. Counts by predicted outcome (using the row’s current state to predict): RETURNED (will clear person_id, transition to IN_STOCK) · DISPOSAL_RECOMMENDED (UNFIT, will clear person_id, state preserved with note) · NO_OP (already in stock or terminal) · NOT_FOUND (no matching number). Operator clicks Submit.

Stage 3 — Outcome summary

Per-number outcome list. Same four codes (RETURNED, DISPOSAL_RECOMMENDED, NO_OP, NOT_FOUND); group-by-outcome with counts in tab headers. DISPOSAL_RECOMMENDED group surfaces a Dispose these N numbers shortcut → opens T04 confirm dialog with the UNFIT ids pre-loaded. Per-row link to T02 drill-down via GET /api/race-numbers/{id}/state-log (CD-5 — Thread B is now scoped to ship this endpoint). Footer: Return more numbers (resets to Stage 1) + Done (navigates back to T02).

Right-rail "About this screen" panel

Persistent collapsible panel (cross-cutting pattern from C04 / C05 / E06). Stage-aware copy: in Stage 1 explains what counts as a valid scan; in Stage 2 explains the four outcome codes; in Stage 3 explains what DISPOSAL_RECOMMENDED means and what to do next.

Main Flow

  1. Operator arrives at T03 — directly via sidebar, or pre-loaded from a T02 bulk-select.

  2. Stage 1 — Build the returned list. Operator scans / pastes / types numbers. For each entry:

    1. Client tries to resolve the input to a RaceNumber.id via search (number, sequence, or tag-mate barcode — match exactly one, scoped to the tenant). On unique match → row added with current state and predicted outcome chip. On zero matches → row added in Not found state. On multiple matches → row added in Ambiguous state with a small picker (rare; only when a barcode partially matches multiple records).

    2. Duplicates within the session are silently rolled up — second scan of the same number shows a Already in list indicator on the existing row, increments a small "scanned ×N" counter, and does not add a new row.

  3. Stage 2 — Confirmation. Operator reviews the predicted-outcome counts and the optional note. Submit calls POST /api/race-numbers/return with { numberIds, note }.

  4. Stage 3 — Outcome summary. Render the response DTO. Group by outcome; offer Dispose these N shortcut on the DISPOSAL_RECOMMENDED group; per-row drill-down link.

  5. From Stage 3 the operator can Return more numbers (resets to Stage 1, fresh empty list), launch T04 dispose, or click Done to return to T02.

Idempotency & double-scan

The return endpoint is idempotent by construction (per RaceNumberResourceEx#returnNumbers Javadoc):

  • ISSUED / IN_USEIN_STOCK, person_id cleared, log RETURNED entry. Outcome RETURNED.

  • UNFIT_FOR_SERVICE → state preserved, person_id cleared, log RETURNED entry with "recommend disposal" note. Outcome DISPOSAL_RECOMMENDED.

  • IN_STOCK / DESTROYED / MANUFACTURED → no-op (no state change, no person_id change, no log entry). Outcome NO_OP.

  • id not found → outcome NOT_FOUND.

UI consequences for double-scan:

  • Within a session — rolled up client-side. The second scan shows the Already in list indicator on the existing row and increments the visible "scanned ×N" counter so the operator gets feedback that the scanner did fire, without bloating the list. Submit only sends the unique id once.

  • Across sessions — re-submitting a number that’s already been returned (now IN_STOCK) produces a NO_OP outcome row in Stage 3; no state mutation, no person_id mutation. The summary’s NO_OP group surfaces all such rows together so the operator can confirm at a glance that the re-submit was harmless.

  • Re-submit after partial network failure — the same property holds: any rows that landed last time are now IN_STOCK and produce NO_OP; rows that didn’t land transition normally. Operators can safely re-submit the entire returned list rather than reconstructing the partial set. This is the design intent — call it out in the right-rail "About this screen" panel.

The client does NOT pre-filter IN_STOCK rows out of the submit body. Sending them is harmless (idempotent) and the resulting NO_OP outcome rows are evidence of the operator’s actual scanning effort.

Outcome aggregation copy (Stage 3)

Outcome Header copy Sub-context

RETURNED

"Returned to stock — N"

"These numbers were with a participant or in use; they’re now back in stock and available for re-issue."

DISPOSAL_RECOMMENDED

"Recommend disposal — N"

"These numbers were flagged UNFIT before return. Ownership cleared. Use Dispose these N below to mark them DESTROYED, or keep them flagged for later." Includes a primary Dispose these N button → T04.

NO_OP

"Already in stock or terminal — N"

"Nothing to do for these — they were already in stock, never tracked, or already destroyed. Safe outcome of a double-scan or a re-submit after a partial failure."

NOT_FOUND

"Not recognised — N"

"We couldn’t find a number matching this scan. Most often a typo, a foreign-system number, or a damaged barcode. Use Edit list to correct or remove."

The NOT_FOUND group also offers Edit list → returns to Stage 1 with the current list rehydrated, focus on the first NOT_FOUND row’s input.

Alternative Flows

  • AF-1 — Scanned ID not found: row added in Not found state (Stage 1) and surfaces in the NOT_FOUND group at Stage 3. Operator can edit / remove / re-scan.

  • AF-2 — UNFIT in the returned list: surfaces as DISPOSAL_RECOMMENDED at Stage 3 with the Dispose these N shortcut to T04.

  • AF-3 — Loan number return: clears person_id (state appropriate to the row); preserves event_participant.number_id history for past results (per the lifecycle journal — EP→number link is intentionally retained).

  • AF-4 — Operator re-submits the same list mid-session (e.g. clicks Submit twice): client disables the Submit button while the request is in flight; if the network fails, the button re-enables and the operator can retry — re-submission is idempotent (see above).

  • AF-5 — Partial server-side failure (e.g. one id throws): per-id outcome row appears in Stage 3 under a fifth code only if the backend returns it. (Today’s endpoint always returns one of the four codes; we don’t pre-design for new codes.)

  • AF-6 — Operator scans a DESTROYED number: NO_OP outcome with sub-context "this number was previously destroyed" so the surprise is visible. No special path; just clear copy.

  • AF-7 — Bulk pre-load from T02 → T03: arriving with the list rehydrated, Stage 1 still allows editing (add/remove rows, append new scans) before confirming. Operator might combine a T02-selected set with hand-scans before submitting.

Acceptance Criteria

  • Three-stage UI: build → confirm → outcome.

  • Pre-assigned + stock-held returns treated identically; ownership (person_id) cleared uniformly. EP→number history preserved.

  • UNFIT_FOR_SERVICE rows surface in DISPOSAL_RECOMMENDED outcome with a Dispose these N shortcut to T04.

  • Double-scan within a session is rolled up client-side with a visible "scanned ×N" counter; submit body contains the id once.

  • Re-submit across sessions yields NO_OP outcome rows; no state mutation. Outcome summary explains this is a safe outcome.

  • NOT_FOUND rows offer an Edit list action that returns to Stage 1 with the list rehydrated and focus on the first NOT_FOUND row’s input.

  • Drill-down link per outcome row navigates to T02 with the row’s bib id pre-opened in the audit-log side panel (loads from GET /api/race-numbers/{id}/state-log per CD-5).

  • Right-rail "About this screen" panel renders stage-aware copy.

  • T02 → T03 pre-load via route state works; operator can edit the list before submit.

API Surface

Call Purpose

POST /api/race-numbers/return

Batch return. Request: { "numberIds": [Long…​], "note": "string?" }. Response: RaceNumberReturnResponseDTO with per-id outcome (RETURNED / DISPOSAL_RECOMMENDED / NO_OP / NOT_FOUND). Existing endpoint on RaceNumberResourceEx.

GET /api/race-numbers/stock?…​&q={scan} or equivalent lookup

Stage 1 client-side resolution of scan input → RaceNumber.id. Calls the existing /stock query (CD-6) with q=<scan>&size=2 and treats >1 result as an Ambiguous outcome. (Open Question 1 below — Thread B may add a dedicated single-row lookup during the criteria sweep; T03 prefers it if it lands.)

GET /api/race-numbers/{id}/state-log

Drill-down link from outcome rows (CD-5). T03 navigates to T02 with ?openLog={id} so T02’s drill-down panel pre-opens; T03 itself does not embed the panel.

POST /api/race-numbers/dispose

Used indirectly via the Dispose these N shortcut → T04 confirm dialog.

Out of Scope

  • Stock browsing — T02.

  • Bulk flag/dispose for UNFIT — T04 (T03 only links to it).

  • Tag-level scanning (scan a tag barcode to identify the paired number) — pending WS3 (RaceNumber.tag_id FK; Thread D ships #478a; tag-mate scanning UI lives under C02 Track 3).

  • CSV import of returned numbers — out of scope this round; pasted spreadsheet rows are absorbed into the Stage 1 input (one number per line) but a structured CSV import path is deferred.

  • Customer notification on return — out of scope this round.

Design Anchors

  • Portal Pattern

  • UI Design Principles

  • T02 — entry point and post-flight return

  • T04Dispose these N shortcut from DISPOSAL_RECOMMENDED

  • ADR-0001RaceNumberAssignmentServiceEx.returnNumbers is the single state-log writer

  • Project memory: feedback_returns_stock_model.md — uniform ownership clearing rule

  • feedback_explain_disabled_ui.md — disabled-action tooltip rule

Design Decisions

  • Idempotent by construction; UI does not pre-filter (2026-05-07). The backend is idempotent for IN_STOCK / MANUFACTURED / DESTROYED inputs (returns NO_OP). The UI does NOT strip these out client-side because (a) the operator’s intent is "I scanned these"; (b) NO_OP outcomes are positive evidence of effort; (c) it makes session-restart and partial-failure recovery trivial — re-submit the whole list.

  • Within-session de-dup is client-side only (2026-05-07). Same number scanned twice in one session: client rolls it up to a single list entry with a visible "scanned ×N" counter. Submit body contains the id once. Across sessions, no de-dup — re-submit is harmless and produces a NO_OP outcome row.

  • Three stages, never two (2026-05-07). Build → confirm → summary. The confirmation stage is non-negotiable even when the list is small — operators are about to mutate stock state and the predicted-outcome counts are the last point at which they can back out without a state change. Inline auto-submit (skip Stage 2) was considered and rejected: it removes the only point in the flow where they see what’s about to happen.

  • DISPOSAL_RECOMMENDED routes to T04 (2026-05-07). Returning UNFIT numbers does not auto-dispose them — the dispose action remains explicit (per the lifecycle journal’s WS1a session 2 decision). T03’s outcome summary makes the next step a single click via the Dispose these N shortcut to T04, but the actual → DESTROYED transition still goes through T04’s confirm dialog with its irreversible-DESTROYED warning copy.

  • Right-rail "About this screen" panel (2026-05-07). Same cross-cutting pattern as C04 / C05 / E06. Stage-aware copy. Default expanded on first open, collapse state persisted in localStorage.

  • No CSV import in v1 (2026-05-07). Pasted spreadsheet rows (one per line) are absorbed into the Stage 1 input — that’s enough for v1. A structured CSV import would mirror E06/E08 + C05 and is deferred until a real ops complaint surfaces.

Open Questions

  1. Scan-resolution endpoint — Stage 1 needs to resolve scan input to a RaceNumber.id. The existing /stock?…​&q= covers the common case but is paginated (excessive for one row). If Thread B’s criteria sweep adds a dedicated single-row lookup (e.g. GET /api/race-numbers/lookup?value=…​), T03 prefers that. If not, T03 calls /stock?q=…​&size=2 and treats >1 result as an Ambiguous outcome. Action: Thread B reports whether a dedicated lookup is added during the sweep; T03 adopts whatever’s there. Sole remaining open question after CD-4/-5/-6/-7.

Notes

Honour the feedback rule: returns clear ownership uniformly; do NOT scope by event_id or recency. Post-event return clears person_id while preserving EventParticipant.number_id history for past results.

Status: handoff-ready (2026-05-07). T03 inherits T02’s CD-5 (state-log endpoint) and CD-6 (list endpoint at /stock). The Claude Design hand-off prompt below is paste-ready; the orchestrator dispatches it.

Appendix A: Claude Design Prompts

Prompts persisted for audit trail. Most recent first. The v1 prompt is the active hand-off prompt; it will be re-emitted if Coordination Backlog resolutions change the API surface materially.

v1 — 2026-05-07 — derived from this .adoc

Status: paste-ready. Source: this .adoc at :status: handoff-ready. State-log endpoint locked per CD-5; list endpoint at /stock per CD-6. Tenant scoping deferred per CD-7.

I'm designing a screen for the EMS admin portal — a Spring Boot + Angular SPA
admin tool used by stock operators and tenant admins to manage race-number
inventory. Visual language matches existing portal screens you've designed
in this project (C01, C03, E01, E05, E06, T02). JHipster 8 / Angular 16 /
PrimeNG / ng-bootstrap stack; match fonts, spacing, palette, density.

Design **T03 — the Stock Return Workflow**: a three-stage screen where a
stock operator scans / pastes / types a list of race numbers being returned
to stock, reviews predicted outcomes, submits, and sees per-number outcome
rows.

T03 sits inside C01's shell. Sidebar is in the `tenant` scope (a new third
scope alongside `portfolio` and `event`, added in this design round) under
the "Inventory" group: Stock (T02), Return (THIS), Bulk (T04), Onboard,
Manufacturer export. T03 owns the main content area.

A cross-cutting design rule: any disabled, hidden-by-policy, or limited
affordance MUST surface its reason inline (tooltip on hover, helper text,
empty-state copy). Operators should never have to guess why a control is
unavailable.

A second cross-cutting rule: every full-screen flow in this portal
includes a persistent collapsible right-rail "About this screen" panel
with stage-aware copy — same pattern as C04, C05, E06.

============================================================
Three stages on ONE screen, driven by component state
============================================================

The flow is: BUILD list  →  CONFIRM  →  OUTCOME SUMMARY
                stage 1       stage 2        stage 3

A small stage indicator at the top reads `Build · Confirm · Done`. Pills
are read-only (not clickable).

Persistent UI on every stage:
- Stage indicator at top.
- Right-rail "About this screen" panel — collapsible, default expanded,
  collapse state persisted in localStorage. Stage-aware copy.
- Top-right "Reset" button — clears the list, returns to Stage 1.

============================================================
Stage 1 — Build the returned list
============================================================

Two-column layout:
- Left column (1fr) — Scan input.
  - Big focus-on-mount text input with placeholder "Scan, paste, or type
    a number — Enter to add". Accepts: race-number `number` (e.g.
    "P3014"), sequence (e.g. "3014"), or tag-mate barcode (long digit
    string).
  - Below the input: a small live "Last scan: <value>" line so the
    operator gets visual feedback that the keywedge fired.
  - Optional textarea below for pasting multiple lines (one per line).
    Pasted text is absorbed into the list, one entry per line.
  - Helper text: "USB keywedge, hand-scanner, or paste from a
    spreadsheet — one number per line."
  - Optional "Note (applies to this submission)" text input below the
    main input. Free text. Passes through to the API as the request's
    `note` field.

- Right column (1.4fr) — The returned-list builder.
  - Each entry is a row with:
    - the scanned input (left, monospace)
    - the resolved bib number + state pill (centre) — blank until
      lookup resolves
    - a predicted-outcome chip on the right — one of:
        RETURNED · DISPOSAL_RECOMMENDED · NO_OP · NOT_FOUND · AMBIGUOUS
      with a tooltip showing what each predicts.
    - delete (X) button on the row's far right.
  - When a number is scanned twice in the same session, the existing
    row gains a "scanned ×N" badge; no second row is added.
  - Stage-1 footer: "{N} entries — Continue" primary button (disabled
    while {N} === 0 or any AMBIGUOUS row remains unresolved); helper
    counts: `{X} found · {Y} not found · {Z} ambiguous`.

============================================================
Stage 2 — Confirmation
============================================================

Centred summary card:
- Title: "Confirm return — {N} numbers"
- Predicted-outcome counts as four large tiles:
    Returned to stock · Recommend disposal · Already in stock · Not recognised
  Each tile shows the count and a single-line description of what
  happens (or doesn't) for that outcome — see the .adoc copy table.
- Optional note (read-only — shown if the operator typed one in
  Stage 1).
- Two buttons at the bottom:
  - Secondary: "Back — edit list" → returns to Stage 1 with the list
    intact and focus on the input field.
  - Primary: "Submit return" — disables on click, fires
    POST /api/race-numbers/return, transitions to Stage 3 on response.

A failure-state inline notice appears in this card if the request
fails: "Couldn't submit. {error}. Try again — your list is preserved."
The Submit button re-enables on failure.

============================================================
Stage 3 — Outcome summary
============================================================

Layout:
- Page header: "Returned {N} numbers" + finished-at timestamp.
- Tabbed sections, one per outcome code, in this order with these
  exact tab titles + sub-context blurbs:
    1. "Returned to stock — N"
       "These numbers were with a participant or in use; they're now
        back in stock and available for re-issue."
    2. "Recommend disposal — N"
       "These numbers were flagged UNFIT before return. Ownership
        cleared. Use Dispose these N below to mark them DESTROYED,
        or keep them flagged for later."
       Tab body has a primary button "Dispose these {N}" at the top
       that navigates to T04's confirm dialog with the UNFIT ids
       pre-loaded.
    3. "Already in stock or terminal — N"
       "Nothing to do for these — they were already in stock, never
        tracked, or already destroyed. Safe outcome of a double-scan
        or a re-submit after a partial failure."
    4. "Not recognised — N"
       "We couldn't find a number matching this scan. Most often a
        typo, a foreign-system number, or a damaged barcode. Use
        Edit list to correct or remove."
       Tab body has an "Edit list" action that returns to Stage 1
       with the list rehydrated and focus on the first NOT_FOUND
       row's input.
- Tabs that have zero count are still visible but greyed; click
  shows the tab body with an empty state.
- Per-row entries inside each tab: scanned input (left), resolved bib
  number + state pill (centre), drill-down link "View audit log →" on
  the right. Drill-down link navigates to T02 with the bib id in the
  drill-down panel pre-opened (T02 reads `?openLog={id}` from the URL
  and opens the side panel automatically).
- Footer:
  - Secondary: "Return more numbers" — resets to Stage 1 with an
    empty list.
  - Primary: "Done" — navigates back to T02.

============================================================
Idempotency / double-scan behaviour
============================================================

The right-rail "About this screen" panel must explain on Stage 1, 2,
and 3 that:
- The endpoint is idempotent — re-submitting numbers that are already
  in stock produces "Already in stock" outcome rows; nothing else
  happens.
- A scanner double-firing on the same number produces a single list
  entry with a "scanned ×N" badge — submit body still contains the
  number once.
- Operators can safely re-submit the entire list after a partial
  failure — rows that landed last time return as "Already in stock"
  this time; rows that didn't land transition normally.

============================================================
API surface
============================================================

POST /api/race-numbers/return
  Request:  { "numberIds": [Long...], "note": "string?" }
  Response: { "outcomes": [{ "numberId": Long, "outcome": "RETURNED"|"DISPOSAL_RECOMMENDED"|"NO_OP"|"NOT_FOUND", "scannedInput": "string?", "stateBefore": "...", "stateAfter": "..." }, ...] }

(Stage 1 lookup endpoint — TBD on Thread B's criteria sweep — may be
either a dedicated single-row lookup OR a paginated /stock?q= query.
Render the lookup with whatever shape Thread B confirms.)

POST /api/race-numbers/dispose  (used by the "Dispose these N" shortcut)
  See T04.

GET /api/race-numbers/{id}/state-log
  Returns: List<RaceNumberStateLogDTO> ordered by createdDate DESC.
  T03 does NOT call this directly; the drill-down link navigates to
  T02 with `?openLog={id}` and T02's side panel calls the endpoint.

============================================================
Output
============================================================

For each of these screen states:
  - Stage 1 — empty list (just the input)
  - Stage 1 — list with mixed rows (RETURNED + DISPOSAL_RECOMMENDED +
    NOT_FOUND + a "scanned ×3" double-scan row)
  - Stage 2 — confirmation with 12 / 4 / 2 / 1 outcome counts
  - Stage 3 — outcome summary with all four tabs populated, "Recommend
    disposal" tab open showing the Dispose-these-N shortcut button
  - Stage 3 — outcome summary with a NOT_FOUND-heavy result, "Not
    recognised" tab open showing the Edit-list affordance
  - Stage 2 failure inline notice

Provide:
  - HTML/JSX + matching styles per stage / state.
  - Screen README explaining the three stages, the idempotency
    contract (and how it surfaces in the UI), the four outcome codes,
    and the API endpoints consumed.
  - Cross-screen note: T03 hands off to T04 (modal launch) for the
    Dispose-these-N shortcut, and back to T02 for `Done`. The T02 and
    T04 screens are designed separately in this same round.