diff --git a/docs/adr/0023-installation-in-progress-gated-on-design-document.md b/docs/adr/0023-installation-in-progress-gated-on-design-document.md new file mode 100644 index 00000000..6d76712c --- /dev/null +++ b/docs/adr/0023-installation-in-progress-gated-on-design-document.md @@ -0,0 +1,96 @@ +# 23. Installation in Progress is gated on the Retrofit Design Document, not the HubSpot designStatus flag + +Date: 2026-07-24 + +## Status + +Accepted + +## Context + +On the Live projects screen (`your-projects/live`), a Property's delivery stage +is derived from HubSpot deal fields by the stage classifier +(`resolveDisplayStage` → `classifyDeals` → `computeLiveTrackerData` in +`transforms.ts`). After coordination is complete, the classifier decided +*Design in Progress* vs *Installation in Progress* from the self-reported +`designStatus` text field: `designStatus === "UPLOADED"` meant "design is done, +installation can happen". + +That flag is set in the CRM independently of the actual design work. Observed +values include `"UPLOADED"`, `"uploaded"` and `"IN PROGRESS"`, and it is +routinely set before — or without — the **Retrofit Design Document** +(`retrofit_design_doc`) ever being uploaded. The CRM flag and the document store +are two independent sources of truth that had silently diverged, so large +numbers of Properties with no design in place showed as *Installation in +Progress*. Managers could not trust the pipeline counts, the funnel, or the +stage badges, and Properties genuinely waiting on design were hidden inside a +later stage. + +The domain owner confirmed there is only ever one design document per Property — +the Retrofit Design Document — and its presence is the correct trigger for +*Installation in Progress*. + +## Decision + +**Design-document presence, not the `designStatus` flag, drives the +Design-vs-Installation boundary.** + +- The classifier stops reading `designStatus`. `designStatus` remains on the + deal record and may still be displayed (and still feeds the separate design + throughput trends chart), but it no longer influences the stage. +- Whether a `retrofit_design_doc` exists for a deal is passed **into** the pure + classifier as an explicit input — `resolveDisplayStage(deal, hasDesignDoc)`, + `classifyDeals(deals, designDocDealIds)`, + `computeLiveTrackerData(deals, designDocDealIds)`. The classification + functions perform no I/O; presence is derived by + `deriveDesignDocDealIds(docsByDealId)` from the documents already fetched + (direct deal-id match and UPRN fallback), using `DESIGN_DOC_TYPES` as the + definition of a design document. `page.tsx` and `[dealId]/page.tsx` now fetch + documents ahead of classification so the same fetch feeds both the presence + set and the document-status map. + +- **After coordination, downstream evidence wins; document presence only + decides the Design/Installation boundary.** For a coordination-complete deal + (a `(V1|V2|V3) IOE/MTP COMPLETE` marker, not an RA issue) the stage resolves + in this priority: + + ``` + full lodgement date present → Project Complete + measures lodgement date present → At Post Survey + lodgement status present → At Lodgement + measures installed OR installer handover→ Installation Complete + Retrofit Design Document present → Installation in Progress + otherwise → Design in Progress + ``` + + Gating the whole post-coordination block on document presence would drag a + genuinely installed or lodged Property back to *Design in Progress* when its + design document is merely missing from the store. Document presence is the + weakest signal in the ladder, not a gate on the rest. + +- The document-status map's install bucket is corrected: a `retrofit_design_doc` + is no longer mis-bucketed as an install document, so a Property holding only a + design document is not reported as having install documents. + +Coordination and earlier stages are untouched — the RA-issue → Queries path, +the removed-Property short-circuits, the dealstage-id → raw-stage mapping and +the Coordination in Progress path all behave exactly as before. + +## Consequences + +- A Property shows *Installation in Progress* only once its Retrofit Design + Document exists; one whose coordination is complete but whose document is + missing stays at *Design in Progress*, where the outstanding design work is + visible. Funnel and stage-progress counts (and the "All Projects" roll-up) + follow the corrected per-deal classification, as does the Properties-tab stage + filter. +- A prematurely-set or stale `designStatus` flag can no longer push a Property + into *Installation in Progress*. +- `DESIGN_DOC_TYPES` (previously defined but unused) becomes load-bearing as the + single definition of what counts as a design document. +- Out of scope, deliberately: correcting the underlying `designStatus` data or + alerting on CRM-vs-document mismatch; surfacing a design-completeness badge on + the Documents tab; multi-document or per-measure design requirements (there is + only ever one design document per Property). The Live projects feature itself + remains superseded-in-principle by Ara Projects per ADR-0018; this fix keeps + it correct in the meantime. diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx index 18dfe690..b85b3d3a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx @@ -13,7 +13,11 @@ import { user as userTable } from "@/app/db/schema/users"; import type { DocStatus, PortfolioCapabilityType } from "../types"; import { deriveEffectiveRemovalState } from "../removalState"; import { classifyDeals } from "../transforms"; -import { fetchDocsByDealId, computeDocStatusMap } from "../docStatus"; +import { + fetchDocsByDealId, + computeDocStatusMap, + deriveDesignDocDealIds, +} from "../docStatus"; import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "../dealQuery"; import type { DealRow } from "../dealQuery"; import DealPage from "./DealPage"; @@ -74,7 +78,13 @@ export default async function DealDetailPage(props: { } const hubspotDeal = mapDbRowToHubspotDeal(rawDeals[0]); - const [deal] = classifyDeals([hubspotDeal]); + + // Fetch this deal's documents before classifying so Retrofit Design Document + // presence drives the Design-vs-Installation boundary, consistent with the + // Live projects list. The same documents feed the document-status map below. + const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]); + const designDocDealIds = deriveDesignDocDealIds(docsByDealId); + const [deal] = classifyDeals([hubspotDeal], designDocDealIds); const userEmail = session.user.email; let userCapability: PortfolioCapabilityType = []; @@ -151,7 +161,6 @@ export default async function DealDetailPage(props: { ? deriveEffectiveRemovalState(removalRows[0]) : "none"; - const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]); const docStatusMap = computeDocStatusMap([hubspotDeal], docsByDealId, { [dealId]: approvedMeasures }); const docStatus: DocStatus = docStatusMap[dealId] ?? { presentSurveyTypes: [], diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts index cd2c1d08..1ddd9c8b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { computeDocStatusMap } from "./docStatus"; +import { computeDocStatusMap, deriveDesignDocDealIds } from "./docStatus"; import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types"; import type { HubspotDeal, ApprovalsByDeal } from "./types"; @@ -81,6 +81,40 @@ const allSurveyDocs = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.map((t) => ({ measureName: null, })); +describe("deriveDesignDocDealIds", () => { + it("includes a deal that holds a Retrofit Design Document", () => { + // Arrange + const docsByDealId = new Map([ + ["deal-1", [{ fileType: "retrofit_design_doc", measureName: null }]], + ]); + + // Act + const designDocDealIds = deriveDesignDocDealIds(docsByDealId); + + // Assert + expect(designDocDealIds.has("deal-1")).toBe(true); + }); + + it("excludes a deal whose documents contain no Retrofit Design Document", () => { + // Arrange — only survey/install docs, no design document + const docsByDealId = new Map([ + [ + "deal-1", + [ + { fileType: "photo_pack", measureName: null }, + { fileType: "pre_photo", measureName: "CWI" }, + ], + ], + ]); + + // Act + const designDocDealIds = deriveDesignDocDealIds(docsByDealId); + + // Assert + expect(designDocDealIds.has("deal-1")).toBe(false); + }); +}); + describe("computeDocStatusMap", () => { it("marks survey as complete when all 9 mandatory doc types are present", () => { const deal = makeDeal({ dealId: "deal-1" }); @@ -123,6 +157,19 @@ describe("computeDocStatusMap", () => { expect(measureNames).not.toContain("CWI"); }); + it("does not report install documents for a deal holding only a Retrofit Design Document", () => { + // Arrange — a design document is not an install document + const deal = makeDeal({ dealId: "deal-1", proposedMeasures: "CWI" }); + const docs = new Map([["deal-1", [{ fileType: "retrofit_design_doc", measureName: null }]]]); + + // Act + const result = computeDocStatusMap([deal], docs, {}); + + // Assert + expect(result["deal-1"].hasInstallDocs).toBe(false); + expect(result["deal-1"].installStatus).toBe("none"); + }); + describe("installStatus", () => { // CWI requires only BASE_DOCS — simple to satisfy in tests const cwi = "CWI"; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts index 9472b88b..00818e46 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts @@ -5,6 +5,7 @@ import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements"; import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES, + DESIGN_DOC_TYPES, } from "./types"; import type { HubspotDeal, @@ -81,6 +82,24 @@ export async function fetchDocsByDealId( return docsByDealId; } +/** + * The single source of truth for "which deals have a Retrofit Design Document". + * Derived from the already-fetched per-deal documents (which include both the + * direct deal-id match and the UPRN fallback), so no extra query is needed. + * `DESIGN_DOC_TYPES` defines what counts as a design document. + */ +export function deriveDesignDocDealIds( + docsByDealId: Map, +): Set { + const designDocDealIds = new Set(); + for (const [dealId, docs] of docsByDealId) { + if (docs.some((d) => DESIGN_DOC_TYPES.has(d.fileType))) { + designDocDealIds.add(dealId); + } + } + return designDocDealIds; +} + export function computeDocStatusMap( deals: HubspotDeal[], docsByDealId: Map>, @@ -103,7 +122,12 @@ export function computeDocStatusMap( for (const [dealId, docs] of docsByDealId) { const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType)); - const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType)); + // Design documents are their own category — they are neither survey nor + // install docs. Without this carve-out a Retrofit Design Document (absent + // from the survey set) would be mis-bucketed as an install document. + const installDocs = docs.filter( + (d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType) && !DESIGN_DOC_TYPES.has(d.fileType), + ); const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType)); const measures = measuresByDealId.get(dealId) ?? []; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 6b50ee96..a83564df 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -4,7 +4,11 @@ import { redirect } from "next/navigation"; import { eq, inArray, and, desc, sql } from "drizzle-orm"; import LiveTracker from "./LiveTracker"; import { computeLiveTrackerData } from "./transforms"; -import { fetchDocsByDealId, computeDocStatusMap } from "./docStatus"; +import { + fetchDocsByDealId, + computeDocStatusMap, + deriveDesignDocDealIds, +} from "./docStatus"; import { db } from "@/app/db/db"; import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; @@ -112,7 +116,14 @@ export default async function LiveReportingPage(props: { .where(inArray(hubspotDealData.companyId, companyIds)); const deals = rawDeals.map(mapDbRowToHubspotDeal); - const trackerData = computeLiveTrackerData(deals); + const dealIds = deals.map((d) => d.dealId).filter(Boolean); + + // Fetch per-deal documents before classification so Retrofit Design Document + // presence can drive the Design-vs-Installation boundary. The same fetched + // documents feed the document-status map below. + const docsByDealId = await fetchDocsByDealId(deals, dealIds); + const designDocDealIds = deriveDesignDocDealIds(docsByDealId); + const trackerData = computeLiveTrackerData(deals, designDocDealIds); const userEmail = user?.user?.email; @@ -165,7 +176,6 @@ export default async function LiveReportingPage(props: { // Fetch currently approved measures for all deals in scope const approvalsByDeal: ApprovalsByDeal = {}; - const dealIds = deals.map((d) => d.dealId).filter(Boolean); if (dealIds.length > 0) { const approvalRows = await db .select({ @@ -218,7 +228,6 @@ export default async function LiveReportingPage(props: { const removalStatusByDeal: RemovalStatusByDeal = computeRemovalStatusByDeal(removalRows); - const docsByDealId = await fetchDocsByDealId(deals, dealIds); const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal); return ( diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index 60b68e4e..cec5ed4e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -300,81 +300,123 @@ describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => { ).toBe("Design in Progress"); }); - it("is case-insensitive for coord/design status checks", () => { + it("is case-insensitive for the coordination-complete pattern", () => { expect( - resolveDisplayStage(makeDeal({ - dealstage: AFTER_ASSESSMENT_STAGE, - coordinationStatus: "(v1) ioe/mtp complete", - designStatus: "uploaded", - })) - ).toBe("Installation in Progress"); // POST_DESIGN, no install fields set + resolveDisplayStage( + makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(v1) ioe/mtp complete", + }), + true // Retrofit Design Document present + ) + ).toBe("Installation in Progress"); }); }); -describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => { +describe("resolveDisplayStage — after-coordination precedence (downstream evidence wins over document presence)", () => { const AFTER_ASSESSMENT_STAGE = "3948185842"; - function makePostDesignDeal(overrides: Partial = {}): HubspotDeal { + // A coordination-complete deal. designStatus is deliberately unset — it no + // longer influences classification; the Retrofit Design Document (passed via + // hasDesignDoc) is the source of truth for the Design/Installation boundary. + function makeCoordinationCompleteDeal(overrides: Partial = {}): HubspotDeal { return makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: "(V1) IOE/MTP COMPLETE", - designStatus: "UPLOADED", ...overrides, }); } - it("returns Installation in Progress when no install fields are set", () => { - expect(resolveDisplayStage(makePostDesignDeal())).toBe("Installation in Progress"); + it("returns Design in Progress when the design document is absent and there is no downstream evidence", () => { + expect(resolveDisplayStage(makeCoordinationCompleteDeal(), false)).toBe("Design in Progress"); }); - it("returns Installation Complete when actualMeasuresInstalled is set", () => { + it("returns Installation Complete when actualMeasuresInstalled is set even though the design document is absent", () => { expect( - resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" })) + resolveDisplayStage(makeCoordinationCompleteDeal({ actualMeasuresInstalled: "Insulation" }), false) ).toBe("Installation Complete"); }); - it("returns Installation Complete when installerHandover is set", () => { + it("returns Installation Complete when installerHandover is set even though the design document is absent", () => { expect( - resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" })) + resolveDisplayStage(makeCoordinationCompleteDeal({ installerHandover: "2024-01-01" }), false) ).toBe("Installation Complete"); }); - it("returns At Lodgement when lodgementStatus is set", () => { + it("returns At Lodgement when lodgementStatus is set even though the design document is absent", () => { expect( - resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" })) + resolveDisplayStage(makeCoordinationCompleteDeal({ lodgementStatus: "Submitted" }), false) ).toBe("At Lodgement"); }); - it("returns At Post Survey when measuresLodgementDate is set", () => { + it("returns At Post Survey when measuresLodgementDate is set even though the design document is absent", () => { expect( - resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() })) + resolveDisplayStage(makeCoordinationCompleteDeal({ measuresLodgementDate: new Date() }), false) ).toBe("At Post Survey"); }); - it("returns Project Complete when fullLodgementDate is set", () => { + it("returns Project Complete when fullLodgementDate is set even though the design document is absent", () => { expect( - resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() })) + resolveDisplayStage(makeCoordinationCompleteDeal({ fullLodgementDate: new Date() }), false) ).toBe("Project Complete"); }); it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => { expect( - resolveDisplayStage(makePostDesignDeal({ - fullLodgementDate: new Date(), - measuresLodgementDate: new Date(), - lodgementStatus: "Submitted", - })) + resolveDisplayStage( + makeCoordinationCompleteDeal({ + fullLodgementDate: new Date(), + measuresLodgementDate: new Date(), + lodgementStatus: "Submitted", + }), + false + ) ).toBe("Project Complete"); }); it("measuresLodgementDate takes precedence over lodgementStatus", () => { expect( - resolveDisplayStage(makePostDesignDeal({ - measuresLodgementDate: new Date(), - lodgementStatus: "Submitted", - })) + resolveDisplayStage( + makeCoordinationCompleteDeal({ + measuresLodgementDate: new Date(), + lodgementStatus: "Submitted", + }), + false + ) ).toBe("At Post Survey"); }); + + it("does NOT return Installation in Progress for designStatus UPLOADED when the design document is absent (regression for the reported bug)", () => { + // The HubSpot designStatus flag can be set before — or without — the + // Retrofit Design Document ever being uploaded. It must no longer push a + // Property into Installation in Progress. + expect( + resolveDisplayStage(makeCoordinationCompleteDeal({ designStatus: "UPLOADED" }), false) + ).toBe("Design in Progress"); + }); +}); + +describe("resolveDisplayStage — design-document presence drives the Design/Installation boundary", () => { + const AFTER_ASSESSMENT_STAGE = "3948185842"; + + function makeCoordinationCompleteDeal(overrides: Partial = {}): HubspotDeal { + return makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(V1) IOE/MTP COMPLETE", + ...overrides, + }); + } + + it("returns Installation in Progress when the Retrofit Design Document is present and there is no downstream evidence", () => { + // Arrange + const deal = makeCoordinationCompleteDeal(); + + // Act + const stage = resolveDisplayStage(deal, true); + + // Assert + expect(stage).toBe("Installation in Progress"); + }); }); // ----------------------------------------------------------------------- @@ -402,6 +444,19 @@ describe("classifyDeals", () => { expect(result.dealId).toBe("abc-123"); expect(result.dealname).toBe("Test"); }); + + it("classifies a coordination-complete deal as Installation in Progress only when its id is in the design-document set", () => { + // Arrange + const withDoc = makeDeal({ dealId: "has-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }); + const withoutDoc = makeDeal({ dealId: "no-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }); + + // Act + const [a, b] = classifyDeals([withDoc, withoutDoc], new Set(["has-doc"])); + + // Assert + expect(a.displayStage).toBe("Installation in Progress"); + expect(b.displayStage).toBe("Design in Progress"); + }); }); // ----------------------------------------------------------------------- @@ -668,6 +723,25 @@ describe("computeLiveTrackerData", () => { expect(result.projects).toHaveLength(0); expect(result.totalDeals).toBe(0); }); + + it("reflects design-document presence in the project roll-up stage counts", () => { + // Arrange — two coordination-complete deals in one project, one with a + // Retrofit Design Document and one without. + const deals = [ + makeDeal({ projectCode: "PROJ-A", dealId: "has-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }), + makeDeal({ projectCode: "PROJ-A", dealId: "no-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }), + ]; + + // Act + const result = computeLiveTrackerData(deals, new Set(["has-doc"])); + + // Assert + const stageProgress = result.projects[0].progress.stageProgress; + const design = stageProgress.find((s) => s.stage === "Design in Progress")!; + const install = stageProgress.find((s) => s.stage === "Installation in Progress")!; + expect(design.count).toBe(1); + expect(install.count).toBe(1); + }); }); describe("hasMajorConditionIssue", () => { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index 8804a506..cf840911 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -70,14 +70,16 @@ const STAGE_ID_MAP: Record = { // ----------------------------------------------------------------------- // After-assessment sub-classification -// Resolves AFTER_ASSESSMENT deals based on coordinationStatus + designStatus +// Resolves AFTER_ASSESSMENT deals based on coordinationStatus alone. +// Whether coordination is complete is a CRM signal; what happens *after* +// coordination (design vs installation and beyond) is resolved separately +// by resolveAfterCoordinationStage, using document presence + downstream +// evidence rather than the self-reported designStatus flag. // ----------------------------------------------------------------------- function resolveAfterAssessmentStage( - coordinationStatus: string | null, - designStatus: string | null -): "Coordination in Progress" | "Design in Progress" | "POST_DESIGN" | "Queries" { + coordinationStatus: string | null +): "Coordination in Progress" | "AFTER_COORDINATION" | "Queries" { const coord = coordinationStatus?.toUpperCase() ?? ""; - const design = designStatus?.toUpperCase() ?? ""; // RA ISSUE always -> Queries if (coord === "RA ISSUE") return "Queries"; @@ -88,7 +90,7 @@ function resolveAfterAssessmentStage( coord.includes("(V2) IOE/MTP COMPLETE") || coord.includes("(V3) IOE/MTP COMPLETE") ) { - return design === "UPLOADED" ? "POST_DESIGN" : "Design in Progress"; + return "AFTER_COORDINATION"; } // Default for AFTER_ASSESSMENT @@ -96,22 +98,34 @@ function resolveAfterAssessmentStage( } // ----------------------------------------------------------------------- -// Post-design sub-classification -// Called when design is UPLOADED — resolves install / lodgement / completed +// After-coordination sub-classification +// Resolves a coordination-complete deal to design / installation / lodgement +// / completed. Downstream evidence (measures installed, installer handover, +// lodgement, completion) always wins; document presence only decides the +// Design-vs-Installation boundary. Gating the whole block on document +// presence would drag a genuinely installed/lodged Property back to Design +// in Progress when its design document is missing from the store — see +// ADR-0023. // ----------------------------------------------------------------------- -function resolvePostDesignStage(deal: HubspotDeal): DisplayStage { +function resolveAfterCoordinationStage( + deal: HubspotDeal, + hasDesignDoc: boolean +): DisplayStage { if (deal.fullLodgementDate) return "Project Complete"; if (deal.measuresLodgementDate) return "At Post Survey"; if (deal.lodgementStatus) return "At Lodgement"; if (deal.actualMeasuresInstalled || deal.installerHandover) return "Installation Complete"; - return "Installation in Progress"; + if (hasDesignDoc) return "Installation in Progress"; + return "Design in Progress"; } // ----------------------------------------------------------------------- // Resolve display stage for a single deal -// Maps dealstage ID + coordination/design/install status -> DisplayStage +// Maps dealstage ID + coordination/install status + Retrofit Design Document +// presence -> DisplayStage. Design-document presence is passed in explicitly +// (hasDesignDoc) so this function stays pure — it never reads the doc store. // ----------------------------------------------------------------------- -export function resolveDisplayStage(deal: HubspotDeal): DisplayStage { +export function resolveDisplayStage(deal: HubspotDeal, hasDesignDoc = false): DisplayStage { if (deal.batch === "Removed from Program") { return "Removed from Program"; } @@ -126,13 +140,10 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage { const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT"; if (raw === "AFTER_ASSESSMENT") { - const afterAssessment = resolveAfterAssessmentStage( - deal.coordinationStatus, - deal.designStatus - ); + const afterAssessment = resolveAfterAssessmentStage(deal.coordinationStatus); - if (afterAssessment === "POST_DESIGN") { - return resolvePostDesignStage(deal); + if (afterAssessment === "AFTER_COORDINATION") { + return resolveAfterCoordinationStage(deal, hasDesignDoc); } return afterAssessment; @@ -152,10 +163,13 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage { // Classify all deals in a list // Adds displayStage to each deal // ----------------------------------------------------------------------- -export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] { +export function classifyDeals( + deals: HubspotDeal[], + designDocDealIds: ReadonlySet = new Set() +): ClassifiedDeal[] { return deals.map((deal) => ({ ...deal, - displayStage: resolveDisplayStage(deal), + displayStage: resolveDisplayStage(deal, designDocDealIds.has(deal.dealId)), })); } @@ -280,10 +294,11 @@ export function hasMajorConditionIssue(deal: { // Orchestrates all transformations: classify, group by project, compute stats // ----------------------------------------------------------------------- export function computeLiveTrackerData( - rawDeals: HubspotDeal[] + rawDeals: HubspotDeal[], + designDocDealIds: ReadonlySet = new Set() ): Omit { // Classify all deals (add displayStage field) - const classified = classifyDeals(rawDeals); + const classified = classifyDeals(rawDeals, designDocDealIds); // Group deals by projectCode const grouped: Record = {};