assessment-model/src/lib/projects/import/valueMapping.ts
Daniel Roth f76d5aa167 fix(ara-projects): count a blank workstream as blank, not as awaiting scope (#415)
unscopedRows seeded itself with the blank-workstream row count, so the
contractor table's note told the user those rows were "waiting on their
workstream value — map it above and their contractors appear here". They never
would: a blank cell has no value to map, and blankRows.workstream already
reports them. unscopedRows now counts only rows carrying a workstream value that
is not yet mapped — the rows the note's promise is actually true of.

Adds the counting invariant as a test (every row lands in exactly one workstream
entry or in blankRows.workstream), which is the AC's "value-mapping counts match
the file".

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

493 lines
18 KiB
TypeScript

/**
* Work-order import — value mapping (issue #415, step 2 of 3, layer 2 of 2).
*
* Layer 1 (`./columnMapping`) said which column is the workstream. This layer
* says which of *that column's values* is which of the project's workstreams —
* and then, scoped to each of those, which contractor and which stage.
*
* ## The scoping rule (ADR-0019)
*
* A contractor value does not map to "a contractor"; it maps to a contractor
* **assigned to that row's workstream** (`project_workstream_contractor`), and
* a stage maps to a stage on **that workstream's own ladder**
* (`project_workstream_stage`). The same text can therefore need two different
* answers — "Acme" under Windows is a different assignment from "Acme" under
* Doors, and may not exist at all under one of them. So contractor and stage
* entries are keyed by `(resolved workstream, value)`, never by value alone,
* and each offers only that workstream's assignments.
*
* A consequence worth stating: rows whose workstream value is blank or not yet
* mapped have no scope, so their contractor and stage values cannot be listed
* yet. They are counted (`unscopedRows`) rather than guessed at, and appear as
* soon as their workstream is mapped — which is why the whole model is derived
* fresh from `(rows, columnMapping, valueMapping, targets)` on every change
* instead of being accumulated in state.
*
* ## One-to-one vs many-to-one
*
* Column mapping is one-to-one (one column per field), so it assigns greedily.
* Value mapping is deliberately **many-to-one**: "Window Programme" and
* "Windows - Phase 2" may both be the Windows workstream, so each distinct
* value takes its own best match independently.
*
* ## What blocks what
*
* Nothing here blocks progression to validation — that is column mapping's job.
* An unmapped value is a *warning* whose rows become validation errors at #417,
* except a blank Stage, which is legitimate and falls back to the workstream's
* first stage at commit.
*
* Pure and DB-free.
*/
import {
normaliseForMatch,
VALUE_MATCH_THRESHOLD,
valueSimilarity,
} from "./similarity";
import { cellFor, type ImportColumnMapping } from "./columnMapping";
import type { ImportMappingTargets, WorkstreamTarget } from "./mappingTargets";
/**
* The user's value-level decisions.
*
* A key that is **absent** has never been touched, so auto-match speaks for it.
* A key mapped to **null** was explicitly cleared, and auto-match must not
* quietly put its suggestion back — "I looked at this and none of them is
* right" is an answer.
*
* Contractor and stage keys carry their workstream scope (see `scopedValueKey`).
*/
export interface ImportValueMapping {
/** value key → `project_workstream.id`. */
workstreams: Record<string, string | null>;
/** scoped key → `project_workstream_contractor.id`. */
contractors: Record<string, string | null>;
/** scoped key → `project_workstream_stage.id`. */
stages: Record<string, string | null>;
}
export const EMPTY_VALUE_MAPPING: ImportValueMapping = {
workstreams: {},
contractors: {},
stages: {},
};
/**
* The key a file value is remembered by: its normalised form, so "ACME LTD",
* "Acme Ltd" and "acme ltd" are one decision rather than three.
*/
export function valueKey(value: string): string {
return normaliseForMatch(value);
}
/** The key a contractor/stage value is remembered by, within one workstream. */
export function scopedValueKey(
projectWorkstreamId: string,
value: string,
): string {
// The workstream id is all digits (a `bigserial`), so the first space is
// unambiguously the separator however many spaces the value itself carries.
return `${projectWorkstreamId} ${valueKey(value)}`;
}
export type ValueMappingStatus = "matched" | "needs_review";
/** Where a mapped value's answer came from — the UI badges auto-matches. */
export type ValueMatchSource = "user" | "auto" | null;
export interface WorkstreamValueEntry {
/** The value as it first appears in the file, for display. */
value: string;
/** Its `valueKey`, for reading and writing `ImportValueMapping.workstreams`. */
key: string;
/** How many file rows carry this value. */
rowCount: number;
/** `project_workstream.id`, or null when unmapped. */
targetId: string | null;
matchedBy: ValueMatchSource;
status: ValueMappingStatus;
}
export interface ScopedValueEntry {
/** The workstream this decision is scoped to — the row's mapped workstream. */
workstreamId: string;
workstreamName: string;
value: string;
/** Its `scopedValueKey`, for reading and writing the mapping. */
key: string;
rowCount: number;
/** `project_workstream_contractor.id` / `project_workstream_stage.id`, or null. */
targetId: string | null;
matchedBy: ValueMatchSource;
status: ValueMappingStatus;
/**
* True when the workstream this value sits under has nothing to offer — no
* contractor assigned, or no stage on its ladder. The readiness gate
* (ADR-0019) makes this unreachable from the import page, but a caller that
* skipped the gate would otherwise show an empty picker with no explanation.
*/
noTargets: boolean;
}
export interface ValueMappingModel {
/** Distinct workstream values, biggest row count first. */
workstreams: WorkstreamValueEntry[];
/** Distinct (workstream, contractor value) pairs, biggest row count first. */
contractors: ScopedValueEntry[];
/** Distinct (workstream, stage value) pairs. Blank stage cells are not listed. */
stages: ScopedValueEntry[];
/** Whether the column behind each value table is mapped at all. */
hasWorkstreamColumn: boolean;
hasContractorColumn: boolean;
hasStageColumn: boolean;
/** Rows whose cell is blank, per field. A blank stage is legitimate; the others are not. */
blankRows: { workstream: number; contractor: number; stage: number };
/**
* Rows carrying a workstream value that is not yet mapped, so their
* contractor and stage values cannot be scoped and are not listed yet. They
* appear as soon as that workstream value is mapped — which is what separates
* them from rows whose workstream cell is simply blank (`blankRows`).
*/
unscopedRows: number;
/** Entries still needing review, per table. */
needsReview: { workstreams: number; contractors: number; stages: number };
/**
* Rows that will fail validation at #417 as things stand: a blank or unmapped
* workstream, a blank or unmapped contractor, or a *provided* stage that
* matches nothing. Counted per row, so a row with two problems counts once.
*/
warningRows: number;
}
/** Row-level view of one file row's three mappable values. */
interface RowValues {
workstream: string;
contractor: string;
stage: string;
}
/**
* Build the whole value-mapping picture from the file, the column mapping, the
* user's value decisions and the project's targets.
*
* One pass over the rows to count distinct values, then a second, tiny pass
* over the distinct sets to resolve each. It is re-run on every edit — the
* inputs are small enough (20,000 rows at most, three cells each) that deriving
* beats caching, and caching is what would let the tables disagree with the
* mapping the user is looking at.
*/
export function buildValueMappingModel(
rows: string[][],
columnMapping: ImportColumnMapping,
valueMapping: ImportValueMapping,
targets: ImportMappingTargets,
): ValueMappingModel {
const hasWorkstreamColumn = columnMapping.fields.workstream !== undefined;
const hasContractorColumn = columnMapping.fields.contractor !== undefined;
const hasStageColumn = columnMapping.fields.stage !== undefined;
const blankRows = { workstream: 0, contractor: 0, stage: 0 };
// Pass 1 — distinct workstream values, and the contractor/stage values seen
// beneath each of them (keyed by the *file's* workstream value, since the
// workstream it resolves to is not known until pass 2).
const workstreamValues = new Map<string, { value: string; rowCount: number }>();
const rowsByWorkstreamKey = new Map<string, RowValues[]>();
for (const row of rows) {
const values: RowValues = {
workstream: cellFor(row, columnMapping, "workstream"),
contractor: cellFor(row, columnMapping, "contractor"),
stage: cellFor(row, columnMapping, "stage"),
};
if (values.workstream === "") blankRows.workstream++;
if (values.contractor === "") blankRows.contractor++;
if (values.stage === "") blankRows.stage++;
if (values.workstream === "") continue;
const key = valueKey(values.workstream);
const seen = workstreamValues.get(key);
if (seen) seen.rowCount++;
else workstreamValues.set(key, { value: values.workstream, rowCount: 1 });
const bucket = rowsByWorkstreamKey.get(key);
if (bucket) bucket.push(values);
else rowsByWorkstreamKey.set(key, [values]);
}
// Pass 2 — resolve each distinct workstream value.
const workstreams: WorkstreamValueEntry[] = [];
const resolvedByKey = new Map<string, WorkstreamTarget>();
const targetsById = new Map(targets.workstreams.map((w) => [w.id, w]));
for (const [key, { value, rowCount }] of workstreamValues) {
const resolved = resolveValue(
key,
value,
valueMapping.workstreams,
targets.workstreams,
(target) => target.name,
(target) => target.id,
);
workstreams.push({ value, key, rowCount, ...resolved });
const target = resolved.targetId
? targetsById.get(resolved.targetId)
: undefined;
if (target) resolvedByKey.set(key, target);
}
// Pass 3 — contractor and stage values, grouped by the workstream their rows
// resolved to. Two file spellings of the same workstream merge here, which is
// right: they are one workstream, so "Acme" beneath either is one decision.
const contractorTally = new Map<
string,
{ workstream: WorkstreamTarget; value: string; rowCount: number }
>();
const stageTally = new Map<
string,
{ workstream: WorkstreamTarget; value: string; rowCount: number }
>();
// Rows with a *blank* workstream are reported as blank, not as unscoped:
// mapping cannot rescue them, so promising that their contractors will appear
// once the workstream is mapped would be a lie.
let unscopedRows = 0;
for (const [key, bucket] of rowsByWorkstreamKey) {
const workstreamTarget = resolvedByKey.get(key);
if (!workstreamTarget) {
// Unmapped workstream: no scope, so nothing beneath it can be listed yet.
unscopedRows += bucket.length;
continue;
}
for (const values of bucket) {
if (hasContractorColumn && values.contractor !== "") {
tally(contractorTally, workstreamTarget, values.contractor);
}
// A blank stage is legitimate — it falls back to the workstream's first
// stage at commit — so it is counted as blank, never listed for mapping.
if (hasStageColumn && values.stage !== "") {
tally(stageTally, workstreamTarget, values.stage);
}
}
}
const contractors = [...contractorTally.entries()].map(
([key, { workstream, value, rowCount }]) => ({
workstreamId: workstream.id,
workstreamName: workstream.name,
value,
key,
rowCount,
noTargets: workstream.contractors.length === 0,
...resolveValue(
key,
value,
valueMapping.contractors,
workstream.contractors,
(target) => target.name,
(target) => target.id,
),
}),
);
const stages = [...stageTally.entries()].map(
([key, { workstream, value, rowCount }]) => ({
workstreamId: workstream.id,
workstreamName: workstream.name,
value,
key,
rowCount,
noTargets: workstream.stages.length === 0,
...resolveValue(
key,
value,
valueMapping.stages,
workstream.stages,
(target) => target.name,
(target) => target.id,
),
}),
);
byRowCount(workstreams);
byRowCount(contractors);
byRowCount(stages);
return {
workstreams,
contractors,
stages,
hasWorkstreamColumn,
hasContractorColumn,
hasStageColumn,
blankRows,
unscopedRows,
needsReview: {
workstreams: workstreams.filter((e) => e.status === "needs_review").length,
contractors: contractors.filter((e) => e.status === "needs_review").length,
stages: stages.filter((e) => e.status === "needs_review").length,
},
warningRows: countWarningRows(
rowsByWorkstreamKey,
resolvedByKey,
blankRows.workstream,
new Set(
contractors.filter((e) => e.status === "needs_review").map((e) => e.key),
),
new Set(
stages.filter((e) => e.status === "needs_review").map((e) => e.key),
),
),
};
}
/** Add one row's contribution to a scoped tally. */
function tally(
into: Map<
string,
{ workstream: WorkstreamTarget; value: string; rowCount: number }
>,
workstream: WorkstreamTarget,
value: string,
): void {
const key = scopedValueKey(workstream.id, value);
const seen = into.get(key);
if (seen) seen.rowCount++;
else into.set(key, { workstream, value, rowCount: 1 });
}
/**
* Decide one value: the user's answer if they gave one, otherwise the best
* target above the auto-match threshold, otherwise nothing.
*
* A user answer that names a target no longer on offer (they mapped a
* contractor, then re-pointed the workstream at one that contractor is not
* assigned to) is discarded rather than kept as a dangling id — the value falls
* back to needing review, which is the truth of the situation.
*/
function resolveValue<T>(
key: string,
value: string,
decisions: Record<string, string | null>,
candidates: readonly T[],
nameOf: (target: T) => string,
idOf: (target: T) => string,
): { targetId: string | null; matchedBy: ValueMatchSource; status: ValueMappingStatus } {
if (Object.prototype.hasOwnProperty.call(decisions, key)) {
const chosen = decisions[key];
if (chosen === null) {
return { targetId: null, matchedBy: null, status: "needs_review" };
}
if (candidates.some((target) => idOf(target) === chosen)) {
return { targetId: chosen, matchedBy: "user", status: "matched" };
}
return { targetId: null, matchedBy: null, status: "needs_review" };
}
let best: { id: string; score: number } | null = null;
let tied = false;
for (const target of candidates) {
const score = valueSimilarity(value, nameOf(target));
if (score < VALUE_MATCH_THRESHOLD) continue;
if (best === null || score > best.score) {
best = { id: idOf(target), score };
tied = false;
} else if (score === best.score) {
tied = true;
}
}
// Two candidates the value fits equally well is not a match, it is a
// question: "Acme" beneath a workstream carrying both Acme Windows and Acme
// Doors must be asked, not guessed. (ADR-0019 leaves same-org disambiguation
// to #417; this keeps the guess from being made here in the meantime.)
return best && !tied
? { targetId: best.id, matchedBy: "auto", status: "matched" }
: { targetId: null, matchedBy: null, status: "needs_review" };
}
/** Biggest first, then alphabetical — stable as the user maps, since neither key changes. */
function byRowCount(entries: Array<{ rowCount: number; value: string }>): void {
entries.sort(
(a, b) => b.rowCount - a.rowCount || a.value.localeCompare(b.value, "en-GB"),
);
}
/**
* How many rows will fail validation at #417 as things stand.
*
* Counted **per row**, by walking the rows again rather than by summing the
* tables' row counts: a row with an unmapped workstream *and* a blank
* contractor is one broken row, not two, and summing the tables would report it
* twice.
*
* A row is broken when its workstream is blank or unmapped, its contractor is
* blank or unmapped, or it carries a stage that matches nothing. A *blank*
* stage is not broken — it falls back to the workstream's first stage.
*/
function countWarningRows(
rowsByWorkstreamKey: Map<string, RowValues[]>,
resolvedByKey: Map<string, WorkstreamTarget>,
blankWorkstreamRows: number,
unmappedContractorKeys: Set<string>,
unmappedStageKeys: Set<string>,
): number {
// A blank workstream cell is unmappable, so every such row is already broken.
let broken = blankWorkstreamRows;
for (const [key, bucket] of rowsByWorkstreamKey) {
const workstream = resolvedByKey.get(key);
if (!workstream) {
// The whole bucket shares one unmapped workstream value.
broken += bucket.length;
continue;
}
for (const values of bucket) {
const contractorBroken =
values.contractor === "" ||
unmappedContractorKeys.has(scopedValueKey(workstream.id, values.contractor));
const stageBroken =
values.stage !== "" &&
unmappedStageKeys.has(scopedValueKey(workstream.id, values.stage));
if (contractorBroken || stageBroken) broken++;
}
}
return broken;
}
/** Record one workstream-value decision. `null` means "none of these". */
export function setWorkstreamValue(
mapping: ImportValueMapping,
key: string,
targetId: string | null,
): ImportValueMapping {
return {
...mapping,
workstreams: { ...mapping.workstreams, [key]: targetId },
};
}
/** Record one contractor decision, scoped to its workstream. */
export function setContractorValue(
mapping: ImportValueMapping,
key: string,
targetId: string | null,
): ImportValueMapping {
return {
...mapping,
contractors: { ...mapping.contractors, [key]: targetId },
};
}
/** Record one stage decision, scoped to its workstream. */
export function setStageValue(
mapping: ImportValueMapping,
key: string,
targetId: string | null,
): ImportValueMapping {
return { ...mapping, stages: { ...mapping.stages, [key]: targetId } };
}