diff --git a/API_ROUTES.md b/API_ROUTES.md index 33565bf..f4318e6 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -2326,8 +2326,8 @@ Fetch a paginated list of catalog items. Supports search. - `rpp` (optional, default `30`) — Records per page - `search` (optional) — Search by name, description, part number, vendor SKU, or manufacturer - `includeInactive` (optional, default `false`) — Include inactive catalog items in results -- `category` (optional) — Filter by CW category name (e.g. `Technology`, `Field`, `General`) -- `subcategory` (optional) — Filter by CW subcategory name (e.g. `Network-Switch`, `AlarmBurg-Panels`) +- `category` (optional) — Filter by CW category **name or CW ID** (e.g. `Technology` or `18`) +- `subcategory` (optional) — Filter by CW subcategory **name or CW ID** (e.g. `Network-Switch` or `112`) - `group` (optional) — Filter by umbrella group name (e.g. `Network`, `AlarmBurg`, `Cables`). When used with `category`, returns items whose subcategory belongs to that group within the category. - `manufacturer` (optional) — Filter by manufacturer name (case-insensitive contains match) - `ecosystem` (optional) — Filter by ecosystem name (e.g. `Networking`, `Video Surveillance`, `Burg/Alarm`). Applies manufacturer + category + subcategory-prefix matching rules. @@ -2726,8 +2726,8 @@ Fetch the distinct values available for filter dropdowns (categories, subcategor **Query Parameters:** -- `category` (optional) — Scope subcategories and manufacturers to items in this category -- `subcategory` (optional) — Scope manufacturers to items in this subcategory +- `category` (optional) — Scope subcategories and manufacturers to items in this category (accepts CW category name or CW ID) +- `subcategory` (optional) — Scope manufacturers to items in this subcategory (accepts CW subcategory name or CW ID) - `includeInactive` (optional, default `false`) — Include inactive catalog items **Response:** @@ -2988,7 +2988,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The **Query Parameters:** -- `include` _(optional)_ — Comma-separated list of sub-resources to embed in the response. Supported values: `notes`, `contacts`, `products`. Example: `?include=notes,contacts,products`. Sub-resources are fetched in parallel and added as top-level keys on the response object. +- `include` _(optional)_ — Comma-separated list of sub-resources to embed in the response. Supported values: `notes`, `contacts`, `products`. Example: `?include=notes,contacts,products`. Sub-resources are fetched in parallel and added as top-level keys on the response object. When `notes` is included, `data.notes` is returned as an array of note objects and the original opportunity text note is preserved under `data.opportunityNoteText`. **Response:** @@ -3238,7 +3238,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The **GET** `/sales/opportunities/:identifier/products` -Fetch products (forecast/revenue line items) for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`. +Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (`forecastDetailId` link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`. **Authentication Required:** Yes @@ -3490,6 +3490,85 @@ All fields are optional. Only fields the user has the corresponding `sales.oppor --- +### Add SPECIAL ORDER Product + +**POST** `/sales/opportunities/:identifier/products/special-order` + +Add one or more products as **SPECIAL ORDER** procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.product.add.specialOrder` + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) + +**Request Body:** + +Accepts either a single object or an array of objects. + +```json +{ + "desc": "SPECIAL ORDER - Lead time confirmed", + "customerDesc": "Customer-facing special order description", + "qty": 1, + "price": 750, + "cost": 500, + "taxable": true, + "procurementNotes": "Vendor ETA pending confirmation", + "productNarrative": "Install with existing rack accessories" +} +``` + +| Field | Type | Required | Description | +| ------------------ | ------- | -------- | --------------------------------------------------------- | +| `desc` | string | Yes | Internal/sales line description | +| `customerDesc` | string | No | Customer-facing line description | +| `qty` | number | No | Quantity (defaults to `1` when omitted) | +| `price` | number | Yes | Revenue amount | +| `cost` | number | No | Cost amount | +| `taxable` | boolean | No | Taxable flag (defaults to the catalog item's tax setting) | +| `procurementNotes` | string | No | Maps to custom field `id: 29` | +| `productNarrative` | string | No | Maps to custom field `id: 46` | + +**Route-Enforced Defaults:** + +- `catalogItem` is always set to the canonical catalog item with identifier `SPECIAL ORDER` +- `description` is always set from `desc` +- `customerDescription` is always set from `customerDesc` when provided +- `quantity` is always set from `qty` (default `1`) +- `price` is always set from `price` +- `cost` is always set from `cost` when provided +- `dropshipFlag` is always set to `false` +- `billableOption` is always set to `Billable` +- `taxableFlag` is set from `taxable` (or `taxableFlag`), defaulting to the catalog item's `salesTaxable` value +- `customFields` are auto-built when notes are provided: + - `procurementNotes` → `Procurement Notes` (`id: 29`) + - `productNarrative` → `Product Narrative` (`id: 46`) + +**Response:** + +```json +{ + "status": 201, + "message": "Special-order product added successfully!", + "data": { + "id": 88340, + "forecastDetailId": 32280, + "description": "SPECIAL ORDER - Lead time confirmed", + "customerDescription": "Customer-facing special order description", + "quantity": 1, + "price": 750, + "cost": 500, + "taxableFlag": true + }, + "successful": true +} +``` + +--- + ### Get Opportunity Notes **GET** `/sales/opportunities/:identifier/notes` diff --git a/PERMISSIONS.md b/PERMISSIONS.md index b1b710b..71d456c 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -136,16 +136,17 @@ Admin-specific UI permissions that control visibility and data loading for admin Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW. -| Permission Node | Description | Used In | Dependencies | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | -| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | | -| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | | -| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` | +| Permission Node | Description | Used In | Dependencies | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | +| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | | +| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | | +| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/[id]/addSpecialOrderProduct.ts](src/api/sales/[id]/addSpecialOrderProduct.ts) | `sales.opportunity.fetch` |
Field-level permissions for sales.opportunity.product.add diff --git a/src/api/sales/[id]/addSpecialOrderProduct.ts b/src/api/sales/[id]/addSpecialOrderProduct.ts new file mode 100644 index 0000000..264300b --- /dev/null +++ b/src/api/sales/[id]/addSpecialOrderProduct.ts @@ -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"], + }), +); diff --git a/src/api/sales/[id]/fetch.ts b/src/api/sales/[id]/fetch.ts index 18f5ee0..50ebbe8 100644 --- a/src/api/sales/[id]/fetch.ts +++ b/src/api/sales/[id]/fetch.ts @@ -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) => - p - .then((r) => { - console.log( - `[PERF:prewarm] ${label}: ${(performance.now() - _pw0).toFixed(0)}ms`, - ); - return r; - }) - .catch(() => {}); + const _ignoreErrors = (p: Promise) => p.catch(() => {}); const prewarmPromises: Promise[] = []; 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) => - p.then((r) => { - console.log( - `[PERF:sub] ${label}: ${(performance.now() - _st).toFixed(0)}ms`, - ); - return r; - }); - const subResourcePromises: Record> = { - _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"] }), diff --git a/src/api/sales/index.ts b/src/api/sales/index.ts index d723e36..eb3756a 100644 --- a/src/api/sales/index.ts +++ b/src/api/sales/index.ts @@ -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, diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts index 385ae4e..63e35e5 100644 --- a/src/controllers/OpportunityController.ts +++ b/src/controllers/OpportunityController.ts @@ -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>(); 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 { + 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 * diff --git a/src/index.ts b/src/index.ts index 477b9bd..58c3f20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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()); diff --git a/src/managers/opportunities.ts b/src/managers/opportunities.ts index 60b05c0..46783bc 100644 --- a/src/managers/opportunities.ts +++ b/src/managers/opportunities.ts @@ -42,7 +42,6 @@ async function buildCompanyController( ttlMs?: number; }, ): Promise { - 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 { - 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 { - 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, diff --git a/src/managers/procurement.ts b/src/managers/procurement.ts index cbc27e0..29ab764 100644 --- a/src/managers/procurement.ts +++ b/src/managers/procurement.ts @@ -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[] = []; + 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[] = [ + { 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[] = [ + { 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 diff --git a/src/modules/cw-utils/opportunities/opportunities.ts b/src/modules/cw-utils/opportunities/opportunities.ts index 9c5e8ea..06f5e3c 100644 --- a/src/modules/cw-utils/opportunities/opportunities.ts +++ b/src/modules/cw-utils/opportunities/opportunities.ts @@ -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 => { + 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; + }, }; diff --git a/src/modules/cw-utils/opportunities/opportunity.types.ts b/src/modules/cw-utils/opportunities/opportunity.types.ts index 6279a32..c719b5c 100644 --- a/src/modules/cw-utils/opportunities/opportunity.types.ts +++ b/src/modules/cw-utils/opportunities/opportunity.types.ts @@ -113,6 +113,7 @@ export interface CWForecastItem { _info?: Record; }; 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; } @@ -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> + >; +} + +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> + >; +} + +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; } export interface CWOpportunitySummary { diff --git a/src/types/PermissionNodes.ts b/src/types/PermissionNodes.ts index a20bae6..55044eb 100644 --- a/src/types/PermissionNodes.ts +++ b/src/types/PermissionNodes.ts @@ -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"], + }, ], },