mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
0d8adad1d9
commit
251085fb8b
16 changed files with 15683 additions and 79 deletions
283
cypress/e2e/projects/import-work-orders.cy.js
Normal file
283
cypress/e2e/projects/import-work-orders.cy.js
Normal 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");
|
||||
});
|
||||
});
|
||||
4
cypress/fixtures/work-order-import.csv
Normal file
4
cypress/fixtures/work-order-import.csv
Normal 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,,,
|
||||
|
47
src/app/api/projects/[projectId]/import/commit/route.ts
Normal file
47
src/app/api/projects/[projectId]/import/commit/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/app/api/projects/[projectId]/import/validate/route.ts
Normal file
34
src/app/api/projects/[projectId]/import/validate/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
177
src/app/api/projects/[projectId]/import/validationRequest.ts
Normal file
177
src/app/api/projects/[projectId]/import/validationRequest.ts
Normal 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"),
|
||||
);
|
||||
}
|
||||
15
src/app/db/migrations/0281_work_order_status_default.sql
Normal file
15
src/app/db/migrations/0281_work_order_status_default.sql
Normal 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 '';
|
||||
13494
src/app/db/migrations/meta/0281_snapshot.json
Normal file
13494
src/app/db/migrations/meta/0281_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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;
|
||||
}
|
||||
|
|
@ -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 1–2 of 3
|
||||
Work-order import · Steps 1–3 of 3
|
||||
</p>
|
||||
<h1
|
||||
className="text-3xl font-extrabold text-gray-900 tracking-tight"
|
||||
|
|
|
|||
309
src/app/repositories/projects/importCommitRepository.ts
Normal file
309
src/app/repositories/projects/importCommitRepository.ts
Normal 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 identifier→property 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
341
src/lib/projects/import/validation.test.ts
Normal file
341
src/lib/projects/import/validation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
440
src/lib/projects/import/validation.ts
Normal file
440
src/lib/projects/import/validation.ts
Normal 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")}`;
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue