feat(ara-projects): work-order import 3/3 — validation + insert-only commit (#417)

The final step of the work-order import journey: resolve every mapped row
against the project's live configuration, report the outcome, and commit the
ready set in one transaction. Insert-only (no updates/omissions/priority — those
are #425).

Validation core (pure, DB-free — `@/lib/projects/import/validation`):
- Per-row resolution to one of ready / error / skipped-duplicate, one issue per
  row in a fixed order (property → workstream → duplicate → contractor → stage →
  reference). Re-uses the exact `resolveValue` the mapping screen used, so the
  committed set matches what the user previewed.
- Matching rules (ADR-0019): property by landlord property id then UPRN, scoped
  to the project org's portfolios; contractor required from the file and matched
  to an assignment on that row's workstream; stage optional, blank falls back to
  the workstream's first stage, provided-but-unmatched is an error; a
  (property, workstream) pair already present is skipped, not errored — which is
  what makes a re-run idempotent. Reference is the mapped column else auto
  `WO-<zero-padded seq>`; a colliding provided reference is an error so the
  transaction never fails on the UNIQUE.
- 30 unit tests covering every rule, ordering, and idempotency.

Persistence (`importCommitRepository`):
- `loadImportMatchContext` scopes property matching to the org's portfolios via
  `portfolio_organisation` (linkage verified — clean), gathers existing
  (property, workstream) pairs and reference facts.
- `commitImport` upserts `project_property` and inserts one `work_order` per
  ready row in a transaction — all-or-nothing. status/priority are never
  written (they take DB defaults); the stage FK is v1's only lifecycle mechanism.

Routes: POST `/import/validate` (read-only report) and POST `/import/commit`
(the journey's only write), sharing one auth+resolve prefix so validate and
commit cannot drift.

UI: `ImportValidation` — stat tiles (ready/errors/skipped-duplicates), tabbed
issues table, optional CSV export, transactional commit, and a post-commit
per-workstream summary linking to the work-orders table. Wired in as import
step 3.

Schema: migration 0281 gives `work_order.status` a DEFAULT '' so the commit can
omit it (the AC's "status never written"). This mirrors 0280's priority fix,
whose own note anticipated #417 landing rows — a NOT NULL column an insert
legitimately omits needs a default or every such insert fails.

Cypress: `import-work-orders.cy.js` drives the whole flow (stubbed-API group +
gated real-writes end-to-end group), following the create-project.cy.js
convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-24 08:54:48 +00:00
parent 0d8adad1d9
commit 251085fb8b
16 changed files with 15683 additions and 79 deletions

View file

@ -0,0 +1,283 @@
/**
* Ara Projects work-order import, validation and insert-only commit (#417)
*
* The acceptance criterion is "Cypress: import a small fixture file end-to-end".
* Two groups, mirroring create-project.cy.js, because one of them writes:
*
* 1. The flow group drives the real client components drop zone column
* mapping validation report commit summary against a real,
* already-set-up project (so the server-rendered, setup-gated page renders
* the drop zone at all), but STUBS the three import APIs with cy.intercept
* so the assertions are deterministic and no row is written. It needs a
* ready project's id in CYPRESS_ARA_PROJECTS_IMPORT_PROJECT_ID.
*
* 2. The end-to-end group performs a real parse/validate/commit against a
* scratch database and is the AC's "import a small fixture file end to
* end". It INSERTs work orders, so it is gated behind
* CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever point at a
* non-production database seeded with a set-up project whose org portfolios
* contain the fixture's properties (PROP-0001 / PROP-0002).
*
* Like the other project specs this uses `cy.login` (cypress/support/commands.ts)
* to mint a real next-auth JWE, so NEXTAUTH_JWT_SECRET must be set.
*
* NOT YET RUN. The environment this was authored in points DATABASE_URL at
* production, so Cypress was deliberately not executed here (see
* create-project.cy.js). Treat the selectors and assertions as unverified until
* someone runs them against a scratch DB.
*/
const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
const PROJECT_ID = Cypress.env("ARA_PROJECTS_IMPORT_PROJECT_ID");
const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES");
const INTERNAL_USER = {
email: "dev@domna.homes",
name: "Internal User",
onboarded: true,
sub: "cypress-internal",
};
const FIXTURE = "work-order-import.csv";
/** The parse route's shape — the whole file, echoed from the fixture. */
const PARSED = {
filename: FIXTURE,
headers: [
"Property identifier",
"Workstream",
"Contractor",
"Stage",
"Work order reference",
"Forecast end date",
],
rows: [
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
["PROP-0001", "Doors", "Acme Doors Ltd", "", "WO-1002", "2026-10-15"],
["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
],
rowCount: 3,
preview: [
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
["PROP-0001", "Doors", "Acme Doors Ltd", "", "WO-1002", "2026-10-15"],
["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
],
};
describe("Ara Projects — work-order import (flow, stubbed APIs)", function () {
beforeEach(function () {
if (!JWT_SECRET) {
cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
this.skip();
}
if (!PROJECT_ID) {
cy.log(
"ARA_PROJECTS_IMPORT_PROJECT_ID not set — no set-up project to render the drop zone against; skipping",
);
this.skip();
}
cy.login(INTERNAL_USER);
});
it("walks upload → mapping → validation → commit → summary end to end", function () {
// Stub the three client-side import calls so the flow is deterministic and
// writes nothing. The page itself is real: it only renders the drop zone
// when the project is set up (ADR-0019), which is why a ready project id is
// required above.
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/parse`, {
statusCode: 200,
body: PARSED,
}).as("parse");
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/validate`, {
statusCode: 200,
body: {
summary: { total: 3, ready: 2, errors: 1, duplicates: 1 },
errors: [
{
rowNumber: 3,
identifier: "PROP-0002",
issue: "No property in this project's portfolios matches this identifier.",
field: "Property identifier",
},
],
duplicates: [
{
rowNumber: 2,
identifier: "PROP-0001",
issue: "This property already has a Doors work order in this project.",
field: "Workstream",
},
],
createdByWorkstream: [
{ projectWorkstreamId: "10", workstreamName: "Windows", count: 1 },
{ projectWorkstreamId: "11", workstreamName: "Doors", count: 1 },
],
},
}).as("validate");
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/commit`, {
statusCode: 200,
body: {
committed: { workOrdersCreated: 2, propertiesLinked: 2 },
createdByWorkstream: [
{ projectWorkstreamId: "10", workstreamName: "Windows", count: 1 },
{ projectWorkstreamId: "11", workstreamName: "Doors", count: 1 },
],
summary: { total: 3, ready: 2, errors: 1, duplicates: 1 },
},
}).as("commit");
cy.visit(`/projects/${PROJECT_ID}/import`);
// Step 1 — upload the fixture.
cy.get("[data-testid=import-upload]").should("be.visible");
cy.get("[data-testid=import-file-input]").selectFile(
`cypress/fixtures/${FIXTURE}`,
{ force: true },
);
cy.wait("@parse");
cy.get("[data-testid=import-preview]").should("be.visible");
cy.get("[data-testid=import-continue-to-mapping]").click();
// Step 2 — mapping. The required columns auto-match from the template
// headers, so Continue is enabled without any manual mapping.
cy.get("[data-testid=import-mapping]").should("be.visible");
cy.get("[data-testid=mapping-continue]").should("not.be.disabled").click();
// Step 3 — validation report.
cy.wait("@validate")
.its("request.body")
.then((body) => {
expect(body.rows).to.have.length(3);
expect(body.session.columnMapping.fields).to.have.property("workstream");
expect(body.session.columnMapping.fields).to.have.property("contractor");
});
cy.get("[data-testid=stat-ready-value]").should("have.text", "2");
cy.get("[data-testid=stat-errors-value]").should("have.text", "1");
cy.get("[data-testid=stat-duplicates-value]").should("have.text", "1");
// The errors tab is default; its one row is the unmatched property.
cy.get("[data-testid=issues-table]").within(() => {
cy.contains("No property in this project's portfolios").should("be.visible");
});
// The duplicate is reported on its own tab, not as an error.
cy.get("[data-testid=tab-duplicates]").click();
cy.get("[data-testid=issues-table]").within(() => {
cy.contains("already has a Doors work order").should("be.visible");
});
// Commit — the button names the ready count.
cy.get("[data-testid=validation-commit]")
.should("contain", "Commit 2 work orders")
.click();
cy.wait("@commit")
.its("request.body")
.then((body) => {
expect(body.rows).to.have.length(3);
expect(body.session).to.have.property("columnMapping");
});
// Summary — the write is stated plainly, with a link into the table.
cy.get("[data-testid=import-committed]").should("be.visible");
cy.get("[data-testid=import-committed]").should(
"contain",
"Created 2 work orders",
);
cy.get("[data-testid=view-work-orders]")
.should("be.visible")
.parent("a")
.should("have.attr", "href", `/projects/${PROJECT_ID}/work-orders`);
});
it("disables commit when nothing is ready to import", function () {
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/parse`, {
statusCode: 200,
body: PARSED,
}).as("parse");
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/validate`, {
statusCode: 200,
body: {
summary: { total: 3, ready: 0, errors: 3, duplicates: 0 },
errors: [],
duplicates: [],
createdByWorkstream: [],
},
}).as("validate");
cy.visit(`/projects/${PROJECT_ID}/import`);
cy.get("[data-testid=import-file-input]").selectFile(
`cypress/fixtures/${FIXTURE}`,
{ force: true },
);
cy.wait("@parse");
cy.get("[data-testid=import-continue-to-mapping]").click();
cy.get("[data-testid=mapping-continue]").click();
cy.wait("@validate");
cy.get("[data-testid=stat-ready-value]").should("have.text", "0");
cy.get("[data-testid=validation-commit]").should("be.disabled");
});
});
describe("Ara Projects — work-order import (end to end, writes)", function () {
beforeEach(function () {
if (!JWT_SECRET || !E2E_WRITES || !PROJECT_ID) {
cy.log(
"ARA_PROJECTS_E2E_WRITES / ARA_PROJECTS_IMPORT_PROJECT_ID not set — skipping the writing happy path",
);
this.skip();
}
cy.login(INTERNAL_USER);
});
it("imports the fixture file and commits work orders", function () {
// Watch the real routes without stubbing them — pass-through intercepts so
// the payloads and status codes can be asserted while the DB actually
// writes. The project named by PROJECT_ID must be fully set up and its org's
// portfolios must contain PROP-0001 and PROP-0002.
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/validate`).as("validate");
cy.intercept("POST", `/api/projects/${PROJECT_ID}/import/commit`).as("commit");
cy.visit(`/projects/${PROJECT_ID}/import`);
cy.get("[data-testid=import-file-input]").selectFile(
`cypress/fixtures/${FIXTURE}`,
{ force: true },
);
cy.get("[data-testid=import-preview]").should("be.visible");
cy.get("[data-testid=import-continue-to-mapping]").click();
cy.get("[data-testid=mapping-continue]").should("not.be.disabled").click();
cy.wait("@validate").its("response.statusCode").should("eq", 200);
// At least one row should be ready to import from a correctly-seeded project.
cy.get("[data-testid=stat-ready-value]")
.invoke("text")
.then((t) => expect(Number(t.replace(/,/g, ""))).to.be.greaterThan(0));
cy.get("[data-testid=validation-commit]").click();
cy.wait("@commit").then(({ response }) => {
expect(response.statusCode).to.eq(200);
expect(response.body.committed.workOrdersCreated).to.be.greaterThan(0);
});
cy.get("[data-testid=import-committed]").should("be.visible");
// Re-running the same file is idempotent: every row that committed is now a
// (property, workstream) duplicate, so it is skipped and nothing is ready to
// import — the commit button disables itself and no second write is offered.
cy.visit(`/projects/${PROJECT_ID}/import`);
cy.get("[data-testid=import-file-input]").selectFile(
`cypress/fixtures/${FIXTURE}`,
{ force: true },
);
cy.get("[data-testid=import-continue-to-mapping]").click();
cy.get("[data-testid=mapping-continue]").click();
cy.wait("@validate");
cy.get("[data-testid=stat-duplicates-value]")
.invoke("text")
.then((t) => expect(Number(t.replace(/,/g, ""))).to.be.greaterThan(0));
cy.get("[data-testid=stat-ready-value]").should("have.text", "0");
cy.get("[data-testid=validation-commit]").should("be.disabled");
});
});

View file

@ -0,0 +1,4 @@
Property identifier,Workstream,Contractor,Stage,Work order reference,Forecast end date
PROP-0001,Windows,Acme Windows Ltd,Ordered,WO-1001,2026-09-30
PROP-0001,Doors,Acme Doors Ltd,,WO-1002,2026-10-15
PROP-0002,Roofs,Sunrise Roofing Ltd,,,
1 Property identifier Workstream Contractor Stage Work order reference Forecast end date
2 PROP-0001 Windows Acme Windows Ltd Ordered WO-1001 2026-09-30
3 PROP-0001 Doors Acme Doors Ltd WO-1002 2026-10-15
4 PROP-0002 Roofs Sunrise Roofing Ltd

View file

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
createdByWorkstream,
prepareImport,
} from "../validationRequest";
import { commitImport } from "@/app/repositories/projects/importCommitRepository";
type Params = { params: Promise<{ projectId: string }> };
/**
* POST commit a validated work-order import (#417, the only write in the
* whole journey).
*
* Re-resolves the mapping server-side (same `prepareImport` the validate route
* runs), then inserts the ready set in one transaction all valid rows or none
* (the AC's "all-or-nothing per valid row set"). Error rows are excluded and
* duplicate rows are skipped; neither blocks the commit of the rows that are
* fine. Re-running the same file is idempotent: its rows are now
* (property, workstream) duplicates, so nothing new is written.
*
* Every committed `work_order` carries a contractor resolved from the file, and
* `status`/`priority` are never written see `commitImport`.
*/
export async function POST(req: NextRequest, props: Params) {
const { projectId } = await props.params;
const prepared = await prepareImport(req, projectId);
if (!prepared.ok) return prepared.response;
const { result } = prepared;
try {
const committed = await commitImport(BigInt(projectId), result.ready);
return NextResponse.json({
committed,
createdByWorkstream: createdByWorkstream(result),
summary: result.summary,
});
} catch {
// The transaction rolled back — nothing was written. A unique-violation
// here would mean a race we did not resolve at validation; the whole set is
// rejected rather than partially applied.
return NextResponse.json(
{ error: "Couldn't commit the import — no work orders were created." },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import {
createdByWorkstream,
prepareImport,
} from "../validationRequest";
type Params = { params: Promise<{ projectId: string }> };
/**
* POST validate a mapped work-order import without writing anything (#417).
*
* The validation report's data source: it resolves every row against the
* project's live configuration and returns the counts, the error rows and the
* skipped-duplicate rows the report table renders, plus a preview of what a
* commit would create per workstream. It is a pure read no `work_order` or
* `project_property` row is written here; that is the commit route's job.
*
* The ready *rows* are deliberately not returned the report needs only their
* count and their per-workstream grouping, and the commit re-derives the full
* set server-side rather than trusting a client to echo it back.
*/
export async function POST(req: NextRequest, props: Params) {
const { projectId } = await props.params;
const prepared = await prepareImport(req, projectId);
if (!prepared.ok) return prepared.response;
const { result } = prepared;
return NextResponse.json({
summary: result.summary,
errors: result.errors,
duplicates: result.duplicates,
createdByWorkstream: createdByWorkstream(result),
});
}

View file

@ -0,0 +1,177 @@
/**
* Work-order import shared request handling for validate and commit (#417).
*
* Both routes do the same first four things: authenticate, authorise (only a
* manager may import), parse the `{ session, rows }` body, and resolve every
* row against the project's live configuration and database facts. They differ
* only in what they do with the result one reports it, one writes the ready
* set. That shared prefix lives here so the two handlers cannot drift apart on
* auth or resolution, which is exactly where a validate/commit mismatch would
* let an un-previewed row slip in.
*
* The session is re-resolved server-side rather than trusted: the client sends
* the mapping, but the *outcome* (which rows are ready) is computed here from
* the same pure `validateImportRows` the mapping screen used, against
* freshly-loaded targets and match facts. See `@/lib/projects/import/validation`.
*/
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { canManageProject } from "@/lib/projects/authz";
import {
loadAuthzUser,
loadProjectAuthzFacts,
} from "@/app/repositories/projects/authzRepository";
import { loadMappingTargets } from "@/lib/projects/import/mappingTargets";
import { cellFor, type ImportFieldKey } from "@/lib/projects/import/columnMapping";
import { MAX_IMPORT_ROWS } from "@/lib/projects/import/parse";
import {
validateImportRows,
type ImportValidationResult,
} from "@/lib/projects/import/validation";
import { loadImportMatchContext } from "@/app/repositories/projects/importCommitRepository";
import type { ProjectAuthzFacts } from "@/lib/projects/authz";
const columnMappingSchema = z.object({
fields: z.record(z.string(), z.number().int().nonnegative()),
ignoredColumns: z.array(z.number().int().nonnegative()),
});
const valueMappingSchema = z.object({
workstreams: z.record(z.string(), z.string().nullable()),
contractors: z.record(z.string(), z.string().nullable()),
stages: z.record(z.string(), z.string().nullable()),
});
const sessionSchema = z.object({
version: z.number(),
filename: z.string(),
headers: z.array(z.string()),
rowCount: z.number(),
columnMapping: columnMappingSchema,
valueMapping: valueMappingSchema,
});
export const importRequestSchema = z.object({
session: sessionSchema,
rows: z.array(z.array(z.string())).max(MAX_IMPORT_ROWS),
});
export type ImportRequestBody = z.infer<typeof importRequestSchema>;
/** A short-circuit `NextResponse` (auth/parse failure) or the resolved result. */
export type PreparedImport =
| { ok: false; response: NextResponse }
| {
ok: true;
project: ProjectAuthzFacts;
body: ImportRequestBody;
result: ImportValidationResult;
};
function fail(error: string, status: number): { ok: false; response: NextResponse } {
return { ok: false, response: NextResponse.json({ error }, { status }) };
}
/**
* Authenticate, authorise, parse and resolve the shared prefix of both
* routes. Returns the full validation result (ready set included) so the commit
* can write it and the validate route can summarise it.
*/
export async function prepareImport(
req: NextRequest,
projectIdParam: string,
): Promise<PreparedImport> {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) return fail("Unauthorised", 401);
if (!/^\d+$/.test(projectIdParam)) return fail("Invalid project id", 400);
const projectId = BigInt(projectIdParam);
const [user, project] = await Promise.all([
loadAuthzUser(BigInt(session.user.dbId)),
loadProjectAuthzFacts(projectId),
]);
// Existence is not the caller's to learn — 404, not 403, for a project they
// cannot see (matches the parse route).
if (!user || !project) return fail("Project not found", 404);
if (!canManageProject(user, project)) return fail("Forbidden", 403);
const parsed = importRequestSchema.safeParse(
await req.json().catch(() => null),
);
if (!parsed.success) {
return fail(parsed.error.issues[0]?.message ?? "Invalid request body", 400);
}
const body = parsed.data;
const { columnMapping, valueMapping } = body.session;
// Bound the property/reference lookups by what the file actually carries.
const landlordIds = distinctCells(body.rows, columnMapping, "landlordPropertyId");
const uprns = distinctCells(body.rows, columnMapping, "uprn");
const references = distinctCells(body.rows, columnMapping, "workOrderReference");
// Targets are re-loaded server-side — the client's mapping references ids, and
// those ids are only trustworthy against the project's own live configuration.
const [targets, context] = await Promise.all([
loadMappingTargets(projectId),
loadImportMatchContext(
projectId,
project.organisationId,
landlordIds,
uprns,
references,
),
]);
const result = validateImportRows(
body.rows,
columnMapping,
valueMapping,
targets,
context,
);
return { ok: true, project, body, result };
}
/** The distinct, non-blank values of one mapped field across every row. */
function distinctCells(
rows: string[][],
columnMapping: ImportRequestBody["session"]["columnMapping"],
field: ImportFieldKey,
): string[] {
const seen = new Set<string>();
for (const row of rows) {
const cell = cellFor(row, columnMapping, field);
if (cell !== "") seen.add(cell);
}
return [...seen];
}
/**
* Ready rows grouped into per-workstream created counts the post-commit
* summary, and the validate route's preview of what a commit would create.
*/
export function createdByWorkstream(
result: ImportValidationResult,
): { projectWorkstreamId: string; workstreamName: string; count: number }[] {
const byId = new Map<
string,
{ projectWorkstreamId: string; workstreamName: string; count: number }
>();
for (const row of result.ready) {
const existing = byId.get(row.projectWorkstreamId);
if (existing) existing.count += 1;
else
byId.set(row.projectWorkstreamId, {
projectWorkstreamId: row.projectWorkstreamId,
workstreamName: row.workstreamName,
count: 1,
});
}
return [...byId.values()].sort((a, b) =>
a.workstreamName.localeCompare(b.workstreamName, "en-GB"),
);
}

View file

@ -0,0 +1,15 @@
-- work_order.status: add DEFAULT '' (#417).
--
-- work_order.status is NOT NULL with no default. v1 uses the stage FK
-- (project_workstream_stage_id) as the only lifecycle mechanism and leaves
-- status untouched (CONTEXT.md, "Stage"); the programme-import commit (#417)
-- therefore inserts work orders WITHOUT writing status. A NOT NULL column an
-- insert legitimately omits needs a default, or every such insert fails — the
-- same trap 0280 fixed for priority, and 0280's own note anticipated #417
-- landing rows before its default existed.
--
-- '' is the "untouched" sentinel: it is not one of the ladder stage names, so
-- it can never be mistaken for a lifecycle value. When the status value set is
-- finally agreed and the column becomes a pg enum, this default is revisited
-- with it.
ALTER TABLE "work_order" ALTER COLUMN "status" SET DEFAULT '';

File diff suppressed because it is too large Load diff

View file

@ -1961,6 +1961,13 @@
"when": 1784819275099,
"tag": "0280_flashy_dreadnoughts",
"breakpoints": true
},
{
"idx": 281,
"version": "7",
"when": 1784882283273,
"tag": "0281_work_order_status_default",
"breakpoints": true
}
]
}

View file

@ -181,8 +181,13 @@ export const workOrder = pgTable("work_order", {
})
.notNull()
.references(() => projectWorkstreamStage.id),
// Enum values TBC — modelled as text until the value sets are agreed.
status: text("status").notNull(),
// Enum values TBC — modelled as text until the value sets are agreed. Carries
// a DB-level DEFAULT '' (migration 0281) so the work-order import (#417) can
// insert a row WITHOUT writing status: v1's only lifecycle mechanism is the
// stage FK, and CONTEXT.md ("Stage") requires status stay untouched. The
// default mirrors the priority fix in 0280 — a NOT NULL column an insert may
// legitimately omit needs a default, or every omitting insert fails.
status: text("status").notNull().default(""),
forecastEnd: date("forecast_end"),
// A flag, not a scale: a work order either is flagged as priority or is not.
// NOT NULL DEFAULT false because a third "unknown" state has no meaning for a

View file

@ -5,7 +5,6 @@ import { useMutation } from "@tanstack/react-query";
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CheckCircleIcon,
CloudArrowUpIcon,
DocumentTextIcon,
ExclamationTriangleIcon,
@ -19,6 +18,7 @@ import {
import type { ImportMappingTargets } from "@/lib/projects/import/mappingTargets";
import type { ImportSession } from "@/lib/projects/import/session";
import { ImportMapping, type MappingHandoff } from "./ImportMapping";
import { ImportValidation } from "./ImportValidation";
/** The parse route's response — the whole file, not just the preview. */
export interface ParsedImportResponse {
@ -32,7 +32,7 @@ export interface ParsedImportResponse {
const MAX_MB = MAX_IMPORT_FILE_BYTES / (1024 * 1024);
/** Which of the wizard's steps this component is showing. */
type ImportStep = "upload" | "mapping" | "mapped";
type ImportStep = "upload" | "mapping" | "validation";
/**
* Work-order import drop zone (issue #414) and the step-2 mapping screen it
@ -147,17 +147,19 @@ export function ImportUpload({
onBack={() => setStep("upload")}
onContinue={(next) => {
setHandoff(next);
setStep("mapped");
setStep("validation");
}}
/>
);
}
if (parsed && step === "mapped" && handoff) {
if (parsed && step === "validation" && handoff) {
return (
<MappingSaved
handoff={handoff}
onEdit={() => setStep("mapping")}
<ImportValidation
projectId={projectId}
session={handoff.session}
rows={parsed.rows}
onBack={() => setStep("mapping")}
onStartOver={reset}
/>
);
@ -317,64 +319,3 @@ export function ImportUpload({
</div>
);
}
/**
* The end of step 2: the mapping is settled and held in the import session,
* waiting for validation and commit (#417) to consume it.
*
* It states what was captured rather than claiming anything was saved to the
* server, because nothing was an import writes nothing until commit
* (ADR-0020). Both ways back are offered: edit the mapping, or start again with
* a different file.
*/
function MappingSaved({
handoff,
onEdit,
onStartOver,
}: {
handoff: MappingHandoff;
onEdit: () => void;
onStartOver: () => void;
}) {
const { session, warningRows } = handoff;
const mappedFields = Object.keys(session.columnMapping.fields).length;
return (
<div
data-testid="import-mapping-saved"
className="rounded-2xl border border-gray-200 bg-white p-6"
>
<div className="flex items-start">
<CheckCircleIcon className="h-6 w-6 mr-3 shrink-0 text-green-500" />
<div className="min-w-0">
<h2 className="text-lg font-semibold text-gray-900">
Mapping complete
</h2>
<p className="mt-1 text-sm text-gray-600">
{mappedFields} field{mappedFields === 1 ? "" : "s"} mapped across{" "}
{session.rowCount.toLocaleString("en-GB")} row
{session.rowCount === 1 ? "" : "s"} of {session.filename}. Nothing
has been written yet validation and commit arrive in #417.
</p>
{warningRows > 0 && (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{warningRows.toLocaleString("en-GB")} row
{warningRows === 1 ? "" : "s"} still carry values that map to
nothing and will fail validation.
</p>
)}
<div className="mt-5 flex gap-3">
<Button variant="outline" onClick={onEdit} data-testid="mapping-edit">
Edit mapping
</Button>
<Button variant="outline" onClick={onStartOver}>
Choose a different file
</Button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,505 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ArrowPathIcon,
ArrowRightIcon,
ArrowDownTrayIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/app/shadcn_components/ui/button";
import type { ImportSession } from "@/lib/projects/import/session";
/** One reported issue row — mirrors `RowIssue` on the server. */
interface RowIssue {
rowNumber: number;
identifier: string;
issue: string;
field: string;
}
interface CreatedByWorkstream {
projectWorkstreamId: string;
workstreamName: string;
count: number;
}
interface ValidateResponse {
summary: { total: number; ready: number; errors: number; duplicates: number };
errors: RowIssue[];
duplicates: RowIssue[];
createdByWorkstream: CreatedByWorkstream[];
}
interface CommitResponse {
committed: { workOrdersCreated: number; propertiesLinked: number };
createdByWorkstream: CreatedByWorkstream[];
summary: ValidateResponse["summary"];
}
/**
* Work-order import, step 3 of 3 the validation report and commit (#417).
*
* Wireframe: `10-validate-import.html` (reduced no Updates/Omissions/Priority
* tabs; those are #425). Stat tiles for ready / errors / skipped-duplicates over
* a tabbed issues table, then a commit that writes every ready row in one
* transaction and shows the per-workstream summary.
*
* The validation call is a `useQuery` rather than an effect: it writes nothing
* server-side, so it is a read, and `useQuery` runs it on mount without the
* `useEffect` this codebase avoids. The commit is a `useMutation` the one and
* only write in the whole import journey.
*/
export function ImportValidation({
projectId,
session,
rows,
onBack,
onStartOver,
}: {
projectId: string;
session: ImportSession;
/** The parsed file's rows — held client-side since #414, POSTed here. */
rows: string[][];
onBack: () => void;
onStartOver: () => void;
}) {
const [tab, setTab] = useState<"errors" | "duplicates">("errors");
const [committed, setCommitted] = useState<CommitResponse | null>(null);
const validation = useQuery<ValidateResponse, Error>({
queryKey: ["import-validate", projectId, session.filename, session.rowCount],
queryFn: async () => {
const res = await fetch(`/api/projects/${projectId}/import/validate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session, rows }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't validate the import");
return body as ValidateResponse;
},
refetchOnWindowFocus: false,
retry: false,
});
const commit = useMutation<CommitResponse, Error, void>({
mutationFn: async () => {
const res = await fetch(`/api/projects/${projectId}/import/commit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session, rows }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't commit the import");
return body as CommitResponse;
},
onSuccess: (data) => setCommitted(data),
});
if (committed) {
return (
<ImportCommitted
projectId={projectId}
result={committed}
onStartOver={onStartOver}
/>
);
}
if (validation.isLoading) {
return (
<div
data-testid="import-validation-loading"
className="flex flex-col items-center justify-center rounded-2xl border border-gray-200 bg-white px-6 py-16 text-center"
>
<ArrowPathIcon className="h-8 w-8 text-brandblue animate-spin mb-3" />
<p className="text-sm font-semibold text-gray-800">
Validating {session.rowCount.toLocaleString("en-GB")} rows
</p>
<p className="mt-1 text-xs text-gray-500">
Matching properties, workstreams, contractors and stages.
</p>
</div>
);
}
if (validation.isError || !validation.data) {
return (
<div
role="alert"
data-testid="import-validation-error"
className="rounded-2xl border border-red-200 bg-red-50 p-6 text-sm text-red-900"
>
<div className="flex items-start">
<ExclamationTriangleIcon className="h-5 w-5 mr-2.5 shrink-0 text-red-500" />
<div>
<p className="font-semibold">Couldn&apos;t validate the import</p>
<p className="mt-0.5 text-red-800">
{validation.error?.message ?? "Something went wrong."}
</p>
<div className="mt-3 flex gap-3">
<Button variant="outline" size="sm" onClick={() => validation.refetch()}>
Try again
</Button>
<Button variant="outline" size="sm" onClick={onBack}>
Back to mapping
</Button>
</div>
</div>
</div>
</div>
);
}
const { summary, errors, duplicates, createdByWorkstream } = validation.data;
const issues = tab === "errors" ? errors : duplicates;
return (
<div data-testid="import-validation">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-extrabold text-gray-900 tracking-tight">
Validate import
</h2>
<p className="text-sm text-gray-500 mt-1">
Review and resolve issues before committing. Error rows are excluded;
duplicates are skipped.
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={onBack} data-testid="validation-back">
<ArrowLeftIcon className="h-4 w-4 mr-1.5" />
Back
</Button>
<Button
data-testid="validation-commit"
disabled={summary.ready === 0 || commit.isLoading}
onClick={() => commit.mutate()}
>
{commit.isLoading ? (
<ArrowPathIcon className="h-4 w-4 mr-1.5 animate-spin" />
) : null}
Commit {summary.ready.toLocaleString("en-GB")} work order
{summary.ready === 1 ? "" : "s"}
<ArrowRightIcon className="h-4 w-4 ml-1.5" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<StatTile
testId="stat-ready"
label="Ready to import"
value={summary.ready}
tone="success"
icon={<CheckCircleIcon className="h-5 w-5" />}
/>
<StatTile
testId="stat-errors"
label="Errors"
value={summary.errors}
tone="error"
icon={<ExclamationTriangleIcon className="h-5 w-5" />}
/>
<StatTile
testId="stat-duplicates"
label="Skipped duplicates"
value={summary.duplicates}
tone="muted"
icon={<NoSymbolIcon className="h-5 w-5" />}
/>
</div>
{commit.isError && (
<div
role="alert"
data-testid="validation-commit-error"
className="mb-4 flex items-start rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-900"
>
<ExclamationTriangleIcon className="h-5 w-5 mr-2.5 shrink-0 text-red-500" />
<p>{commit.error.message}</p>
</div>
)}
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-100 bg-gray-50 px-4">
<div className="flex">
<TabButton
testId="tab-errors"
active={tab === "errors"}
onClick={() => setTab("errors")}
>
Errors ({summary.errors})
</TabButton>
<TabButton
testId="tab-duplicates"
active={tab === "duplicates"}
onClick={() => setTab("duplicates")}
>
Skipped duplicates ({summary.duplicates})
</TabButton>
</div>
{(errors.length > 0 || duplicates.length > 0) && (
<button
type="button"
data-testid="download-issues"
onClick={() => downloadIssuesCsv(session.filename, errors, duplicates)}
className="inline-flex items-center text-xs font-semibold text-brandblue hover:underline"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-1" />
Download report
</button>
)}
</div>
{issues.length === 0 ? (
<div
data-testid="issues-empty"
className="px-4 py-12 text-center text-sm text-gray-500"
>
{tab === "errors"
? "No errors — every row either imports or is a skipped duplicate."
: "No duplicate rows."}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm" data-testid="issues-table">
<thead className="bg-gray-50 text-left">
<tr>
<Th>Row #</Th>
<Th>Identifier</Th>
<Th>Issue</Th>
<Th>Affected field</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{issues.map((issue) => (
<tr key={`${issue.rowNumber}-${issue.field}`} className="bg-white">
<td className="px-4 py-2 tabular-nums text-gray-500">
{issue.rowNumber}
</td>
<td className="px-4 py-2 font-medium text-gray-800">
{issue.identifier || (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-2 text-gray-700">{issue.issue}</td>
<td className="px-4 py-2 text-gray-500">{issue.field}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{summary.ready > 0 && (
<div className="mt-6" data-testid="ready-breakdown">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
What will be created
</p>
<div className="flex flex-wrap gap-2">
{createdByWorkstream.map((w) => (
<span
key={w.projectWorkstreamId}
className="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-700"
>
{w.workstreamName}
<span className="ml-1.5 font-semibold text-gray-900">
{w.count.toLocaleString("en-GB")}
</span>
</span>
))}
</div>
</div>
)}
</div>
);
}
/**
* The post-commit summary created counts per workstream, with links into the
* work-orders table. States plainly that the write happened (unlike the mapping
* step, which only captured intent).
*/
function ImportCommitted({
projectId,
result,
onStartOver,
}: {
projectId: string;
result: CommitResponse;
onStartOver: () => void;
}) {
const { committed, createdByWorkstream, summary } = result;
return (
<div
data-testid="import-committed"
className="rounded-2xl border border-gray-200 bg-white p-6"
>
<div className="flex items-start">
<CheckCircleIcon className="h-6 w-6 mr-3 shrink-0 text-green-500" />
<div className="min-w-0 w-full">
<h2 className="text-lg font-semibold text-gray-900">Import committed</h2>
<p className="mt-1 text-sm text-gray-600">
Created{" "}
<span className="font-semibold text-gray-900">
{committed.workOrdersCreated.toLocaleString("en-GB")}
</span>{" "}
work order{committed.workOrdersCreated === 1 ? "" : "s"} across{" "}
{committed.propertiesLinked.toLocaleString("en-GB")} propert
{committed.propertiesLinked === 1 ? "y" : "ies"}.
{summary.duplicates > 0 && (
<>
{" "}
{summary.duplicates.toLocaleString("en-GB")} duplicate row
{summary.duplicates === 1 ? " was" : "s were"} skipped.
</>
)}
{summary.errors > 0 && (
<>
{" "}
{summary.errors.toLocaleString("en-GB")} error row
{summary.errors === 1 ? " was" : "s were"} excluded.
</>
)}
</p>
{createdByWorkstream.length > 0 && (
<ul className="mt-4 divide-y divide-gray-100 rounded-xl border border-gray-100">
{createdByWorkstream.map((w) => (
<li
key={w.projectWorkstreamId}
className="flex items-center justify-between px-4 py-2 text-sm"
>
<span className="text-gray-700">{w.workstreamName}</span>
<span className="font-semibold tabular-nums text-gray-900">
{w.count.toLocaleString("en-GB")}
</span>
</li>
))}
</ul>
)}
<div className="mt-5 flex gap-3">
<Link href={`/projects/${projectId}/work-orders`}>
<Button data-testid="view-work-orders">View work orders</Button>
</Link>
<Button variant="outline" onClick={onStartOver}>
Import another file
</Button>
</div>
</div>
</div>
</div>
);
}
function StatTile({
testId,
label,
value,
tone,
icon,
}: {
testId: string;
label: string;
value: number;
tone: "success" | "error" | "muted";
icon: React.ReactNode;
}) {
const tones = {
success: "border-green-200 bg-green-50 text-green-700",
error: "border-red-200 bg-red-50 text-red-700",
muted: "border-gray-200 bg-gray-50 text-gray-600",
} as const;
return (
<div className={`rounded-xl border p-4 ${tones[tone]}`} data-testid={testId}>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{label}</span>
{icon}
</div>
<p
className="mt-2 text-3xl font-extrabold tabular-nums"
data-testid={`${testId}-value`}
>
{value.toLocaleString("en-GB")}
</p>
</div>
);
}
function TabButton({
active,
onClick,
testId,
children,
}: {
active: boolean;
onClick: () => void;
testId: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
data-testid={testId}
onClick={onClick}
className={[
"px-4 py-3 text-sm font-semibold border-b-2 -mb-px transition-colors",
active
? "border-brandblue text-brandblue"
: "border-transparent text-gray-500 hover:text-gray-700",
].join(" ")}
>
{children}
</button>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
{children}
</th>
);
}
/** Client-side CSV of every reported issue — the wireframe's optional export. */
function downloadIssuesCsv(
filename: string,
errors: RowIssue[],
duplicates: RowIssue[],
): void {
const rows = [
["Row", "Identifier", "Type", "Issue", "Affected field"],
...errors.map((e) => [e.rowNumber, e.identifier, "Error", e.issue, e.field]),
...duplicates.map((d) => [
d.rowNumber,
d.identifier,
"Skipped duplicate",
d.issue,
d.field,
]),
];
const csv = rows
.map((r) => r.map((cell) => csvCell(String(cell))).join(","))
.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename.replace(/\.[^.]+$/, "")}-import-issues.csv`;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string): string {
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
}

View file

@ -14,14 +14,16 @@ export const metadata = {
};
/**
* Work-order import, steps 1 and 2 of 3 upload and parse (issue #414), then
* column mapping and value mapping (issue #415).
* Work-order import, all three steps upload and parse (issue #414), column
* and value mapping (issue #415), then validation and the insert-only commit
* (issue #417).
*
* Wireframes: `07-import-upload.html`, then `08-map-columns-rebuilt.html` and
* `09-map-columns-workstream-unlocked.html`. Both steps live behind this one
* route because the parsed file is held client-side (ADR-0020) a separate
* mapping URL would drop it. Validation and commit (#417) follow; this page
* still writes nothing.
* Wireframes: `07-import-upload.html`, `08-map-columns-rebuilt.html`,
* `09-map-columns-workstream-unlocked.html`, `10-validate-import.html`. All
* three steps live behind this one route because the parsed file is held
* client-side (ADR-0020) a separate URL per step would drop it. This server
* component still writes nothing; the only write is the commit route the
* validation step POSTs to.
*
* Only internal and client-org managers may import, so this checks
* `canManageProject` rather than settling for the layout's view-level guard
@ -73,7 +75,7 @@ export default async function ImportPage(props: {
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Work-order import · Steps 12 of 3
Work-order import · Steps 13 of 3
</p>
<h1
className="text-3xl font-extrabold text-gray-900 tracking-tight"

View file

@ -0,0 +1,309 @@
/**
* Work-order import the commit repository (issue #417, step 3 of 3).
*
* The persistence boundary for validation and commit, mirroring the split the
* rest of Ara Projects uses (`@/lib/projects/import/*` decides, this loads and
* writes):
*
* - `loadImportMatchContext` gathers the database facts row resolution needs
* which identifiers match a property in the project org's portfolios,
* which (property, workstream) pairs already have a work order, and which
* references are taken and returns them as the plain-data
* `ImportMatchContext` the pure `validateImportRows` consumes.
* - `commitImport` performs the all-or-nothing insert of the ready set inside
* one transaction.
*
* ## Property scoping (the matching rule; verified while implementing)
*
* A row's identifier is matched to a `property` scoped to the **project
* organisation's portfolios**: `property.portfolio_id` the portfolios linked
* to `project.organisation_id` via `portfolio_organisation`. That linkage
* exists and is clean `portfolio_organisation(portfolio_id, organisation_id)`
* so properties scope to the org without ambiguity. (The join is
* many-to-many, so a portfolio *may* sit under more than one organisation;
* that widens the set a project can match into but never mis-scopes it, because
* we only ever gather portfolios for this project's single org.)
*
* Soft-deleted properties (`marked_for_deletion`) are excluded a work order
* against a property queued for physical deletion is not a real match.
*/
import { db } from "@/app/db/db";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { property } from "@/app/db/schema/property";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import {
projectProperty,
projectWorkstream,
projectWorkstreamStage,
workOrder,
} from "@/app/db/schema/projects/projects";
import {
formatAutoReference,
workOrderKey,
type ImportMatchContext,
type ReadyWorkOrder,
} from "@/lib/projects/import/validation";
/**
* Gather the match context for one project's import. Read-only.
*
* `landlordIds` and `uprns` are the distinct, non-blank identifier cells the
* file carries passed in so the property lookup is bounded by the file rather
* than scanning every property in the org.
*/
export async function loadImportMatchContext(
projectId: bigint,
organisationId: string,
landlordIds: string[],
uprns: string[],
fileReferences: string[],
): Promise<ImportMatchContext> {
// The org's portfolios. With none, nothing can match — and `inArray` over an
// empty set is invalid — so short-circuit to an all-unmatched context.
const portfolioRows = await db
.select({ portfolioId: portfolioOrganisation.portfolioId })
.from(portfolioOrganisation)
.where(eq(portfolioOrganisation.organisationId, organisationId));
const portfolioIds = portfolioRows.map((r) => r.portfolioId);
const [propertyMatch, existingWorkOrderKeys, references] = await Promise.all([
portfolioIds.length === 0
? Promise.resolve({
propertyIdByLandlordId: {},
propertyIdByUprn: {},
ambiguousIdentifiers: [],
})
: loadPropertyMatches(portfolioIds, landlordIds, uprns),
loadExistingWorkOrderKeys(projectId),
loadReferenceFacts(fileReferences),
]);
return {
...propertyMatch,
existingWorkOrderKeys,
existingReferences: references.colliding,
autoReferenceStart: references.autoStart,
};
}
/**
* Match the file's identifiers to properties in the given portfolios, building
* landlord-id and UPRN lookup maps. An identifier that resolves to more than
* one property is dropped from its map and recorded as ambiguous a work order
* must not silently pick one of two candidates.
*/
async function loadPropertyMatches(
portfolioIds: bigint[],
landlordIds: string[],
uprns: string[],
): Promise<{
propertyIdByLandlordId: Record<string, string>;
propertyIdByUprn: Record<string, string>;
ambiguousIdentifiers: string[];
}> {
// UPRNs are a bigint column; keep only the well-formed ones for the lookup.
const uprnBigints = uprns
.filter((u) => /^\d+$/.test(u))
.map((u) => BigInt(u));
const filters = [];
if (landlordIds.length > 0) {
filters.push(inArray(property.landlordPropertyId, landlordIds));
}
if (uprnBigints.length > 0) {
filters.push(inArray(property.uprn, uprnBigints));
}
// Nothing to look up (a file with no usable identifiers at all).
if (filters.length === 0) {
return {
propertyIdByLandlordId: {},
propertyIdByUprn: {},
ambiguousIdentifiers: [],
};
}
const rows = await db
.select({
id: property.id,
landlordPropertyId: property.landlordPropertyId,
uprn: property.uprn,
})
.from(property)
.where(
and(
inArray(property.portfolioId, portfolioIds),
eq(property.markedForDeletion, false),
// Matched by either identifier column.
filters.length === 1 ? filters[0] : or(...filters),
),
);
const wantedLandlord = new Set(landlordIds);
const wantedUprn = new Set(uprns);
const ambiguous = new Set<string>();
const byLandlord: Record<string, string> = {};
const byUprn: Record<string, string> = {};
for (const row of rows) {
const propId = row.id.toString();
if (row.landlordPropertyId !== null && wantedLandlord.has(row.landlordPropertyId)) {
recordMatch(byLandlord, ambiguous, row.landlordPropertyId, propId);
}
if (row.uprn !== null) {
const uprnStr = row.uprn.toString();
if (wantedUprn.has(uprnStr)) {
recordMatch(byUprn, ambiguous, uprnStr, propId);
}
}
}
return {
propertyIdByLandlordId: byLandlord,
propertyIdByUprn: byUprn,
ambiguousIdentifiers: [...ambiguous],
};
}
/**
* Record one identifierproperty match. The first match populates the map; a
* second, different property makes the identifier ambiguous it is removed
* from the map and never restored, so no row resolves against it.
*/
function recordMatch(
map: Record<string, string>,
ambiguous: Set<string>,
key: string,
propertyId: string,
): void {
if (ambiguous.has(key)) return;
const existing = map[key];
if (existing === undefined) {
map[key] = propertyId;
} else if (existing !== propertyId) {
delete map[key];
ambiguous.add(key);
}
}
/**
* Every (property, workstream) pair that already has a work order in this
* project, as the duplicate/idempotency key. Joined work_order stage
* workstream so the pair is (work_order.property_id, project_workstream.id).
*/
async function loadExistingWorkOrderKeys(projectId: bigint): Promise<string[]> {
const rows = await db
.select({
propertyId: workOrder.propertyId,
projectWorkstreamId: projectWorkstreamStage.projectWorkstreamId,
})
.from(workOrder)
.innerJoin(
projectWorkstreamStage,
eq(workOrder.projectWorkstreamStageId, projectWorkstreamStage.id),
)
.innerJoin(
projectWorkstream,
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
)
.where(eq(projectWorkstream.projectId, projectId));
return rows.map((r) =>
workOrderKey(r.propertyId.toString(), r.projectWorkstreamId.toString()),
);
}
/**
* Reference facts: which of the file's provided references are already taken
* (so a colliding one is an error, not a transaction failure), and where the
* auto `WO-<seq>` sequence should start (one past the highest existing `WO-n`,
* so auto refs never collide across imports).
*/
async function loadReferenceFacts(
fileReferences: string[],
): Promise<{ colliding: string[]; autoStart: number }> {
const [colliding, autoRows] = await Promise.all([
fileReferences.length === 0
? Promise.resolve<{ reference: string }[]>([])
: db
.select({ reference: workOrder.reference })
.from(workOrder)
.where(inArray(workOrder.reference, fileReferences)),
// Bounded by however many auto references prior imports created.
db
.select({ reference: workOrder.reference })
.from(workOrder)
.where(sql`${workOrder.reference} ~ '^WO-[0-9]+$'`),
]);
let maxSeq = 0;
for (const { reference } of autoRows) {
const match = reference.match(/^WO-(\d+)$/);
if (match) maxSeq = Math.max(maxSeq, Number(match[1]));
}
return {
colliding: colliding.map((r) => r.reference),
autoStart: maxSeq + 1,
};
}
export interface CommitImportResult {
workOrdersCreated: number;
propertiesLinked: number;
}
/**
* Insert the ready set in one transaction all rows or none (the #417 AC's
* "all-or-nothing per valid row set").
*
* - `project_property` is upserted (`onConflictDoNothing` on the
* `(project_id, property_id)` unique) so a property already on the project
* is left alone and a re-run adds nothing.
* - one `work_order` per ready row. `status` and `priority` are **never
* written** (the #417 AC): both are omitted from the insert and take their
* DB defaults (`''` from migration 0281, `false` from 0280). The stage FK is
* the row's resolved stage — v1's only lifecycle mechanism.
*
* Returns nothing to insert as a no-op success, so committing an all-duplicate
* re-run does not open an empty transaction.
*/
export async function commitImport(
projectId: bigint,
ready: ReadyWorkOrder[],
): Promise<CommitImportResult> {
if (ready.length === 0) {
return { workOrdersCreated: 0, propertiesLinked: 0 };
}
const distinctPropertyIds = [...new Set(ready.map((r) => r.propertyId))];
return db.transaction(async (tx) => {
await tx
.insert(projectProperty)
.values(
distinctPropertyIds.map((propertyId) => ({
projectId,
propertyId: BigInt(propertyId),
})),
)
.onConflictDoNothing();
await tx.insert(workOrder).values(
ready.map((r) => ({
reference: r.reference,
projectWorkstreamContractorId: BigInt(r.projectWorkstreamContractorId),
propertyId: BigInt(r.propertyId),
projectWorkstreamStageId: BigInt(r.projectWorkstreamStageId),
forecastEnd: r.forecastEnd,
// status and priority deliberately omitted — see the doc comment.
})),
);
return {
workOrdersCreated: ready.length,
propertiesLinked: distinctPropertyIds.length,
};
});
}

View file

@ -0,0 +1,341 @@
import { describe, it, expect } from "vitest";
import type { ImportColumnMapping } from "./columnMapping";
import { EMPTY_VALUE_MAPPING, type ImportValueMapping } from "./valueMapping";
import type { ImportMappingTargets } from "./mappingTargets";
import {
formatAutoReference,
validateImportRows,
workOrderKey,
type ImportMatchContext,
} from "./validation";
/**
* Column order matches the template: identifier, UPRN, workstream, contractor,
* stage, WO reference, forecast end.
*/
const COLUMN_MAPPING: ImportColumnMapping = {
fields: {
landlordPropertyId: 0,
uprn: 1,
workstream: 2,
contractor: 3,
stage: 4,
workOrderReference: 5,
forecastEndDate: 6,
},
ignoredColumns: [],
};
/** [landlord, uprn, workstream, contractor, stage, reference, forecastEnd]. */
function row(
landlord: string,
uprn: string,
workstream: string,
contractor: string,
stage = "",
reference = "",
forecastEnd = "",
): string[] {
return [landlord, uprn, workstream, contractor, stage, reference, forecastEnd];
}
const TARGETS: ImportMappingTargets = {
workstreams: [
{
id: "10",
workstreamId: "1",
name: "Windows",
stages: [
{ id: "100", name: "Ordered", order: 1 },
{ id: "101", name: "In progress", order: 2 },
],
contractors: [
{ id: "1000", organisationId: "org-a", name: "Acme Windows Ltd" },
],
},
{
id: "11",
workstreamId: "2",
name: "Doors",
stages: [{ id: "110", name: "Ordered", order: 1 }],
contractors: [
{ id: "1100", organisationId: "org-b", name: "Best Doors Ltd" },
],
},
],
};
function context(overrides: Partial<ImportMatchContext> = {}): ImportMatchContext {
return {
propertyIdByLandlordId: { "PROP-1": "500", "PROP-2": "501" },
propertyIdByUprn: { "999": "502" },
ambiguousIdentifiers: [],
existingWorkOrderKeys: [],
existingReferences: [],
autoReferenceStart: 1,
...overrides,
};
}
function validate(
rows: string[][],
ctx = context(),
valueMapping: ImportValueMapping = EMPTY_VALUE_MAPPING,
) {
return validateImportRows(rows, COLUMN_MAPPING, valueMapping, TARGETS, ctx);
}
describe("validateImportRows — happy path", () => {
it("resolves a fully-specified row to a ready work order", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "In progress", "WO-A", "2026-09-30"),
]);
expect(result.summary).toEqual({ total: 1, ready: 1, errors: 0, duplicates: 0 });
expect(result.ready[0]).toMatchObject({
rowNumber: 1,
identifier: "PROP-1",
propertyId: "500",
projectWorkstreamId: "10",
projectWorkstreamContractorId: "1000",
projectWorkstreamStageId: "101",
reference: "WO-A",
forecastEnd: "2026-09-30",
});
});
it("never carries status or priority on a ready row", () => {
const result = validate([row("PROP-1", "", "Windows", "Acme Windows Ltd")]);
expect(result.ready[0]).not.toHaveProperty("status");
expect(result.ready[0]).not.toHaveProperty("priority");
});
});
describe("validateImportRows — stage", () => {
it("falls back to the workstream's first stage when the cell is blank", () => {
const result = validate([row("PROP-1", "", "Windows", "Acme Windows Ltd", "")]);
// stages[0] for Windows is Ordered (order 1) = id 100.
expect(result.ready[0].projectWorkstreamStageId).toBe("100");
});
it("uses the matched stage when one is provided", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "In progress"),
]);
expect(result.ready[0].projectWorkstreamStageId).toBe("101");
});
it("errors on a provided stage that is not on the ladder", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "Charged"),
]);
expect(result.summary.ready).toBe(0);
expect(result.errors[0]).toMatchObject({ rowNumber: 1, field: "Stage" });
});
});
describe("validateImportRows — property matching", () => {
it("matches on landlord property id first", () => {
const result = validate([row("PROP-2", "", "Doors", "Best Doors Ltd")]);
expect(result.ready[0].propertyId).toBe("501");
});
it("falls back to UPRN when the landlord id does not match", () => {
const result = validate([row("", "999", "Windows", "Acme Windows Ltd")]);
expect(result.ready[0].propertyId).toBe("502");
});
it("errors when neither identifier matches", () => {
const result = validate([row("NOPE", "", "Windows", "Acme Windows Ltd")]);
expect(result.errors[0]).toMatchObject({
rowNumber: 1,
field: "Property identifier",
});
expect(result.errors[0].issue).toContain("No property");
});
it("errors when the row has no identifier at all", () => {
const result = validate([row("", "", "Windows", "Acme Windows Ltd")]);
expect(result.errors[0].issue).toContain("no property identifier");
});
it("reports an ambiguous identifier distinctly, and never guesses", () => {
const result = validate(
[row("AMB", "", "Windows", "Acme Windows Ltd")],
context({ ambiguousIdentifiers: ["AMB"] }),
);
expect(result.summary.ready).toBe(0);
expect(result.errors[0].issue).toContain("more than one property");
});
});
describe("validateImportRows — workstream and contractor", () => {
it("errors on a blank workstream", () => {
const result = validate([row("PROP-1", "", "", "Acme Windows Ltd")]);
expect(result.errors[0]).toMatchObject({ field: "Workstream" });
});
it("errors on an unmapped workstream value", () => {
const result = validate([row("PROP-1", "", "Loft insulation", "Acme Windows Ltd")]);
expect(result.errors[0]).toMatchObject({ field: "Workstream" });
});
it("errors on a blank contractor (contractor is required from the file)", () => {
const result = validate([row("PROP-1", "", "Windows", "")]);
expect(result.errors[0]).toMatchObject({ field: "Contractor" });
});
it("errors when the contractor is not assigned to that workstream", () => {
// Best Doors is a real contractor, but only on Doors — not on Windows.
const result = validate([row("PROP-1", "", "Windows", "Best Doors Ltd")]);
expect(result.summary.ready).toBe(0);
expect(result.errors[0]).toMatchObject({ field: "Contractor" });
expect(result.errors[0].issue).toContain("Windows");
});
it("resolves the same contractor scoped to its own workstream", () => {
const result = validate([row("PROP-1", "", "Doors", "Best Doors Ltd")]);
expect(result.ready[0].projectWorkstreamContractorId).toBe("1100");
});
});
describe("validateImportRows — duplicates", () => {
it("skips a (property, workstream) pair that already has a work order", () => {
const result = validate(
[row("PROP-1", "", "Windows", "Acme Windows Ltd")],
context({ existingWorkOrderKeys: [workOrderKey("500", "10")] }),
);
expect(result.summary).toMatchObject({ ready: 0, errors: 0, duplicates: 1 });
expect(result.duplicates[0]).toMatchObject({ rowNumber: 1, field: "Workstream" });
});
it("imports a repeated pair once — first ready, rest skipped", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd"),
row("PROP-1", "", "Windows", "Acme Windows Ltd"),
]);
expect(result.summary).toMatchObject({ ready: 1, duplicates: 1 });
expect(result.ready[0].rowNumber).toBe(1);
expect(result.duplicates[0].rowNumber).toBe(2);
});
it("does not let an errored row reserve the pair for a later good row", () => {
const result = validate([
// Same (property, workstream) but a bad contractor — an error, not ready.
row("PROP-1", "", "Windows", "Nobody Ltd"),
row("PROP-1", "", "Windows", "Acme Windows Ltd"),
]);
expect(result.summary).toMatchObject({ ready: 1, errors: 1, duplicates: 0 });
expect(result.ready[0].rowNumber).toBe(2);
});
it("is idempotent: a re-run of an already-committed file is all duplicates", () => {
const rows = [
row("PROP-1", "", "Windows", "Acme Windows Ltd"),
row("PROP-2", "", "Doors", "Best Doors Ltd"),
];
const first = validate(rows);
expect(first.summary.ready).toBe(2);
const seeded = context({
existingWorkOrderKeys: first.ready.map((r) =>
workOrderKey(r.propertyId, r.projectWorkstreamId),
),
});
const rerun = validate(rows, seeded);
expect(rerun.summary).toMatchObject({ ready: 0, duplicates: 2, errors: 0 });
});
});
describe("validateImportRows — references", () => {
it("auto-generates zero-padded WO references from the given start", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd"),
row("PROP-2", "", "Doors", "Best Doors Ltd"),
]);
expect(result.ready.map((r) => r.reference)).toEqual(["WO-0001", "WO-0002"]);
});
it("starts the auto sequence past the highest existing WO number", () => {
const result = validate(
[row("PROP-1", "", "Windows", "Acme Windows Ltd")],
context({ autoReferenceStart: 42 }),
);
expect(result.ready[0].reference).toBe("WO-0042");
});
it("keeps a provided reference and skips it in the auto sequence", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "", "ASSET-7"),
row("PROP-2", "", "Doors", "Best Doors Ltd"),
]);
expect(result.ready.map((r) => r.reference)).toEqual(["ASSET-7", "WO-0001"]);
});
it("errors on a provided reference already used in the database", () => {
const result = validate(
[row("PROP-1", "", "Windows", "Acme Windows Ltd", "", "WO-EXISTING")],
context({ existingReferences: ["WO-EXISTING"] }),
);
expect(result.errors[0]).toMatchObject({ field: "Work order reference" });
});
it("errors on a reference repeated within the same file", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "", "DUP"),
row("PROP-2", "", "Doors", "Best Doors Ltd", "", "DUP"),
]);
expect(result.summary).toMatchObject({ ready: 1, errors: 1 });
expect(result.errors[0].rowNumber).toBe(2);
});
});
describe("validateImportRows — forecast end date", () => {
it("passes an ISO date through", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "", "", "2026-12-01"),
]);
expect(result.ready[0].forecastEnd).toBe("2026-12-01");
});
it("parses a UK dd/mm/yyyy date", () => {
const result = validate([
row("PROP-1", "", "Windows", "Acme Windows Ltd", "", "", "01/12/2026"),
]);
expect(result.ready[0].forecastEnd).toBe("2026-12-01");
});
it("treats a blank or unparseable forecast end as null", () => {
const blank = validate([row("PROP-1", "", "Windows", "Acme Windows Ltd")]);
expect(blank.ready[0].forecastEnd).toBeNull();
const junk = validate([
row("PROP-2", "", "Doors", "Best Doors Ltd", "", "", "sometime"),
]);
expect(junk.ready[0].forecastEnd).toBeNull();
});
});
describe("validateImportRows — one issue per row, in order", () => {
it("reports the property problem before the workstream problem", () => {
// Both the identifier and the workstream are bad; property wins.
const result = validate([row("NOPE", "", "Loft", "Acme Windows Ltd")]);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].field).toBe("Property identifier");
});
it("settles a duplicate before looking at its contractor", () => {
const result = validate(
[row("PROP-1", "", "Windows", "Nobody At All")],
context({ existingWorkOrderKeys: [workOrderKey("500", "10")] }),
);
expect(result.summary).toMatchObject({ duplicates: 1, errors: 0 });
});
});
describe("formatAutoReference", () => {
it("zero-pads to four digits and does not clip larger numbers", () => {
expect(formatAutoReference(7)).toBe("WO-0007");
expect(formatAutoReference(12345)).toBe("WO-12345");
});
});

View file

@ -0,0 +1,440 @@
/**
* Work-order import validation and row resolution (issue #417, step 3 of 3).
*
* This is the pure core the validation report and the commit route both run.
* It takes the parsed rows, the mapping the user settled in step 2
* (`ImportSession`), the project's targets, and a bundle of database-derived
* facts (`ImportMatchContext`), and decides per row one of three outcomes:
*
* - **ready** every value resolved; the row becomes exactly one
* `work_order`. Carries the resolved ids the commit inserts.
* - **error** a value could not be resolved (unmatched property, blank or
* unmapped workstream/contractor, a *provided* stage that matches nothing,
* or a work-order reference already in use). Excluded from the commit.
* - **duplicate** the row's (property, workstream) pair already has a work
* order in this project (or an earlier ready row in the same file already
* claimed it). Reported and skipped never an error (ADR-0019 / #417 AC).
*
* Pure and DB-free on purpose, mirroring the rest of `@/lib/projects/import`:
* every fact it needs is passed in as plain data, so it unit-tests against
* in-memory fixtures with no connection. The repository
* (`@/app/repositories/projects/importCommitRepository`) gathers the facts;
* the route handlers combine the two.
*
* ## Why commit re-resolves rather than trusting the client
*
* The value mapping stores only the user's *explicit* decisions an untouched
* value relies on auto-match. So "what the user saw as ready" is not written
* down anywhere; it is re-derived from `(value, decisions, targets)` by the
* exact same {@link resolveValue} the mapping screen used. Running that here
* guarantees the committed set matches the previewed one, and it means a
* tampered or stale client payload cannot smuggle in a row whose values do not
* actually resolve against the project's live configuration.
*
* ## One issue per row
*
* A row is reported once, against the first check it fails, in this order:
* property workstream duplicate contractor stage reference. The order
* is deliberate: a row with no matchable property cannot have a meaningful
* workstream check, and a duplicate is skipped whatever else is wrong with it,
* so it is settled before contractor/stage are even looked at.
*/
import { formatUkDate, parseUkDate } from "@/utils/dates";
import { cellFor, type ImportColumnMapping } from "./columnMapping";
import {
resolveValue,
scopedValueKey,
valueKey,
type ImportValueMapping,
} from "./valueMapping";
import type {
ContractorTarget,
ImportMappingTargets,
StageTarget,
WorkstreamTarget,
} from "./mappingTargets";
/** The field labels the report shows in its "Affected field" column. */
export const IMPORT_FIELD_LABELS = {
property: "Property identifier",
workstream: "Workstream",
contractor: "Contractor",
stage: "Stage",
reference: "Work order reference",
} as const;
/** Default zero-pad width for an auto-generated `WO-<seq>` reference. */
export const AUTO_REFERENCE_PAD = 4;
/**
* A resolved row, ready to become one `work_order`. Every id is a string (the
* `bigserial`/uuid form that crossed the boundary from the mapping targets);
* the commit parses them back to `bigint` at the insert.
*/
export interface ReadyWorkOrder {
/** 1-based data-row number, as the report and the file's user count rows. */
rowNumber: number;
/** The landlord property id or UPRN cell, for display in the report. */
identifier: string;
/** `property.id`. */
propertyId: string;
/** `project_workstream.id`. */
projectWorkstreamId: string;
/** `workstream.name`, for the post-commit per-workstream summary. */
workstreamName: string;
/** `project_workstream_contractor.id` — the assignment, never null. */
projectWorkstreamContractorId: string;
/** `project_workstream_stage.id` — the mapped stage, or the ladder's first. */
projectWorkstreamStageId: string;
/** The mapped reference, or the auto `WO-<seq>` this row was given. */
reference: string;
/** `YYYY-MM-DD`, or null when the column is unmapped/blank/unparseable. */
forecastEnd: string | null;
}
/** One reported problem — an error or a skipped duplicate. */
export interface RowIssue {
rowNumber: number;
/** The row's identifier cell, for display; "" when even that is blank. */
identifier: string;
/** Human-readable description of what is wrong. */
issue: string;
/** Which field the issue is against — the report's "Affected field". */
field: string;
}
/** The database-derived facts row resolution needs, all as plain data. */
export interface ImportMatchContext {
/**
* `landlordPropertyId` `property.id`, for properties in the project org's
* portfolios only. An identifier that matches more than one property is
* absent here and present in {@link ambiguousIdentifiers} instead.
*/
propertyIdByLandlordId: Record<string, string>;
/** `uprn` (as text) → `property.id`, same scoping and ambiguity handling. */
propertyIdByUprn: Record<string, string>;
/** Identifiers that matched more than one property — reported, never guessed. */
ambiguousIdentifiers: string[];
/**
* `"<propertyId>:<projectWorkstreamId>"` pairs that already have a work order
* in this project. Seeds the duplicate check, which is what makes a re-run of
* the same file idempotent.
*/
existingWorkOrderKeys: string[];
/** Every existing `work_order.reference` — a provided ref colliding is an error. */
existingReferences: string[];
/**
* The seq the first auto `WO-<seq>` reference starts from one past the
* highest existing `WO-<n>`, so auto refs never collide across imports.
*/
autoReferenceStart: number;
}
export interface ImportValidationResult {
ready: ReadyWorkOrder[];
errors: RowIssue[];
duplicates: RowIssue[];
summary: {
total: number;
ready: number;
errors: number;
duplicates: number;
};
}
/** `"<propertyId>:<projectWorkstreamId>"` — the duplicate/idempotency key. */
export function workOrderKey(
propertyId: string,
projectWorkstreamId: string,
): string {
return `${propertyId}:${projectWorkstreamId}`;
}
const name = <T extends { name: string }>(t: T): string => t.name;
const id = <T extends { id: string }>(t: T): string => t.id;
/**
* Resolve and classify every row. See the module header for the outcome set and
* the fixed check order.
*/
export function validateImportRows(
rows: string[][],
columnMapping: ImportColumnMapping,
valueMapping: ImportValueMapping,
targets: ImportMappingTargets,
context: ImportMatchContext,
): ImportValidationResult {
const workstreamsById = new Map(targets.workstreams.map((w) => [w.id, w]));
const ambiguous = new Set(context.ambiguousIdentifiers);
// Seeded with the project's existing (property, workstream) pairs so a re-run
// skips what it already created; grows as ready rows claim new pairs, so a
// file that names the same pair twice imports it once.
const seen = new Set(context.existingWorkOrderKeys);
// Existing + this-run references, so no two committed work orders collide on
// `reference` (which is UNIQUE) and the transaction never fails on it.
const usedReferences = new Set(context.existingReferences);
let autoSeq = context.autoReferenceStart;
const ready: ReadyWorkOrder[] = [];
const errors: RowIssue[] = [];
const duplicates: RowIssue[] = [];
rows.forEach((row, i) => {
const rowNumber = i + 1;
const landlord = cellFor(row, columnMapping, "landlordPropertyId");
const uprn = cellFor(row, columnMapping, "uprn");
const identifier = landlord !== "" ? landlord : uprn;
// 1 — Property. Landlord property id first, then UPRN (the matching rule).
const propertyId = matchProperty(landlord, uprn, context, ambiguous);
if (propertyId === null) {
errors.push({
rowNumber,
identifier,
issue: propertyIssue(landlord, uprn, ambiguous),
field: IMPORT_FIELD_LABELS.property,
});
return;
}
// 2 — Workstream. Blank or unmatched is an error.
const workstreamCell = cellFor(row, columnMapping, "workstream");
if (workstreamCell === "") {
errors.push({
rowNumber,
identifier,
issue: "This row has no workstream value.",
field: IMPORT_FIELD_LABELS.workstream,
});
return;
}
const workstreamMatch = resolveValue<WorkstreamTarget>(
valueKey(workstreamCell),
workstreamCell,
valueMapping.workstreams,
targets.workstreams,
name,
id,
);
const workstream =
workstreamMatch.targetId !== null
? workstreamsById.get(workstreamMatch.targetId)
: undefined;
if (!workstream) {
errors.push({
rowNumber,
identifier,
issue: `Workstream "${workstreamCell}" isn't one of this project's workstreams.`,
field: IMPORT_FIELD_LABELS.workstream,
});
return;
}
// 3 — Duplicate. A pair already claimed (in the DB or earlier in this file)
// is skipped whatever else the row says, so it is settled before contractor
// and stage are checked.
const key = workOrderKey(propertyId, workstream.id);
if (seen.has(key)) {
duplicates.push({
rowNumber,
identifier,
issue: `This property already has a ${workstream.name} work order in this project.`,
field: IMPORT_FIELD_LABELS.workstream,
});
return;
}
// 4 — Contractor. Blank or not-assigned-to-this-workstream is an error.
const contractorCell = cellFor(row, columnMapping, "contractor");
if (contractorCell === "") {
errors.push({
rowNumber,
identifier,
issue: "This row has no contractor value.",
field: IMPORT_FIELD_LABELS.contractor,
});
return;
}
const contractorMatch = resolveValue<ContractorTarget>(
scopedValueKey(workstream.id, contractorCell),
contractorCell,
valueMapping.contractors,
workstream.contractors,
name,
id,
);
if (contractorMatch.targetId === null) {
errors.push({
rowNumber,
identifier,
issue: `Contractor "${contractorCell}" isn't assigned to the ${workstream.name} workstream.`,
field: IMPORT_FIELD_LABELS.contractor,
});
return;
}
// 5 — Stage. Blank falls back to the ladder's first stage; a *provided*
// stage that matches nothing is an error.
const stageCell = cellFor(row, columnMapping, "stage");
const stageId = resolveStage(
stageCell,
workstream,
valueMapping,
);
if (stageId === null) {
errors.push({
rowNumber,
identifier,
issue:
stageCell === ""
? `The ${workstream.name} workstream has no stages configured.`
: `Stage "${stageCell}" isn't on the ${workstream.name} workstream's ladder.`,
field: IMPORT_FIELD_LABELS.stage,
});
return;
}
// 6 — Reference. A provided one that collides is an error; otherwise the row
// is given the next free auto `WO-<seq>`.
const referenceCell = cellFor(row, columnMapping, "workOrderReference");
let reference: string;
if (referenceCell !== "") {
if (usedReferences.has(referenceCell)) {
errors.push({
rowNumber,
identifier,
issue: `Work order reference "${referenceCell}" is already in use.`,
field: IMPORT_FIELD_LABELS.reference,
});
return;
}
reference = referenceCell;
} else {
reference = nextAutoReference(autoSeq, usedReferences);
autoSeq = referenceSeq(reference) + 1;
}
// 7 — Forecast end. Optional: blank or unparseable becomes null.
const forecastEnd = parseForecastEnd(
cellFor(row, columnMapping, "forecastEndDate"),
);
seen.add(key);
usedReferences.add(reference);
ready.push({
rowNumber,
identifier,
propertyId,
projectWorkstreamId: workstream.id,
workstreamName: workstream.name,
projectWorkstreamContractorId: contractorMatch.targetId,
projectWorkstreamStageId: stageId,
reference,
forecastEnd,
});
});
return {
ready,
errors,
duplicates,
summary: {
total: rows.length,
ready: ready.length,
errors: errors.length,
duplicates: duplicates.length,
},
};
}
/** Landlord property id first, then UPRN. null when neither resolves. */
function matchProperty(
landlord: string,
uprn: string,
context: ImportMatchContext,
ambiguous: Set<string>,
): string | null {
if (landlord !== "" && !ambiguous.has(landlord)) {
const byLandlord = context.propertyIdByLandlordId[landlord];
if (byLandlord !== undefined) return byLandlord;
}
if (uprn !== "" && !ambiguous.has(uprn)) {
const byUprn = context.propertyIdByUprn[uprn];
if (byUprn !== undefined) return byUprn;
}
return null;
}
/** Why a property did not match — ambiguity gets its own message. */
function propertyIssue(
landlord: string,
uprn: string,
ambiguous: Set<string>,
): string {
if (landlord === "" && uprn === "") {
return "This row has no property identifier.";
}
if (ambiguous.has(landlord) || ambiguous.has(uprn)) {
return "This identifier matches more than one property in the project's portfolios.";
}
return "No property in this project's portfolios matches this identifier.";
}
/**
* The stage id for a row: the ladder's first stage when the cell is blank
* (ADR-0019), the matched stage when it is not, or null when a provided stage
* matches nothing or the ladder is somehow empty.
*/
function resolveStage(
stageCell: string,
workstream: WorkstreamTarget,
valueMapping: ImportValueMapping,
): string | null {
if (stageCell === "") {
// stages[0] is the ladder start (mappingTargets sorts by `order` asc). The
// readiness gate guarantees ≥1 stage; the ?? null is defence, not a path.
return workstream.stages[0]?.id ?? null;
}
const match = resolveValue<StageTarget>(
scopedValueKey(workstream.id, stageCell),
stageCell,
valueMapping.stages,
workstream.stages,
name,
id,
);
return match.targetId;
}
/** `YYYY-MM-DD` for an ISO or dd/mm/yyyy cell; null for blank or unparseable. */
function parseForecastEnd(cell: string): string | null {
if (cell === "") return null;
// Already a real ISO date? formatUkDate returns "" for anything that isn't.
if (formatUkDate(cell) !== "") return cell;
// Otherwise accept what a person would type into the UK date field.
return parseUkDate(cell);
}
/** The numeric seq of a `WO-<n>` reference, or -1 when it is not one. */
function referenceSeq(reference: string): number {
const match = reference.match(/^WO-(\d+)$/);
return match ? Number(match[1]) : -1;
}
/** The next `WO-<seq>` at or after `seq` that is not already taken. */
function nextAutoReference(seq: number, used: Set<string>): string {
let candidate = seq;
let reference = formatAutoReference(candidate);
while (used.has(reference)) {
candidate += 1;
reference = formatAutoReference(candidate);
}
return reference;
}
/** `WO-` + zero-padded seq. Padding is a floor, so large seqs are not clipped. */
export function formatAutoReference(seq: number): string {
return `WO-${String(seq).padStart(AUTO_REFERENCE_PAD, "0")}`;
}

View file

@ -368,7 +368,7 @@ function tally(
* 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>(
export function resolveValue<T>(
key: string,
value: string,
decisions: Record<string, string | null>,