Compare commits

...

2 Commits

Author SHA1 Message Date
HoloPanio 33b34d08a7 Add migration for CwMember table
Creates the CwMember table migration that was missing from the migration history
(previously applied locally via db push but never migrated for production).
2026-03-09 17:59:17 -05:00
HoloPanio 5afda8cb34 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
2026-03-09 17:48:47 -05:00
6 changed files with 89 additions and 13 deletions
+4 -2
View File
@@ -3875,12 +3875,13 @@ 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) |
@@ -3888,6 +3889,7 @@ At least one field is required.
| `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:**
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "CwMember" (
"id" TEXT NOT NULL,
"cwMemberId" INTEGER NOT NULL,
"identifier" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"officeEmail" TEXT,
"inactiveFlag" BOOLEAN NOT NULL DEFAULT false,
"apiKey" TEXT,
"cwLastUpdated" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CwMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CwMember_cwMemberId_key" ON "CwMember"("cwMemberId");
-- CreateIndex
CREATE UNIQUE INDEX "CwMember_identifier_key" ON "CwMember"("identifier");
@@ -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