[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.

Return and Stock take both post their scans to one endpoint, POST /api/race-numbers/scan, in the session’s batch. It is idempotent per session: a number already scanned in the batch is skipped (SKIPPED_ALREADY_SCANNED) unless it has been issued again since, and a number already in IN_STOCK, MANUFACTURED or DESTROYED is recorded as present with no other 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: the screen runs in one of three modes, chosen before it opens and fixed for the visit.

  • Numbers & Tags command row: Stock take (/o/:org/numbers/stock-take), Return (/o/:org/numbers/return) and Flag unfit (/o/:org/numbers/flag-unfit) — empty list.

  • T02 bulk-select toolbar Return action — Return mode, 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 Organisation ▸ Numbers & Tags ▸ <mode> (Stock take, Return or Flag unfit) + Jump-to (⌘K) + notifications bell. Provided by C01. T03 is a command page, so the Numbers & Tags tab strip is hidden; the section crumb leads back (UI Design Principles — Command pages).

"About this screen" band

Full-width collapsible band above the stage content, below the command row, per UI Design Principles — About this screen band. Expanded on the first visit to a stage, collapsed on later visits until the operator expands it; state remembered per stage. It is the same band on all three stages, so Confirmation and Outcome summary keep the band-above layout. Stage-aware copy: in Stage 1 explains what counts as a valid scan; in Stage 2 explains the outcome codes, including that Already scanned means the number was scanned before in this batch and nothing was recorded again; in Stage 3 explains what DISPOSAL_RECOMMENDED means and what to do next.

Stage 1 — Build the list

Two-column layout across the full content width, below the "About this screen" band.

  • Left, the input panel: scan / paste / type input (focus-on-mount), the flush status and the optional note. The note (free text) applies to every entry confirmed in this session and passes through as the note field on the request. At the foot of the panel, outside its scroll, sit the counters and Continue. In the two-column layout they are always in view without scrolling the page. The counters are found and not found; a Stock take adds expected, the size of the batch’s expected set.

  • Right, the list: its heading and its rows, nothing else. Each row shows the entry and its lookup status (Found, Not found, Already submitted in this session). The heading names the session: Counted list in a Stock take, Returned list in a Return, Flag list in Flag unfit. There is no footer under the list.

Below 900px wide the list stacks under the input panel, full width, and the page is the single scroll owner: the input panel and the list take their natural height, and nothing scrolls inside the stage. In a narrow list, a resolved row puts the resolved board (number, state, type and chip) on its own line under the scan. Type and chip truncate with an ellipsis rather than wrap. Stacked, the counters and Continue sit between the input panel and the list, and scroll with the page; they are not held in view.

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; recorded as present) · ALREADY_SCANNED (Return and Stock take: scanned before in this batch, nothing recorded again) · NOT_FOUND (no matching number). A row a flush already sent shows its actual outcome. Operator clicks Submit.

Stage 3 — Outcome summary

Per-number outcome list. Same codes (RETURNED, DISPOSAL_RECOMMENDED, NO_OP, ALREADY_SCANNED, 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).

Main Flow

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

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

    1. The entry appears in the list at once, showing the captured value in a pending state, and the input is immediately ready for the next scan. The client resolves it in the background by the rules in Number resolution. When the lookup returns, the row becomes resolved (current state and predicted outcome chip), Not found (the captured value stays on show), Ambiguous (a chooser in the row) or Lookup failed (retry).

    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. In Return and Stock take, Continue first sends any queued scans (see Phase 3 — batched flush + offline queue + navigation guard), so the counts are actual outcomes for every row already sent; Submit sends only the rows not yet sent, through POST /api/race-numbers/scan in the session’s batch. Flag unfit sends its whole list to POST /api/race-numbers/flag-unfit.

  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.

Number resolution

The same rules apply on every screen that turns a typed or scanned value into a RaceNumber. This screen is the first to use them.

Source. An entry is manual (typed or pasted, committed with Enter or a button) or wedge (a rapid burst from an RFID or barcode reader emulating a keyboard, which supplies its own Enter). The source is shown as a badge on the row. The source itself is not sent to the server; where an action needs to know it, the screen derives what it submits from it (see Flag unfit by scanning (planned)). A fast burst shorter than 12 characters is not recognised as a wedge scan and is handled as a manual entry; no reader in use produces one.

Classification, applied to the trimmed entry in this order:

Shape Treated as Resolution

Starts with a full stop and at least two letters (.WN, .WP, .WT)

A printed barcode code

Not yet supported — reports Not found.

12 or more characters

A tag barcode (Tag.barcode)

Normalised, then resolved through the number’s tag.

Anything else

A face value (RaceNumber.number)

Exact match, and only for a manual entry. A wedge cannot read a face value, so a wedge entry is never matched against one.

Tag barcode normalisation, done in the client so that the server only ever receives 12 or 13 characters:

  1. If the entry is longer than 17 characters and starts with 2000230316, drop the first four characters. One batch of boards was manufactured with that prefix; the stored barcode does not carry it.

  2. If the entry is longer than 13 characters, keep the first 13. A reader returns whole 16-bit words, so it delivers 16 or 20 characters where 13 are stored.

Lookup. A 13-character barcode is matched exactly. A 12-character barcode — the form the timing system holds — matches any stored barcode that starts with those 12 characters and has one more. The result is every RaceNumber whose tag has that barcode; a number with no tag recorded can only be found by its face value. Sequence is not a lookup key.

Duplicates. A tag barcode is unique, but the same tag can be recorded against more than one number, and the same face value can exist in more than one number type. So a lookup can return several numbers. The screen carries a number-type preference — a multi-select of zero or more number types. Every number type is always searched, and the lookup always returns every match. The preference is applied in the client, to the candidates it holds: where they include a preferred type, only those are offered. Because the client keeps all the candidates, changing the preference — at any time, including while lookups are still in flight — re-evaluates every unresolved row against the current selection, and removing a type can bring candidates back.

Ambiguous rows. A row with more than one remaining candidate shows Ambiguous and a drop-down of the candidates, each labelled with its number type and number. Choosing one resolves the row, adds that number type to the preference if it is not already there, and re-applies the preference to every other ambiguous row — so one choice usually settles the rest. Scanning is never blocked; Continue is disabled while any row is pending or ambiguous.

Idempotency & double-scan

The scan endpoint answers each number with SCANNED, SKIPPED_ALREADY_SCANNED or NOT_FOUND, with its old and new state. The screen reads the outcome from the answer and the old state:

  • SCANNED from ISSUED / IN_USE → IN_STOCK, person_id cleared. Outcome RETURNED.

  • SCANNED from UNFIT_FOR_SERVICE → state preserved, person_id cleared, row noted "recommend disposal". Outcome DISPOSAL_RECOMMENDED.

  • SCANNED from IN_STOCK / DESTROYED / MANUFACTURED → recorded as present; no other change. Outcome NO_OP ("Already in stock"). Unlike the retired /return, this writes a log row, so a board already in stock shows as present rather than No change.

  • SKIPPED_ALREADY_SCANNED → already scanned in this session’s batch (an earlier visit to the same open batch, or another operator); nothing recorded again. Outcome ALREADY_SCANNED ("Already scanned"). A number issued again since its earlier scan is scanned again instead.

  • NOT_FOUND, or a code the client does not know → outcome NOT_FOUND.

In a Return session the scan also takes the board off the entries a return releases (any entry of an event not yet started, and an inactive entry of an event not yet ended), with their chips; the response lists them per number as detachedParticipants, and the Done stage shows them under the row as Cleared from <event> (marked inactive entry where it was). A Stock take leaves entries alone.

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.

  • Within a batch, across visits — a number scanned on an earlier visit to the same open batch comes back ALREADY_SCANNED; nothing is recorded twice.

  • Across batches — a number already returned (now IN_STOCK) is recorded as present in the new batch and comes back NO_OP; no state or person_id change.

  • Re-submit after partial network failure — rows that landed last time come back ALREADY_SCANNED; rows that didn’t land are scanned normally. Operators can safely re-submit the entire list rather than reconstructing the partial set.

Each row keeps the answer from the flush that sent it, so neither a later flush nor Confirm sends it again. The client does NOT pre-filter IN_STOCK rows: the presence row a scan writes is evidence of the operator’s actual scanning effort, and a stock take needs it.

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 — N"

"These were already in stock, never issued, or destroyed. Each is recorded as present in this session; nothing else changed."

ALREADY_SCANNED

"Already scanned — N"

"These were scanned before in this session, on an earlier visit or by someone else. Nothing was recorded again."

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.

  • A number scanned again into the same open batch (a later visit to a Return left open, or a re-submit after a partial failure) yields an ALREADY_SCANNED row and nothing is recorded again, unless the board was issued again since, in which case it is scanned (returned) again.

  • A board already returned and scanned into a new batch yields a NO_OP row: it is recorded as present, with no state or person_id change. The outcome summary explains both are safe outcomes.

  • 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).

  • The "About this screen" band renders full width above the content on every stage, with stage-aware copy; it is expanded on a stage’s first visit and collapsed on later visits.

  • The Numbers & Tags tab strip is not shown on any stage; the breadcrumb reads Organisation ▸ Numbers & Tags ▸ <mode>, and the section crumb leads back.

  • On Build, the counters (found, not found, and expected in a Stock take) and Continue sit at the foot of the input panel, outside its scroll. In the two-column layout (900px and wider) they stay in view without scrolling the page; stacked, they sit between the input panel and the list. Nothing renders under the list.

  • The list heading reads Counted list in a Stock take, Returned list in a Return and Flag list in Flag unfit.

  • Below 900px wide, the list stacks under the input panel, full width; the page is the only scroll, and nothing scrolls inside the stage.

  • Below 900px wide, a resolved row shows the resolved board (number, state, type, chip) on its own line under the scan; type and chip truncate with an ellipsis and do not wrap.

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

  • An entry is listed immediately in a pending state and the input accepts the next scan without waiting for the lookup.

  • A face value is matched exactly, and only for a manual entry; 12 does not match 112.

  • A tag scan of 13, 16 or 20 characters resolves to the number that carries that tag; a 12-character entry resolves too.

  • A 20-character scan starting 2000230316 resolves to the same number as its 13-character stored barcode.

  • The number-type preference accepts zero or more number types and never hides a number that exists only in another type.

  • Changing the number-type preference while lookups are in flight gives the same result as if it had been set before they were sent.

  • Choosing a candidate on an ambiguous row adds its number type to the preference and resolves any other ambiguous row that now has one candidate.

  • A Not found row keeps the captured value visible.

  • Numbers & Tags offers Stock take, Return and Flag unfit; each opens this screen in that mode, and the mode cannot change on the screen. There is no Session block, Full mode checkbox or Start session.

  • Stock take: the number types are chosen by name first; until one is chosen, the scan, paste and type inputs are disabled with the reason shown. The first scan opens the full stock take with those types, which are then locked and shown by name. Continue waits until the batch is open.

  • Return and Flag unfit: scanning starts at once; the server opens the batch on the first submission (batchId: null on the wire). No empty batch is left if nothing is scanned.

  • Return posts /api/race-numbers/scan with type: RETURN and no batchId on its first flush; the server uses the open RETURN batch or opens one on the first scan that resolves, and echoes its id, which later flushes and Confirm send.

  • Stock take posts /api/race-numbers/scan with its open batch’s id (opened on the first scan with its number types).

  • Outcomes are read from the scan response (SCANNED / SKIPPED_ALREADY_SCANNED / NOT_FOUND, old and new state): a board already in stock shows as Already in stock (present), a repeat in the batch as Already scanned. Entries a Return cleared the board from are shown on the Done stage.

  • Confirm sends only the rows no flush has sent, through /scan too. Nothing on this screen calls /api/race-numbers/return or /api/race-numbers/stock-take-scan.

  • Reset returns to the empty state of the same mode.

  • Done stage renders a "View batch — Batch #N · type · opened-at" link, "Back to Stock" link, and (for STOCK_TAKE + full mode + missingCount > 0) a "Review missing (N)" card.

  • Done-stage exit closes the batch — normal mode for RETURN, full-stock-take mode for STOCK_TAKE + full. A Return’s batch closes on any departure from Done (buttons, View batch, sidebar, browser Back), without asking and only once.

  • While Continue sends the queued scans, scanning, pasting and Reset are blocked; Continue reaches Confirm only if the list is still ready when the flush settles.

  • In Return and Stock take, the Build stage accumulates resolved scans into a client-side flush queue and submits in batches of 10 items or 15 seconds, whichever fires first (US #745). Flag unfit never queues: its list is submitted once, at Confirm.

  • Build stage exposes a Submit now affordance + inline N pending submit counter + Last flush: HH:MM:SS status so the operator can read the queue at a glance.

  • Going offline (browser offline event / navigator.onLine == false) suspends the 15-second timer and surfaces an "Offline — scans queued" banner; reconnecting replays the queue against the same OPEN batch.

  • Stock take: navigation away from Build or Confirm with the batch open always asks Finish this stock take?, naming the batch and "N boards counted of M expected". Finish now stays and goes to Confirm through Continue, and is disabled with the reason shown when the list is not ready. Leave open sends the queued and unsent scans, then leaves with the batch open. Cancel stays.

  • Stock take: if sending fails on Leave open, the dialog stays and says so, and offers Leave anyway, which drops the unsent scans and leaves with the batch open.

  • Stock take: leaving the Done stage asks nothing.

  • Return: navigation away from Build or Confirm with the batch open, with scans queued, or with rows left unsent, asks Save (send the queued scans, close the batch, leave), Leave open (send the queued scans, leave with the batch open, to continue later) or Cancel (stay). If sending fails, the modal stays open and adds Leave anyway (drop the unsent scans, leave, batch stays open), offered only after a failed send. With no batch open, nothing queued and nothing unsent, it asks nothing.

  • Navigation away from a Flag unfit list with resolved numbers not yet flagged, on Build or Confirm, opens the modal with Discard and Cancel only: nothing is flagged except from Confirm.

  • Every flush emits a telemetry record { event: 't03.flush', batchId, count, durationMs, reason, success, status } so the 10/15 thresholds can be tuned later.

Batch attribution

T03 sessions attribute their per-row state-log entries to an operational_batch row so the Stock activity screen (T?? — US #748) can stitch the audit trail back to the operator action. The mode the screen opened in decides the batch type; there is no setup to start a session.

Mode Batch

Stock take

Always a full stock take. A Number-type for Stock Take multi-select, of the organisation’s own number types by name, comes first; at least one type is required, and until one is chosen the inputs are disabled with the reason shown. The first scan opens the batch: POST /api/operational-batches with type: STOCK_TAKE, fullMode: true and numberTypeIds, which pre-snapshots the expected set — every IN_STOCK number of the chosen types. The server refuses an empty selection, or types outside one organisation, with 400, shown inline. Scans wait on the open; once it lands the types are locked and shown by name with the batch number, and Reset is the only way to count other types. Choosing a type also ticks it in the number-type preference of Number resolution, which only settles duplicates and is otherwise separate. At close, each expected number not scanned and still IN_STOCK moves to ISSUED with reason=STOCK_TAKE_MISSED, keeping its holder or restoring its last known holder; numbers in any other state are left unchanged. The full rules, scanned and not scanned, are in RaceNumber Lifecycle, Full-mode stock take.

Return

The first flush goes out to /api/race-numbers/scan with type: RETURN and no batchId; the server uses the open RETURN batch, or opens one on the first scan that resolves, and echoes its id, which later flushes and Confirm carry. If nothing resolves, no batch is opened and the echoed id is null. Since the server picks the most recently opened open RETURN batch, a Return left open is continued by the next Return session.

Flag unfit

Submitted once, at Confirm; the server opens a FLAG_UNFIT batch.

Every submit after the batch is open carries batchId so the server attributes the state-log rows to this batch. Return and Stock take post /api/race-numbers/scan, which records every scanned number as present and returns any that were out (ISSUED / IN_USE → IN_STOCK, ownership cleared); the rows' reason follows the batch type (RETURNED in a Return, STOCK_TAKE_SCAN in a Stock take). Every scan also sends type, so a batch of another type is refused rather than written to. Flag unfit posts /api/race-numbers/flag-unfit.

The Done stage surfaces:

  • A View batch link rendering Batch #N · type · opened-at and navigating to T??'s drill-down (US #748).

  • A Review missing (N) card — rendered only for STOCK_TAKE + fullMode sessions whose close response carried a non-zero missingCount. Click navigates to the same Stock activity drill-down with the missing category pre-opened.

  • A Back to Stock link in the top-left header — anticipates US #750’s navigation consistency pass.

Batch close via POST /api/operational-batches/{id}/close:

  • RETURN — closes on leaving the Done stage (default mode) by any route: the Done / Back to Numbers buttons, the View batch link, the sidebar or browser Back. It closes without asking. The button and the route guard share one close, so the batch is never closed twice, and a failed close is not retried (the batch stays OPEN).

  • STOCK_TAKE + fullMode — closes at the Confirm→Done transition (?mode=full-stock-take), before the Done stage renders, so the close-time missing detection runs and missingCount populates the "Review missing (N)" card. (Closing on Done-exit would compute the missing set only as the operator navigates away — the card could never show its count. US #777.) Done-exit then just navigates; it does not re-close.

  • Repeats and conflicts — a repeated normal close returns 200 with the batch unchanged and alreadyClosed: true; the repeat’s note is not recorded. A repeated cancel likewise returns the cancelled batch unchanged. A transition that conflicts with the batch’s state (closing a cancelled batch, cancelling a closed one, cancelling a print batch that has lines) answers 409. A repeated full-stock-take close answers 400, because the missing set cannot be reported twice.

  • RETURN, leaving early — navigating away from Build or Confirm asks whether to close the batch (Save) or leave it open (see Navigation guard).

  • STOCK_TAKE, leaving early — navigating away from Build or Confirm with the batch open always asks: Finish now (stay, and go to Confirm), Leave open or Cancel (see Navigation guard). A stock take is finished only through Confirm, because its close computes the missing set. A stock take left open is finished later from Numbers & Tags › Activity, where the batch offers Close batch (T09).

Other cancellation paths (Reset, a Stock take’s Leave open, a Return’s Leave open) leave the batch OPEN — auto-close-stale is a future operational concern per the 2026-05-09 session-11 design journal.

Phase 3 — batched flush + offline queue + navigation guard

US #745 layers three UX behaviours onto the Build stage’s submit path. The batched flush and the offline queue apply to Return and Stock take only. Flag unfit never flushes from the Build stage: flagging has no undo on this screen, so the list is submitted once, at Confirm (US #1446). Continue sends whatever is still queued before moving to Confirm, whose Submit then sends only rows no flush could send (an offline queue, a failed flush). While Continue waits, scanning, pasting and Reset are blocked with the reason shown. When the flush settles, the readiness gate is checked again: no row pending, ambiguous or failed, and a non-empty list. If it fails, the operator stays on Build.

Batched flush — 10 items or 15 seconds

Resolved scans enter an in-memory queue on the Build stage. The queue auto-flushes the unique resolved numberId`s to `POST /api/race-numbers/scan, with the session’s type and batchId, when either condition fires:

  • Queue length reaches 10 items, or

  • 15 seconds have elapsed since the last flush (or first scan after an empty queue).

The originating values (10 / 15) come from the 2026-02-27 stock-take design journal entry; they are the working defaults, subject to tuning by the per-flush telemetry below.

Operators see an inline status block under the scan input:

  • N pending submit — live counter of the queue.

  • Submit now — fires a manual flush of the current queue regardless of the thresholds.

  • Last flush: HH:MM:SS — time of the most recent successful flush.

In Return, the first flush of a session sends type: RETURN and no batchId; the server uses the open RETURN batch or opens one, and echoes its id back on the response. Subsequent flushes pass that id explicitly so every flush in the session attributes to the same OperationalBatch. In Stock take, the batch is opened by the first scan (see Batch attribution), and a flush waits for that open before it sends.

Offline queue

When navigator.onLine == false (or the browser fires the offline event), the 15-second timer is suspended and the controller refuses to flush. The inline block surfaces an "Offline — scans queued. Will submit when reconnected." banner; resolved scans continue to accumulate.

When the browser reports back online, the controller fires an immediate flush of whatever the queue holds.

The queue is memory-only in v1 per the US #745 open-question decision. Closing the tab while offline loses queued entries. Promoting to localStorage persistence is a future US — wait for evidence from telemetry before adding the complexity (encryption-at-rest, multi-tab coordination, replay-on-cold-start).

Failure handling

A flush failure (HTTP 5xx, network drop) keeps the entries queued, surfaces a "Last flush failed: {detail}. Will retry shortly." banner, and schedules a backoff retry (5 s default). The next successful flush clears the banner.

Navigation guard

The route registers an Angular CanDeactivate guard that fires when the operator attempts to navigate away from any of the three mode routes (/o/:org/numbers/stock-take, /return, /flag-unfit) while there is unsaved work: in Stock take, on Build or Confirm, an open batch; in Return, on Build or Confirm, an open batch, a non-empty queue, or rows a failed or offline flush left unsent; in Flag unfit, resolved numbers not yet flagged, on Build or Confirm. The Done stage asks nothing: leaving it by any route closes a Return’s batch, and a Stock take’s batch was closed by Confirm’s submit.

Return asks Save this return?, naming the open batch:

Action Effect

Save

Sends every unsent scan (see below), then closes the batch (POST /api/operational-batches/{id}/close with the note) and leaves. The batch closed is the one known after the send, so a first flush that opened it is closed too. A close that fails still leaves, with the batch left OPEN and a warning logged, as on Done-exit.

Leave open

Sends every unsent scan (see below), then leaves with the batch OPEN. The next Return session continues in it, because the server resolves the open RETURN batch on its first scan.

Cancel

Blocks the navigation; nothing is sent or closed. Disabled while Save or Leave open is running, and Escape and backdrop clicks are ignored then, so a save the operator started cannot go on to close the batch after they chose to stay.

Sending before leaving. Rows a failed or offline flush left unsent stay unsent through Confirm, and both leave actions cover them:

  • On Build, the queue is drained: after each successful flush it flushes again until the queue is empty. A flush already in flight carries only the scans it started with, so scans queued behind it would otherwise be lost.

  • On Confirm, the rows no flush has sent go to /api/race-numbers/scan in the session’s batch, and their answers are kept on the rows.

  • Sending stops at the first failure, with no retry loop. Nothing is closed, the modal stays open, and it says the scans could not be sent and that the operator is still on the screen. They can try again once the connection is back, or Cancel. The modal says up front that a failed send keeps them on the screen.

  • After a failed send, and only then, the modal also offers Leave anyway. Its copy says how many scans will be lost and that the batch stays open. Choosing it drops the queued and unsent scans and leaves, closing nothing. Cancel still stays on the screen.

Product owner decision, 2026-10-21. Return no longer offers Discard.

Stock take never lets an open batch go silently. It asks Finish this stock take?, naming the batch and the count so far: "N boards counted of M expected", where M is the size of the expected set.

Action Effect

Finish now

Cancels the navigation and goes to Confirm through Continue, which sends the queued scans first. The stock take is then finished by Confirm’s submit, which closes the batch and computes the missing set. Disabled, with the reason shown, when the list is not ready to continue (for example, a row is pending or ambiguous).

Leave open

Sends every unsent scan, as a Return’s Leave open does, then leaves with the batch OPEN. The operator finishes it later from Numbers & Tags › Activity.

Cancel

Blocks the navigation; nothing is sent or closed.

If sending fails, the dialog stays open and says so, and offers Leave anyway, as Return’s does: it drops the unsent scans and leaves, and the batch stays open.

Reopening Stock take starts a new session. Resuming an open stock take there is planned, not in this release: it needs the batch to record its organisation and number types.

Flag unfit renders:

Action Effect

Discard

Drops the queue without flushing, then proceeds. The modal copy spells out "N unsubmitted scans will be lost" — destructive choice, surfaced loudly.

Cancel

Blocks the navigation. Operator stays on the Build stage; the queue is untouched.

In Flag unfit, with no resolved numbers left unflagged the guard short-circuits to true immediately — the BC gate (CD-1). A Stock take with no batch open asks nothing; once its batch is open it always asks. A Return on Build or Confirm skips the guard only when its queue is empty, and no batch is open, and no rows are left unsent. After a successful flush the batch is open, so leaving still asks Save / Leave open / Cancel.

Telemetry

Every flush emits a console.debug record (placeholder transport):

{
  "event": "t03.flush",
  "batchId": 42,
  "count": 7,
  "durationMs": 184,
  "reason": "time",
  "success": true,
  "status": 200
}

reason is size | time | manual | reconnect | navigate. A follow-up US wires this to the real OTel transport once admin-portal has a frontend telemetry SDK choice.

Flag unfit by scanning (planned)

Not yet built. The screen is to gain a third session type, Flag unfit, so that an operator holding a pile of damaged boards can scan them instead of finding and ticking each one in the stock list.

  • Entries are captured and resolved exactly as for a return, by the rules in Number resolution.

  • Submit posts the resolved ids to POST /api/race-numbers/flag-unfit with the note and the session’s batch id; the session is attributed to a FLAG_UNFIT batch.

  • Predicted outcome per row: any number that is not already UNFIT_FOR_SERVICE or DESTROYED will be flagged; those two are skipped; an unresolved entry is Not found.

  • A board that arrived by reader was in the operator’s hand, so a wedge row submits clearHolder and the number is returned and flagged in one step; its row predicts Flagged, returned to us. A typed row may describe a board still with the participant, so it submits without clearHolder and predicts Flagged, holder kept.

  • A Notify column, one checkbox per row, submits notifyHolder. Wedge rows are unticked and disabled, since the board is with us. Typed rows are ticked when the number has a holder, and disabled with a tooltip when it has none. The rules the server applies, and what the holder receives, are in RaceNumber Lifecycle, Possession and the holder.

  • The outcome summary shows, per row, what became of the notice (queued, declined, no holder, no upcoming entry, not collected), so the operator leaves knowing who will be emailed.

  • Confirm and Done copy speaks of flagging; for wedge rows it also says the board was returned.

The first increment ships the session type with the flat request the endpoint takes today, so every row flags without clearing the holder and the notice follows the server’s default rule. The per-row inputs and the Notify column follow once the endpoint accepts them. * Flag unfit has no stock-take setup; scanning starts at once. * The flag dialog launched from the stock list (T04) stays as it is, for numbers already selected there.

API Surface

Call Purpose

POST /api/race-numbers/scan

Record boards scanned in a Return or Stock take session; the one endpoint both use, for the flush and for Confirm. Request: { "numberIds": [Long…​] (required, non-empty), "batchId": Long?, "type": "RETURN" | "STOCK_TAKE"?, "note": "string?" (max 480) }. With batchId (an OPEN RETURN or STOCK_TAKE batch) the session takes the batch’s type; a type that differs is 400 batchTypeMismatch. Without batchId, type: RETURN uses the open RETURN batch or opens one on the first scan that resolves (none is opened if nothing resolves, and batchId comes back null); type: STOCK_TAKE is 400 batchIdRequired, and neither is 400 scanSessionRequired. An unknown batch is 400 batchNotFound, a batch of another type 400 batchTypeMismatch, a closed or cancelled one 409 batchNotOpen. Every resolved number writes one row (RETURNED in a Return, STOCK_TAKE_SCAN in a Stock take): ISSUED / IN_USE → IN_STOCK with person_id cleared; UNFIT_FOR_SERVICE keeps its state with person_id cleared ("recommend disposal"); IN_STOCK, MANUFACTURED and DESTROYED are recorded as present. A repeat in the batch is skipped unless the number has been issued again since. A Return also clears the entries a return releases. Response: BulkRaceNumberActionResponseDTO — per number outcome (SCANNED / SKIPPED_ALREADY_SCANNED / NOT_FOUND), oldState, newState and, in a Return, detachedParticipants — plus the session’s batchId.

POST /api/race-numbers/return (deprecated)

Superseded by /scan with type: RETURN; no longer called by this screen. Kept on the server until it is removed. Request: { "numberIds": [Long…​], "note": "string?", "batchId": Long? }. Response: RaceNumberReturnResponseDTO (RETURNED / DISPOSAL_RECOMMENDED / NO_OP / NOT_FOUND).

POST /api/race-numbers/stock-take-scan (deprecated)

Superseded by /scan, which takes the same body; no longer called by this screen. Record stock-take scans — STOCK_TAKE sessions only. Request: { "numberIds": [Long…​], "batchId": Long } (batchId required). A scan means "this number is physically present", so it writes one STOCK_TAKE_SCAN log row per resolved id and applies the return: ISSUED / IN_USE → IN_STOCK with person_id cleared; UNFIT_FOR_SERVICE keeps its state with person_id cleared (note "recommend disposal"); IN_STOCK, MANUFACTURED and DESTROYED are recorded as present with no transition. The row carries the real old and new state. Idempotent per (batch, number). Response: BulkRaceNumberActionResponseDTO (SCANNED / SKIPPED_ALREADY_SCANNED / NOT_FOUND) + the resolved batchId. The close-time delta reads these rows to compute the missing set. Required because /return would NO_OP an already-IN_STOCK number and write nothing — leaving the close to flag everything missing.

POST /api/operational-batches

Open a new operational batch (US #777 schema, US #743 wiring). Request: { name, type, fullMode?, numberTypeIds?, numberTypeId?, eventId? }. With fullMode, at least one number type is required, through numberTypeIds or the older single numberTypeId; every type must be visible to the caller and all must belong to one organisation, or the request is refused with 400. Response: persisted OperationalBatch. Sent by a stock take’s first scan.

POST /api/operational-batches/{id}/close

Close an open batch. ?mode=full-stock-take triggers the close-time delta computation that moves expected, unscanned numbers still IN_STOCK to ISSUED via reason=STOCK_TAKE_MISSED, keeping or restoring the holder; numbers in any other state are left unchanged. Used by the Done stage exit path, by the full stock take at Confirm→Done, and by a Return’s Save on leaving.

Number lookup (to be built)

Stage 1 resolution. Takes the entries, each as normalised text with its class, tag barcode or face value. Returns every matching RaceNumber per entry, with number type and Tag.barcode on each. The number-type preference is not sent: the client applies it to the returned candidates, so a response is never filtered by a selection the operator has since changed. After the preference is applied, zero candidates is Not found, one is resolved, several is Ambiguous. Replaces the free-text /stock?q.contains= probe, which matches substrings and does not search tags.

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).

  • Printed barcode codes (.WN, .WP, .WT) — recognised by their shape and reported as Not found until that lookup is built.

  • Tags not recorded against any number — a standalone tag reports as Not found.

  • 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

  • T04 — Dispose these N shortcut from DISPOSAL_RECOMMENDED

  • ADR-0001 — RaceNumberAssignmentServiceEx.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.

  • "About this screen" band (2026-05-07). Same cross-cutting pattern as C04 / C05 / E06, now specified in UI Design Principles — About this screen band. Stage-aware copy. Expanded on first open, collapse state persisted in localStorage. Superseded 2026-10-21: this was a right-rail panel beside the content; it is now a band above the content, and the Stage 1 work area uses the freed width.

  • 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.

  • Stock-take scans go to a dedicated endpoint, not /return (2026-06-24). A stock-take scan must record presence for every number it finds; /return on an already-IN_STOCK number is a NO_OP that writes no log row, so reusing it would leave the close-time delta with an empty scanned set and flag every number missing. STOCK_TAKE sessions therefore post /api/race-numbers/stock-take-scan (writes STOCK_TAKE_SCAN, idempotent per (batch, number)); RETURN sessions keep /return. The earlier .adoc wording that described stock-take scanning via /return was the inversion bug — corrected here.

  • A stock-take scan also returns what it finds (2026-09-22). A number found on the shelf is with us, whatever its recorded state, so the scan clears ownership and moves ISSUED / IN_USE numbers to IN_STOCK itself — a stock take needs no separate return pass. UNFIT_FOR_SERVICE keeps its state with ownership cleared, as a return does. This supersedes the presence-only behaviour of the entry above; the dedicated endpoint is still needed because /return writes nothing for a number already in stock.

  • Return is a scan session (2026-10-21). A Return posts its scans to /api/race-numbers/scan with type: RETURN, the endpoint a stock take uses, so both sessions record presence and skip a repeat in the batch the same way, and the screen reads one response shape. /return and /stock-take-scan are deprecated. A board already in stock now shows as present (Already in stock), where /return reported No change and wrote nothing. Leaving a Return with its batch open asks Save / Leave open / Cancel (product owner decision).

  • Full-mode close runs at Confirm→Done, not Done-exit (2026-06-24, US #777). The full-stock-take close computes the missing set; it must run before the Done stage renders so missingCount can populate the "Review missing" card. The previous flow closed on Done-exit and navigated away immediately, so the card never showed a count.

  • Minimal STOCK_TAKE relabel of Confirm/Done (2026-06-24, US #777). The three-stage shell is shared; STOCK_TAKE sessions relabel the Confirm title/subtitle/submit-button and the Done header to present/missing terms. A purpose-built stock-take stage set (present/missing/outsider breakdown inline) is deferred — the Stock activity drill-down already surfaces the four categories.

  • A tag scan resolves to its number (2026-10-07). The number-to-tag link is in place, so a scanned tag is no longer out of scope: it resolves to the number carrying that tag and is processed like any other entry. This replaces the earlier plan to show a tag hit as a separate, non-returnable row.

  • Resolution is exact and source-aware (2026-10-07). The free-text probe matched substrings, which made short face values ambiguous by construction, and it ignored where the entry came from. A face value now matches exactly and only when typed or pasted; sequence is dropped as a lookup key.

  • Preference, not filter; chooser, not popup (2026-10-07). The number-type selection is multi-valued and only prefers among duplicates, so it cannot hide a number. It is applied in the client to the full set of candidates the lookup returns, never by the server, because lookups run in the background and the selection can change before a response arrives. Ambiguity is settled in the row because a popup interrupts rapid scanning, and a choice extends the preference so later duplicates settle themselves.

  • Lookups run in the background (2026-10-07). The operator scans faster than the server answers and does not watch for a response, so the input never waits. Previously the input was disabled for the duration of each lookup.

  • Flag unfit by scan is a session type here, not a scan input in the flag dialog (2026-10-07). This screen already has the scan input, the list, number resolution, batch attribution and the confirm step; a second scan surface inside the dialog would duplicate all of them.

  • Count and Continue live in the input panel (2026-10-21). The counters and Continue sit at the foot of the input panel, outside its scroll, so in the two-column layout they stay in view however long the list grows. Stacked below 900px the page scrolls as one, and they sit between the input panel and the list. A footer under the list scrolled out of sight on a long take. The list is only the list, and its heading names the session.

  • Leaving an open stock take always asks (2026-10-21). A stock take left open silently was easy to forget, and it reports nothing missing until it is closed. The dialog offers Finish now (through Confirm, which computes the missing set), Leave open and Cancel, with Leave anyway after a failed send, as Return’s dialog does. It replaces Submit now / Discard. Product owner decision.

  • T03 is a command page (2026-10-21). The Numbers & Tags tab strip is hidden on every stage, so tabs do not invite the operator to leave mid-session. See UI Design Principles — Command pages.

  • The mode is chosen before the screen opens; a session starts on the first scan (2026-10-20). The operator arrives knowing which job they came to do, so Numbers & Tags offers Stock take, Return and Flag unfit, and the route carries the mode. The Session block (type radio, Full mode, number-type field, Start session) asked again and then asked to start; it is removed. A stock take is always full, so Full mode stops being a choice, and its number types are picked by name before the first scan, which opens the batch so the expected set is taken before anything is counted.

Open Questions

  1. None open. The scan-resolution endpoint question is closed: a dedicated lookup is specified in Number resolution and under API Surface.

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.

The prompt below is kept as sent and is not edited. Its "right-rail" information panel is superseded (2026-10-21). Build the "About this screen" band instead: full width above the content, with the Stage 1 input and the list side by side beneath it. See Layout and UI Design Principles — About this screen band. Its Stage 1 footer is superseded too (2026-10-21): the counters and Continue sit at the foot of the input panel, not under the list.

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.