Result Import Functional Requirements

1. Business Context

After a race, the timing system (currently RaceDay Scoring) produces a CSV export containing finishing positions, times, and lap counts for every EventParticipant. Operations staff import this file into the admin service so that:

  • Each EventCategory race’s ResultSet reflects the authoritative finishing order

  • Series leaderboards can calculate standings from RaceResult.points

  • Participants can view their results via the membership and registration portals

  • Downstream systems (reporting, federation submissions) have a single source of truth

The timing system’s CSV schema, PK conventions, and column labels can all change between releases, and results are often re-exported after the initial run (corrections, appeal outcomes). The import must therefore be idempotent, forgiving of minor schema drift, and auditable against the source file.

1.1. Scope

In scope:

  • Bulk CSV import for multi-category race result files

  • Resolution of the CSV’s external identifier to EventParticipant via configurable mode

  • Full reconciliation of each ResultSet against its category group in the CSV

  • Points calculation via a pluggable strategy

  • Detection and optional application of race-number changes observed in the file

  • A reconciliation summary that accounts for every line of the input file

Out of scope (handled elsewhere):

  • Leaderboard aggregation across ResultSets (see Leaderboard Synchronization)

  • Single-race XLSX import (soft-deprecated, documented separately under Common Design)

  • Interactive category mapping for fuzzy name matches (deferred to async import framework)

2. Functional Requirements

2.1. FR-RI-001: Bulk CSV Import Endpoint

ID FR-RI-001

Title

Multi-Category Bulk CSV Import (async)

Priority

High

Description

The system shall expose a single HTTP endpoint that accepts a CSV containing finisher rows for multiple EventCategory races and routes each row to the corresponding ResultSet. The endpoint returns asynchronously: the upload creates a background import job and the caller polls a companion endpoint for the final result DTO.

Acceptance Criteria

  • Upload endpoint: PUT /api/result-sets/import-bulk

    • Required query parameter: eventId (scopes the import to one event)

    • Request body: multipart/form-data with a single file field

    • Returns HTTP 202 Accepted + ImportJobDTO (containing the job identifier and current status) with a Location header pointing at the results endpoint

  • Results endpoint: GET /api/result-sets/import/{jobId}

    • Returns HTTP 200 OK + BulkResultImportResponseDTO once the job reaches COMPLETED or FAILED

    • Returns HTTP 409 Conflict while the job is still processing

    • Returns HTTP 404 Not Found if the job identifier is unknown or belongs to a different import type

  • Rows are grouped by the Registration Event column value

  • Each group maps to the ResultSet of the Race whose EventCategory.name matches the group value

  • A group whose category cannot be resolved is reported in unmatchedCategories and its rows are not imported

2.2. FR-RI-002: Participant Identifier Resolution Modes

ID FR-RI-002

Title

Configurable Participant Resolution

Priority

High

Description

The system shall support three mutually-exclusive modes for resolving the CSV’s External Reference ID column to an EventParticipant record, selected via the participantIdMode query parameter.

Acceptance Criteria

  • epid (default): resolve as EventParticipant.id

  • regid: resolve as EventParticipant.registrationId scoped to the target event (used when the timing system holds legacy / external PKs)

  • pid: resolve as EventParticipant.person.id scoped to the target event (used when the timing system holds User/Person ids)

  • The mode is case-insensitive

  • Unknown values fall back to the default with a log warning

  • When resolution fails, the row is counted as skipped and must be reported in sufficient detail for the operator to identify the cause

2.3. FR-RI-003: Full Reconciliation per ResultSet

ID FR-RI-003

Title

ResultSet Mirrors CSV Exactly

Priority

High

Description

The system shall reconcile each ResultSet so that, after a successful import, its set of RaceResult records matches exactly the rows for that category group in the CSV.

Acceptance Criteria

  • Rows present in the CSV with a matching existing seq update in place

  • Rows present in the CSV with no matching existing seq are inserted

  • Existing RaceResult records whose seq is not present in the CSV are deleted

  • Participants moved between categories (different Registration Event value than previously imported) naturally disappear from the old ResultSet and appear in the new one

  • No RaceResult state from a prior import survives outside what the CSV dictates

2.4. FR-RI-004: Upsert by Seq (Record Identity Preservation)

ID FR-RI-004

Title

Preserve RaceResult Identity Across Re-imports

Priority

High

Description

The system shall use a stable seq (1-based row order within each category group) as the upsert key for RaceResult within a ResultSet, so that re-importing an unchanged CSV leaves RaceResult.id values unchanged.

Acceptance Criteria

  • Each RaceResult carries a non-null seq after a successful import

  • Re-importing the same CSV against the same ResultSet does not change any RaceResult.id

  • Legacy rows with seq = NULL (from before upsert-by-seq existed) are removed on first import after deploy

  • Upsert matches on (resultSet, seq), not (resultSet, position) - positions can tie (dead heats) or duplicate (lapped riders)

Related

Upsert by Seq design

2.5. FR-RI-005: Reconciliation Summary

ID FR-RI-005

Title

Account for Every File Line

Priority

High

Description

The response shall contain a reconciliation summary that allows an operator to answer "was the file fully consumed?" without inspecting logs or the database.

Acceptance Criteria

  • Response contains fileLines: total lines in the uploaded file

  • Response contains summary.dataRows, summary.imported, summary.skipped

  • Response contains summary.nonDataRows.blankLines, summary.nonDataRows.repeatedHeaders, summary.nonDataRows.malformedRows

  • Invariant: fileLines == 1 (header) + summary.nonDataRows.totalNonData + summary.dataRows

  • Invariant: summary.dataRows == summary.imported + summary.skipped

  • If either invariant fails, the response is considered diagnostic rather than complete and the operator must investigate

2.6. FR-RI-006: Non-Data Row Classification

ID FR-RI-006

Title

Classify Non-Data Lines Distinctly

Priority

Medium

Description

The system shall classify non-data lines into three named buckets so that the operator can distinguish "expected noise" from "unexpected malformed input".

Acceptance Criteria

  • blankLines: empty or whitespace-only lines (expected in concatenated per-category exports)

  • repeatedHeaders: rows whose Registration Event cell equals the literal header text (detected dynamically against the actual header cell, not a hardcoded string, so behaviour survives timing-system column renames)

  • malformedRows: rows with too few fields to reach the category column

  • The three buckets never overlap

  • The sum is surfaced as summary.nonDataRows.totalNonData

2.7. FR-RI-007: Unmatched Category Reporting

ID FR-RI-007

Title

Report Unresolvable Category Names

Priority

Medium

Description

The system shall report any Registration Event value that does not resolve to an EventCategory for the target event, so that the operator can distinguish "new category needed" from "typo in export".

Acceptance Criteria

  • Genuinely unresolvable category names appear in unmatchedCategories

  • Rows belonging to an unmatched category are not imported, and are excluded from any summary.imported count

  • Repeated header rows are not surfaced as unmatched categories (they are counted in nonDataRows.repeatedHeaders instead)

2.8. FR-RI-008: Pluggable Points Calculator

ID FR-RI-008

Title

Per-Import Points Calculation Strategy

Priority

High

Description

The system shall recalculate RaceResult.points from position using a pluggable calculator strategy selected per request.

Acceptance Criteria

  • Query parameter pointsCalculator selects the strategy by short code or fully-qualified class name

  • Default strategy is the SA School Cycling calculator

  • A calculator exists for the WPCA Road League point scheme (50/45/40/36/34/32/30/28/26/24/22/20 for positions 1–12; 2 participation points for positions 13+; 0 for non-finishers)

  • Non-finishers (position NULL, status DNF/DNS/DQ/LAPPED) score 0 points regardless of synthetic position

  • New strategies can be added without modifying the import code

2.9. FR-RI-009: Number Change Detection

ID FR-RI-009

Title

Detect and Classify Race Number Changes

Priority

Medium

Description

The system shall compare the imported Bib value against the EventParticipant.number.number value and, where they differ, classify the change and report it in the response.

Acceptance Criteria

  • Changes are classified as:

    • SIMPLE — target bib’s RaceNumber.person is NULL (unassigned stock)

    • SWAP — two participants mutually exchange numbers (reverse change detected in same import)

    • CONFLICT — target bib is held by another person with no matching reverse, OR the bib does not exist as a RaceNumber for the event’s NumberType

  • Each change is reported with the EventParticipant id, name, old and new numbers, classification, status, and a human-readable message

  • Detection is non-destructive: no EventParticipant.number is modified unless applyNumberChanges=true

2.10. FR-RI-010: Optional Number Change Application

ID FR-RI-010

Title

Apply SIMPLE Number Changes on Opt-In

Priority

Medium

Description

When invoked with applyNumberChanges=true, the system shall apply every detected SIMPLE change via the standard number-assignment service layer.

Acceptance Criteria

  • applyNumberChanges defaults to false

  • SIMPLE changes are applied through RaceNumberAssignmentServiceEx.addNumberNoLaterThan (same path as UI-driven assignment, preserving validation and history)

  • The applied change’s status transitions to APPLIED in the response

  • SWAP changes are reported but not auto-applied (operator decides)

  • CONFLICT changes are never auto-applied

  • Apply failures are reported with status=ERROR and a diagnostic message; other rows are unaffected

2.11. FR-RI-011: Idempotent Re-Import

ID FR-RI-011

Title

Idempotent Re-Import

Priority

High

Description

Re-importing an unchanged CSV against the same event shall produce the same end-state without churning RaceResult.id or inflating counts.

Acceptance Criteria

  • RaceResult.id values are preserved across identical re-imports

  • points, laps, position, duration_milli_second, status are recalculated and rewritten on every run (last-write-wins)

  • Number-change detection is idempotent (re-running reports the same changes)

  • There is no side-effect that only happens on second import (no "double-apply" of number changes)

2.12. FR-RI-012: Mode Mismatch Safety

ID FR-RI-012

Title

Guard Against Catastrophic Delete on Mode Mismatch

Priority

High

Description

When participantIdMode is wrong for a file (every or almost every row fails to resolve), the system shall not silently delete the pre-existing RaceResult rows.

Acceptance Criteria

  • If the resolution failure rate for a category exceeds a safety threshold, the reconciliation delete pass is aborted for that category

  • The response indicates the category was skipped for safety and names the most likely cause (wrong mode, stale file, missing registrationId population)

  • Successful categories in the same import still commit (partial success is allowed)

Status

Not yet implemented — item surfaced by 2026-04-20 production incident (event 119 wiped). Tracked as Outstanding Item #10 in design-journal/2026-04/result-import-improvements.adoc.

2.13. FR-RI-013: DNF / Non-Finisher Status Handling

ID FR-RI-013

Title

Preserve Non-Finisher Status

Priority

Medium

Description

The system shall preserve the distinction between "finished at position N", "did not finish", "did not start", and "disqualified" so that reports and leaderboards render and score them correctly.

Acceptance Criteria

  • Non-numeric Place values (Drop, DNF, DNS, DQ) are parsed and stored as RaceResult.status

  • Non-finishers receive 0 points regardless of calculator

  • Synthetic trailing positions are assigned so reports sort consistently (finishers first, then non-finishers at the end)

  • The original Place string is preserved for audit (pending schema change)

Status

Partially implemented — admin-service-only subset shipped via ADO-457; schema changes (RaceResult.originalPlace, StartGroupParticipant.status propagation) deferred to ADO-458.

2.14. FR-RI-014: Error Handling and HTTP Semantics

ID FR-RI-014

Title

Errors Mapped to Meaningful HTTP Responses

Priority

High

Description

The system shall translate parsing and business errors into HTTP status codes that let a thin client distinguish retryable from non-retryable failures.

Acceptance Criteria

  • Unknown eventId → results endpoint returns COMPLETED with FAILED status on the job and a diagnostic message (async path) — the upload itself still accepts.

  • Malformed multipart or missing file → upload returns 400 Bad Request synchronously (before the job is created).

  • Unparseable CSV (IO error, encoding failure) → upload returns 500 Internal Server Error at creation time, or the job transitions to FAILED if detected during PROCESSING.

  • Successful import (including partial success with summary.skipped > 0) → job reaches COMPLETED; GET /api/result-sets/import/{jobId} returns 200 OK with BulkResultImportResponseDTO.

  • Exceptions are logged with the event id and file name; no exception is swallowed silently. Status-transition events are also emitted to the Micrometer counter import.status.transitions tagged with importType, from, to.

3. Non-Functional Requirements

3.1. NFR-RI-001: Throughput

The system shall accept an import upload within 5 seconds end-to-end (synchronous upload response, before background processing starts). Background processing of up to 1,000 data rows shall complete within 60 seconds wall-clock; larger files are no longer bounded by the nginx/gateway timeout because the HTTP request returns 202 Accepted as soon as the job is persisted. Observed production baseline on 2.3.31 (pre-async): event 121 (314 rows, 16 categories) completed in ~21 s; event 117 (938 rows, 14 categories) completed in ~3 min — post-async the ~3 min case no longer risks a 180 s gateway timeout. Post-upload processing latency is observable via the import.processWholeFile Micrometer timer (tagged importType).

3.2. NFR-RI-002: Auditability

Every import shall produce a log entry per category with row counts (created / updated / skipped / removed). This is the primary forensic trail for "what did the last import do?" questions.

3.3. NFR-RI-003: Re-run Safety

An import may be re-run multiple times in a row (correcting operator inputs, applying a new pointsCalculator, re-reading an updated CSV) without accumulating state or requiring manual cleanup.

4. Traceability

Artefact Reference

ADO Feature

#442 (WPCA Event Migration — EP Import & Result Import Improvements)

ADO User Stories

#443, #444, #455, #456, #457

ADO Bugs

#417 (duplicate RaceResult), #452 (laps), #453 (repeated headers), #454 (exception swallowing), #459 (WPCA participation points)

Design Journal

design-journal/2026-04/result-import-improvements.adoc

Design Doc

Results Import Design

Operations Runbook

Import Operations Guide