Add special-order product flow for sales opportunities

This commit is contained in:
2026-03-04 00:11:40 -06:00
parent a048e1e824
commit d5c22c8eff
12 changed files with 457 additions and 114 deletions
@@ -0,0 +1,134 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { procurement } from "../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { z } from "zod";
const specialOrderItemSchema = z
.object({
desc: z.string().min(1),
customerDesc: z.string().min(1).optional(),
qty: z.number().positive().optional(),
price: z.number(),
cost: z.number().optional(),
taxable: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
const addSpecialOrderSchema = z.union([
specialOrderItemSchema,
z
.array(specialOrderItemSchema)
.min(1, "At least one special-order product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products/special-order */
export default createRoute(
"post",
["/opportunities/:identifier/products/special-order"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const validated = addSpecialOrderSchema.parse(body);
const inputItems = Array.isArray(validated) ? validated : [validated];
const specialOrderCatalogItem =
await procurement.fetchItem("SPECIAL ORDER");
const makeCustomField = (
caption: string,
value: string,
fieldId: number,
) => ({
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
});
const normalizedItems = inputItems.map((item) => ({
...(item.procurementNotes || item.productNarrative
? {
customFields: [
...(item.procurementNotes
? [
makeCustomField(
"Procurement Notes",
item.procurementNotes,
29,
),
]
: []),
...(item.productNarrative
? [
makeCustomField(
"Product Narrative",
item.productNarrative,
46,
),
]
: []),
],
}
: {}),
catalogItem: { id: specialOrderCatalogItem.cwCatalogId },
description: item.desc,
customerDescription: item.customerDesc,
quantity: item.qty ?? 1,
price: item.price,
cost: item.cost,
taxableFlag:
item.taxable ??
item.taxableFlag ??
specialOrderCatalogItem.salesTaxable,
dropshipFlag: false,
billableOption: "Billable",
}));
const opportunity = await opportunities.fetchRecord(identifier);
const created = await opportunity.addProcurementProducts(normalizedItems);
const serialized = created.map((item: any) => {
const fields = Array.isArray(item?.customFields) ? item.customFields : [];
const procurementNotes =
fields.find((f: any) => f?.id === 29)?.value ?? null;
const productNarrative =
fields.find((f: any) => f?.id === 46)?.value ?? null;
return {
id: item?.id ?? null,
forecastDetailId: item?.forecastDetailId ?? null,
description: item?.description ?? null,
productDescription: item?.description ?? null,
customerDescription: item?.customerDescription ?? null,
quantity: item?.quantity ?? null,
price: item?.price ?? null,
revenue: item?.price ?? null,
cost: item?.cost ?? null,
taxableFlag: item?.taxableFlag ?? null,
specialOrderFlag: item?.specialOrderFlag ?? null,
procurementNotes,
productNarrative,
};
});
const isBatch = Array.isArray(body);
const response = apiResponse.created(
isBatch
? `${created.length} special-order product(s) added successfully!`
: "Special-order product added successfully!",
isBatch ? serialized : serialized[0]!,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["sales.opportunity.product.add.specialOrder"],
}),
);
+20 -52
View File
@@ -24,7 +24,6 @@ export default createRoute(
"get",
["/opportunities/:identifier"],
async (c) => {
const t0 = performance.now();
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
const includes = new Set(
@@ -80,24 +79,14 @@ export default createRoute(
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _pw0 = performance.now();
const _wrapPw = (label: string, p: Promise<any>) =>
p
.then((r) => {
console.log(
`[PERF:prewarm] ${label}: ${(performance.now() - _pw0).toFixed(0)}ms`,
);
return r;
})
.catch(() => {});
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_wrapPw(
"site",
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
@@ -106,8 +95,7 @@ export default createRoute(
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_wrapPw(
"notes",
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
@@ -115,8 +103,7 @@ export default createRoute(
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_wrapPw(
"contacts",
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
@@ -124,8 +111,7 @@ export default createRoute(
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_wrapPw(
"products",
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
@@ -138,46 +124,25 @@ export default createRoute(
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
const t1 = performance.now();
console.log(`[PERF] fetchItem + prewarm: ${(t1 - t0).toFixed(0)}ms`);
// Sub-resources now hit warm Redis cache (near-instant)
const _st = performance.now();
const _wrapTimed = (label: string, p: Promise<any>) =>
p.then((r) => {
console.log(
`[PERF:sub] ${label}: ${(performance.now() - _st).toFixed(0)}ms`,
);
return r;
});
const subResourcePromises: Record<string, Promise<any>> = {
_site: _wrapTimed("site", item.fetchSite()),
_site: item.fetchSite(),
};
if (includes.has("notes")) {
subResourcePromises.notes = _wrapTimed("notes", item.fetchNotes());
subResourcePromises.notes = item.fetchNotes();
}
if (includes.has("contacts")) {
subResourcePromises.contacts = _wrapTimed(
"contacts",
item.fetchContacts(),
);
subResourcePromises.contacts = item.fetchContacts();
}
if (includes.has("products")) {
subResourcePromises.products = _wrapTimed(
"products",
item
.fetchProducts()
.then((products) => products.map((p) => p.toJson())),
);
subResourcePromises.products = item
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
}
const keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
const t2 = performance.now();
console.log(
`[PERF] sub-resources (${keys.join(",")}): ${(t2 - t1).toFixed(0)}ms`,
);
// Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms(
@@ -185,8 +150,8 @@ export default createRoute(
"obj.opportunity",
c.get("user"),
);
const t3 = performance.now();
console.log(`[PERF] processObjectValuePerms: ${(t3 - t2).toFixed(0)}ms`);
const originalOpportunityNoteText = (gatedData as any).notes;
// Attach sub-resources (skip the internal _site key)
keys.forEach((k, i) => {
@@ -195,14 +160,17 @@ export default createRoute(
}
});
if (includes.has("notes")) {
(gatedData as any).opportunityNoteText =
typeof originalOpportunityNoteText === "string"
? originalOpportunityNoteText
: null;
}
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
);
console.log(
`[PERF] total handler: ${(performance.now() - t0).toFixed(0)}ms (includes=${includeParam || "none"})`,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
+2
View File
@@ -5,6 +5,7 @@ import { default as fetch } from "./[id]/fetch";
import { default as refresh } from "./[id]/refresh";
import { default as products } from "./[id]/products";
import { default as addProduct } from "./[id]/addProduct";
import { default as addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct";
import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote";
@@ -15,6 +16,7 @@ import { default as contacts } from "./[id]/contacts";
export {
addProduct,
addSpecialOrderProduct,
count,
fetch,
fetchAll,
+60 -3
View File
@@ -14,6 +14,8 @@ import {
CWForecastItemCreate,
CWOpportunity,
CWOpportunityNote,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types";
import {
resolveMember,
@@ -547,15 +549,25 @@ export class OpportunityController {
// Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) {
const forecastDetailId = pp.forecastDetailId as number | undefined;
if (forecastDetailId) {
const rawForecastDetailId = (pp as any)?.forecastDetailId;
const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
cancellationMap.set(forecastDetailId, pp);
}
}
// Procurement-only view: only include forecast items that have a
// matching procurement record (via forecastDetailId).
const forecastItems = (forecast.forecastItems ?? []).filter((fi: any) =>
cancellationMap.has(fi.id),
);
// Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber.
const forecastItems = forecast.forecastItems ?? [];
let ordered: typeof forecastItems;
if (this.productSequence.length > 0) {
@@ -816,6 +828,51 @@ export class OpportunityController {
}
}
/**
* Add Procurement Products
*
* Creates one or more procurement products linked to this opportunity.
* Use this when payloads include procurement-only fields such as customFields.
*/
public async addProcurementProducts(
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> {
try {
const items = Array.isArray(data) ? data : [data];
const normalized = items.map((item) => ({
...item,
opportunity: { id: this.cwOpportunityId },
}));
const created = await opportunityCw.createProcurementProducts(normalized);
await invalidateProductsCache(this.cwOpportunityId);
return created;
} catch (err: any) {
console.error(
`[addProcurementProducts] Failed to create procurement product(s) on opportunity ${this.cwOpportunityId}`,
JSON.stringify(
{
data,
status: err?.response?.status,
statusText: err?.response?.statusText,
responseData: err?.response?.data,
message: err?.message,
},
null,
2,
),
);
throw new GenericError({
status: err?.response?.status ?? 500,
name: "AddProcurementProductFailed",
message:
err?.response?.data?.message ??
"Failed to add procurement product(s) to opportunity",
cause: err?.message,
});
}
}
/**
* Add Note
*
+10 -7
View File
@@ -149,13 +149,16 @@ setInterval(() => {
// NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above.
safeStartup("refreshOpportunityCache", refreshOpportunityCache);
setInterval(() => {
return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
}, 20 * 60 * 1000);
setInterval(
() => {
return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
},
20 * 60 * 1000,
);
// Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
-31
View File
@@ -42,7 +42,6 @@ async function buildCompanyController(
ttlMs?: number;
},
): Promise<CompanyController> {
const _ct0 = performance.now();
const strategy = opts?.strategy ?? "cache-then-cw";
const ctrl = new CompanyController(company);
@@ -82,10 +81,6 @@ async function buildCompanyController(
} else {
await ctrl.hydrateCwData();
}
console.log(
`[PERF:buildCompany] ${(performance.now() - _ct0).toFixed(0)}ms (strategy=${strategy}, hit=miss)`,
);
return ctrl;
}
@@ -105,7 +100,6 @@ async function buildActivities(
ttlMs?: number;
},
): Promise<ActivityController[]> {
const _at0 = performance.now();
const strategy = opts?.strategy ?? "cache-then-cw";
// ── cw-first: always fetch from CW (and cache the result) ──────────
@@ -129,9 +123,6 @@ async function buildActivities(
const arr = opts?.ttlMs
? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
: await activityCw.fetchByOpportunityDirect(cwOpportunityId);
console.log(
`[PERF:buildActivities] ${(performance.now() - _at0).toFixed(0)}ms (strategy=${strategy}, hit=miss, count=${arr.length})`,
);
return arr.map((item) => new ActivityController(item));
}
@@ -202,7 +193,6 @@ export const opportunities = {
identifier: string | number,
opts?: { fresh?: boolean },
): Promise<OpportunityController> {
const _t0 = performance.now();
const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh
? "cw-first"
: "cache-then-cw";
@@ -216,8 +206,6 @@ export const opportunities = {
: { id: identifier as string },
include: { company: true },
});
const _t1 = performance.now();
console.log(`[PERF:fetchItem] DB lookup: ${(_t1 - _t0).toFixed(0)}ms`);
if (!existing) {
throw new GenericError({
@@ -245,10 +233,6 @@ export const opportunities = {
// Try the Redis cache first
cwData = await getCachedOppCwData(existing.cwOpportunityId);
}
const _t2 = performance.now();
console.log(
`[PERF:fetchItem] Redis cache check: ${(_t2 - _t1).toFixed(0)}ms (hit=${!!cwData})`,
);
// ── Parallel block: CW opp fetch + activities + company ────────────
// Activities and company hydration only need existing.cwOpportunityId
@@ -261,10 +245,6 @@ export const opportunities = {
cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId);
const _t2b = performance.now();
console.log(
`[PERF:fetchItem] CW opp fetch: ${(_t2b - _t2).toFixed(0)}ms`,
);
if (!cwData) {
throw new GenericError({
@@ -291,12 +271,8 @@ export const opportunities = {
data: { ...mapped, companyId },
include: { company: true },
});
console.log(
`[PERF:fetchItem] DB update: ${(performance.now() - _t2b).toFixed(0)}ms`,
);
})();
const _t3 = performance.now();
// Hydrate activities and company in parallel with CW opp fetch
const [, activities, company] = await Promise.all([
cwOppPromise,
@@ -305,13 +281,6 @@ export const opportunities = {
? buildCompanyController(existing.company, { strategy, ttlMs })
: Promise.resolve(undefined),
]);
const _t4 = performance.now();
console.log(
`[PERF:fetchItem] parallel block (cw+activities+company): ${(_t4 - _t3).toFixed(0)}ms`,
);
console.log(
`[PERF:fetchItem] TOTAL: ${(_t4 - _t0).toFixed(0)}ms (strategy=${strategy}, ttl=${ttlMs}ms)`,
);
return new OpportunityController(record, {
company,
+67 -5
View File
@@ -2,9 +2,11 @@ import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError";
import {
CATEGORY_TREE,
getSubcategoriesForCategory,
getSubcategoriesForGroup,
ECOSYSTEM_TREE,
isCategoryGroup,
} from "../modules/catalog-categories/catalogCategories";
/**
@@ -36,22 +38,82 @@ export interface CatalogFilterOpts {
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = [];
const parseNumericId = (value?: string): number | null => {
if (!value) return null;
if (!/^\d+$/.test(value)) return null;
return Number(value);
};
const resolveCategoryNameById = (cwId: number): string | null => {
return CATEGORY_TREE.find((c) => c.cwId === cwId)?.name ?? null;
};
const resolveSubcategoryNameById = (cwId: number): string | null => {
for (const category of CATEGORY_TREE) {
for (const entry of category.entries) {
if (isCategoryGroup(entry)) {
const child = entry.children.find((c) => c.cwId === cwId);
if (child) return child.name;
continue;
}
if (entry.cwId === cwId) return entry.name;
}
}
return null;
};
const categoryId = parseNumericId(opts.category);
const subcategoryId = parseNumericId(opts.subcategory);
const resolvedCategoryName = categoryId
? resolveCategoryNameById(categoryId)
: opts.category;
if (!opts.includeInactive) {
conditions.push({ inactive: false });
}
if (opts.category) {
conditions.push({ category: opts.category });
if (categoryId) {
const categoryOr: Record<string, unknown>[] = [
{ categoryCwId: categoryId },
];
if (resolvedCategoryName) {
categoryOr.push({ category: resolvedCategoryName });
}
conditions.push({ OR: categoryOr });
} else {
conditions.push({ category: opts.category });
}
}
if (opts.subcategory) {
conditions.push({ subcategory: opts.subcategory });
if (subcategoryId) {
const resolvedSubcategoryName = resolveSubcategoryNameById(subcategoryId);
const subcategoryOr: Record<string, unknown>[] = [
{ subcategoryCwId: subcategoryId },
];
if (resolvedSubcategoryName) {
subcategoryOr.push({ subcategory: resolvedSubcategoryName });
}
conditions.push({ OR: subcategoryOr });
} else {
conditions.push({ subcategory: opts.subcategory });
}
}
if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
if (!resolvedCategoryName) {
conditions.push({ category: "__unknown_category__" });
}
if (resolvedCategoryName) {
const subcats = getSubcategoriesForGroup(
resolvedCategoryName,
opts.group,
);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
}
} else if (opts.group && !opts.category) {
// Try to find the group in any category
@@ -6,6 +6,8 @@ import {
CWForecast,
CWForecastItem,
CWForecastItemCreate,
CWProcurementProduct,
CWProcurementProductCreate,
CWOpportunityNote,
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
@@ -356,4 +358,27 @@ export const opportunityCw = {
);
return response.data;
},
/**
* Create Procurement Products
*
* Creates one or more procurement products linked to an opportunity.
* This endpoint supports procurement customFields (unlike forecast items).
*/
createProcurementProducts: async (
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> => {
const productsToCreate = Array.isArray(data) ? data : [data];
const created: CWProcurementProduct[] = [];
for (const product of productsToCreate) {
const response = await connectWiseApi.post(
`/procurement/products`,
product,
);
created.push(response.data as CWProcurementProduct);
}
return created;
},
};
@@ -113,6 +113,7 @@ export interface CWForecastItem {
_info?: Record<string, string>;
};
productDescription: string;
customerDescription?: string;
productClass: string;
revenue: number;
cost: number;
@@ -129,6 +130,7 @@ export interface CWForecastItem {
sequenceNumber: number;
subNumber: number;
taxableFlag: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>;
}
@@ -210,6 +212,7 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number };
forecastDescription?: string;
productDescription?: string;
customerDescription?: string;
quantity?: number;
status?: { id: number };
productClass?: string;
@@ -224,6 +227,39 @@ export interface CWForecastItemCreate {
recurringCost?: number;
cycles?: number;
sequenceNumber?: number;
customFields?: Array<
Partial<Omit<CWCustomField, "connectWiseId" | "rowNum" | "podId">>
>;
}
export interface CWProcurementProductCreate {
opportunity?: { id: number };
catalogItem: { id: number };
description: string;
customerDescription?: string;
quantity?: number;
price?: number;
cost?: number;
taxableFlag?: boolean;
dropshipFlag?: boolean;
billableOption?: string;
customFields?: Array<
Partial<Omit<CWCustomField, "connectWiseId" | "rowNum" | "podId">>
>;
}
export interface CWProcurementProduct {
id: number;
forecastDetailId?: number;
description?: string;
customerDescription?: string;
quantity?: number;
price?: number;
cost?: number;
taxableFlag?: boolean;
specialOrderFlag?: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>;
}
export interface CWOpportunitySummary {
+7
View File
@@ -473,6 +473,13 @@ export const PERMISSION_NODES = {
"sales.opportunity.product.field.sequenceNumber",
],
},
{
node: "sales.opportunity.product.add.specialOrder",
description:
'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.',
usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"],
dependencies: ["sales.opportunity.fetch"],
},
],
},