feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features: - ActivityController and manager for CW sales activities (CRUD) - ForecastProductController for opportunity forecast/product lines - CW member cache with dual-layer (in-memory + Redis) resolution - Catalog category/subcategory/ecosystem taxonomy module - Quote statuses type definitions with CW mapping - User-defined fields (UDF) module with cache and event refresh - Company sites CW module with serialization - Procurement manager filters (category, ecosystem, manufacturer, price, stock) - Opportunity notes CRUD and product line management via CW API - Opportunity type definitions endpoint Updates: - OpportunityController: CW refresh, company hydration, activities, custom fields - UserController: cwIdentifier field for CW member linking - CatalogItemController: category/subcategory fields from CW - PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions - API routes: procurement categories/filters, sales notes/products, opportunity types - Global events: UDF and member refresh intervals on startup Tests (414 passing): - ActivityController, ForecastProductController, OpportunityController unit tests - UserController cwIdentifier tests - catalogCategories, companySites, memberCache, procurement module tests - activityTypes, opportunityTypes, quoteStatuses type tests - permissionNodes subCategories and getAllPermissionNodes tests - Updated test setup with redis mock, API method mocks, and builder helpers
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* One-time backfill script to populate category/subcategory fields
|
||||
* on existing CatalogItem records from ConnectWise data.
|
||||
*
|
||||
* Usage: bun utils/backfillCatalogCategories.ts
|
||||
*/
|
||||
import { prisma, connectWiseApi } from "../src/constants";
|
||||
|
||||
interface CWReference {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CatalogItemPartial {
|
||||
id: number;
|
||||
category: CWReference;
|
||||
subcategory: CWReference;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. Find all DB items that are missing category data
|
||||
const dbItems = await prisma.catalogItem.findMany({
|
||||
where: { category: null },
|
||||
select: { cwCatalogId: true, id: true },
|
||||
});
|
||||
|
||||
if (dbItems.length === 0) {
|
||||
console.log("All catalog items already have category data. Nothing to do.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${dbItems.length} catalog items missing category data.`);
|
||||
|
||||
// 2. Fetch all items from CW with category/subcategory fields
|
||||
const pageSize = 1000;
|
||||
const countRes = await connectWiseApi.get("/procurement/catalog/count");
|
||||
const totalCount = countRes.data.count;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const cwMap = new Map<
|
||||
number,
|
||||
{ category: CWReference; subcategory: CWReference }
|
||||
>();
|
||||
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const res = await connectWiseApi.get(
|
||||
`/procurement/catalog?page=${page}&pageSize=${pageSize}&fields=id,category,subcategory`,
|
||||
);
|
||||
const items: CatalogItemPartial[] = res.data;
|
||||
for (const item of items) {
|
||||
cwMap.set(item.id, {
|
||||
category: item.category,
|
||||
subcategory: item.subcategory,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Fetched ${cwMap.size} items from CW. Updating DB...`);
|
||||
|
||||
// 3. Batch update
|
||||
let updated = 0;
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < dbItems.length; i += batchSize) {
|
||||
const batch = dbItems.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (dbItem) => {
|
||||
const cwData = cwMap.get(dbItem.cwCatalogId);
|
||||
if (!cwData) return;
|
||||
|
||||
await prisma.catalogItem.update({
|
||||
where: { id: dbItem.id },
|
||||
data: {
|
||||
category: cwData.category?.name ?? null,
|
||||
categoryCwId: cwData.category?.id ?? null,
|
||||
subcategory: cwData.subcategory?.name ?? null,
|
||||
subcategoryCwId: cwData.subcategory?.id ?? null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
` Updated ${Math.min(i + batchSize, dbItems.length)}/${dbItems.length}...`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Done. Updated ${updated} catalog items with category data.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Quick utility to fetch all distinct categories, subcategories, and manufacturers
|
||||
* from the ConnectWise catalog and print them for reference.
|
||||
*/
|
||||
import { connectWiseApi } from "../src/constants";
|
||||
|
||||
interface CWReference {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CatalogItem {
|
||||
id: number;
|
||||
identifier: string;
|
||||
description: string;
|
||||
category: CWReference;
|
||||
subcategory: CWReference;
|
||||
manufacturer: CWReference;
|
||||
inactiveFlag: boolean;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pageSize = 1000;
|
||||
|
||||
// Get total count
|
||||
const countRes = await connectWiseApi.get("/procurement/catalog/count");
|
||||
const totalCount = countRes.data.count;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
console.log(`Total catalog items: ${totalCount}`);
|
||||
|
||||
const categories = new Map<number, string>();
|
||||
const subcategories = new Map<
|
||||
number,
|
||||
{ name: string; categoryId: number; categoryName: string }
|
||||
>();
|
||||
const manufacturers = new Map<number, string>();
|
||||
const catSubcatPairs = new Map<
|
||||
string,
|
||||
{ category: string; subcategory: string; count: number }
|
||||
>();
|
||||
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const res = await connectWiseApi.get(
|
||||
`/procurement/catalog?page=${page}&pageSize=${pageSize}&fields=id,identifier,description,category,subcategory,manufacturer,inactiveFlag`,
|
||||
);
|
||||
const items: CatalogItem[] = res.data;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.category) {
|
||||
categories.set(item.category.id, item.category.name);
|
||||
}
|
||||
if (item.subcategory) {
|
||||
subcategories.set(item.subcategory.id, {
|
||||
name: item.subcategory.name,
|
||||
categoryId: item.category?.id,
|
||||
categoryName: item.category?.name,
|
||||
});
|
||||
}
|
||||
if (item.manufacturer) {
|
||||
manufacturers.set(item.manufacturer.id, item.manufacturer.name);
|
||||
}
|
||||
|
||||
const key = `${item.category?.name ?? "None"}::${item.subcategory?.name ?? "None"}`;
|
||||
const existing = catSubcatPairs.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
catSubcatPairs.set(key, {
|
||||
category: item.category?.name ?? "None",
|
||||
subcategory: item.subcategory?.name ?? "None",
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== CATEGORIES ===");
|
||||
const sortedCats = [...categories.entries()].sort((a, b) =>
|
||||
a[1].localeCompare(b[1]),
|
||||
);
|
||||
for (const [id, name] of sortedCats) {
|
||||
console.log(` [${id}] ${name}`);
|
||||
}
|
||||
|
||||
console.log("\n=== SUBCATEGORIES (grouped by category) ===");
|
||||
const groupedSubs = new Map<string, { id: number; name: string }[]>();
|
||||
for (const [id, sub] of subcategories) {
|
||||
const catName = sub.categoryName ?? "None";
|
||||
if (!groupedSubs.has(catName)) groupedSubs.set(catName, []);
|
||||
groupedSubs.get(catName)!.push({ id, name: sub.name });
|
||||
}
|
||||
for (const [catName, subs] of [...groupedSubs.entries()].sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
)) {
|
||||
console.log(`\n ${catName}:`);
|
||||
for (const sub of subs.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
console.log(` [${sub.id}] ${sub.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== MANUFACTURERS ===");
|
||||
const sortedMfgs = [...manufacturers.entries()].sort((a, b) =>
|
||||
a[1].localeCompare(b[1]),
|
||||
);
|
||||
for (const [id, name] of sortedMfgs) {
|
||||
console.log(` [${id}] ${name}`);
|
||||
}
|
||||
|
||||
console.log("\n=== CATEGORY → SUBCATEGORY PAIRS (with item counts) ===");
|
||||
const sortedPairs = [...catSubcatPairs.values()].sort(
|
||||
(a, b) =>
|
||||
a.category.localeCompare(b.category) ||
|
||||
a.subcategory.localeCompare(b.subcategory),
|
||||
);
|
||||
for (const pair of sortedPairs) {
|
||||
console.log(
|
||||
` ${pair.category} → ${pair.subcategory} (${pair.count} items)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user