Add sales item labor/product route updates and permission docs
This commit is contained in:
+266
@@ -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
|
||||
|
||||
**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.
|
||||
|
||||
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
|
||||
|
||||
**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:
|
||||
- `procurementNotes` → `Procurement Notes` (`id: 29`)
|
||||
- `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:**
|
||||
|
||||
@@ -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** `/sales/opportunities/:identifier/notes`
|
||||
|
||||
Reference in New Issue
Block a user