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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user