diff --git a/API_ROUTES.md b/API_ROUTES.md index f4318e6..a1a488b 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -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.` 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` diff --git a/PERMISSIONS.md b/PERMISSIONS.md index 71d456c..aa1468c 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -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.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.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.` 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.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` |
Field-level permissions for sales.opportunity.product.add diff --git a/src/api/sales/[id]/addLabor.ts b/src/api/sales/[id]/addLabor.ts new file mode 100644 index 0000000..459b62b --- /dev/null +++ b/src/api/sales/[id]/addLabor.ts @@ -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"] }), +); diff --git a/src/api/sales/[id]/cancelProduct.ts b/src/api/sales/[id]/cancelProduct.ts new file mode 100644 index 0000000..737d275 --- /dev/null +++ b/src/api/sales/[id]/cancelProduct.ts @@ -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"] }), +); diff --git a/src/api/sales/[id]/laborOptions.ts b/src/api/sales/[id]/laborOptions.ts new file mode 100644 index 0000000..4b11293 --- /dev/null +++ b/src/api/sales/[id]/laborOptions.ts @@ -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"] }), +); diff --git a/src/api/sales/[id]/updateProduct.ts b/src/api/sales/[id]/updateProduct.ts new file mode 100644 index 0000000..02f27df --- /dev/null +++ b/src/api/sales/[id]/updateProduct.ts @@ -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>, + 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 = {}; + 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 = {}; + 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>; + 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"] }), +); diff --git a/src/api/sales/index.ts b/src/api/sales/index.ts index eb3756a..28cd291 100644 --- a/src/api/sales/index.ts +++ b/src/api/sales/index.ts @@ -6,7 +6,11 @@ import { default as refresh } from "./[id]/refresh"; import { default as products } from "./[id]/products"; import { default as addProduct } from "./[id]/addProduct"; 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 updateProduct } from "./[id]/updateProduct"; +import { default as cancelProduct } from "./[id]/cancelProduct"; import { default as notes } from "./[id]/notes"; import { default as fetchNote } from "./[id]/fetchNote"; import { default as createNote } from "./[id]/createNote"; @@ -16,6 +20,8 @@ import { default as contacts } from "./[id]/contacts"; export { addProduct, + addLabor, + laborOptions, addSpecialOrderProduct, count, fetch, @@ -23,6 +29,8 @@ export { fetchOpportunityTypes, products, resequenceProducts, + updateProduct, + cancelProduct, notes, fetchNote, createNote, diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts index 63e35e5..992cadb 100644 --- a/src/controllers/OpportunityController.ts +++ b/src/controllers/OpportunityController.ts @@ -782,6 +782,41 @@ export class OpportunityController { 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 { + 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 * @@ -800,6 +835,7 @@ export class OpportunityController { this.cwOpportunityId, data, ); + await this.appendProductSequenceIds(created.map((item) => item.id)); await invalidateProductsCache(this.cwOpportunityId); return created.map((item) => new ForecastProductController(item)); } catch (err: any) { @@ -845,6 +881,11 @@ export class OpportunityController { })); 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); return created; } 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 { + 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, + ): Promise { + 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 { + 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 * @@ -938,7 +1064,7 @@ export class OpportunityController { id: this.id, cwOpportunityId: this.cwOpportunityId, name: this.name, - notes: this.notes, + description: this.notes, type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null, stage: this.stageCwId ? { id: this.stageCwId, name: this.stageName } diff --git a/src/managers/procurement.ts b/src/managers/procurement.ts index 29ab764..ff2c6e2 100644 --- a/src/managers/procurement.ts +++ b/src/managers/procurement.ts @@ -17,6 +17,61 @@ const catalogItemInclude = { linkedItems: true, } 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 { + 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 { + 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. */ @@ -204,6 +259,36 @@ export const procurement = { 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) * diff --git a/src/modules/cw-utils/opportunities/opportunities.ts b/src/modules/cw-utils/opportunities/opportunities.ts index 06f5e3c..59f2ccc 100644 --- a/src/modules/cw-utils/opportunities/opportunities.ts +++ b/src/modules/cw-utils/opportunities/opportunities.ts @@ -381,4 +381,45 @@ export const opportunityCw = { 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 => { + 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, + ): Promise => { + 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; + }, }; diff --git a/src/types/PermissionNodes.ts b/src/types/PermissionNodes.ts index 55044eb..74ecb9a 100644 --- a/src/types/PermissionNodes.ts +++ b/src/types/PermissionNodes.ts @@ -444,7 +444,11 @@ export const PERMISSION_NODES = { node: "sales.opportunity.product.update", description: "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"], }, { @@ -480,6 +484,16 @@ export const PERMISSION_NODES = { usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"], 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"], + }, ], },