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
|
### 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
@@ -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>
|
||||||
|
|||||||
@@ -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"] }),
|
||||||
|
);
|
||||||
@@ -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"] }),
|
||||||
|
);
|
||||||
@@ -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"] }),
|
||||||
|
);
|
||||||
@@ -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"] }),
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user