Add special-order product flow for sales opportunities
This commit is contained in:
+85
-6
@@ -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`
|
||||
|
||||
+2
-1
@@ -137,7 +137,7 @@ 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` |
|
||||
@@ -146,6 +146,7 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
||||
| `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.<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>
|
||||
<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"],
|
||||
}),
|
||||
);
|
||||
+19
-51
@@ -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
|
||||
subResourcePromises.products = item
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson())),
|
||||
);
|
||||
.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"] }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
+5
-2
@@ -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(() => {
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshOpportunityCache().catch((err) => {
|
||||
console.error(
|
||||
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
|
||||
);
|
||||
});
|
||||
}, 20 * 60 * 1000);
|
||||
},
|
||||
20 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh User Defined Fields every 5 minutes
|
||||
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,23 +38,83 @@ 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) {
|
||||
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 (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 (!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
|
||||
const {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user