mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Merge pull request #461 from Hestia-Homes/issue-460-design-doc-gates-installation
fix(live-projects): gate Installation in Progress on Retrofit Design Document presence (#460)
This commit is contained in:
commit
4859d7df55
7 changed files with 336 additions and 62 deletions
|
|
@ -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.
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<string, DocEntry[]>,
|
||||
): Set<string> {
|
||||
const designDocDealIds = new Set<string>();
|
||||
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<string, Array<{ fileType: string; measureName: string | null }>>,
|
||||
|
|
@ -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) ?? [];
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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> = {}): 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> = {}): 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", () => {
|
||||
|
|
|
|||
|
|
@ -70,14 +70,16 @@ const STAGE_ID_MAP: Record<string, string> = {
|
|||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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<string> = 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<string> = new Set()
|
||||
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "instructedMeasuresByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
|
||||
// Classify all deals (add displayStage field)
|
||||
const classified = classifyDeals(rawDeals);
|
||||
const classified = classifyDeals(rawDeals, designDocDealIds);
|
||||
|
||||
// Group deals by projectCode
|
||||
const grouped: Record<string, ClassifiedDeal[]> = {};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue