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
**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`