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
This commit is contained in:
2026-03-09 17:48:47 -05:00
parent ee3e0a7377
commit 5afda8cb34
5 changed files with 67 additions and 13 deletions
+12 -10
View File
@@ -3875,19 +3875,21 @@ At least one field is required.
"unitCost": 62.5, "unitCost": 62.5,
"customerDescription": "Onsite labor for rack install", "customerDescription": "Onsite labor for rack install",
"productNarrative": "Install, cable, and validate cutover", "productNarrative": "Install, cable, and validate cutover",
"procurementNotes": "Coordinate site contact before arrival" "procurementNotes": "Coordinate site contact before arrival",
"taxableFlag": true
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
| --------------------- | ------ | ---------------------------------------------------------- | | --------------------- | ------- | ---------------------------------------------------------- |
| `productDescription` | string | Product description | | `productDescription` | string | Product description |
| `quantity` | number | Quantity | | `quantity` | number | Quantity |
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) | | `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) | | `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) |
| `customerDescription` | string | Customer-facing description | | `customerDescription` | string | Customer-facing description |
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) | | `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) | | `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
| `taxableFlag` | boolean | Whether this item is taxable (forecast field) |
**Response:** **Response:**
@@ -18,6 +18,7 @@ const updateProductSchema = z
customerDescription: z.string().nullable().optional(), customerDescription: z.string().nullable().optional(),
productNarrative: z.string().nullable().optional(), productNarrative: z.string().nullable().optional(),
procurementNotes: z.string().nullable().optional(), procurementNotes: z.string().nullable().optional(),
taxableFlag: z.boolean().optional(),
}) })
.strict() .strict()
.refine( .refine(
@@ -108,6 +109,9 @@ export default createRoute(
(input.unitCost * effectiveQuantity).toFixed(2), (input.unitCost * effectiveQuantity).toFixed(2),
); );
} }
if (input.taxableFlag !== undefined) {
forecastPatch.taxableFlag = input.taxableFlag;
}
const existingProcurement = const existingProcurement =
await opportunity.fetchProcurementProductByForecastItem(productId); await opportunity.fetchProcurementProductByForecastItem(productId);
+18 -2
View File
@@ -718,11 +718,20 @@ export class OpportunityController {
await this._hydrateCustomFields(); await this._hydrateCustomFields();
const quoteNarrative = options.includeQuoteNarrative const quoteNarrativeField = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() || ? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined undefined
: 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); console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company"; const companyLine = this.companyName ?? company?.name ?? "Customer Company";
@@ -818,11 +827,18 @@ export class OpportunityController {
const salesRep = await this._resolveSalesRep(); const salesRep = await this._resolveSalesRep();
await this._hydrateCustomFields(); await this._hydrateCustomFields();
const quoteNarrative = quoteOptions.includeQuoteNarrative const quoteNarrativeField = quoteOptions.includeQuoteNarrative
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ?? ? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
null) null)
: 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 ─────────────────── // ── Pre-generate IDs & timestamps for metadata ───────────────────
const quoteId = crypto.randomUUID(); const quoteId = crypto.randomUUID();
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
@@ -2,6 +2,7 @@ import { prisma } from "../../../constants";
import { events } from "../../globalEvents"; import { events } from "../../globalEvents";
import { opportunityCw } from "./opportunities"; import { opportunityCw } from "./opportunities";
import { OpportunityController } from "../../../controllers/OpportunityController"; import { OpportunityController } from "../../../controllers/OpportunityController";
import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache";
/** /**
* Refresh Opportunities * Refresh Opportunities
@@ -21,7 +22,7 @@ export const refreshOpportunities = async () => {
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated // 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
const dbItems = await prisma.opportunity.findMany({ const dbItems = await prisma.opportunity.findMany({
select: { cwOpportunityId: true, cwLastUpdated: true }, select: { id: true, cwOpportunityId: true, cwLastUpdated: true },
}); });
const dbMap = new Map( const dbMap = new Map(
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]), 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) { if (staleIds.length === 0) {
events.emit("cw:opportunities:refresh:skipped", { events.emit("cw:opportunities:refresh:skipped", {
totalCw: cwSummaries.size, totalCw: cwSummaries.size,
totalDb: dbItems.length, totalDb: dbItems.length,
staleCount: 0, staleCount: 0,
orphanedCount: orphanedItems.length,
}); });
return; return;
} }
@@ -106,5 +131,6 @@ export const refreshOpportunities = async () => {
totalDb: dbItems.length, totalDb: dbItems.length,
staleCount: staleIds.length, staleCount: staleIds.length,
itemsUpdated: updatedCount, itemsUpdated: updatedCount,
orphanedCount: orphanedItems.length,
}); });
}; };
+6
View File
@@ -171,11 +171,17 @@ interface EventTypes {
totalDb: number; totalDb: number;
staleCount: number; staleCount: number;
itemsUpdated: number; itemsUpdated: number;
orphanedCount: number;
}) => void;
"cw:opportunities:refresh:reconciled": (data: {
orphanedCount: number;
removedCwIds: number[];
}) => void; }) => void;
"cw:opportunities:refresh:skipped": (data: { "cw:opportunities:refresh:skipped": (data: {
totalCw: number; totalCw: number;
totalDb: number; totalDb: number;
staleCount: number; staleCount: number;
orphanedCount: number;
}) => void; }) => void;
// Cache Events // Cache Events