assessment-model/src/lib/projects/import/columnMapping.ts
Daniel Roth 4422cebe0b feat(ara-projects): import mapping — column + value mapping logic (#415)
The pure half of import step 2 of 3, built on #414's parsed-rows-held-client-side
model. No database writes, no migrations; four new modules under
src/lib/projects/import, all pure except one read-only loader.

similarity.ts — the auto-match primitive
  Normalisation plus a Sørensen–Dice bigram score, with word containment ranked
  above letter overlap ("Contractor" ⊂ "Contractor Name" beats "Stage"/"Storage").
  Headings and cell values match by *different* rules: valueSimilarity adds
  directional, length-weighted word coverage so "Window Programme" reaches
  "Windows" (0.91) and "Door Replacement" reaches "Doors", at a higher threshold
  (0.75 vs 0.62) so "Acme Windows Ltd" does not reach "Acme Doors Ltd" (0.67).
  The leniency is deliberately kept out of heading matching, where it would map
  "Forecast Start" onto "Forecast end date" and mis-read every row in the file.

columnMapping.ts — file column -> Ara field
  Seven fields (landlord property id, UPRN, workstream, contractor, stage, WO
  reference, forecast end date) with alias lists drawn from real asset-management
  exports. Requirements are modelled as groups: property identifier is satisfied
  by landlord property id *or* UPRN, so a file may carry either or both.
  autoMatchColumns assigns greedily — one column per field, best pair first, and
  no guess below the threshold. Two deliberate non-matches: a Status column is
  not the Stage field (CONTEXT.md keeps them apart), and nothing matches Priority
  or cost (v1 ignores them; #426 owns priority). Per-column status is
  matched / ignored / needs_review, where "ignored" is the user's explicit
  "don't import" and is distinct from not having looked yet.

valueMapping.ts — cell values -> the project's own entities
  Contractor and stage values are keyed by (resolved workstream, value), never by
  value alone: "Acme" under Windows is a different assignment from "Acme" under
  Doors and may not exist under one of them (ADR-0019). Two file spellings of one
  workstream merge into a single contractor question. Rows whose workstream is
  blank or unmapped have no scope, so their contractor/stage values are counted
  (unscopedRows) rather than guessed, and appear the moment the workstream is
  mapped. Value matching is many-to-one (several spellings, one workstream) where
  column matching is one-to-one, and a tie between two equally-good candidates is
  refused rather than guessed. A user's answer that the workstream no longer
  offers lapses to needs-review instead of persisting as a dangling id.
  warningRows is counted per row by a second pass, not by summing the tables — a
  row with an unmapped workstream and a blank contractor is one broken row.

mappingTargets.ts — what values may map to
  Pure assembleMappingTargets + one read-only loadMappingTargets (three SELECTs).
  Every bigserial id is stringified once, here, because bigint cannot cross the
  server->client boundary. Stages keep ladder order (stages[0] is the blank-stage
  fallback at commit); workstreams and contractors sort alphabetically.

session.ts — the artefact the mapping is persisted alongside
  ImportSession carries the file identity plus both mapping layers, JSON-clean
  end to end. sessionMatchesFile refuses a session whose headers have moved,
  since a mapping is column indices.

Blocking rule (AC): only unmapped *required* columns block progression to
validation. Unmapped values are warnings whose rows fail at #417; a blank stage
is not a warning at all (it falls back to the workstream's first stage).

112 tests pass in src/lib/projects/import (66 new); tsc clean. Tests mock
@/app/db/db and open no connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:15:24 +00:00

396 lines
12 KiB
TypeScript

/**
* Work-order import — column mapping (issue #415, step 2 of 3, layer 1 of 2).
*
* Layer 1 answers "which file column is which Ara field?". Layer 2
* (`./valueMapping`) answers "which of this column's values is which of the
* project's workstreams / contractors / stages?".
*
* Pure and DB-free like `./parse`: it takes the headers the parser found and
* returns data. Every function is total and immutable — the component holds one
* `ImportColumnMapping` in state and swaps it for a new one on each edit, so
* the whole screen (statuses, counts, the value-mapping tables) re-derives from
* that single value with no effect and no cached copy to fall out of step.
*
* ## Required vs optional (ADR-0019)
*
* The parser is header-agnostic on purpose; requiredness is enforced *here*.
* Property identifier, Workstream and Contractor are required; Stage, Work
* order reference and Forecast end date are optional. Property identifier is
* required as a **group**: a file may carry a landlord property id, a UPRN or
* both, and mapping either one satisfies it (which one a row matches on is
* validation's business at #417).
*
* Priority and cost columns are deliberately absent from the field list — v1
* ignores them (#426 owns priority), so they land as unmapped columns the user
* can dismiss.
*/
import {
AUTO_MATCH_THRESHOLD,
bestSimilarity,
greedyPairs,
} from "./similarity";
/** The Ara fields an import file column can be mapped to. */
export type ImportFieldKey =
| "landlordPropertyId"
| "uprn"
| "workstream"
| "contractor"
| "stage"
| "workOrderReference"
| "forecastEndDate";
export interface ImportFieldDef {
key: ImportFieldKey;
/** What the field is called in the UI, and the first phrase auto-match scores against. */
label: string;
/** One line under the label in the field picker. */
hint: string;
/** Extra phrases auto-match scores against — the headings real files use. */
aliases: readonly string[];
}
/**
* Every mappable field, in the order the template ships its columns.
*
* The alias lists are drawn from the headings asset-management exports actually
* use ("Asset Ref", "Works Order Number", "Work Type"). Two deliberate
* omissions:
* - `stage` does **not** alias "status". A file's Status column is usually a
* contractor's own workflow state rather than a stage on this project's
* ladder, and CONTEXT.md keeps "status" away from Stage for that reason.
* It shows as *needs review* so the user makes the call.
* - nothing aliases "priority" or "cost" — v1 has no field for them.
*/
export const IMPORT_FIELDS: readonly ImportFieldDef[] = [
{
key: "landlordPropertyId",
label: "Landlord property id",
hint: "Your own reference for the property",
aliases: [
"property id",
"property identifier",
"property reference",
"property ref",
"landlord property id",
"landlord reference",
"asset id",
"asset ref",
"asset reference",
"asset number",
],
},
{
key: "uprn",
label: "UPRN",
hint: "Unique Property Reference Number",
aliases: ["uprn", "unique property reference number"],
},
{
key: "workstream",
label: "Workstream",
hint: "The category of works — mapped to this project's workstreams",
aliases: [
"workstream",
"work stream",
"work type",
"works type",
"programme",
"work programme",
"works programme",
"job type",
"trade",
],
},
{
key: "contractor",
label: "Contractor",
hint: "Who delivers the work — mapped to this workstream's contractors",
aliases: [
"contractor",
"contractor name",
"supplier",
"supplier name",
"installer",
"delivery partner",
"company",
],
},
{
key: "stage",
label: "Stage",
hint: "Optional — blank rows start at the workstream's first stage",
aliases: ["stage", "stage name", "current stage", "work stage", "phase"],
},
{
key: "workOrderReference",
label: "Work order reference",
hint: "Optional — your reference for the work order",
aliases: [
"work order reference",
"work order ref",
"work order number",
"works order number",
"wo reference",
"wo ref",
"wo number",
"order number",
"job number",
"job reference",
],
},
{
key: "forecastEndDate",
label: "Forecast end date",
hint: "Optional — when the work is expected to finish",
aliases: [
"forecast end",
"forecast end date",
"forecast completion",
"forecast completion date",
"planned end date",
"target completion date",
"completion date",
"due date",
"end date",
],
},
] as const;
export const IMPORT_FIELDS_BY_KEY: Record<ImportFieldKey, ImportFieldDef> =
Object.fromEntries(IMPORT_FIELDS.map((f) => [f.key, f])) as Record<
ImportFieldKey,
ImportFieldDef
>;
/**
* A requirement is satisfied when **any** of its keys is mapped.
*
* Single-key requirements are the ordinary case; `propertyIdentifier` is the
* reason the concept exists — landlord property id *or* UPRN (or both) will do.
*/
export interface ImportRequirement {
id: string;
label: string;
keys: readonly ImportFieldKey[];
/** Shown when the requirement is unmet, so the blocker explains itself. */
reason: string;
}
export const IMPORT_REQUIREMENTS: readonly ImportRequirement[] = [
{
id: "propertyIdentifier",
label: "Property identifier",
keys: ["landlordPropertyId", "uprn"],
reason:
"Map a landlord property id or a UPRN column (or both) so rows can be matched to properties.",
},
{
id: "workstream",
label: "Workstream",
keys: ["workstream"],
reason: "Every row must say which workstream the work belongs to.",
},
{
id: "contractor",
label: "Contractor",
keys: ["contractor"],
reason: "Every row must name the contractor delivering the work.",
},
] as const;
/** Field keys that are optional — everything not named by a requirement. */
export const OPTIONAL_FIELD_KEYS: readonly ImportFieldKey[] = IMPORT_FIELDS.map(
(f) => f.key,
).filter((key) => !IMPORT_REQUIREMENTS.some((r) => r.keys.includes(key)));
/**
* The user's declaration of which column is which field.
*
* `fields` is keyed by field rather than by column because that is the question
* every downstream consumer asks ("which column holds the contractor?") and
* because it makes the one-column-per-field invariant unrepresentable-otherwise
* rather than merely enforced.
*
* `ignoredColumns` records columns the user has *decided* not to import, which
* is a different state from a column they have not looked at yet — the first
* shows as settled, the second as needs review.
*/
export interface ImportColumnMapping {
fields: Partial<Record<ImportFieldKey, number>>;
ignoredColumns: number[];
}
export const EMPTY_COLUMN_MAPPING: ImportColumnMapping = {
fields: {},
ignoredColumns: [],
};
/** Per-file-column status, as the wireframe's column-mapping table renders it. */
export type ColumnMappingStatus = "matched" | "ignored" | "needs_review";
export interface ColumnMappingRow {
columnIndex: number;
header: string;
/** The field this column supplies, or null when unmapped. */
field: ImportFieldKey | null;
status: ColumnMappingStatus;
/** A non-blank sample cell for this column, for the "e.g. …" hint. Empty when none. */
sample: string;
}
/**
* Suggest a mapping from the file's headings alone.
*
* Scores every (column, field) pair on heading similarity and takes the best
* pairs first, so a file carrying both "Property Ref" and "UPRN" gives each its
* own field instead of letting the first column win both. Columns that match
* nothing are left unmapped — *needs review*, never a guess.
*/
export function autoMatchColumns(headers: string[]): ImportColumnMapping {
const pairs = greedyPairs(
headers,
IMPORT_FIELDS,
(header, field) => bestSimilarity(header, [field.label, ...field.aliases]),
AUTO_MATCH_THRESHOLD,
);
const fields: Partial<Record<ImportFieldKey, number>> = {};
for (const pair of pairs) {
fields[IMPORT_FIELDS[pair.targetIndex].key] = pair.candidateIndex;
}
return { fields, ignoredColumns: [] };
}
/**
* Point a column at a field, at nothing, or explicitly at "don't import".
*
* Returns a new mapping; the caller's copy is untouched. Assigning a field that
* another column already supplies moves it, because two columns cannot both be
* the contractor and silently dropping one would be worse than saying so.
*/
export function assignColumn(
mapping: ImportColumnMapping,
columnIndex: number,
target: ImportFieldKey | "ignore" | null,
): ImportColumnMapping {
const fields = { ...mapping.fields };
// This column is being repurposed, so it stops supplying whatever it did.
for (const [key, index] of Object.entries(fields)) {
if (index === columnIndex) delete fields[key as ImportFieldKey];
}
const ignoredColumns = mapping.ignoredColumns.filter((i) => i !== columnIndex);
if (target === "ignore") {
ignoredColumns.push(columnIndex);
ignoredColumns.sort((a, b) => a - b);
} else if (target !== null) {
// One column per field: whoever held it before gives it up.
fields[target] = columnIndex;
}
return { fields, ignoredColumns };
}
/** The field a column supplies, or null. */
export function fieldForColumn(
mapping: ImportColumnMapping,
columnIndex: number,
): ImportFieldKey | null {
for (const field of IMPORT_FIELDS) {
if (mapping.fields[field.key] === columnIndex) return field.key;
}
return null;
}
/**
* One row per file column, in file order — the left-hand table.
*
* `rows` supplies the "e.g. …" sample under each heading; pass the preview
* rather than all 20,000, the first non-blank cell is all it looks for.
*/
export function columnMappingRows(
headers: string[],
mapping: ImportColumnMapping,
rows: string[][] = [],
): ColumnMappingRow[] {
return headers.map((header, columnIndex) => {
const field = fieldForColumn(mapping, columnIndex);
const ignored = mapping.ignoredColumns.includes(columnIndex);
return {
columnIndex,
header,
field,
status: field ? "matched" : ignored ? "ignored" : "needs_review",
sample: firstNonBlank(rows, columnIndex),
};
});
}
/** The first non-blank cell in a column, for the sample hint. */
function firstNonBlank(rows: string[][], columnIndex: number): string {
for (const row of rows) {
const cell = row[columnIndex];
if (cell !== undefined && cell !== "") return cell;
}
return "";
}
export interface ColumnMappingSummary {
/** Columns pointed at a field — the "Auto-matched" figure once nothing has been edited. */
matched: number;
/** Columns the user has explicitly dismissed. */
ignored: number;
/** Columns neither mapped nor dismissed — the "Needs review" figure. */
needsReview: number;
/** Requirements with no column behind them. Empty means the step may proceed. */
missingRequirements: ImportRequirement[];
/** Optional fields with no column — informational, never a blocker. */
unmappedOptionalFields: ImportFieldKey[];
/**
* True when every required field has a column. This — and only this — is what
* gates progression to validation; unmapped *values* are warnings whose rows
* fail downstream (#417), not blockers here.
*/
complete: boolean;
}
export function summariseColumnMapping(
headers: string[],
mapping: ImportColumnMapping,
): ColumnMappingSummary {
const rows = columnMappingRows(headers, mapping);
const missingRequirements = IMPORT_REQUIREMENTS.filter(
(requirement) =>
!requirement.keys.some((key) => mapping.fields[key] !== undefined),
);
return {
matched: rows.filter((r) => r.status === "matched").length,
ignored: rows.filter((r) => r.status === "ignored").length,
needsReview: rows.filter((r) => r.status === "needs_review").length,
missingRequirements,
unmappedOptionalFields: OPTIONAL_FIELD_KEYS.filter(
(key) => mapping.fields[key] === undefined,
),
complete: missingRequirements.length === 0,
};
}
/**
* A row's cell for a field, or `""` when the field is unmapped or the row is
* short. Shared by the value-mapping layer and (at #417) by validation, so
* "unmapped reads as blank" is decided in exactly one place.
*/
export function cellFor(
row: string[],
mapping: ImportColumnMapping,
field: ImportFieldKey,
): string {
const columnIndex = mapping.fields[field];
if (columnIndex === undefined) return "";
return row[columnIndex] ?? "";
}