Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c22c8eff |
+85
-6
@@ -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`
|
||||||
|
|||||||
+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.
|
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` |
|
||||||
@@ -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.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"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
+19
-51
@@ -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",
|
|
||||||
item
|
|
||||||
.fetchProducts()
|
.fetchProducts()
|
||||||
.then((products) => products.map((p) => p.toJson())),
|
.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"] }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
+5
-2
@@ -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) => {
|
return refreshOpportunityCache().catch((err) => {
|
||||||
console.error(
|
console.error(
|
||||||
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
|
`[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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,23 +38,83 @@ 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) {
|
||||||
|
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 });
|
conditions.push({ category: opts.category });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.subcategory) {
|
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 });
|
conditions.push({ subcategory: opts.subcategory });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.group && opts.category) {
|
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) {
|
if (subcats.length > 0) {
|
||||||
conditions.push({ subcategory: { in: subcats } });
|
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
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user