Import: Results

Related reading

  • Result Import Requirements — the "what" (functional requirements, acceptance criteria)

  • Import Operations Guide — the "run it" (operator runbook, query-param choices, recovery)

  • This document — the "how" (API surface, data dictionary, processing flow, open items)

1. Overview

Race results are imported from a CSV produced by the timing system (RaceDay Scoring). A single file typically contains rows for multiple EventCategory groups — the importer groups them by the Registration Event column and reconciles each group against the matching Race’s ResultSet. Results are upserted by seq (row order within a group), so re-importing the same file updates existing records in place rather than churning row IDs.

The HTTP surface is asynchronous from release 2.4: the upload returns HTTP 202 immediately and the caller polls a companion GET endpoint for the result DTO.

The previous single-race XLSX endpoint (PUT /api/result-sets/import backed by ResultImportXLS.process) was deleted in the 2026-04 async cutover. Soft-deprecated on develop, it never gained the operational improvements (number-change detection, DNF classification, reconciliation summary, issues list) and had no production consumers beyond ad-hoc Postman. Callers that still need single-race imports can use the bulk CSV endpoint with a one-race file. The ResultImportXLS.process(…​) and readStream(…​) service methods remain on the class as @Deprecated and are not exposed over HTTP.

2. API

2.1. Endpoint Reference

Endpoint Purpose

PUT /api/result-sets/import-bulk

Upload a multi-category CSV and start the async import. Returns HTTP 202 Accepted with an ImportJobDTO — the job is queued on the generic async framework.

GET /api/result-sets/import/{jobId}

Retrieve the BulkResultImportResponseDTO once the job reaches COMPLETED or FAILED. Returns HTTP 409 while still processing; HTTP 404 for unknown ids or wrong importType.

2.2. Async Delivery Mode

The per-entity endpoint is a thin facade over the generic async framework. Behind the scenes RaceResultRowProcessor runs in whole-file mode, delegating to ResultImportXLS.processBulkCsv(…​) — the same bulk importer that produced the response DTO inline in the pre-async implementation. The resulting BulkResultImportResponseDTO is JSON-serialised to ImportJob.resultPayloadJson and echoed back verbatim by the GET endpoint. Response shape is byte-identical to what the prior synchronous PUT returned inline — only the delivery mode changed.

PUT /api/result-sets/import-bulk
  ?eventId={long}                                    (required)
  &participantIdMode={epid|regid|pid}                (default: epid)
  &applyNumberChanges={true|false}                   (default: false)
  &pointsCalculator={short-code|FQCN}                (default: SA School)
  Content-Type: multipart/form-data
  Body: file=<csv>

HTTP/1.1 202 Accepted
Location: /api/result-sets/import/{jobId}
{ "identifier": "<jobId>", "status": "PROCESSING", "skipMappingPhases": true, ... }

# operator polls ...

GET /api/result-sets/import/{jobId}

HTTP/1.1 200 OK                     # once COMPLETED or FAILED
{ ... BulkResultImportResponseDTO ... }
HTTP/1.1 409 Conflict               # while still processing

See Import Operations Runbook for the polling procedure, cancellation semantics, and failure recovery steps.

2.3. Request Body

Multipart form with a single file part containing the CSV. A single file may contain multiple concatenated per-category sections — each with its own header row and blank-line separator — and the importer groups rows by the Registration Event column.

Query parameters:

  • eventId (required) — target event; all groups in the file must resolve to EventCategory records within this event

  • participantIdMode (default epid) — how to interpret External Reference ID: EP id (epid), legacy registrationId (regid), or Person/User id (pid). Choosing wrongly can empty the ResultSet — see Mode-Mismatch Risk (Outstanding #10).

  • applyNumberChanges (default false) — when true, SIMPLE number changes (target bib is unassigned stock) are auto-applied; SWAP and CONFLICT are always reported but never auto-applied

  • pointsCalculator (default SA School) — short code or FQCN; see Points Calculators

2.4. Response: BulkResultImportResponseDTO

{
  "eventId": 117,
  "fileLines": 615,
  "summary": {
    "dataRows": 592,
    "imported": 591,
    "skipped": 1,
    "nonDataRows": {
      "blankLines": 11,
      "repeatedHeaders": 11,
      "malformedRows": 0,
      "totalNonData": 22
    }
  },
  "categories": [
    {"categoryName": "Sub Nipper Boys", "raceId": 4131, "resultSetId": 527,
     "rowCount": 107, "importedCount": 107, "skippedCount": 0}
  ],
  "unmatchedCategories": [],
  "numberChanges": [...]
}

Invariants:

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

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

If the maths doesn’t add up, the parser bailed mid-file. Non-zero summary.skipped is the primary "look here" signal.

Bucket definitions:

Bucket What it counts

summary.imported

Data rows successfully written to a ResultSet (created or updated by upsert-by-seq).

summary.skipped

Data rows that could not be processed: unresolved External Reference ID, mapping failures, save errors. Detail is surfaced in the issues list (in progress).

summary.nonDataRows.blankLines

Empty separator lines, expected in concatenated exports. Skipped silently.

summary.nonDataRows.repeatedHeaders

Rows whose category column equals the literal header text "Registration Event". The timing system emits one per concatenated section. Skipped silently.

unmatchedCategories

Category names that did not resolve to an EventCategory for the event. Repeated header rows are not surfaced here — they go to nonDataRows.repeatedHeaders.

2.5. What to Look At

After polling returns the DTO, these are the patterns to confirm the import landed the way you expected:

  • First import of an event: imported == dataRows, skipped = 0, unmatchedCategories = []

  • Re-import after a correction: same as first-import, plus RaceResult.id values in the database are unchanged compared to before the re-import (upsert-by-seq preserves identity)

  • Normal "number change noise": if the event’s NumberType has no RaceNumber stock loaded, every row triggers a CONFLICT number-change report. This is operational data config, not an import error.

  • summary.imported = 0: almost always a participantIdMode mismatch — see Mode-Mismatch Risk (Outstanding #10). Stop and investigate before re-running.

See Import Operations Runbook for the full recovery procedures.

3. Column Definitions

The timing system (RaceDay Scoring) produces a CSV with one header row at the top, followed by rows for each finisher. The same export may concatenate per-category sheets — each sheet retains its own header row and a blank line between sections.

Column Required Notes

External Reference ID

yes

Identifier resolved per participantIdMode: an EventParticipant id (epid), the legacy registrationId (regid), or a Person/User id (pid).

Registration Event

yes

Maps to EventCategory.name by exact case-sensitive match.

Place

-

Numeric finishing position. Non-numeric values (Drop, DNF, DNS, DQ) currently land as position = NULL — DNF status handling is in progress (see Outstanding Items).

Total Time

-

HH:mm:ss.SS — stored as duration_milli_second.

Num. Laps

-

Integer lap count, stored on RaceResult.laps.

Bib

-

Triggers number-change detection against EventParticipant.number. Aliases: Bib, BibNumber, RaceNumber, Number.

First Name, Last Name, School

-

Currently informational only (used for logs and the issues list).

Header normalization strips spaces, slashes, dashes, underscores, and periods, then uppercases — so Num. Laps, NumLaps, NUM_LAPS all collapse to the same key.

4. Processing Flow

4.1. Multi-Pass Architecture

The bulk CSV import processes the file in sequential passes. Each pass reads state collected by the previous pass.

PASS 1 — Results Reconciliation
  For each Registration Event group in CSV:
    - Resolve EventCategory, Race, ResultSet
    - Reconcile ResultSet (upsert-by-seq, delete untouched)
    - Detect number changes: collect list of (EP, oldBib, newBib)
    - Detect category changes (design-only): collect (EP, oldCat, newCat)

PASS 2 — Number Changes (if applyNumberChanges=true)
  - Classify collected changes: SIMPLE / SWAP / CONFLICT
  - Apply SIMPLE changes via RaceNumberAssignmentServiceEx
  - Report SWAP / CONFLICT as DETECTED

PASS 3 — Category Changes (if applyCategoryChanges=true)
  - NOT YET IMPLEMENTED (design captured in the journal)
  - Would cascade via StartGroupParticipantServiceEx

Why the passes matter: collecting all changes before applying enables swap detection for numbers (EP_A and EP_B trading bibs 5 and 7). It also means the operator can review number-change reports from Pass 1 before deciding to re-run with applyNumberChanges=true.

See design-journal/2026-04/result-import-improvements.adoc for the full multi-pass design including the deferred Pass 3 (category changes).

4.2. Upsert-by-Seq

seq is the 1-based row index within each Registration Event group. On import:

  1. Build a map of existing RaceResult by seq within the ResultSet.

  2. For each CSV row in the group: if a matching seq exists, update in place; otherwise insert.

  3. After processing all rows for a group: delete any existing RaceResult whose seq was not touched (participant removed from the export).

  4. Legacy rows with seq = NULL (created before this scheme) are deleted on first import after deploy.

This preserves RaceResult.id across re-imports, which keeps any external references stable. Verified in production 2026-04-20 on WPCA Road League events 120 and 121 (515/515 rr_id values preserved across a re-import).

4.3. Full Reconciliation Semantics

The ResultSet is an exact mirror of the CSV per Registration Event group. This is a full reconciliation, not a partial upsert. After a successful import:

  • Every CSV row has a matching RaceResult (inserted or updated)

  • Every pre-existing RaceResult whose seq is absent from the CSV has been deleted

  • A participant whose Registration Event value differs from the prior import naturally disappears from the old ResultSet and appears in the new one

This semantic has a sharp edge: if every row fails to resolve (wrong participantIdMode, stale file), the ResultSet is emptied. See Mode-Mismatch Risk (Outstanding #10).

4.4. Number Change Detection

For each row with a Bib value, the imported bib is compared against EventParticipant.number. Mismatches are collected, then classified:

Type Meaning

SIMPLE

Target bib’s RaceNumber.person is NULL — straightforward reassignment. Applied automatically when applyNumberChanges=true.

SWAP

Two participants each hold the other’s new bib. Detected and reported with swapWithParticipantId, but not auto-applied.

CONFLICT

Target bib is held by another person and there is no matching reverse change, OR the bib does not exist as a RaceNumber for the event’s NumberType. Reported only.

applyNumberChanges=true applies SIMPLE changes via RaceNumberAssignmentServiceEx.addNumberNoLaterThan(…​).

5. Points Calculators

The PointsCalculatorFactory resolves the pointsCalculator query-parameter value to a concrete calculator. Both short codes and fully-qualified class names are accepted.

Short code Class Scheme

(default, no param)

SASchoolCyclingSeriesPointsCalculator

SA School Cycling Series — sliding scale down to ~position 67

wpca-road-league

WpcaRoadLeaguePointsCalculator

WPCA Road League — top 12: 50/45/40/36/34/32/30/28/26/24/22/20; position 13+: 2 points (participation); non-finishers: 0

Calculators are invoked once per RaceResult after position is parsed. Non-finishers (position = NULL) receive 0 points regardless of the calculator’s position→points function, enforced by a regression test in ResultImportXLSIT.

6. Source Code

File Purpose

ResultImportXLS.java

Bulk CSV importer. processBulkCsv() is the canonical path; the older readStream() / process() XLSX helpers remain @Deprecated and are no longer reachable from HTTP.

RaceResultRowProcessor.java

Whole-file bridge — parses ImportJob.configJson (participantIdMode, pointsCalculator, applyNumberChanges), resolves the PointsCalculator via PointsCalculatorFactory, delegates to ResultImportXLS.processBulkCsv, writes counters back onto the ImportJob. See Async Import Architecture — Whole-File Bridge.

ResultSetResourceEx.java

REST endpoints PUT /import-bulk (async upload) and GET /import/{jobId} (result retrieval). Thin facade over ImportJobService.createWholeFileImportJob.

BulkResultImportResponseDTO.java

Response payload, including Summary, NonDataRows, CategoryResult, NumberChangeDTO.

ResultSetQueryService.createOrReplace(…​)

Find-or-create ResultSet for a race.

RaceNumberAssignmentServiceEx.addNumberNoLaterThan(…​)

Number assignment with duplicate detection used by applyNumberChanges.

PointsCalculator + factory

Pluggable points calculation; SA School Cycling and WPCA Road League implementations available.

Location: admin-service/src/main/java/za/co/idealogic/event/admin/

7. Outstanding Items

Tracked in design-journal/2026-04/result-import-improvements.adoc (see Outstanding Issues table):

  • #2: DNF/DNS/Drop status handling — non-numeric Place values currently become position = NULL. Partial admin-service-only subset shipped (ADO-457); schema changes deferred (ADO-458).

  • #3: Category change detection & apply (Pass 3) — designed in the journal, not implemented.

  • #7: Surface unresolved EPs in an issues list on the response. ADO US #456.

  • #9: This document kept in sync with the latest implementation state.

  • #10: Mode-mismatch catastrophic delete guard — see Mode-Mismatch Risk (Outstanding #10). Production incident 2026-04-20, no ADO ticket yet.

7.1. Mode-Mismatch Risk (Outstanding #10)

When every CSV row fails participant resolution, all rows are counted as skipped, no seq is touched, and the reconciliation delete pass removes every pre-existing row as "absent from the file". This is a silent catastrophic delete with no distinguishing signal in the response — summary and category counts look the same as a file containing zero matching participants.

Surfaced in production on 2026-04-20 (event 119 wiped by an epid vs regid operator error). Proposed fix: gate the delete pass on a resolution-success threshold per category and return a 422-style diagnostic when the threshold is not met.

Until the guard ships, operators must rely on the pre-flight checklist in Import Operations Guide.