Compare commits

..

1 Commits

Author SHA1 Message Date
HoloPanio d5c22c8eff Add special-order product flow for sales opportunities 2026-03-04 00:11:40 -06:00
12 changed files with 457 additions and 114 deletions
+85 -6
View File
@@ -2326,8 +2326,8 @@ Fetch a paginated list of catalog items. Supports search.
- `rpp` (optional, default `30`) — Records per page - `rpp` (optional, default `30`) — Records per page
- `search` (optional) — Search by name, description, part number, vendor SKU, or manufacturer - `search` (optional) — Search by name, description, part number, vendor SKU, or manufacturer
- `includeInactive` (optional, default `false`) — Include inactive catalog items in results - `includeInactive` (optional, default `false`) — Include inactive catalog items in results
- `category` (optional) — Filter by CW category name (e.g. `Technology`, `Field`, `General`) - `category` (optional) — Filter by CW category **name or CW ID** (e.g. `Technology` or `18`)
- `subcategory` (optional) — Filter by CW subcategory name (e.g. `Network-Switch`, `AlarmBurg-Panels`) - `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. - `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) - `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. - `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:** **Query Parameters:**
- `category` (optional) — Scope subcategories and manufacturers to items in this category - `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 - `subcategory` (optional) — Scope manufacturers to items in this subcategory (accepts CW subcategory name or CW ID)
- `includeInactive` (optional, default `false`) — Include inactive catalog items - `includeInactive` (optional, default `false`) — Include inactive catalog items
**Response:** **Response:**
@@ -2988,7 +2988,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
**Query Parameters:** **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:** **Response:**
@@ -3238,7 +3238,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
**GET** `/sales/opportunities/:identifier/products` **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 **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 Opportunity Notes
**GET** `/sales/opportunities/:identifier/notes` **GET** `/sales/opportunities/:identifier/notes`
+11 -10
View File
@@ -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. 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 | | 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` | 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.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.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.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.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.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.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.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.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.<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` |
<details> <details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary> <summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
@@ -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", "get",
["/opportunities/:identifier"], ["/opportunities/:identifier"],
async (c) => { async (c) => {
const t0 = performance.now();
const identifier = c.req.param("identifier"); const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? ""; const includeParam = c.req.query("include") ?? "";
const includes = new Set( const includes = new Set(
@@ -80,24 +79,14 @@ export default createRoute(
// Check Redis first — if the background refresh has kept the keys warm, // Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss. // skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId; const cwOppId = dbRecord.cwOpportunityId;
const _pw0 = performance.now(); const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
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 prewarmPromises: Promise<any>[] = []; const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) { if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId, const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId; siteId = dbRecord.siteCwId;
prewarmPromises.push( prewarmPromises.push(
_wrapPw( _ignoreErrors(
"site",
getCachedSite(compId, siteId).then( getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId), (c) => c ?? fetchAndCacheSite(compId, siteId),
), ),
@@ -106,8 +95,7 @@ export default createRoute(
} }
if (includes.has("notes") && subTtl) if (includes.has("notes") && subTtl)
prewarmPromises.push( prewarmPromises.push(
_wrapPw( _ignoreErrors(
"notes",
getCachedNotes(cwOppId).then( getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl), (c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
), ),
@@ -115,8 +103,7 @@ export default createRoute(
); );
if (includes.has("contacts") && subTtl) if (includes.has("contacts") && subTtl)
prewarmPromises.push( prewarmPromises.push(
_wrapPw( _ignoreErrors(
"contacts",
getCachedContacts(cwOppId).then( getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl), (c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
), ),
@@ -124,8 +111,7 @@ export default createRoute(
); );
if (includes.has("products") && prodTtl) if (includes.has("products") && prodTtl)
prewarmPromises.push( prewarmPromises.push(
_wrapPw( _ignoreErrors(
"products",
getCachedProducts(cwOppId).then( getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl), (c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
), ),
@@ -138,46 +124,25 @@ export default createRoute(
opportunities.fetchItem(identifier), opportunities.fetchItem(identifier),
...prewarmPromises, ...prewarmPromises,
]); ]);
const t1 = performance.now();
console.log(`[PERF] fetchItem + prewarm: ${(t1 - t0).toFixed(0)}ms`);
// Sub-resources now hit warm Redis cache (near-instant) // 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>> = { const subResourcePromises: Record<string, Promise<any>> = {
_site: _wrapTimed("site", item.fetchSite()), _site: item.fetchSite(),
}; };
if (includes.has("notes")) { if (includes.has("notes")) {
subResourcePromises.notes = _wrapTimed("notes", item.fetchNotes()); subResourcePromises.notes = item.fetchNotes();
} }
if (includes.has("contacts")) { if (includes.has("contacts")) {
subResourcePromises.contacts = _wrapTimed( subResourcePromises.contacts = item.fetchContacts();
"contacts",
item.fetchContacts(),
);
} }
if (includes.has("products")) { if (includes.has("products")) {
subResourcePromises.products = _wrapTimed( subResourcePromises.products = item
"products", .fetchProducts()
item .then((products) => products.map((p) => p.toJson()));
.fetchProducts()
.then((products) => products.map((p) => p.toJson())),
);
} }
const keys = Object.keys(subResourcePromises); const keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k])); 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) // Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms( const gatedData = await processObjectValuePerms(
@@ -185,8 +150,8 @@ export default createRoute(
"obj.opportunity", "obj.opportunity",
c.get("user"), 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) // Attach sub-resources (skip the internal _site key)
keys.forEach((k, i) => { 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( const response = apiResponse.successful(
"Opportunity fetched successfully!", "Opportunity fetched successfully!",
gatedData, gatedData,
); );
console.log(
`[PERF] total handler: ${(performance.now() - t0).toFixed(0)}ms (includes=${includeParam || "none"})`,
);
return c.json(response, response.status as ContentfulStatusCode); return c.json(response, response.status as ContentfulStatusCode);
}, },
authMiddleware({ permissions: ["sales.opportunity.fetch"] }), 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 refresh } from "./[id]/refresh";
import { default as products } from "./[id]/products"; import { default as products } from "./[id]/products";
import { default as addProduct } from "./[id]/addProduct"; import { default as addProduct } from "./[id]/addProduct";
import { default as addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct";
import { default as resequenceProducts } from "./[id]/resequenceProducts"; import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes"; import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote"; import { default as fetchNote } from "./[id]/fetchNote";
@@ -15,6 +16,7 @@ import { default as contacts } from "./[id]/contacts";
export { export {
addProduct, addProduct,
addSpecialOrderProduct,
count, count,
fetch, fetch,
fetchAll, fetchAll,
+60 -3
View File
@@ -14,6 +14,8 @@ import {
CWForecastItemCreate, CWForecastItemCreate,
CWOpportunity, CWOpportunity,
CWOpportunityNote, CWOpportunityNote,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types"; } from "../modules/cw-utils/opportunities/opportunity.types";
import { import {
resolveMember, resolveMember,
@@ -547,15 +549,25 @@ export class OpportunityController {
// Build a map of forecastDetailId → procurement product cancellation data // Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>(); const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) { for (const pp of procProducts) {
const forecastDetailId = pp.forecastDetailId as number | undefined; const rawForecastDetailId = (pp as any)?.forecastDetailId;
if (forecastDetailId) { const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
cancellationMap.set(forecastDetailId, pp); 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 // Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber. // to CW sequenceNumber.
const forecastItems = forecast.forecastItems ?? [];
let ordered: typeof forecastItems; let ordered: typeof forecastItems;
if (this.productSequence.length > 0) { 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 * Add Note
* *
+10 -7
View File
@@ -149,13 +149,16 @@ setInterval(() => {
// NOTE: Do NOT await — register the interval immediately so the cache refresh // NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above. // is never blocked by a slow/stuck startup task above.
safeStartup("refreshOpportunityCache", refreshOpportunityCache); safeStartup("refreshOpportunityCache", refreshOpportunityCache);
setInterval(() => { setInterval(
return refreshOpportunityCache().catch((err) => { () => {
console.error( return refreshOpportunityCache().catch((err) => {
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`, console.error(
); `[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
}); );
}, 20 * 60 * 1000); });
},
20 * 60 * 1000,
);
// Refresh User Defined Fields every 5 minutes // Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh()); await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
-31
View File
@@ -42,7 +42,6 @@ async function buildCompanyController(
ttlMs?: number; ttlMs?: number;
}, },
): Promise<CompanyController> { ): Promise<CompanyController> {
const _ct0 = performance.now();
const strategy = opts?.strategy ?? "cache-then-cw"; const strategy = opts?.strategy ?? "cache-then-cw";
const ctrl = new CompanyController(company); const ctrl = new CompanyController(company);
@@ -82,10 +81,6 @@ async function buildCompanyController(
} else { } else {
await ctrl.hydrateCwData(); await ctrl.hydrateCwData();
} }
console.log(
`[PERF:buildCompany] ${(performance.now() - _ct0).toFixed(0)}ms (strategy=${strategy}, hit=miss)`,
);
return ctrl; return ctrl;
} }
@@ -105,7 +100,6 @@ async function buildActivities(
ttlMs?: number; ttlMs?: number;
}, },
): Promise<ActivityController[]> { ): Promise<ActivityController[]> {
const _at0 = performance.now();
const strategy = opts?.strategy ?? "cache-then-cw"; const strategy = opts?.strategy ?? "cache-then-cw";
// ── cw-first: always fetch from CW (and cache the result) ────────── // ── cw-first: always fetch from CW (and cache the result) ──────────
@@ -129,9 +123,6 @@ async function buildActivities(
const arr = opts?.ttlMs const arr = opts?.ttlMs
? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs) ? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
: await activityCw.fetchByOpportunityDirect(cwOpportunityId); : 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)); return arr.map((item) => new ActivityController(item));
} }
@@ -202,7 +193,6 @@ export const opportunities = {
identifier: string | number, identifier: string | number,
opts?: { fresh?: boolean }, opts?: { fresh?: boolean },
): Promise<OpportunityController> { ): Promise<OpportunityController> {
const _t0 = performance.now();
const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh
? "cw-first" ? "cw-first"
: "cache-then-cw"; : "cache-then-cw";
@@ -216,8 +206,6 @@ export const opportunities = {
: { id: identifier as string }, : { id: identifier as string },
include: { company: true }, include: { company: true },
}); });
const _t1 = performance.now();
console.log(`[PERF:fetchItem] DB lookup: ${(_t1 - _t0).toFixed(0)}ms`);
if (!existing) { if (!existing) {
throw new GenericError({ throw new GenericError({
@@ -245,10 +233,6 @@ export const opportunities = {
// Try the Redis cache first // Try the Redis cache first
cwData = await getCachedOppCwData(existing.cwOpportunityId); 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 ──────────── // ── Parallel block: CW opp fetch + activities + company ────────────
// Activities and company hydration only need existing.cwOpportunityId // Activities and company hydration only need existing.cwOpportunityId
@@ -261,10 +245,6 @@ export const opportunities = {
cwData = ttlMs cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs) ? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId); : await opportunityCw.fetch(existing.cwOpportunityId);
const _t2b = performance.now();
console.log(
`[PERF:fetchItem] CW opp fetch: ${(_t2b - _t2).toFixed(0)}ms`,
);
if (!cwData) { if (!cwData) {
throw new GenericError({ throw new GenericError({
@@ -291,12 +271,8 @@ export const opportunities = {
data: { ...mapped, companyId }, data: { ...mapped, companyId },
include: { company: true }, 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 // Hydrate activities and company in parallel with CW opp fetch
const [, activities, company] = await Promise.all([ const [, activities, company] = await Promise.all([
cwOppPromise, cwOppPromise,
@@ -305,13 +281,6 @@ export const opportunities = {
? buildCompanyController(existing.company, { strategy, ttlMs }) ? buildCompanyController(existing.company, { strategy, ttlMs })
: Promise.resolve(undefined), : 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, { return new OpportunityController(record, {
company, company,
+67 -5
View File
@@ -2,9 +2,11 @@ import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController"; import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError"; import GenericError from "../Errors/GenericError";
import { import {
CATEGORY_TREE,
getSubcategoriesForCategory, getSubcategoriesForCategory,
getSubcategoriesForGroup, getSubcategoriesForGroup,
ECOSYSTEM_TREE, ECOSYSTEM_TREE,
isCategoryGroup,
} from "../modules/catalog-categories/catalogCategories"; } from "../modules/catalog-categories/catalogCategories";
/** /**
@@ -36,22 +38,82 @@ export interface CatalogFilterOpts {
function buildFilterWhere(opts: CatalogFilterOpts = {}) { function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = []; 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) { if (!opts.includeInactive) {
conditions.push({ inactive: false }); conditions.push({ inactive: false });
} }
if (opts.category) { 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) { 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) { if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group); if (!resolvedCategoryName) {
if (subcats.length > 0) { conditions.push({ category: "__unknown_category__" });
conditions.push({ subcategory: { in: subcats } }); }
if (resolvedCategoryName) {
const subcats = getSubcategoriesForGroup(
resolvedCategoryName,
opts.group,
);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
} }
} else if (opts.group && !opts.category) { } else if (opts.group && !opts.category) {
// Try to find the group in any category // Try to find the group in any category
@@ -6,6 +6,8 @@ import {
CWForecast, CWForecast,
CWForecastItem, CWForecastItem,
CWForecastItemCreate, CWForecastItemCreate,
CWProcurementProduct,
CWProcurementProductCreate,
CWOpportunityNote, CWOpportunityNote,
CWOpportunityNoteCreate, CWOpportunityNoteCreate,
CWOpportunityNoteUpdate, CWOpportunityNoteUpdate,
@@ -356,4 +358,27 @@ export const opportunityCw = {
); );
return response.data; 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>; _info?: Record<string, string>;
}; };
productDescription: string; productDescription: string;
customerDescription?: string;
productClass: string; productClass: string;
revenue: number; revenue: number;
cost: number; cost: number;
@@ -129,6 +130,7 @@ export interface CWForecastItem {
sequenceNumber: number; sequenceNumber: number;
subNumber: number; subNumber: number;
taxableFlag: boolean; taxableFlag: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>; _info?: Record<string, string>;
} }
@@ -210,6 +212,7 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number }; catalogItem?: { id: number };
forecastDescription?: string; forecastDescription?: string;
productDescription?: string; productDescription?: string;
customerDescription?: string;
quantity?: number; quantity?: number;
status?: { id: number }; status?: { id: number };
productClass?: string; productClass?: string;
@@ -224,6 +227,39 @@ export interface CWForecastItemCreate {
recurringCost?: number; recurringCost?: number;
cycles?: number; cycles?: number;
sequenceNumber?: 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 { export interface CWOpportunitySummary {
+7
View File
@@ -473,6 +473,13 @@ export const PERMISSION_NODES = {
"sales.opportunity.product.field.sequenceNumber", "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"],
},
], ],
}, },