From 5afda8cb3452ca0a7a2e957c4d0879e307ac45a2 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Mon, 9 Mar 2026 17:48:47 -0500 Subject: [PATCH] Add taxableFlag to product updates, QUO-Narrative quote fallback, and orphan reconciliation - Add taxableFlag boolean field to product update schema and forecast patch - Fall back to QUO-Narrative product customerDescription for quote narrative - Reconcile orphaned local opportunity records not found in CW during refresh - Invalidate caches for removed orphaned opportunities - Add reconciled event and orphanedCount to refresh events - Update API_ROUTES.md with taxableFlag field documentation --- API_ROUTES.md | 22 ++++++++------- .../opportunities/[id]/products/update.ts | 4 +++ src/controllers/OpportunityController.ts | 20 +++++++++++-- .../opportunities/refreshOpportunities.ts | 28 ++++++++++++++++++- src/modules/globalEvents.ts | 6 ++++ 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/API_ROUTES.md b/API_ROUTES.md index f978208..b9615eb 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -3875,19 +3875,21 @@ At least one field is required. "unitCost": 62.5, "customerDescription": "Onsite labor for rack install", "productNarrative": "Install, cable, and validate cutover", - "procurementNotes": "Coordinate site contact before arrival" + "procurementNotes": "Coordinate site contact before arrival", + "taxableFlag": true } ``` -| Field | Type | Description | -| --------------------- | ------ | ---------------------------------------------------------- | -| `productDescription` | string | Product description | -| `quantity` | number | Quantity | -| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) | -| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) | -| `customerDescription` | string | Customer-facing description | -| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) | -| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) | +| Field | Type | Description | +| --------------------- | ------- | ---------------------------------------------------------- | +| `productDescription` | string | Product description | +| `quantity` | number | Quantity | +| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) | +| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) | +| `customerDescription` | string | Customer-facing description | +| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) | +| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) | +| `taxableFlag` | boolean | Whether this item is taxable (forecast field) | **Response:** diff --git a/src/api/sales/opportunities/[id]/products/update.ts b/src/api/sales/opportunities/[id]/products/update.ts index 1cd5e69..8f4582d 100644 --- a/src/api/sales/opportunities/[id]/products/update.ts +++ b/src/api/sales/opportunities/[id]/products/update.ts @@ -18,6 +18,7 @@ const updateProductSchema = z customerDescription: z.string().nullable().optional(), productNarrative: z.string().nullable().optional(), procurementNotes: z.string().nullable().optional(), + taxableFlag: z.boolean().optional(), }) .strict() .refine( @@ -108,6 +109,9 @@ export default createRoute( (input.unitCost * effectiveQuantity).toFixed(2), ); } + if (input.taxableFlag !== undefined) { + forecastPatch.taxableFlag = input.taxableFlag; + } const existingProcurement = await opportunity.fetchProcurementProductByForecastItem(productId); diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts index 1616048..43f79ae 100644 --- a/src/controllers/OpportunityController.ts +++ b/src/controllers/OpportunityController.ts @@ -718,11 +718,20 @@ export class OpportunityController { await this._hydrateCustomFields(); - const quoteNarrative = options.includeQuoteNarrative + const quoteNarrativeField = options.includeQuoteNarrative ? this._customFields?.find((f) => f.id === 35)?.value?.toString() || undefined : undefined; + // Fall back to the customerDescription of a QUO-Narrative product + const quoNarrativeProduct = !quoteNarrativeField + ? activeProducts.find((p) => p.catalogItemIdentifier === "QUO-Narrative") + : undefined; + const quoteNarrative = + quoteNarrativeField ?? + quoNarrativeProduct?.customerDescription ?? + undefined; + console.log("[generateQuote] quoteNarrative:", quoteNarrative); const companyLine = this.companyName ?? company?.name ?? "Customer Company"; @@ -818,11 +827,18 @@ export class OpportunityController { const salesRep = await this._resolveSalesRep(); await this._hydrateCustomFields(); - const quoteNarrative = quoteOptions.includeQuoteNarrative + const quoteNarrativeField = quoteOptions.includeQuoteNarrative ? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ?? null) : null; + // Fall back to the customerDescription of a QUO-Narrative product + const quoNarrativeProduct = !quoteNarrativeField + ? products.find((p) => p.catalogItemIdentifier === "QUO-Narrative") + : undefined; + const quoteNarrative = + quoteNarrativeField ?? quoNarrativeProduct?.customerDescription ?? null; + // ── Pre-generate IDs & timestamps for metadata ─────────────────── const quoteId = crypto.randomUUID(); const createdAt = new Date().toISOString(); diff --git a/src/modules/cw-utils/opportunities/refreshOpportunities.ts b/src/modules/cw-utils/opportunities/refreshOpportunities.ts index 9ad383a..655b568 100644 --- a/src/modules/cw-utils/opportunities/refreshOpportunities.ts +++ b/src/modules/cw-utils/opportunities/refreshOpportunities.ts @@ -2,6 +2,7 @@ import { prisma } from "../../../constants"; import { events } from "../../globalEvents"; import { opportunityCw } from "./opportunities"; import { OpportunityController } from "../../../controllers/OpportunityController"; +import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache"; /** * Refresh Opportunities @@ -21,7 +22,7 @@ export const refreshOpportunities = async () => { // 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated const dbItems = await prisma.opportunity.findMany({ - select: { cwOpportunityId: true, cwLastUpdated: true }, + select: { id: true, cwOpportunityId: true, cwLastUpdated: true }, }); const dbMap = new Map( dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]), @@ -41,11 +42,35 @@ export const refreshOpportunities = async () => { } } + // 3b. Reconcile — find local records that no longer exist in CW + const orphanedItems = dbItems.filter( + (item) => !cwSummaries.has(item.cwOpportunityId), + ); + + if (orphanedItems.length > 0) { + console.log( + `[refreshOpportunities] Reconciling ${orphanedItems.length} orphaned local record(s) not found in CW`, + ); + + await Promise.all( + orphanedItems.map(async (item) => { + await prisma.opportunity.delete({ where: { id: item.id } }); + await invalidateAllOpportunityCaches(item.cwOpportunityId); + }), + ); + + events.emit("cw:opportunities:refresh:reconciled", { + orphanedCount: orphanedItems.length, + removedCwIds: orphanedItems.map((i) => i.cwOpportunityId), + }); + } + if (staleIds.length === 0) { events.emit("cw:opportunities:refresh:skipped", { totalCw: cwSummaries.size, totalDb: dbItems.length, staleCount: 0, + orphanedCount: orphanedItems.length, }); return; } @@ -106,5 +131,6 @@ export const refreshOpportunities = async () => { totalDb: dbItems.length, staleCount: staleIds.length, itemsUpdated: updatedCount, + orphanedCount: orphanedItems.length, }); }; diff --git a/src/modules/globalEvents.ts b/src/modules/globalEvents.ts index 0bb7a54..e07f94a 100644 --- a/src/modules/globalEvents.ts +++ b/src/modules/globalEvents.ts @@ -171,11 +171,17 @@ interface EventTypes { totalDb: number; staleCount: number; itemsUpdated: number; + orphanedCount: number; + }) => void; + "cw:opportunities:refresh:reconciled": (data: { + orphanedCount: number; + removedCwIds: number[]; }) => void; "cw:opportunities:refresh:skipped": (data: { totalCw: number; totalDb: number; staleCount: number; + orphanedCount: number; }) => void; // Cache Events