Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b34d08a7 | |||
| 5afda8cb34 |
+4
-2
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user