Add sales item labor/product route updates and permission docs

This commit is contained in:
2026-03-04 18:43:54 -06:00
parent d5c22c8eff
commit 4efca6cc53
11 changed files with 1057 additions and 3 deletions
+266
View File
@@ -3382,12 +3382,136 @@ When a `productSequence` is set, `GET .../products` returns items in that order.
--- ---
### Edit Opportunity Product
**PATCH** `/sales/opportunities/:identifier/products/:productId/edit`
Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.update`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `productId` — Forecast item ID (numeric)
**Request Body:**
At least one field is required.
```json
{
"productDescription": "Labor & Installation - Field (Updated)",
"quantity": 2,
"unitPrice": 125,
"unitCost": 62.5,
"customerDescription": "Onsite labor for rack install",
"productNarrative": "Install, cable, and validate cutover",
"procurementNotes": "Coordinate site contact before arrival"
}
```
| Field | Type | Description |
| --------------------- | ------ | ---------------------------------------------------------- |
| `productDescription` | string | Product description |
| `quantity` | number | Quantity |
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) |
| `customerDescription` | string | Customer-facing description |
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
**Response:**
```json
{
"status": 200,
"message": "Product updated successfully!",
"data": {
"id": 32281,
"productDescription": "Labor & Installation - Field (Updated)",
"quantity": 2,
"unitPrice": 125,
"unitCost": 62.5,
"customerDescription": "Onsite labor for rack install",
"productNarrative": "Install, cable, and validate cutover",
"procurementNotes": "Coordinate site contact before arrival"
},
"successful": true
}
```
---
### Cancel / Uncancel Opportunity Product
**PATCH** `/sales/opportunities/:identifier/products/:productId/cancel`
Set cancellation state for a product line item using procurement cancellation fields.
- `quantityCancelled = 0` → item is treated as **uncancelled**
- `quantityCancelled > 0 && < quantity` → **partial** cancellation
- `quantityCancelled >= quantity` → **full** cancellation
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.update`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `productId` — Forecast item ID (numeric)
**Request Body:**
```json
{
"quantityCancelled": 1,
"cancellationReason": "Out of stock"
}
```
| Field | Type | Required | Description |
| -------------------- | ---------------- | -------- | ------------------------------------------------------------- |
| `quantityCancelled` | number (integer) | Yes | Number of units to cancel. Use `0` to uncancel the line item. |
| `cancellationReason` | string \| null | No | Optional reason that is passed through to ConnectWise. |
**Validation Rules:**
- `quantityCancelled` must be >= `0`
- `quantityCancelled` cannot exceed the line item's quantity
**Response:**
```json
{
"status": 200,
"message": "Product cancellation updated successfully!",
"data": {
"id": 32281,
"quantity": 2,
"cancelled": true,
"cancellationType": "partial",
"quantityCancelled": 1,
"cancelledReason": "Out of stock",
"cancelledDate": "2026-03-04T00:00:00.000Z"
},
"successful": true
}
```
---
### Add Product to Opportunity ### Add Product to Opportunity
**POST** `/sales/opportunities/:identifier/products` **POST** `/sales/opportunities/:identifier/products`
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise. Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
After creation, the new forecast item ID is appended to the opportunity's local `productSequence` array in the database (if not already present) so display ordering remains stable.
**Authentication Required:** Yes **Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.add` **Required Permissions:** `sales.opportunity.product.add`
@@ -3546,6 +3670,7 @@ Accepts either a single object or an array of objects.
- `customFields` are auto-built when notes are provided: - `customFields` are auto-built when notes are provided:
- `procurementNotes` → `Procurement Notes` (`id: 29`) - `procurementNotes` → `Procurement Notes` (`id: 29`)
- `productNarrative` → `Product Narrative` (`id: 46`) - `productNarrative` → `Product Narrative` (`id: 46`)
- When CW returns `forecastDetailId`, it is appended to the opportunity's local `productSequence` array in the database (if not already present)
**Response:** **Response:**
@@ -3569,6 +3694,147 @@ Accepts either a single object or an array of objects.
--- ---
### Get Labor Product Options
**GET** `/sales/opportunities/:identifier/products/labor/options`
Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.add.labor`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Response:**
```json
{
"status": 200,
"message": "Labor product options fetched successfully!",
"data": {
"defaults": {
"customerType": "corporate",
"rates": {
"corporate": 100,
"residential": 85
},
"cpuMultiplier": 0.5,
"quantity": 1
},
"options": {
"field": {
"cwCatalogId": 3756,
"identifier": "Labor & Installation - Field",
"name": "Labor & Installation - Field",
"taxableFlag": true
},
"tech": {
"cwCatalogId": 3757,
"identifier": "Labor & Installation - Tech",
"name": "Labor & Installation - Tech",
"taxableFlag": true
}
}
},
"successful": true
}
```
---
### Add Labor Product
**POST** `/sales/opportunities/:identifier/products/labor`
Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.add.labor`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Request Body:**
```json
{
"laborStyle": "field",
"customerType": "corporate",
"hours": 1,
"taxable": true,
"rate": 100,
"ppu": 100,
"cpu": 50,
"procurementNotes": "Schedule with PM before install",
"productNarrative": "Install and validate onsite",
"customerDescription": "Onsite installation labor"
}
```
| Field | Type | Required | Description |
| --------------------- | ------------------------------ | -------- | -------------------------------------------------------------------------- |
| `laborStyle` | `"field" \| "tech"` | Yes | Chooses which labor catalog product to use |
| `customerType` | `"corporate" \| "residential"` | No | Selects default rate (`corporate=100`, `residential=85`) when rate omitted |
| `hours` | number | No | Quantity/hours (defaults to `1`) |
| `rate` | number | No | Hourly labor rate used when `ppu` is not provided |
| `ppu` | number | No | Price per unit/hour (overrides `rate`) |
| `cpu` | number | No | Cost per unit/hour (defaults to `50%` of selected price) |
| `taxable` | boolean | No | Taxable flag override |
| `taxableFlag` | boolean | No | Alternate taxable flag input (same behavior as `taxable`) |
| `description` | string | No | Internal line description override |
| `customerDescription` | string | No | Customer-facing description |
| `procurementNotes` | string | No | Maps to custom field `Procurement Notes` (`id: 29`) |
| `productNarrative` | string | No | Maps to custom field `Product Narrative` (`id: 46`) |
**Route-Enforced Defaults / Behavior:**
- Resolves both labor products from local catalog and uses `laborStyle` to select one
- Uses `customerType` defaults when `rate/ppu/cpu` are not supplied
- Sets `quantity` from `hours` (default `1`)
- Sets `price` from selected `ppu`, `cost` from selected `cpu`
- Sets `dropshipFlag` to `false` and `billableOption` to `Billable`
- Sets taxable flag from `taxable`/`taxableFlag`, falling back to the selected catalog item's tax setting
- When CW returns `forecastDetailId`, it is appended to the opportunity's local `productSequence` array in the database (if not already present)
**Response:**
```json
{
"status": 201,
"message": "Labor added to opportunity successfully!",
"data": {
"id": 88341,
"forecastDetailId": 32281,
"laborStyle": "field",
"customerType": "corporate",
"catalogItem": {
"id": 3756,
"identifier": "Labor & Installation - Field",
"name": "Labor & Installation - Field"
},
"description": "Labor & Installation - Field",
"customerDescription": "Onsite installation labor",
"quantity": 1,
"rate": 100,
"ppu": 100,
"cpu": 50,
"revenue": 100,
"cost": 50,
"taxableFlag": true,
"procurementNotes": "Schedule with PM before install",
"productNarrative": "Install and validate onsite"
},
"successful": true
}
```
---
### Get Opportunity Notes ### Get Opportunity Notes
**GET** `/sales/opportunities/:identifier/notes` **GET** `/sales/opportunities/:identifier/notes`
+2 -1
View File
@@ -144,9 +144,10 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
| `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), [src/api/sales/[id]/updateProduct.ts](src/api/sales/[id]/updateProduct.ts), [src/api/sales/[id]/cancelProduct.ts](src/api/sales/[id]/cancelProduct.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` | | `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` |
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/[id]/addLabor.ts](src/api/sales/[id]/addLabor.ts), [src/api/sales/[id]/laborOptions.ts](src/api/sales/[id]/laborOptions.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>
+147
View File
@@ -0,0 +1,147 @@
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 LABOR_DEFAULT_RATE = {
corporate: 100,
residential: 85,
} as const;
const roundMoney = (value: number) => Math.round(value * 100) / 100;
const addLaborSchema = z
.object({
laborStyle: z.enum(["field", "tech"]),
customerType: z.enum(["corporate", "residential"]).optional(),
hours: z.number().positive().optional(),
rate: z.number().min(0).optional(),
ppu: z.number().min(0).optional(),
cpu: z.number().min(0).optional(),
taxable: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
description: z.string().min(1).optional(),
customerDescription: z.string().min(1).optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
/* POST /v1/sales/opportunities/:identifier/products/labor */
export default createRoute(
"post",
["/opportunities/:identifier/products/labor"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const input = addLaborSchema.parse(body);
const laborCatalog = await procurement.fetchLaborCatalogItems();
const selectedCatalog =
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
const customerType = input.customerType ?? "corporate";
const defaultRate = LABOR_DEFAULT_RATE[customerType];
const quantity = input.hours ?? 1;
const ppu = input.ppu ?? input.rate ?? defaultRate;
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
const taxableFlag =
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
const makeCustomField = (
caption: string,
value: string,
fieldId: number,
) => ({
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
});
const payload = {
...(input.procurementNotes || input.productNarrative
? {
customFields: [
...(input.procurementNotes
? [
makeCustomField(
"Procurement Notes",
input.procurementNotes,
29,
),
]
: []),
...(input.productNarrative
? [
makeCustomField(
"Product Narrative",
input.productNarrative,
46,
),
]
: []),
],
}
: {}),
catalogItem: { id: selectedCatalog.cwCatalogId },
description:
input.description ??
selectedCatalog.name ??
selectedCatalog.identifier ??
`${input.laborStyle.toUpperCase()} Labor`,
customerDescription: input.customerDescription,
quantity,
price: ppu,
cost: cpu,
taxableFlag,
dropshipFlag: false,
billableOption: "Billable",
};
const opportunity = await opportunities.fetchRecord(identifier);
const [created] = await opportunity.addProcurementProducts(payload);
const fields = Array.isArray(created?.customFields)
? created.customFields
: [];
const procurementNotes =
fields.find((f: any) => f?.id === 29)?.value ?? null;
const productNarrative =
fields.find((f: any) => f?.id === 46)?.value ?? null;
const response = apiResponse.created(
"Labor added to opportunity successfully!",
{
id: created?.id ?? null,
forecastDetailId: created?.forecastDetailId ?? null,
laborStyle: input.laborStyle,
customerType,
catalogItem: {
id: selectedCatalog.cwCatalogId,
identifier: selectedCatalog.identifier,
name: selectedCatalog.name,
},
description: created?.description ?? payload.description,
customerDescription:
created?.customerDescription ?? input.customerDescription ?? null,
quantity: created?.quantity ?? quantity,
rate: ppu,
ppu,
cpu,
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
cost: roundMoney((created?.quantity ?? quantity) * cpu),
taxableFlag: created?.taxableFlag ?? taxableFlag,
procurementNotes,
productNarrative,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
);
+82
View File
@@ -0,0 +1,82 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { z } from "zod";
const cancelProductSchema = z
.object({
quantityCancelled: z.number().int().min(0),
cancellationReason: z.string().nullable().optional(),
})
.strict();
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/cancel"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
const body = await c.req.json();
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const input = cancelProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const products = await opportunity.fetchProducts();
const product = products.find((item) => item.cwForecastId === productId);
if (!product) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const quantity = product.quantity ?? 0;
if (input.quantityCancelled > quantity) {
throw new GenericError({
status: 400,
name: "InvalidCancelledQuantity",
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
});
}
await opportunity.setProductCancellation(productId, {
quantityCancelled: input.quantityCancelled,
cancellationReason: input.cancellationReason,
});
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
const updated = refreshedProducts.find((item) => item.cwForecastId === productId);
if (!updated) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const response = apiResponse.successful(
input.quantityCancelled === 0
? "Product uncancelled successfully!"
: "Product cancellation updated successfully!",
updated.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
+51
View File
@@ -0,0 +1,51 @@
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";
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
export default createRoute(
"get",
["/opportunities/:identifier/products/labor/options"],
async (c) => {
const identifier = c.req.param("identifier");
await opportunities.fetchRecord(identifier);
const laborCatalog = await procurement.fetchLaborCatalogItems();
const response = apiResponse.successful(
"Labor product options fetched successfully!",
{
defaults: {
customerType: "corporate",
rates: {
corporate: 100,
residential: 85,
},
cpuMultiplier: 0.5,
quantity: 1,
},
options: {
field: {
cwCatalogId: laborCatalog.field.cwCatalogId,
identifier: laborCatalog.field.identifier,
name: laborCatalog.field.name,
taxableFlag: laborCatalog.field.salesTaxable,
},
tech: {
cwCatalogId: laborCatalog.tech.cwCatalogId,
identifier: laborCatalog.tech.identifier,
name: laborCatalog.tech.name,
taxableFlag: laborCatalog.tech.salesTaxable,
},
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
);
+233
View File
@@ -0,0 +1,233 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { z } from "zod";
const PRODUCT_NARRATIVE_FIELD_ID = 46;
const PROCUREMENT_NOTES_FIELD_ID = 29;
const updateProductSchema = z
.object({
productDescription: z.string().min(1).optional(),
quantity: z.number().positive().optional(),
unitPrice: z.number().min(0).optional(),
unitCost: z.number().min(0).optional(),
customerDescription: z.string().nullable().optional(),
productNarrative: z.string().nullable().optional(),
procurementNotes: z.string().nullable().optional(),
})
.strict()
.refine(
(value) =>
Object.values(value).some(
(item) => item !== undefined && item !== null,
),
"At least one editable field is required",
);
const upsertCustomTextField = (
fields: Array<Record<string, unknown>>,
fieldId: number,
caption: string,
value: string,
) => {
const next = [...fields];
const idx = next.findIndex((f) => Number(f.id) === fieldId);
const field = {
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
};
if (idx === -1) {
next.push(field);
return next;
}
next[idx] = {
...next[idx],
...field,
};
return next;
};
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/edit"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
const body = await c.req.json();
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const input = updateProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const forecastItems = await opportunity.fetchProducts();
const forecastItem = forecastItems.find(
(item) => item.cwForecastId === productId,
);
if (!forecastItem) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const forecastJson = forecastItem.toJson();
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
const forecastPatch: Record<string, unknown> = {};
if (input.productDescription !== undefined) {
forecastPatch.productDescription = input.productDescription;
}
if (input.quantity !== undefined) {
forecastPatch.quantity = input.quantity;
}
if (input.customerDescription !== undefined && input.customerDescription !== null) {
forecastPatch.customerDescription = input.customerDescription;
}
if (input.unitPrice !== undefined) {
forecastPatch.revenue = Number(
(input.unitPrice * effectiveQuantity).toFixed(2),
);
}
if (input.unitCost !== undefined) {
forecastPatch.cost = Number((input.unitCost * effectiveQuantity).toFixed(2));
}
const existingProcurement =
await opportunity.fetchProcurementProductByForecastItem(productId);
if (
(input.productNarrative !== undefined ||
input.procurementNotes !== undefined) &&
!existingProcurement
) {
throw new GenericError({
status: 400,
name: "ProcurementLinkRequired",
message:
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
});
}
let updatedProcurement = existingProcurement;
if (existingProcurement) {
const procurementPatch: Record<string, unknown> = {};
if (input.productDescription !== undefined) {
procurementPatch.description = input.productDescription;
}
if (input.quantity !== undefined) {
procurementPatch.quantity = input.quantity;
}
if (input.unitPrice !== undefined) {
procurementPatch.price = input.unitPrice;
}
if (input.unitCost !== undefined) {
procurementPatch.cost = input.unitCost;
}
if (
input.customerDescription !== undefined &&
input.customerDescription !== null
) {
procurementPatch.customerDescription = input.customerDescription;
}
const existingFields = Array.isArray(existingProcurement.customFields)
? existingProcurement.customFields.map((field) => ({ ...field }))
: [];
let updatedFields = existingFields as Array<Record<string, unknown>>;
if (input.procurementNotes !== undefined && input.procurementNotes !== null) {
updatedFields = upsertCustomTextField(
updatedFields,
PROCUREMENT_NOTES_FIELD_ID,
"Procurement Notes",
input.procurementNotes,
);
}
if (input.productNarrative !== undefined && input.productNarrative !== null) {
updatedFields = upsertCustomTextField(
updatedFields,
PRODUCT_NARRATIVE_FIELD_ID,
"Product Narrative",
input.productNarrative,
);
}
if (
(input.procurementNotes !== undefined &&
input.procurementNotes !== null) ||
(input.productNarrative !== undefined &&
input.productNarrative !== null)
) {
procurementPatch.customFields = updatedFields;
}
if (Object.keys(procurementPatch).length > 0) {
updatedProcurement =
await opportunity.updateProcurementProductByForecastItem(
productId,
procurementPatch,
);
}
}
let updatedForecast = forecastJson;
if (Object.keys(forecastPatch).length > 0) {
const patched = await opportunity.updateProduct(productId, forecastPatch);
updatedForecast = patched.toJson();
}
const updatedFields = Array.isArray(updatedProcurement?.customFields)
? updatedProcurement.customFields
: [];
const procurementNotes =
updatedFields.find((field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID)
?.value ?? null;
const productNarrative =
updatedFields.find((field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID)
?.value ?? null;
const quantity = updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
const unitPrice = updatedProcurement?.price ?? null;
const unitCost = updatedProcurement?.cost ?? null;
const response = apiResponse.successful(
"Product updated successfully!",
{
...updatedForecast,
productDescription:
updatedProcurement?.description ?? updatedForecast.productDescription,
customerDescription:
updatedProcurement?.customerDescription ??
updatedForecast.customerDescription ??
null,
quantity,
unitPrice,
unitCost,
procurementNotes,
productNarrative,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
+8
View File
@@ -6,7 +6,11 @@ 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 addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct";
import { default as addLabor } from "./[id]/addLabor";
import { default as laborOptions } from "./[id]/laborOptions";
import { default as resequenceProducts } from "./[id]/resequenceProducts"; import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as updateProduct } from "./[id]/updateProduct";
import { default as cancelProduct } from "./[id]/cancelProduct";
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";
import { default as createNote } from "./[id]/createNote"; import { default as createNote } from "./[id]/createNote";
@@ -16,6 +20,8 @@ import { default as contacts } from "./[id]/contacts";
export { export {
addProduct, addProduct,
addLabor,
laborOptions,
addSpecialOrderProduct, addSpecialOrderProduct,
count, count,
fetch, fetch,
@@ -23,6 +29,8 @@ export {
fetchOpportunityTypes, fetchOpportunityTypes,
products, products,
resequenceProducts, resequenceProducts,
updateProduct,
cancelProduct,
notes, notes,
fetchNote, fetchNote,
createNote, createNote,
+127 -1
View File
@@ -782,6 +782,41 @@ export class OpportunityController {
return this.fetchProducts(); return this.fetchProducts();
} }
/**
* Append Product Sequence IDs
*
* Adds newly created forecast item IDs to the end of the local
* productSequence array, preserving existing order and avoiding duplicates.
*/
private async appendProductSequenceIds(ids: number[]): Promise<void> {
const normalizedIds = ids.filter(
(id): id is number => Number.isInteger(id) && id > 0,
);
if (normalizedIds.length === 0) return;
const current = await prisma.opportunity.findUnique({
where: { id: this.id },
select: { productSequence: true },
});
const existing = current?.productSequence ?? [];
const existingSet = new Set(existing);
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
if (idsToAppend.length === 0) {
this.productSequence = existing;
return;
}
const updatedSequence = [...existing, ...idsToAppend];
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
/** /**
* Add Products * Add Products
* *
@@ -800,6 +835,7 @@ export class OpportunityController {
this.cwOpportunityId, this.cwOpportunityId,
data, data,
); );
await this.appendProductSequenceIds(created.map((item) => item.id));
await invalidateProductsCache(this.cwOpportunityId); await invalidateProductsCache(this.cwOpportunityId);
return created.map((item) => new ForecastProductController(item)); return created.map((item) => new ForecastProductController(item));
} catch (err: any) { } catch (err: any) {
@@ -845,6 +881,11 @@ export class OpportunityController {
})); }));
const created = await opportunityCw.createProcurementProducts(normalized); const created = await opportunityCw.createProcurementProducts(normalized);
await this.appendProductSequenceIds(
created
.map((item) => item.forecastDetailId)
.filter((id): id is number => typeof id === "number"),
);
await invalidateProductsCache(this.cwOpportunityId); await invalidateProductsCache(this.cwOpportunityId);
return created; return created;
} catch (err: any) { } catch (err: any) {
@@ -873,6 +914,91 @@ export class OpportunityController {
} }
} }
/**
* Fetch Procurement Product By Forecast Item
*
* Returns the linked procurement product for a forecast item ID,
* or null when no procurement record exists.
*/
public async fetchProcurementProductByForecastItem(
forecastItemId: number,
): Promise<CWProcurementProduct | null> {
return opportunityCw.fetchProcurementProductByForecastDetail(
this.cwOpportunityId,
forecastItemId,
);
}
/**
* Update Procurement Product By Forecast Item
*
* Finds the linked procurement product for a forecast item and updates it.
* Returns null when no linked procurement product exists.
*/
public async updateProcurementProductByForecastItem(
forecastItemId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct | null> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) return null;
const updated = await opportunityCw.updateProcurementProduct(
linked.id,
data,
);
await invalidateProductsCache(this.cwOpportunityId);
return updated;
}
/**
* Set Product Cancellation
*
* Updates cancellation fields on the procurement product linked to a
* forecast item. A quantity of 0 is treated as uncancelled.
*/
public async setProductCancellation(
forecastItemId: number,
opts: { quantityCancelled: number; cancellationReason?: string | null },
): Promise<CWProcurementProduct> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
const cancelledFlag = quantityCancelled > 0;
const updated = await this.updateProcurementProductByForecastItem(
forecastItemId,
{
quantityCancelled,
cancelledFlag,
cancelledReason: cancelledFlag
? (opts.cancellationReason ?? null)
: null,
},
);
if (!updated) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
return updated;
}
/** /**
* Add Note * Add Note
* *
@@ -938,7 +1064,7 @@ export class OpportunityController {
id: this.id, id: this.id,
cwOpportunityId: this.cwOpportunityId, cwOpportunityId: this.cwOpportunityId,
name: this.name, name: this.name,
notes: this.notes, description: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null, type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName } ? { id: this.stageCwId, name: this.stageName }
+85
View File
@@ -17,6 +17,61 @@ const catalogItemInclude = {
linkedItems: true, linkedItems: true,
} as const; } as const;
const LABOR_STYLE_CANDIDATES = {
field: ["LABOR & INSTALLATION - FIELD", "LABOR - FIELD", "LABOR FIELD"],
tech: ["LABOR & INSTALLATION - TECH", "LABOR - TECH", "LABOR TECH"],
} as const;
async function findCatalogByExactCandidates(
candidates: readonly string[],
): Promise<CatalogItemController | null> {
for (const candidate of candidates) {
const item = await prisma.catalogItem.findFirst({
where: {
inactive: false,
OR: [
{ identifier: { equals: candidate, mode: "insensitive" } },
{ name: { equals: candidate, mode: "insensitive" } },
],
},
include: catalogItemInclude,
});
if (item) return new CatalogItemController(item);
}
return null;
}
async function findCatalogByLaborStyle(
style: "field" | "tech",
): Promise<CatalogItemController | null> {
const fallback = await prisma.catalogItem.findFirst({
where: {
inactive: false,
AND: [
{
OR: [
{ identifier: { contains: "labor", mode: "insensitive" } },
{ name: { contains: "labor", mode: "insensitive" } },
],
},
{
OR: [
{ identifier: { contains: style, mode: "insensitive" } },
{ name: { contains: style, mode: "insensitive" } },
],
},
],
},
include: catalogItemInclude,
orderBy: { name: "asc" },
});
if (!fallback) return null;
return new CatalogItemController(fallback);
}
/** /**
* Filter options for catalog item queries. * Filter options for catalog item queries.
*/ */
@@ -204,6 +259,36 @@ export const procurement = {
return new CatalogItemController(item); return new CatalogItemController(item);
}, },
/**
* Fetch Labor Catalog Items
*
* Resolves canonical Field and Tech labor products from the local catalog.
* Prefers exact identifier/name matches, then falls back to keyword matching.
*/
async fetchLaborCatalogItems(): Promise<{
field: CatalogItemController;
tech: CatalogItemController;
}> {
const fieldItem =
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.field)) ??
(await findCatalogByLaborStyle("field"));
const techItem =
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.tech)) ??
(await findCatalogByLaborStyle("tech"));
if (!fieldItem || !techItem) {
throw new GenericError({
message: "Labor catalog products are not configured",
name: "LaborCatalogProductsNotFound",
cause:
"Expected active FIELD and TECH labor catalog items in the local catalog",
status: 500,
});
}
return { field: fieldItem, tech: techItem };
},
/** /**
* Fetch All Catalog Items (Paginated) * Fetch All Catalog Items (Paginated)
* *
@@ -381,4 +381,45 @@ export const opportunityCw = {
return created; return created;
}, },
/**
* Fetch Procurement Product by Forecast Detail
*
* Finds the procurement product linked to a given forecast item ID
* on an opportunity.
*/
fetchProcurementProductByForecastDetail: async (
opportunityId: number,
forecastDetailId: number,
): Promise<CWProcurementProduct | null> => {
const conditions = `opportunity/id=${opportunityId} and forecastDetailId=${forecastDetailId}`;
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(conditions)}&fields=id,forecastDetailId,description,customerDescription,quantity,price,cost,taxableFlag,specialOrderFlag,customFields`,
);
const items = (response.data ?? []) as CWProcurementProduct[];
return items[0] ?? null;
},
/**
* Update Procurement Product
*
* Applies a JSON Patch update to a procurement product record.
*/
updateProcurementProduct: async (
procurementProductId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/procurement/products/${procurementProductId}`,
operations,
);
return response.data as CWProcurementProduct;
},
}; };
+15 -1
View File
@@ -444,7 +444,11 @@ export const PERMISSION_NODES = {
node: "sales.opportunity.product.update", node: "sales.opportunity.product.update",
description: description:
"Update products (forecast items) on an opportunity, including resequencing", "Update products (forecast items) on an opportunity, including resequencing",
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"], usedIn: [
"src/api/sales/[id]/resequenceProducts.ts",
"src/api/sales/[id]/updateProduct.ts",
"src/api/sales/[id]/cancelProduct.ts",
],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
@@ -480,6 +484,16 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"], usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{
node: "sales.opportunity.product.add.labor",
description:
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
usedIn: [
"src/api/sales/[id]/addLabor.ts",
"src/api/sales/[id]/laborOptions.ts",
],
dependencies: ["sales.opportunity.fetch"],
},
], ],
}, },