mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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>
493 lines
18 KiB
TypeScript
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 } };
|
|
}
|