mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(ara-projects): import — contractor/stage columns + setup gate
Extends the #414 upload-and-parse work (does not rewrite it) with a per-row contractor, an optional stage, and a setup-completeness gate on the import page. New ADR-0019 records the design. Still writes nothing. CHANGE 1 — template + parser expected fields Final column order: Property identifier, Workstream, Contractor, Stage, Work order reference (optional), Forecast end date (optional). Contractor is required; Stage is optional (blank falls back to the workstream's first stage at commit). buildImportTemplateCsv, its worked example and the field docs are updated. The raw parser stays header-agnostic — required/optional is enforced in mapping (#415) and validation/commit (#417), not here — with a test proving a file missing the required Contractor column still parses. CHANGE 2 — setup-completeness gate (the substantive feature) src/lib/projects/import/readiness.ts: a pure computeImportReadiness(facts) plus a read-only loadImportReadiness(projectId) round trip. Rule: every project_workstream must have >=1 project_workstream_stage AND >=1 project_workstream_contractor, and the project must have >=1 workstream. Evidence requirements (#412) are advisory and excluded from the gate. The helper returns per-workstream detail (which are incomplete, and why) and is built to be reused by #413's final wizard step — noted in a comment. The import page loads readiness and, when not ready, renders an ImportSetupIncomplete state (lists the incomplete workstreams with the reason, links to project setup) instead of the drop zone — not a 404, not a silent empty zone. When ready, the drop zone renders as before. The gate applies on every entry: the route stays reachable standalone, and a comment notes #413 will also route the wizard into it (that wiring is out of scope here). CHANGE 3 — ADR-0019 Records: import is the final, setup-gated wizard step; contractor is supplied per row and must match a contractor already assigned to the workstream (contractor creation is out of scope — the existing "invite company to Ara" flow owns it); stage is optional per row with first-stage fallback. States the trade-off vs the replaced #417 design, which derived the contractor from the workstream's single assignee — abandoned because project_workstream_contractor is a collection, so one workstream's properties can be split across contractors, which a single-assignee lookup cannot express. Notes on the readiness query count(distinct project_workstream_stage.id) / (…contractor.id) over two left joins — the distinct is load-bearing because joining both children at once makes a stages x contractors cartesian product a plain count(*) would over-count. count(distinct) returns a bigint that node-postgres hands back as a string, so each count is coerced with Number(); a loader test drives a mocked db to prove that coercion. Production-DB safety No migrations, seeds or writes. loadImportReadiness is read-only SELECTs and is not executed here. Unit tests mock @/app/db/db (vi.mock) and open no connection; the pure readiness logic is tested with in-memory fixtures. Full suite: 575 passing. tsc and eslint clean. authz modules and src/app/projects/page.tsx untouched (other branches own them). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9cfde2767f
commit
4204b1fe0e
7 changed files with 640 additions and 35 deletions
|
|
@ -0,0 +1,104 @@
|
|||
# 19. Work-order import is the setup-gated final wizard step, and carries contractor per row
|
||||
|
||||
Date: 2026-07-22
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
Numbering note: 0015–0017 are in flight on the reporting-redesign branch, and
|
||||
0010 alongside them (see ADR-0018's note). 0019 follows 0018 and avoids all of
|
||||
them.
|
||||
|
||||
## Context
|
||||
|
||||
The Ara Projects "second import" (issue #414) matches a client-supplied
|
||||
spreadsheet to Properties already in the DB and declares which Workstreams each
|
||||
Property gets, as one row per (Property, Workstream). Committing those rows as
|
||||
Work orders is #417.
|
||||
|
||||
Two questions came up once the schema (PR #404) and the setup wizard shape
|
||||
(wireframes 02–07) were concrete:
|
||||
|
||||
1. **When is a project allowed to import?** A `work_order` row is
|
||||
`(property, project_workstream_stage, project_workstream_contractor,
|
||||
reference, forecast_end)` — all NOT NULL except `forecast_end`. So a Work
|
||||
order cannot exist for a workstream that has no stage ladder and no assigned
|
||||
contractor. Importing against a half-configured project would fail row by
|
||||
row at commit with nothing the user could act on up front.
|
||||
|
||||
2. **Where does each row's contractor come from?** The original #417 sketch
|
||||
derived the contractor from the workstream: each `project_workstream` was
|
||||
assumed to have a single contractor assignment, so the importer would look
|
||||
it up and every Work order for that workstream went to that one org. But
|
||||
`project_workstream_contractor` is a **collection** — a workstream can have
|
||||
more than one contractor assigned (CONTEXT.md, "Contractor"), and real
|
||||
programmes split one workstream's properties across several contractors by
|
||||
area or batch. A single-assignee lookup cannot express that.
|
||||
|
||||
## Decision
|
||||
|
||||
**Import is the final, setup-gated step of the setup wizard.**
|
||||
|
||||
- The wizard's earlier steps select workstreams, configure each workstream's
|
||||
stage ladder, and assign contractors. Import is the last step; #413 routes
|
||||
the wizard into this same `/projects/[projectId]/import` screen. It also
|
||||
stays reachable standalone from its own nav button.
|
||||
- The screen is **gated on setup completeness** regardless of entry point.
|
||||
Rule: every `project_workstream` on the project must have **≥1
|
||||
`project_workstream_stage` AND ≥1 `project_workstream_contractor`**, and the
|
||||
project must have at least one workstream. Until then the drop zone is
|
||||
replaced by a "finish setup" state naming the incomplete workstreams and
|
||||
linking back to configuration — not a 404 and not a silent empty zone.
|
||||
- Evidence requirements (#412) are advisory and are **not** part of the gate.
|
||||
- The gate is computed by `computeImportReadiness` (pure) /
|
||||
`loadImportReadiness` (one read-only query) in
|
||||
`src/lib/projects/import/readiness.ts`, returning per-workstream detail so
|
||||
both this page and #413 can render *what* is missing.
|
||||
|
||||
**Contractor is supplied per import row.**
|
||||
|
||||
- The template gains a required `Contractor` column. Each row names the
|
||||
contractor that delivers that (Property, Workstream), and it must match a
|
||||
contractor **already assigned** to that workstream via
|
||||
`project_workstream_contractor`. Validation (#417) rejects a contractor that
|
||||
is not an existing assignment.
|
||||
- **Contractor creation is out of scope for import.** Bringing a new company
|
||||
into Ara and assigning it to a workstream is owned by the pre-existing
|
||||
"invite company to Ara" flow; import only references assignments that flow
|
||||
has already made. This is why the gate requires a contractor per workstream
|
||||
before import opens — the assignments must exist first.
|
||||
|
||||
**Stage is optional per row.**
|
||||
|
||||
- The template gains an optional `Stage` column. A blank stage falls back at
|
||||
commit to the workstream's first stage (`project_workstream_stage` ordered by
|
||||
`order`, ascending — the ladder's start; see CONTEXT.md, "Stage"). A named
|
||||
stage must match one on that workstream's ladder.
|
||||
|
||||
The raw parser (`parse.ts`) stays header-agnostic: it does not enforce the
|
||||
required/optional distinction or that names resolve. Required-ness is the
|
||||
mapping step's job (#415) and value resolution is the commit step's (#417).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Trade-off vs the replaced #417 design.** Deriving the contractor from the
|
||||
workstream's single assignee was simpler — one fewer required column, and no
|
||||
per-row contractor to validate. We give that up because it silently assumed
|
||||
one contractor per workstream, which the schema does not guarantee and real
|
||||
programmes violate. Carrying the contractor per row costs the client a column
|
||||
they must fill and a validation failure mode ("contractor X isn't assigned to
|
||||
workstream Y"), but it is the only shape that supports a workstream split
|
||||
across contractors, and it keeps the assignment set — not the spreadsheet —
|
||||
as the source of truth for who may receive work.
|
||||
- The setup gate means a project cannot reach the drop zone mid-configuration.
|
||||
That is the intended safety property (no un-committable imports), but it does
|
||||
make import unreachable until contractors are assigned — acceptable because
|
||||
assignment is a prerequisite for issuing any work order at all.
|
||||
- `readiness.ts` has two consumers (this page and #413), so its interface is
|
||||
kept deliberately stable and detail-rich.
|
||||
- Not yet decided here: how a row's named contractor disambiguates when the
|
||||
same org is assigned to a workstream more than once, and the exact
|
||||
stage-name matching rule. Both are #417's to settle at commit; this ADR fixes
|
||||
only that the contractor is per-row-and-pre-assigned and the stage is
|
||||
optional-with-first-stage-fallback.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import Link from "next/link";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import type { ImportReadiness } from "@/lib/projects/import/readiness";
|
||||
|
||||
/** Human-readable phrase for each unmet requirement. */
|
||||
const GAP_LABEL = {
|
||||
stages: "no stages",
|
||||
contractor: "no contractor assigned",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shown in place of the drop zone when the project is not yet set up enough to
|
||||
* import against (ADR-0019). A plain server component — it renders the
|
||||
* `ImportReadiness` the page already loaded, so no client state is involved.
|
||||
*
|
||||
* It deliberately explains *which* workstreams are incomplete and *why*, rather
|
||||
* than a bare "not ready", so the user knows exactly what to fix before
|
||||
* returning. `setupHref` points at the project's configuration; the concrete
|
||||
* per-step wizard routes (select workstreams / configure stages / assign
|
||||
* contractors) land in other issues, so the page passes a single sensible
|
||||
* destination rather than deep-linking each gap.
|
||||
*/
|
||||
export function ImportSetupIncomplete({
|
||||
readiness,
|
||||
setupHref,
|
||||
}: {
|
||||
readiness: ImportReadiness;
|
||||
setupHref: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="import-setup-incomplete"
|
||||
className="rounded-2xl border border-amber-200 bg-amber-50/70 p-6"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 mr-3 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-amber-900">
|
||||
Finish project setup first
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
{readiness.hasWorkstreams
|
||||
? "Every workstream needs at least one stage and an assigned contractor before you can import work orders against it."
|
||||
: "This project has no workstreams yet. Add the workstreams it covers, give each a stage ladder and a contractor, then come back to import."}
|
||||
</p>
|
||||
|
||||
{readiness.incompleteWorkstreams.length > 0 && (
|
||||
<ul className="mt-4 space-y-2" data-testid="import-incomplete-list">
|
||||
{readiness.incompleteWorkstreams.map((w) => (
|
||||
<li
|
||||
key={String(w.projectWorkstreamId)}
|
||||
className="flex items-center justify-between rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-800">
|
||||
{w.workstreamName}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-amber-700">
|
||||
{w.missing.map((gap) => GAP_LABEL[gap]).join(" · ")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={setupHref}
|
||||
data-testid="import-setup-link"
|
||||
className="mt-5 inline-flex items-center rounded-md bg-brandblue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brandblue/90"
|
||||
>
|
||||
Go to project setup
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ import {
|
|||
loadAuthzUser,
|
||||
loadProjectAuthzFacts,
|
||||
} from "@/app/repositories/projects/authzRepository";
|
||||
import { loadImportReadiness } from "@/lib/projects/import/readiness";
|
||||
import { ImportUpload } from "./components/ImportUpload";
|
||||
import { ImportSetupIncomplete } from "./components/ImportSetupIncomplete";
|
||||
|
||||
export const metadata = {
|
||||
title: "Import programme data | Ara",
|
||||
|
|
@ -15,14 +17,32 @@ export const metadata = {
|
|||
* Work-order import, step 1 of 3 — upload and parse (issue #414).
|
||||
*
|
||||
* Wireframe: `docs/wireframes/ara-projects/1-admin-import/07-import-upload.html`.
|
||||
* The mapping step (#415), validation (#416) and commit (#417) follow; this
|
||||
* page ends at a parsed preview and writes nothing.
|
||||
* The mapping step (#415), validation and commit (#417) follow; this page ends
|
||||
* at a parsed preview and writes nothing.
|
||||
*
|
||||
* Only internal and client-org managers may import, so this checks
|
||||
* `canManageProject` rather than settling for the layout's view-level guard —
|
||||
* a contractor with legitimate access to the project must not see the drop
|
||||
* zone at all, not merely have their upload rejected by the route handler.
|
||||
*
|
||||
* The page is also **setup-gated** (ADR-0019): unless every selected workstream
|
||||
* has a stage and a contractor, the drop zone is replaced by a "finish setup"
|
||||
* state. The gate applies regardless of entry point — this route is reachable
|
||||
* standalone from its own nav button today, and #413 will additionally route
|
||||
* into it as the final step of the setup wizard, so it must not assume it is
|
||||
* only ever reached once setup is known-complete.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where the "finish setup" state sends the user. The concrete per-step wizard
|
||||
* routes (select workstreams / configure stages / assign contractors) are
|
||||
* owned by other issues; this points at the project's configuration home, and
|
||||
* #413 can narrow it to a deep link when those routes firm up.
|
||||
*/
|
||||
function setupHref(projectId: string): string {
|
||||
return `/projects/${projectId}/settings`;
|
||||
}
|
||||
|
||||
export default async function ImportPage(props: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
|
|
@ -38,6 +58,9 @@ export default async function ImportPage(props: {
|
|||
// 404 rather than 403 throughout, so project existence stays private.
|
||||
if (!user || !project || !canManageProject(user, project)) notFound();
|
||||
|
||||
// Read-only: gather whether the project is set up enough to import against.
|
||||
const readiness = await loadImportReadiness(BigInt(projectId));
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-10">
|
||||
<div className="mb-8">
|
||||
|
|
@ -51,13 +74,21 @@ export default async function ImportPage(props: {
|
|||
Import programme data
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-2 max-w-2xl">
|
||||
Upload a file listing which workstreams each property takes — one row
|
||||
per property and workstream. A property taking Windows and Doors
|
||||
appears twice, sharing its identifier.
|
||||
Upload a file listing which workstreams each property takes and which
|
||||
contractor delivers each — one row per property and workstream. A
|
||||
property taking Windows and Doors appears twice, sharing its
|
||||
identifier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ImportUpload projectId={projectId} />
|
||||
{readiness.ready ? (
|
||||
<ImportUpload projectId={projectId} />
|
||||
) : (
|
||||
<ImportSetupIncomplete
|
||||
readiness={readiness}
|
||||
setupHref={setupHref(projectId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ describe("parseImportSheet", () => {
|
|||
it("returns headers, rows, count and a preview", () => {
|
||||
const result = parseImportSheet([
|
||||
HEADERS,
|
||||
["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
|
||||
["PROP-0002", "Roofs", "", ""],
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
|
||||
["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
|
|
@ -37,13 +37,13 @@ describe("parseImportSheet", () => {
|
|||
file: {
|
||||
headers: HEADERS,
|
||||
rows: [
|
||||
["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
|
||||
["PROP-0002", "Roofs", "", ""],
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
|
||||
["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
|
||||
],
|
||||
rowCount: 2,
|
||||
preview: [
|
||||
["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
|
||||
["PROP-0002", "Roofs", "", ""],
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
|
||||
["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -102,16 +102,17 @@ describe("parseImportSheet", () => {
|
|||
it("trims cells, drops blank rows, and pads ragged ones", () => {
|
||||
const result = parseImportSheet([
|
||||
HEADERS,
|
||||
[" PROP-0001 ", " Windows ", "", ""],
|
||||
["", "", "", ""],
|
||||
["PROP-0002", "Doors"],
|
||||
[" PROP-0001 ", " Windows ", " Acme Windows Ltd ", "", "", ""],
|
||||
["", "", "", "", "", ""],
|
||||
["PROP-0002", "Doors", "Acme Doors Ltd"],
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
// Short rows pad out to the 6-column header width.
|
||||
expect(result.file.rows).toEqual([
|
||||
["PROP-0001", "Windows", "", ""],
|
||||
["PROP-0002", "Doors", "", ""],
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""],
|
||||
["PROP-0002", "Doors", "Acme Doors Ltd", "", "", ""],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ describe("parseImportSheet", () => {
|
|||
it("caps the preview at IMPORT_PREVIEW_ROWS but keeps every row", () => {
|
||||
const rows: string[][] = [HEADERS];
|
||||
for (let i = 0; i < IMPORT_PREVIEW_ROWS + 5; i++) {
|
||||
rows.push([`PROP-${i}`, "Windows", "", ""]);
|
||||
rows.push([`PROP-${i}`, "Windows", "Acme Windows Ltd", "", "", ""]);
|
||||
}
|
||||
|
||||
const result = parseImportSheet(rows);
|
||||
|
|
@ -150,7 +151,14 @@ describe("parseImportSheet", () => {
|
|||
expect(result.file.rowCount).toBe(IMPORT_PREVIEW_ROWS + 5);
|
||||
expect(result.file.rows).toHaveLength(IMPORT_PREVIEW_ROWS + 5);
|
||||
expect(result.file.preview).toHaveLength(IMPORT_PREVIEW_ROWS);
|
||||
expect(result.file.preview[0]).toEqual(["PROP-0", "Windows", "", ""]);
|
||||
expect(result.file.preview[0]).toEqual([
|
||||
"PROP-0",
|
||||
"Windows",
|
||||
"Acme Windows Ltd",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
it("errors on an entirely empty sheet", () => {
|
||||
|
|
@ -215,7 +223,10 @@ describe("parseImportSheet", () => {
|
|||
describe("parseImportFile", () => {
|
||||
it("parses a real XLSX buffer", () => {
|
||||
const buf = workbookBuffer(
|
||||
[HEADERS, ["PROP-0001", "Windows", "WO-1001", "2026-09-30"]],
|
||||
[
|
||||
HEADERS,
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
|
||||
],
|
||||
"xlsx",
|
||||
);
|
||||
|
||||
|
|
@ -224,20 +235,22 @@ describe("parseImportFile", () => {
|
|||
if (!result.ok) return;
|
||||
expect(result.file.headers).toEqual(HEADERS);
|
||||
expect(result.file.rows).toEqual([
|
||||
["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a real CSV buffer", () => {
|
||||
const buf = workbookBuffer(
|
||||
[HEADERS, ["PROP-0001", "Windows", "", ""]],
|
||||
[HEADERS, ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""]],
|
||||
"csv",
|
||||
);
|
||||
|
||||
const result = parseImportFile(buf);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.file.rows).toEqual([["PROP-0001", "Windows", "", ""]]);
|
||||
expect(result.file.rows).toEqual([
|
||||
["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the leading zero on a numeric-looking identifier", () => {
|
||||
|
|
@ -262,11 +275,30 @@ describe("parseImportFile", () => {
|
|||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.file.headers).toEqual(HEADERS);
|
||||
// The template demonstrates one property taking two workstreams.
|
||||
// The template demonstrates one property taking two workstreams...
|
||||
expect(result.file.rows[0][0]).toBe("PROP-0001");
|
||||
expect(result.file.rows[1][0]).toBe("PROP-0001");
|
||||
expect(result.file.rows[0][1]).toBe("Windows");
|
||||
expect(result.file.rows[1][1]).toBe("Doors");
|
||||
// ...with a Contractor on every row (required) and Stage blank on the
|
||||
// second (optional, defaults to the workstream's first stage).
|
||||
expect(result.file.rows[0][2]).toBe("Acme Windows Ltd");
|
||||
expect(result.file.rows[1][2]).toBe("Acme Doors Ltd");
|
||||
expect(result.file.rows[0][3]).toBe("Ordered");
|
||||
expect(result.file.rows[1][3]).toBe("");
|
||||
});
|
||||
|
||||
it("stays header-agnostic when the required Contractor column is absent", () => {
|
||||
// The raw parser does not enforce required-ness — a file missing Contractor
|
||||
// still parses cleanly, and the mapping (#415) / commit (#417) steps are
|
||||
// what reject it. Enforcing here would make the mapping step unreachable.
|
||||
const result = parseImportFile(
|
||||
new TextEncoder().encode("Property identifier,Workstream\nPROP-1,Windows\n"),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.file.headers).toEqual(["Property identifier", "Workstream"]);
|
||||
});
|
||||
|
||||
it("rejects empty and single-line rubbish with a friendly error", () => {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@
|
|||
*
|
||||
* Scope note: this module is **header-agnostic on purpose**. It reports the
|
||||
* headers it found and hands back the rows; it does not insist on the template
|
||||
* columns and does not validate cell contents. Mapping arbitrary client headings
|
||||
* onto the template columns is the mapping step (#415), and validating the
|
||||
* mapped values is #416. Rejecting an unrecognised heading here would make the
|
||||
* mapping step unreachable for exactly the files that need it most.
|
||||
* columns and does not validate cell contents. In particular it does **not**
|
||||
* enforce that `Contractor` is present or that `Workstream`/`Contractor` name a
|
||||
* real assignment — required/optional is the mapping step's job (#415) and
|
||||
* value validation is the commit step's (#417). Rejecting an unrecognised
|
||||
* heading here would make the mapping step unreachable for exactly the files
|
||||
* that need it most.
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
|
|
@ -26,11 +28,26 @@ import * as XLSX from "xlsx";
|
|||
* The columns the "Download template" artefact ships with, in order.
|
||||
*
|
||||
* `Property identifier` is the landlord property id *or* the UPRN — matching
|
||||
* decides which at validation time (#416). The last two are optional per row.
|
||||
* decides which at validation time (#417).
|
||||
*
|
||||
* Required vs optional (enforced downstream, **not** here — see the module
|
||||
* header; this list is only the template's shape):
|
||||
* - `Property identifier` — required.
|
||||
* - `Workstream` — required; must be one the project selected.
|
||||
* - `Contractor` — required; must match a contractor already
|
||||
* assigned to that workstream (ADR-0019).
|
||||
* Contractor creation is out of scope — the
|
||||
* existing "invite company to Ara" flow owns it.
|
||||
* - `Stage` — optional; blank falls back to the workstream's
|
||||
* first stage at commit (ADR-0019).
|
||||
* - `Work order reference`— optional per row.
|
||||
* - `Forecast end date` — optional per row.
|
||||
*/
|
||||
export const IMPORT_TEMPLATE_COLUMNS = [
|
||||
"Property identifier",
|
||||
"Workstream",
|
||||
"Contractor",
|
||||
"Stage",
|
||||
"Work order reference",
|
||||
"Forecast end date",
|
||||
] as const;
|
||||
|
|
@ -238,17 +255,25 @@ export function hasAcceptedImportExtension(filename: string): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* The "Download template" artefact: a CSV carrying the expected headings plus
|
||||
* one worked example showing a Property that takes two Workstreams — the file
|
||||
* shape's one non-obvious rule, demonstrated rather than explained.
|
||||
* The "Download template" artefact: a CSV carrying the expected headings plus a
|
||||
* worked example that demonstrates the file shape's non-obvious rules rather
|
||||
* than explaining them:
|
||||
* - a Property taking two Workstreams appears on two rows sharing its
|
||||
* identifier (rows 1 and 2, `PROP-0001`);
|
||||
* - `Contractor` is filled on every row (it is required, and must already be
|
||||
* assigned to that workstream — ADR-0019);
|
||||
* - `Stage` is blank on rows 2 and 3 to show it is optional (it falls back to
|
||||
* the workstream's first stage at commit);
|
||||
* - `Work order reference` and `Forecast end date` are blank on the last row
|
||||
* to show they are optional too.
|
||||
*
|
||||
* CSV rather than XLSX so it opens the same everywhere and stays diffable.
|
||||
*/
|
||||
export function buildImportTemplateCsv(): string {
|
||||
const example = [
|
||||
["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
|
||||
["PROP-0001", "Doors", "WO-1002", "2026-10-15"],
|
||||
["PROP-0002", "Roofs", "", ""],
|
||||
["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", "", "", ""],
|
||||
];
|
||||
return (
|
||||
[[...IMPORT_TEMPLATE_COLUMNS], ...example]
|
||||
|
|
|
|||
171
src/lib/projects/import/readiness.test.ts
Normal file
171
src/lib/projects/import/readiness.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The module under test imports `@/app/db/db` for its `loadImportReadiness`
|
||||
// loader. Mock it so importing the module opens no connection and the
|
||||
// production database is never touched — the pure `computeImportReadiness`
|
||||
// tests below never reach it, and the one loader test drives it explicitly.
|
||||
const mockSelect = vi.fn();
|
||||
vi.mock("@/app/db/db", () => ({ db: { select: () => mockSelect() } }));
|
||||
|
||||
import {
|
||||
computeImportReadiness,
|
||||
loadImportReadiness,
|
||||
type WorkstreamReadinessFacts,
|
||||
} from "./readiness";
|
||||
|
||||
function facts(
|
||||
over: Partial<WorkstreamReadinessFacts> & { workstreamName: string },
|
||||
): WorkstreamReadinessFacts {
|
||||
return {
|
||||
projectWorkstreamId: 1n,
|
||||
workstreamId: 10n,
|
||||
stageCount: 1,
|
||||
contractorCount: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeImportReadiness", () => {
|
||||
it("is ready when every workstream has a stage and a contractor", () => {
|
||||
const result = computeImportReadiness([
|
||||
facts({ workstreamName: "Windows", projectWorkstreamId: 1n }),
|
||||
facts({ workstreamName: "Doors", projectWorkstreamId: 2n }),
|
||||
]);
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.hasWorkstreams).toBe(true);
|
||||
expect(result.incompleteWorkstreams).toEqual([]);
|
||||
expect(result.workstreams.every((w) => w.complete)).toBe(true);
|
||||
expect(result.workstreams[0].missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("is not ready, and reports 'contractor', when a workstream has no contractor", () => {
|
||||
const result = computeImportReadiness([
|
||||
facts({ workstreamName: "Windows" }),
|
||||
facts({ workstreamName: "Doors", projectWorkstreamId: 2n, contractorCount: 0 }),
|
||||
]);
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.incompleteWorkstreams).toHaveLength(1);
|
||||
const doors = result.incompleteWorkstreams[0];
|
||||
expect(doors.workstreamName).toBe("Doors");
|
||||
expect(doors.complete).toBe(false);
|
||||
expect(doors.missing).toEqual(["contractor"]);
|
||||
});
|
||||
|
||||
it("is not ready, and reports 'stages', when a workstream has no stage", () => {
|
||||
const result = computeImportReadiness([
|
||||
facts({ workstreamName: "Roofs", stageCount: 0 }),
|
||||
]);
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.incompleteWorkstreams[0].missing).toEqual(["stages"]);
|
||||
});
|
||||
|
||||
it("reports both gaps, stages before contractor, when a workstream has neither", () => {
|
||||
const result = computeImportReadiness([
|
||||
facts({ workstreamName: "Heating", stageCount: 0, contractorCount: 0 }),
|
||||
]);
|
||||
|
||||
expect(result.incompleteWorkstreams[0].missing).toEqual([
|
||||
"stages",
|
||||
"contractor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists only the incomplete workstreams, but keeps all in `workstreams`", () => {
|
||||
const result = computeImportReadiness([
|
||||
facts({ workstreamName: "Windows", projectWorkstreamId: 1n }),
|
||||
facts({ workstreamName: "Doors", projectWorkstreamId: 2n, stageCount: 0 }),
|
||||
facts({ workstreamName: "Roofs", projectWorkstreamId: 3n, contractorCount: 0 }),
|
||||
]);
|
||||
|
||||
expect(result.workstreams).toHaveLength(3);
|
||||
expect(result.incompleteWorkstreams.map((w) => w.workstreamName)).toEqual([
|
||||
"Doors",
|
||||
"Roofs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is not ready when the project has selected no workstreams", () => {
|
||||
// Vacuously "all complete", but there is nothing to import into.
|
||||
const result = computeImportReadiness([]);
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.hasWorkstreams).toBe(false);
|
||||
expect(result.workstreams).toEqual([]);
|
||||
expect(result.incompleteWorkstreams).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats counts above one as complete (not just exactly one)", () => {
|
||||
const result = computeImportReadiness([
|
||||
facts({ workstreamName: "Windows", stageCount: 5, contractorCount: 3 }),
|
||||
]);
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadImportReadiness", () => {
|
||||
it("coerces string counts from the driver and decides via the pure function", async () => {
|
||||
// node-postgres returns count(distinct …) as a string; the loader must
|
||||
// Number() it or every workstream would read as complete (Number("0") vs
|
||||
// "0" truthiness). Drive the mocked query chain to prove the coercion.
|
||||
const groupBy = vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectWorkstreamId: 1n,
|
||||
workstreamId: 10n,
|
||||
workstreamName: "Windows",
|
||||
stageCount: "2",
|
||||
contractorCount: "1",
|
||||
},
|
||||
{
|
||||
projectWorkstreamId: 2n,
|
||||
workstreamId: 11n,
|
||||
workstreamName: "Doors",
|
||||
stageCount: "1",
|
||||
contractorCount: "0",
|
||||
},
|
||||
]);
|
||||
const chain = {
|
||||
from: () => chain,
|
||||
innerJoin: () => chain,
|
||||
leftJoin: () => chain,
|
||||
where: () => chain,
|
||||
groupBy,
|
||||
};
|
||||
mockSelect.mockReturnValue(chain);
|
||||
|
||||
const result = await loadImportReadiness(42n);
|
||||
|
||||
expect(groupBy).toHaveBeenCalledOnce();
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.workstreams[0]).toMatchObject({
|
||||
workstreamName: "Windows",
|
||||
stageCount: 2,
|
||||
contractorCount: 1,
|
||||
complete: true,
|
||||
});
|
||||
expect(result.incompleteWorkstreams).toHaveLength(1);
|
||||
expect(result.incompleteWorkstreams[0]).toMatchObject({
|
||||
workstreamName: "Doors",
|
||||
missing: ["contractor"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not-ready for a project with no workstream rows", async () => {
|
||||
const chain = {
|
||||
from: () => chain,
|
||||
innerJoin: () => chain,
|
||||
leftJoin: () => chain,
|
||||
where: () => chain,
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
mockSelect.mockReturnValue(chain);
|
||||
|
||||
const result = await loadImportReadiness(7n);
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.hasWorkstreams).toBe(false);
|
||||
});
|
||||
});
|
||||
166
src/lib/projects/import/readiness.ts
Normal file
166
src/lib/projects/import/readiness.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Import readiness — is a Project set up enough to import work orders against?
|
||||
* (issue #414, extended.)
|
||||
*
|
||||
* The gate (ADR-0019): **every** workstream the project selected
|
||||
* (`project_workstream`) must have BOTH at least one stage
|
||||
* (`project_workstream_stage`) AND at least one assigned contractor
|
||||
* (`project_workstream_contractor`). A project with no workstreams is not
|
||||
* importable either — there is nothing to import into. Evidence requirements
|
||||
* (#412) are advisory and deliberately play no part in this gate.
|
||||
*
|
||||
* The module is split the way the rest of Ara Projects splits domain from
|
||||
* persistence (see `@/lib/projects/authz` + its repository):
|
||||
* - `computeImportReadiness` is **pure** — it takes per-workstream facts and
|
||||
* decides. It imports no database client, so it unit-tests against
|
||||
* in-memory fixtures with no connection.
|
||||
* - `loadImportReadiness` is the single read-only round trip that gathers
|
||||
* those facts for one project and hands them to the pure function.
|
||||
*
|
||||
* ## Reused by #413
|
||||
*
|
||||
* The final step of the Ara Projects setup wizard (#413) routes into this same
|
||||
* import screen, so it needs the identical "is setup complete?" answer this
|
||||
* page needs. Both call `loadImportReadiness(projectId)` and branch on
|
||||
* `ImportReadiness.ready`; the `incompleteWorkstreams` detail is what each
|
||||
* surface renders to tell the user what is still missing. Keep this interface
|
||||
* stable for that reason — it has a second consumer.
|
||||
*/
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
projectWorkstream,
|
||||
projectWorkstreamContractor,
|
||||
projectWorkstreamStage,
|
||||
workstream,
|
||||
} from "@/app/db/schema/projects/projects";
|
||||
|
||||
/** The two things a workstream can be missing before the project can import. */
|
||||
export type ImportReadinessGap = "stages" | "contractor";
|
||||
|
||||
/** The raw per-workstream facts the readiness decision is computed from. */
|
||||
export interface WorkstreamReadinessFacts {
|
||||
/** `project_workstream.id`. */
|
||||
projectWorkstreamId: bigint;
|
||||
/** `project_workstream.workstream_id` — the reference-data workstream. */
|
||||
workstreamId: bigint;
|
||||
/** `workstream.name`, for rendering which workstreams are incomplete. */
|
||||
workstreamName: string;
|
||||
/** Count of `project_workstream_stage` rows for this workstream. */
|
||||
stageCount: number;
|
||||
/** Count of `project_workstream_contractor` rows for this workstream. */
|
||||
contractorCount: number;
|
||||
}
|
||||
|
||||
/** One workstream's readiness, derived from its facts. */
|
||||
export interface WorkstreamReadiness {
|
||||
projectWorkstreamId: bigint;
|
||||
workstreamId: bigint;
|
||||
workstreamName: string;
|
||||
stageCount: number;
|
||||
contractorCount: number;
|
||||
/** True when this workstream has both a stage and a contractor. */
|
||||
complete: boolean;
|
||||
/** Which requirements are unmet — empty when `complete`. In stable order. */
|
||||
missing: ImportReadinessGap[];
|
||||
}
|
||||
|
||||
/** The whole-project readiness verdict. */
|
||||
export interface ImportReadiness {
|
||||
/**
|
||||
* True only when the project has at least one workstream and every workstream
|
||||
* is complete. This is the gate the import page and #413 branch on.
|
||||
*/
|
||||
ready: boolean;
|
||||
/** False when the project has selected no workstreams at all. */
|
||||
hasWorkstreams: boolean;
|
||||
/** Every workstream's readiness, in the order the facts arrived. */
|
||||
workstreams: WorkstreamReadiness[];
|
||||
/** Just the incomplete ones — what the "finish setup" state lists. */
|
||||
incompleteWorkstreams: WorkstreamReadiness[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, from per-workstream facts, whether a project may import — pure, no
|
||||
* database. A workstream is complete when it has ≥1 stage AND ≥1 contractor;
|
||||
* the project is ready when it has workstreams and all of them are complete.
|
||||
*/
|
||||
export function computeImportReadiness(
|
||||
facts: WorkstreamReadinessFacts[],
|
||||
): ImportReadiness {
|
||||
const workstreams: WorkstreamReadiness[] = facts.map((f) => {
|
||||
const missing: ImportReadinessGap[] = [];
|
||||
// Order matters only for stable rendering: stages before contractor.
|
||||
if (f.stageCount < 1) missing.push("stages");
|
||||
if (f.contractorCount < 1) missing.push("contractor");
|
||||
return {
|
||||
projectWorkstreamId: f.projectWorkstreamId,
|
||||
workstreamId: f.workstreamId,
|
||||
workstreamName: f.workstreamName,
|
||||
stageCount: f.stageCount,
|
||||
contractorCount: f.contractorCount,
|
||||
complete: missing.length === 0,
|
||||
missing,
|
||||
};
|
||||
});
|
||||
|
||||
const incompleteWorkstreams = workstreams.filter((w) => !w.complete);
|
||||
const hasWorkstreams = workstreams.length > 0;
|
||||
|
||||
return {
|
||||
ready: hasWorkstreams && incompleteWorkstreams.length === 0,
|
||||
hasWorkstreams,
|
||||
workstreams,
|
||||
incompleteWorkstreams,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather one project's per-workstream readiness facts and decide. A single
|
||||
* read-only round trip: it writes nothing.
|
||||
*
|
||||
* The stage and contractor counts come from `count(distinct …)` over two left
|
||||
* joins — the distinct is load-bearing, because joining both children at once
|
||||
* produces a stages×contractors cartesian product per workstream that a plain
|
||||
* `count(*)` would over-count. `count(distinct)` returns a bigint, which
|
||||
* node-postgres hands back as a string, so each is coerced with `Number`.
|
||||
*/
|
||||
export async function loadImportReadiness(
|
||||
projectId: bigint,
|
||||
): Promise<ImportReadiness> {
|
||||
const rows = await db
|
||||
.select({
|
||||
projectWorkstreamId: projectWorkstream.id,
|
||||
workstreamId: projectWorkstream.workstreamId,
|
||||
workstreamName: workstream.name,
|
||||
stageCount: sql<string>`count(distinct ${projectWorkstreamStage.id})`,
|
||||
contractorCount: sql<string>`count(distinct ${projectWorkstreamContractor.id})`,
|
||||
})
|
||||
.from(projectWorkstream)
|
||||
.innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
|
||||
.leftJoin(
|
||||
projectWorkstreamStage,
|
||||
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
|
||||
)
|
||||
.leftJoin(
|
||||
projectWorkstreamContractor,
|
||||
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
|
||||
)
|
||||
.where(eq(projectWorkstream.projectId, projectId))
|
||||
.groupBy(
|
||||
projectWorkstream.id,
|
||||
projectWorkstream.workstreamId,
|
||||
workstream.name,
|
||||
);
|
||||
|
||||
return computeImportReadiness(
|
||||
rows.map((r) => ({
|
||||
projectWorkstreamId: r.projectWorkstreamId,
|
||||
workstreamId: r.workstreamId,
|
||||
workstreamName: r.workstreamName,
|
||||
stageCount: Number(r.stageCount),
|
||||
contractorCount: Number(r.contractorCount),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue