/** * 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; /** scoped key → `project_workstream_contractor.id`. */ contractors: Record; /** scoped key → `project_workstream_stage.id`. */ stages: Record; } 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(); const rowsByWorkstreamKey = new Map(); 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(); 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( key: string, value: string, decisions: Record, 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, resolvedByKey: Map, blankWorkstreamRows: number, unmappedContractorKeys: Set, unmappedStageKeys: Set, ): 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 } }; }