feat: add product to opportunity route, local product sequencing
- Add POST /v1/sales/opportunities/:identifier/products with field-level permission gating - Add CWForecastItemCreate type for forecast item creation - Store product display order locally (productSequence Int[] on Opportunity) - Rewrite resequenceProducts to be local-only (no CW PUT, stable IDs) - Remove reorderProducts CW util (PUT regenerated IDs & broke procurement) - Update fetchProducts to apply local ordering with CW sequenceNumber fallback - Add productSequence to OpportunityController.toJson() - Update API_ROUTES.md, PERMISSIONS.md, PermissionNodes.ts
This commit is contained in:
@@ -24,9 +24,10 @@ Keep each layer focused:
|
||||
|
||||
## Runtime / tooling
|
||||
|
||||
The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
|
||||
The project runs on **Bun** exclusively — **always use `bun` commands, never `npm`, `npx`, or `yarn`**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Test preloads are configured in `bunfig.toml` so bare `bun test` works. Key scripts in `package.json`:
|
||||
|
||||
- `dev` — `NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload)
|
||||
- `test` — `bun test` (runs all tests with preload from `bunfig.toml`)
|
||||
- `db:gen` — `prisma generate`
|
||||
- `db:push` — `prisma migrate dev --skip-generate`
|
||||
- `utils:dev` — `docker compose -f .docker/docker-compose.yml up --build`
|
||||
@@ -36,7 +37,7 @@ The project runs on **Bun**. DB tooling uses **Prisma**; the generated client li
|
||||
|
||||
## Data layer
|
||||
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`.
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `bun run db:gen` after updating `schema.prisma`.
|
||||
|
||||
## Shared constants (`src/constants.ts`)
|
||||
|
||||
@@ -174,13 +175,14 @@ The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniF
|
||||
|
||||
## Local dev / quick checks
|
||||
|
||||
- Start dev server: `npm run dev`
|
||||
- Regenerate Prisma client: `npm run db:gen`
|
||||
- Apply DB migrations locally: `npm run db:push`
|
||||
- Docker dev utilities: `npm run utils:dev`
|
||||
- Generate private keys: `npm run utils:gen_private_keys`
|
||||
- Create admin role: `npm run utils:create_admin_role`
|
||||
- Assign user role: `npm run utils:assign_user_role`
|
||||
- Start dev server: `bun run dev`
|
||||
- Run tests: `bun test`
|
||||
- Regenerate Prisma client: `bun run db:gen`
|
||||
- Apply DB migrations locally: `bun run db:push`
|
||||
- Docker dev utilities: `bun run utils:dev`
|
||||
- Generate private keys: `bun run utils:gen_private_keys`
|
||||
- Create admin role: `bun run utils:create_admin_role`
|
||||
- Assign user role: `bun run utils:assign_user_role`
|
||||
|
||||
## When editing generated or infra files
|
||||
|
||||
|
||||
+131
-13
@@ -2832,6 +2832,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu
|
||||
"closedBy": null,
|
||||
"companyId": "clx...",
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"productSequence": [31848, 31846, 31847],
|
||||
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-26T10:00:00.000Z",
|
||||
"customFields": [],
|
||||
@@ -3015,6 +3016,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
|
||||
"closedBy": null,
|
||||
"companyId": "clx...",
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"productSequence": [31848, 31846, 31847],
|
||||
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-26T10:00:00.000Z",
|
||||
"customFields": [],
|
||||
@@ -3180,7 +3182,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/products`
|
||||
|
||||
Fetch products (forecast/revenue line items) for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
|
||||
Fetch products (forecast/revenue line items) for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
@@ -3266,7 +3268,9 @@ Internal inventory data is sourced from the local CatalogItem database. If the p
|
||||
|
||||
**PATCH** `/sales/opportunities/:identifier/products/sequence`
|
||||
|
||||
Update the sequence order of products (forecast items) on an opportunity. Sends a `sequenceNumber` PATCH to each forecast item in ConnectWise.
|
||||
Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.
|
||||
|
||||
When a `productSequence` is set, `GET .../products` returns items in that order. Any forecast items not included in the array (e.g. newly added items) are appended at the end in CW `sequenceNumber` order.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
@@ -3280,11 +3284,11 @@ Update the sequence order of products (forecast items) on an opportunity. Sends
|
||||
|
||||
```json
|
||||
{
|
||||
"orderedIds": [31846, 31847, 31848]
|
||||
"orderedIds": [31848, 31846, 31847]
|
||||
}
|
||||
```
|
||||
|
||||
- `orderedIds` — Array of forecast item IDs in the desired sequence order. Position in the array determines the `sequenceNumber` (1-based).
|
||||
- `orderedIds` — Array of CW forecast item IDs in the desired display order. All IDs must exist on the opportunity's forecast in ConnectWise.
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -3295,24 +3299,138 @@ Update the sequence order of products (forecast items) on an opportunity. Sends
|
||||
"data": {
|
||||
"products": [
|
||||
{
|
||||
"id": 31850,
|
||||
"id": 31848,
|
||||
"forecastDescription": "Hardware",
|
||||
"sequenceNumber": 3,
|
||||
"..."
|
||||
},
|
||||
{
|
||||
"id": 31846,
|
||||
"forecastDescription": "Service",
|
||||
"sequenceNumber": 1,
|
||||
"..."
|
||||
},
|
||||
{
|
||||
"id": 31847,
|
||||
"forecastDescription": "Licensing",
|
||||
"sequenceNumber": 2,
|
||||
"..."
|
||||
}
|
||||
],
|
||||
"idMap": {
|
||||
"31846": 31850,
|
||||
"31847": 31851,
|
||||
"31848": 31852
|
||||
}
|
||||
]
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
- `data.products` — Full updated product objects (IDs may change after PUT to ConnectWise).
|
||||
- `data.idMap` — Maps each original forecast item ID (from the request) to the new ID returned by ConnectWise. Use this to update references in the UI.
|
||||
- `data.products` — Full product objects in the new display order. IDs are unchanged — CW `sequenceNumber` still reflects the original CW order, but the array order matches the locally stored sequence.
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.product.add`
|
||||
|
||||
**Field-Level Permission Gating:** Yes — uses `processObjectValuePerms` with scope `sales.opportunity.product.field` on the **input body**. See the field-level permissions table under `sales.opportunity.product.add` in PERMISSIONS.md.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Request Body:**
|
||||
|
||||
All fields are optional. Only fields the user has the corresponding `sales.opportunity.product.field.<field>` permission for will be sent to ConnectWise.
|
||||
|
||||
```json
|
||||
{
|
||||
"catalogItem": { "id": 1234 },
|
||||
"forecastDescription": "Managed Services",
|
||||
"productDescription": "Monthly managed services agreement",
|
||||
"quantity": 1,
|
||||
"status": { "id": 1 },
|
||||
"productClass": "Agreement",
|
||||
"forecastType": "Product",
|
||||
"revenue": 500.0,
|
||||
"cost": 250.0,
|
||||
"includeFlag": true,
|
||||
"linkFlag": false,
|
||||
"recurringFlag": true,
|
||||
"taxableFlag": true,
|
||||
"recurringRevenue": 500.0,
|
||||
"recurringCost": 250.0,
|
||||
"cycles": 12,
|
||||
"sequenceNumber": 1
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------------- | ---------------- | ------------------------------------------------ |
|
||||
| `catalogItem` | `{ id: number }` | ConnectWise catalog item reference |
|
||||
| `forecastDescription` | string | Forecast description text |
|
||||
| `productDescription` | string | Product description text |
|
||||
| `quantity` | number | Quantity (must be positive) |
|
||||
| `status` | `{ id: number }` | ConnectWise status reference |
|
||||
| `productClass` | string | Product class (e.g. Product, Service, Agreement) |
|
||||
| `forecastType` | string | Forecast type |
|
||||
| `revenue` | number | Revenue amount |
|
||||
| `cost` | number | Cost amount |
|
||||
| `includeFlag` | boolean | Whether to include in forecast totals |
|
||||
| `linkFlag` | boolean | Whether the item is linked |
|
||||
| `recurringFlag` | boolean | Whether this is a recurring item |
|
||||
| `taxableFlag` | boolean | Whether this item is taxable |
|
||||
| `recurringRevenue` | number | Recurring revenue amount |
|
||||
| `recurringCost` | number | Recurring cost amount |
|
||||
| `cycles` | number | Number of recurring cycles (integer, min 0) |
|
||||
| `sequenceNumber` | number | Display sequence number (integer, min 0) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 201,
|
||||
"message": "Product added to opportunity successfully!",
|
||||
"data": {
|
||||
"id": 31855,
|
||||
"forecastDescription": "Managed Services",
|
||||
"opportunity": { "id": 5678, "name": "Example Opportunity" },
|
||||
"quantity": 1,
|
||||
"status": { "id": 1, "name": "Open" },
|
||||
"catalogItem": { "id": 1234, "identifier": "MSP-001" },
|
||||
"productDescription": "Monthly managed services agreement",
|
||||
"productClass": "Agreement",
|
||||
"forecastType": "Product",
|
||||
"revenue": 500.0,
|
||||
"cost": 250.0,
|
||||
"margin": 250.0,
|
||||
"profit": 250.0,
|
||||
"percentage": 0,
|
||||
"includeFlag": true,
|
||||
"linkFlag": false,
|
||||
"recurringFlag": true,
|
||||
"taxableFlag": true,
|
||||
"recurringRevenue": 500.0,
|
||||
"recurringCost": 250.0,
|
||||
"cycles": 12,
|
||||
"sequenceNumber": 1,
|
||||
"subNumber": 0,
|
||||
"cwLastUpdated": "2026-03-01T00:00:00.000Z",
|
||||
"cwUpdatedBy": "Admin1",
|
||||
"cancelled": false,
|
||||
"cancellationType": null,
|
||||
"quantityCancelled": 0,
|
||||
"cancelledReason": null,
|
||||
"cancelledDate": null,
|
||||
"onHand": null,
|
||||
"inStock": null
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
+29
-1
@@ -129,7 +129,7 @@ Admin-specific UI permissions that control visibility and data loading for admin
|
||||
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
|
||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||
@@ -137,6 +137,34 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
||||
| `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.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` |
|
||||
|
||||
<details>
|
||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||
|
||||
Each submitted field is gated by a `sales.opportunity.product.field.<field>` permission node. Only fields the user has permission for are forwarded to ConnectWise.
|
||||
|
||||
| Field Permission Node | Description |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference |
|
||||
| `sales.opportunity.product.field.forecastDescription` | Set the forecast description |
|
||||
| `sales.opportunity.product.field.productDescription` | Set the product description |
|
||||
| `sales.opportunity.product.field.quantity` | Set the quantity |
|
||||
| `sales.opportunity.product.field.status` | Set the status reference |
|
||||
| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) |
|
||||
| `sales.opportunity.product.field.forecastType` | Set the forecast type |
|
||||
| `sales.opportunity.product.field.revenue` | Set the revenue amount |
|
||||
| `sales.opportunity.product.field.cost` | Set the cost amount |
|
||||
| `sales.opportunity.product.field.includeFlag` | Set the include flag |
|
||||
| `sales.opportunity.product.field.linkFlag` | Set the link flag |
|
||||
| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag |
|
||||
| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag |
|
||||
| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount |
|
||||
| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount |
|
||||
| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles |
|
||||
| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) |
|
||||
|
||||
</details>
|
||||
|
||||
### UniFi Permissions
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
File diff suppressed because one or more lines are too long
@@ -1334,6 +1334,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
closedByName: 'closedByName',
|
||||
closedByCwId: 'closedByCwId',
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
|
||||
@@ -221,6 +221,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
closedByName: 'closedByName',
|
||||
closedByCwId: 'closedByCwId',
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
|
||||
@@ -43,6 +43,7 @@ export type OpportunityAvgAggregateOutputType = {
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
productSequence: number | null
|
||||
}
|
||||
|
||||
export type OpportunitySumAggregateOutputType = {
|
||||
@@ -62,6 +63,7 @@ export type OpportunitySumAggregateOutputType = {
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
productSequence: number[]
|
||||
}
|
||||
|
||||
export type OpportunityMinAggregateOutputType = {
|
||||
@@ -206,6 +208,7 @@ export type OpportunityCountAggregateOutputType = {
|
||||
closedByName: number
|
||||
closedByCwId: number
|
||||
companyId: number
|
||||
productSequence: number
|
||||
cwLastUpdated: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
@@ -230,6 +233,7 @@ export type OpportunityAvgAggregateInputType = {
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
productSequence?: true
|
||||
}
|
||||
|
||||
export type OpportunitySumAggregateInputType = {
|
||||
@@ -249,6 +253,7 @@ export type OpportunitySumAggregateInputType = {
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
productSequence?: true
|
||||
}
|
||||
|
||||
export type OpportunityMinAggregateInputType = {
|
||||
@@ -393,6 +398,7 @@ export type OpportunityCountAggregateInputType = {
|
||||
closedByName?: true
|
||||
closedByCwId?: true
|
||||
companyId?: true
|
||||
productSequence?: true
|
||||
cwLastUpdated?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
@@ -529,6 +535,7 @@ export type OpportunityGroupByOutputType = {
|
||||
closedByName: string | null
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -601,6 +608,7 @@ export type OpportunityWhereInput = {
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
@@ -651,6 +659,7 @@ export type OpportunityOrderByWithRelationInput = {
|
||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -704,6 +713,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
@@ -754,6 +764,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -811,6 +822,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
||||
closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||
@@ -859,6 +871,7 @@ export type OpportunityCreateInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -909,6 +922,7 @@ export type OpportunityUncheckedCreateInput = {
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -957,6 +971,7 @@ export type OpportunityUpdateInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1007,6 +1022,7 @@ export type OpportunityUncheckedUpdateInput = {
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1056,6 +1072,7 @@ export type OpportunityCreateManyInput = {
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1104,6 +1121,7 @@ export type OpportunityUpdateManyMutationInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1153,6 +1171,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1168,6 +1187,14 @@ export type OpportunityOrderByRelationAggregateInput = {
|
||||
_count?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type IntNullableListFilter<$PrismaModel = never> = {
|
||||
equals?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
has?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
hasEvery?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
hasSome?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityCountOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwOpportunityId?: Prisma.SortOrder
|
||||
@@ -1212,6 +1239,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
||||
closedByName?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -1234,6 +1262,7 @@ export type OpportunityAvgOrderByAggregateInput = {
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityMaxOrderByAggregateInput = {
|
||||
@@ -1351,6 +1380,7 @@ export type OpportunitySumOrderByAggregateInput = {
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityCreateNestedManyWithoutCompanyInput = {
|
||||
@@ -1395,6 +1425,15 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyNestedInput = {
|
||||
deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateproductSequenceInput = {
|
||||
set: number[]
|
||||
}
|
||||
|
||||
export type OpportunityUpdateproductSequenceInput = {
|
||||
set?: number[]
|
||||
push?: number | number[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateWithoutCompanyInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
@@ -1438,6 +1477,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1486,6 +1526,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1564,6 +1605,7 @@ export type OpportunityScalarWhereInput = {
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
@@ -1612,6 +1654,7 @@ export type OpportunityCreateManyCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1660,6 +1703,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1708,6 +1752,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1756,6 +1801,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1807,6 +1853,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1857,6 +1904,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1907,6 +1955,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1957,12 +2006,13 @@ export type OpportunitySelectScalar = {
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
}
|
||||
@@ -2022,6 +2072,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
closedByName: string | null
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -2492,6 +2543,7 @@ export interface OpportunityFieldRefs {
|
||||
readonly closedByName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
||||
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
||||
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
|
||||
@@ -185,6 +185,10 @@ model Opportunity {
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
|
||||
// Local product sequence — array of CW forecast item IDs in display order.
|
||||
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
|
||||
productSequence Int[] @default([])
|
||||
|
||||
cwLastUpdated DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
import { z } from "zod";
|
||||
|
||||
const productItemSchema = z
|
||||
.object({
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
forecastType: z.string().optional(),
|
||||
revenue: z.number().optional(),
|
||||
cost: z.number().optional(),
|
||||
includeFlag: z.boolean().optional(),
|
||||
linkFlag: z.boolean().optional(),
|
||||
recurringFlag: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
recurringRevenue: z.number().optional(),
|
||||
recurringCost: z.number().optional(),
|
||||
cycles: z.number().int().min(0).optional(),
|
||||
sequenceNumber: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const addProductSchema = z.union([
|
||||
productItemSchema,
|
||||
z.array(productItemSchema).min(1, "At least one product is required"),
|
||||
]);
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const validated = addProductSchema.parse(body);
|
||||
const inputItems = Array.isArray(validated) ? validated : [validated];
|
||||
|
||||
// Gate each submitted field against user permissions.
|
||||
// Only fields the user has permission for are forwarded to ConnectWise.
|
||||
const user = c.get("user");
|
||||
const gatedItems = await Promise.all(
|
||||
inputItems.map((item) =>
|
||||
processObjectValuePerms(item, "sales.opportunity.product.field", user),
|
||||
),
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
isBatch
|
||||
? `${created.length} product(s) added to opportunity successfully!`
|
||||
: "Product added to opportunity successfully!",
|
||||
isBatch ? created.map((p) => p.toJson()) : created[0]!.toJson(),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add"] }),
|
||||
);
|
||||
@@ -24,17 +24,10 @@ export default createRoute(
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const updated = await item.resequenceProducts(orderedIds);
|
||||
|
||||
// Map original IDs to the new IDs returned by ConnectWise
|
||||
const idMap: Record<number, number> = {};
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
idMap[orderedIds[i]!] = updated[i]!.cwForecastId;
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product sequence updated successfully!",
|
||||
{
|
||||
products: updated.map((p) => p.toJson()),
|
||||
idMap,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { default as count } from "./count";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refresh } from "./[id]/refresh";
|
||||
import { default as products } from "./[id]/products";
|
||||
import { default as addProduct } from "./[id]/addProduct";
|
||||
import { default as resequenceProducts } from "./[id]/resequenceProducts";
|
||||
import { default as notes } from "./[id]/notes";
|
||||
import { default as fetchNote } from "./[id]/fetchNote";
|
||||
@@ -13,6 +14,7 @@ import { default as deleteNote } from "./[id]/deleteNote";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
|
||||
export {
|
||||
addProduct,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import {
|
||||
CWCustomField,
|
||||
CWForecastItemCreate,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
@@ -78,6 +79,10 @@ export class OpportunityController {
|
||||
public companyId: string | null;
|
||||
public cwLastUpdated: Date | null;
|
||||
|
||||
// Local product display order — array of CW forecast item IDs.
|
||||
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
||||
public productSequence: number[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
@@ -145,6 +150,7 @@ export class OpportunityController {
|
||||
|
||||
this.companyId = data.companyId;
|
||||
this.cwLastUpdated = data.cwLastUpdated;
|
||||
this.productSequence = data.productSequence;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
@@ -400,9 +406,29 @@ export class OpportunityController {
|
||||
}
|
||||
}
|
||||
|
||||
const controllers = (forecast.forecastItems ?? [])
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
|
||||
.map((item) => {
|
||||
// Apply local ordering if productSequence is set, otherwise fall back
|
||||
// to CW sequenceNumber.
|
||||
const forecastItems = forecast.forecastItems ?? [];
|
||||
let ordered: typeof forecastItems;
|
||||
|
||||
if (this.productSequence.length > 0) {
|
||||
const itemById = new Map(forecastItems.map((fi) => [fi.id, fi]));
|
||||
// Items in the specified order first, then any new items not yet sequenced
|
||||
const sequenced = this.productSequence
|
||||
.map((id) => itemById.get(id))
|
||||
.filter((fi): fi is NonNullable<typeof fi> => fi !== undefined);
|
||||
const sequencedIds = new Set(this.productSequence);
|
||||
const unsequenced = forecastItems
|
||||
.filter((fi) => !sequencedIds.has(fi.id))
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
ordered = [...sequenced, ...unsequenced];
|
||||
} else {
|
||||
ordered = [...forecastItems].sort(
|
||||
(a, b) => a.sequenceNumber - b.sequenceNumber,
|
||||
);
|
||||
}
|
||||
|
||||
const controllers = ordered.map((item) => {
|
||||
const ctrl = new ForecastProductController(item);
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
@@ -556,25 +582,22 @@ export class OpportunityController {
|
||||
/**
|
||||
* Resequence Products
|
||||
*
|
||||
* Updates the sequenceNumber on each forecast item to match the
|
||||
* order provided. Fetches the current items first so the PUT
|
||||
* includes all required fields. Expects an array of forecast item
|
||||
* IDs in the desired order.
|
||||
* Stores the desired display order of forecast item IDs locally in
|
||||
* the database. No CW API calls are made — CW item IDs are stable
|
||||
* and ordering is applied when `fetchProducts()` is called.
|
||||
*
|
||||
* @param orderedIds - Forecast item IDs in the desired sequence order
|
||||
* @param orderedIds - Forecast item IDs in the desired display order
|
||||
*/
|
||||
public async resequenceProducts(
|
||||
orderedIds: number[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
// Fetch existing items so we can include required fields in the PUT
|
||||
// Validate all IDs exist in CW
|
||||
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
|
||||
const itemMap = new Map(
|
||||
(forecast.forecastItems ?? []).map((fi) => [fi.id, fi]),
|
||||
const existingIds = new Set(
|
||||
(forecast.forecastItems ?? []).map((fi) => fi.id),
|
||||
);
|
||||
|
||||
// Validate all IDs exist before making any updates
|
||||
for (const id of orderedIds) {
|
||||
if (!itemMap.has(id)) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
@@ -583,43 +606,60 @@ export class OpportunityController {
|
||||
}
|
||||
}
|
||||
|
||||
// Run updates in reverse order to CW
|
||||
const results: ForecastProductController[] = new Array(orderedIds.length);
|
||||
for (let index = orderedIds.length - 1; index >= 0; index--) {
|
||||
const id = orderedIds[index]!;
|
||||
const existing = itemMap.get(id)!;
|
||||
const raw = JSON.parse(JSON.stringify(existing)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Strip read-only _info fields at top level and nested sub-objects
|
||||
delete raw._info;
|
||||
for (const key of ["opportunity", "status", "catalogItem"]) {
|
||||
if (raw[key] && typeof raw[key] === "object") {
|
||||
delete (raw[key] as Record<string, unknown>)._info;
|
||||
}
|
||||
}
|
||||
|
||||
const newSeq = index + 1;
|
||||
|
||||
const result = await this.updateProduct(id, {
|
||||
...raw,
|
||||
sequenceNumber: newSeq,
|
||||
// Persist the sequence locally
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: orderedIds },
|
||||
});
|
||||
this.productSequence = orderedIds;
|
||||
|
||||
results[index] = result;
|
||||
}
|
||||
return results;
|
||||
// Return items in the new order
|
||||
return this.fetchProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Product
|
||||
* Add Products
|
||||
*
|
||||
* Adds a new product/line item to this opportunity.
|
||||
* Adds one or more products/line items to this opportunity via the
|
||||
* ConnectWise forecast endpoint. The caller passes only the fields
|
||||
* the user is permitted to set (already filtered by field-level
|
||||
* permission gating in the route handler).
|
||||
*
|
||||
* Accepts a single item or an array of items.
|
||||
*/
|
||||
public async addProduct(): Promise<void> {
|
||||
// TODO: implement
|
||||
public async addProducts(
|
||||
data: CWForecastItemCreate | CWForecastItemCreate[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
try {
|
||||
const created = await opportunityCw.createProducts(
|
||||
this.cwOpportunityId,
|
||||
data,
|
||||
);
|
||||
return created.map((item) => new ForecastProductController(item));
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText,
|
||||
responseData: err?.response?.data,
|
||||
message: err?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw new GenericError({
|
||||
status: err?.response?.status ?? 500,
|
||||
name: "AddProductFailed",
|
||||
message:
|
||||
err?.response?.data?.message ??
|
||||
"Failed to add product(s) to opportunity",
|
||||
cause: err?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -751,6 +791,7 @@ export class OpportunityController {
|
||||
: null,
|
||||
companyId: this.companyId,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
productSequence: this.productSequence,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
customFields: this._customFields ?? [],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CWOpportunitySummary,
|
||||
CWForecast,
|
||||
CWForecastItem,
|
||||
CWForecastItemCreate,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityNoteCreate,
|
||||
CWOpportunityNoteUpdate,
|
||||
@@ -118,27 +119,137 @@ export const opportunityCw = {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities/${opportunityId}/forecast`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[CW fetchProducts] Opportunity ${opportunityId} forecast raw data:`,
|
||||
JSON.stringify(response.data, null, 2),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Forecast Items
|
||||
*
|
||||
* Adds one or more forecast items (products) to an opportunity using
|
||||
* POST. The CW forecast endpoint expects a Forecast object with a
|
||||
* `forecastItems` array — we wrap just the new items inside that
|
||||
* structure so existing items are never sent or touched.
|
||||
*/
|
||||
createProducts: async (
|
||||
opportunityId: number,
|
||||
data: CWForecastItemCreate | CWForecastItemCreate[],
|
||||
): Promise<CWForecastItem[]> => {
|
||||
const items_to_add = Array.isArray(data) ? data : [data];
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
|
||||
// 1. Fetch existing forecast to derive defaults & diff IDs later
|
||||
const existing = await opportunityCw.fetchProducts(opportunityId);
|
||||
const existingIds = new Set(
|
||||
(existing.forecastItems ?? []).map((fi) => fi.id),
|
||||
);
|
||||
|
||||
// Derive sensible defaults from an existing item when available
|
||||
const templateItem = (existing.forecastItems ?? [])[0];
|
||||
const defaultStatus = templateItem?.status
|
||||
? { id: templateItem.status.id }
|
||||
: { id: 1 };
|
||||
const defaultForecastType = templateItem?.forecastType ?? "Product";
|
||||
|
||||
// 2. Build forecast items with required CW fields filled in
|
||||
const forecastItems = items_to_add.map((newItem) => ({
|
||||
opportunity: { id: opportunityId },
|
||||
status: defaultStatus,
|
||||
forecastType: defaultForecastType,
|
||||
...(newItem as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
// 3. POST a Forecast wrapper containing only the new items
|
||||
const response = await connectWiseApi.post(url, { forecastItems });
|
||||
const updatedForecast: CWForecast = response.data;
|
||||
|
||||
// 4. Find newly-created item(s) by diffing IDs
|
||||
const newItems = (updatedForecast.forecastItems ?? []).filter(
|
||||
(fi) => !existingIds.has(fi.id),
|
||||
);
|
||||
|
||||
// Fall back to the last N items if ID diffing finds nothing
|
||||
return newItems.length > 0
|
||||
? newItems
|
||||
: (updatedForecast.forecastItems ?? []).slice(-items_to_add.length);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Forecast Item
|
||||
*
|
||||
* Updates a single forecast item (product) on an opportunity using PUT.
|
||||
* PATCHes a single forecast item on the parent `/forecast` endpoint.
|
||||
* CW supports JSON Patch with paths like `/forecastItems/{index}/field`.
|
||||
* This preserves item IDs (unlike PUT which always regenerates them)
|
||||
* and does NOT recalculate revenue/cost from linked catalog items.
|
||||
*
|
||||
* NOTE: Not all fields are patchable — `sequenceNumber` and `quantity`
|
||||
* are read-only on forecast items. Product ordering is managed locally
|
||||
* via `OpportunityController.resequenceProducts()` and stored in the
|
||||
* database `productSequence` field.
|
||||
*/
|
||||
updateProduct: async (
|
||||
opportunityId: number,
|
||||
forecastItemId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<CWForecastItem> => {
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast/${forecastItemId}`;
|
||||
const response = await connectWiseApi.put(url, data);
|
||||
return response.data;
|
||||
const forecast = await opportunityCw.fetchProducts(opportunityId);
|
||||
const items = forecast.forecastItems ?? [];
|
||||
const idx = items.findIndex((fi) => fi.id === forecastItemId);
|
||||
if (idx === -1) {
|
||||
throw new Error(
|
||||
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const operations = Object.entries(data).map(([key, value]) => ({
|
||||
op: "replace" as const,
|
||||
path: `/forecastItems/${idx}/${key}`,
|
||||
value,
|
||||
}));
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
const response = await connectWiseApi.patch(url, operations);
|
||||
const updated: CWForecast = response.data;
|
||||
return (updated.forecastItems ?? [])[idx]!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk-update Forecast Items
|
||||
*
|
||||
* PATCHes multiple forecast items in a single request via the parent
|
||||
* `/forecast` endpoint. All patch operations are sent in one array.
|
||||
*/
|
||||
bulkUpdateProducts: async (
|
||||
opportunityId: number,
|
||||
updates: Map<number, Record<string, unknown>>,
|
||||
): Promise<CWForecastItem[]> => {
|
||||
const forecast = await opportunityCw.fetchProducts(opportunityId);
|
||||
const items = forecast.forecastItems ?? [];
|
||||
|
||||
const operations: { op: "replace"; path: string; value: unknown }[] = [];
|
||||
const touchedIndices: number[] = [];
|
||||
|
||||
for (const [itemId, changes] of updates) {
|
||||
const idx = items.findIndex((fi) => fi.id === itemId);
|
||||
if (idx === -1) {
|
||||
throw new Error(
|
||||
`Forecast item ${itemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
touchedIndices.push(idx);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `/forecastItems/${idx}/${key}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
const response = await connectWiseApi.patch(url, operations);
|
||||
const updated: CWForecast = response.data;
|
||||
|
||||
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -206,6 +206,26 @@ export interface CWOpportunityContact {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWForecastItemCreate {
|
||||
catalogItem?: { id: number };
|
||||
forecastDescription?: string;
|
||||
productDescription?: string;
|
||||
quantity?: number;
|
||||
status?: { id: number };
|
||||
productClass?: string;
|
||||
forecastType?: string;
|
||||
revenue?: number;
|
||||
cost?: number;
|
||||
includeFlag?: boolean;
|
||||
linkFlag?: boolean;
|
||||
recurringFlag?: boolean;
|
||||
taxableFlag?: boolean;
|
||||
recurringRevenue?: number;
|
||||
recurringCost?: number;
|
||||
cycles?: number;
|
||||
sequenceNumber?: number;
|
||||
}
|
||||
|
||||
export interface CWOpportunitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
|
||||
@@ -440,6 +440,32 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.add",
|
||||
description:
|
||||
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
|
||||
usedIn: ["src/api/sales/[id]/addProduct.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
fieldLevelPermissions: [
|
||||
"sales.opportunity.product.field.catalogItem",
|
||||
"sales.opportunity.product.field.forecastDescription",
|
||||
"sales.opportunity.product.field.productDescription",
|
||||
"sales.opportunity.product.field.quantity",
|
||||
"sales.opportunity.product.field.status",
|
||||
"sales.opportunity.product.field.productClass",
|
||||
"sales.opportunity.product.field.forecastType",
|
||||
"sales.opportunity.product.field.revenue",
|
||||
"sales.opportunity.product.field.cost",
|
||||
"sales.opportunity.product.field.includeFlag",
|
||||
"sales.opportunity.product.field.linkFlag",
|
||||
"sales.opportunity.product.field.recurringFlag",
|
||||
"sales.opportunity.product.field.taxableFlag",
|
||||
"sales.opportunity.product.field.recurringRevenue",
|
||||
"sales.opportunity.product.field.recurringCost",
|
||||
"sales.opportunity.product.field.cycles",
|
||||
"sales.opportunity.product.field.sequenceNumber",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Test Script: Forecast Item Resequencing & Procurement Linkage
|
||||
*
|
||||
* Validates the CW forecast API behaviour discovered via probing:
|
||||
* - `sequenceNumber` is read-only — display order = array position
|
||||
* - PUT always regenerates all forecast item IDs
|
||||
* - Revenue & cost are preserved through PUT
|
||||
* - PATCH on /forecast with `/forecastItems/{idx}/field` paths works
|
||||
* for some fields (e.g. forecastDescription) and preserves IDs
|
||||
*
|
||||
* Test flow:
|
||||
* 1. Create opportunity under XYZ Test Company
|
||||
* 2. Add 4 products via POST
|
||||
* 3. Create procurement products (linked by forecastDetailId)
|
||||
* 4. Cancel one procurement product
|
||||
* 5. Reorder forecast items via PUT (reverse order)
|
||||
* 6. Remap procurement forecastDetailId to new IDs
|
||||
* 7. Verify: order correct, prices preserved, cancellation data intact
|
||||
* 8. Clean up
|
||||
*
|
||||
* Usage: bun run test-forecast-resequence.ts
|
||||
*/
|
||||
import axios from "axios";
|
||||
|
||||
const cw = axios.create({
|
||||
baseURL: "https://ttscw.totaltech.net/v4_6_release/apis/3.0/",
|
||||
headers: {
|
||||
Authorization: `Basic ${process.env.CW_BASIC_TOKEN}`,
|
||||
clientId: `${process.env.CW_CLIENT_ID}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const log = (label: string, ...args: unknown[]) =>
|
||||
console.log(`\n[${label}]`, ...args);
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
// ── 1. Find company ─────────────────────────────────────────────────────
|
||||
log("SETUP", "Finding XYZ Test Company...");
|
||||
const compRes = await cw.get(
|
||||
`/company/companies?conditions=${encodeURIComponent("name like 'XYZ Test%'")}&fields=id,identifier,name`,
|
||||
);
|
||||
if (compRes.data.length === 0) {
|
||||
console.error("ERROR: 'XYZ Test Company' not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
const company = compRes.data[0];
|
||||
log("SETUP", `Company: ${company.name} (id=${company.id})`);
|
||||
|
||||
// ── 2. Create opportunity ───────────────────────────────────────────────
|
||||
log("SETUP", "Creating test opportunity...");
|
||||
const oppRes = await cw.post("/sales/opportunities", {
|
||||
name: `[TEST] Resequence – ${new Date().toISOString().slice(0, 16)}`,
|
||||
company: { id: company.id },
|
||||
contact: { id: 1 },
|
||||
primarySalesRep: { id: 153 },
|
||||
expectedCloseDate: new Date(Date.now() + 30 * 86_400_000)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, "Z"),
|
||||
});
|
||||
const oppId = oppRes.data.id;
|
||||
log("SETUP", `Created opportunity id=${oppId}`);
|
||||
|
||||
const forecastUrl = `/sales/opportunities/${oppId}/forecast`;
|
||||
|
||||
// Track IDs for cleanup
|
||||
const procIdsToClean: number[] = [];
|
||||
|
||||
try {
|
||||
// ── 3. Add 4 products ───────────────────────────────────────────────────
|
||||
log("PRODUCTS", "Adding 4 products...");
|
||||
const postRes = await cw.post(forecastUrl, {
|
||||
forecastItems: [
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Alpha",
|
||||
revenue: 100,
|
||||
cost: 50,
|
||||
forecastType: "Product",
|
||||
},
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Bravo",
|
||||
revenue: 250,
|
||||
cost: 125,
|
||||
forecastType: "Product",
|
||||
},
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Charlie",
|
||||
revenue: 30,
|
||||
cost: 10,
|
||||
forecastType: "Product",
|
||||
},
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Delta",
|
||||
revenue: 75,
|
||||
cost: 40,
|
||||
forecastType: "Product",
|
||||
},
|
||||
],
|
||||
});
|
||||
const items: any[] = postRes.data.forecastItems ?? [];
|
||||
log("PRODUCTS", `Created ${items.length} items:`);
|
||||
for (const it of items) {
|
||||
console.log(
|
||||
` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot prices
|
||||
const priceSnap = new Map<string, { rev: number; cost: number }>(
|
||||
items.map((i) => [
|
||||
i.forecastDescription,
|
||||
{ rev: i.revenue, cost: i.cost },
|
||||
]),
|
||||
);
|
||||
|
||||
// ── 4. Create procurement products ──────────────────────────────────────
|
||||
log("PROCUREMENT", "Creating procurement products...");
|
||||
const procProducts: any[] = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
const pr = await cw.post("/procurement/products", {
|
||||
catalogItem: { id: 87 },
|
||||
description: item.forecastDescription,
|
||||
quantity: 1,
|
||||
price: item.revenue,
|
||||
cost: item.cost,
|
||||
billableOption: "Billable",
|
||||
opportunity: { id: oppId },
|
||||
forecastDetailId: item.id,
|
||||
});
|
||||
procProducts.push(pr.data);
|
||||
procIdsToClean.push(pr.data.id);
|
||||
console.log(
|
||||
` ✓ Proc ${pr.data.id} → forecastDetailId=${pr.data.forecastDetailId} "${item.forecastDescription}"`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.log(
|
||||
` ✗ Failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (procProducts.length === 0) {
|
||||
log(
|
||||
"PROCUREMENT",
|
||||
"Could not create procurement products (permission issue?).",
|
||||
);
|
||||
log(
|
||||
"PROCUREMENT",
|
||||
"Will run reorder test without cancellation verification.",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. Cancel "Bravo" procurement product ───────────────────────────────
|
||||
const bravoProc = procProducts.find((p: any) => p.description === "Bravo");
|
||||
if (bravoProc) {
|
||||
log("CANCEL", `Cancelling Bravo (proc id=${bravoProc.id})...`);
|
||||
try {
|
||||
await cw.patch(`/procurement/products/${bravoProc.id}`, [
|
||||
{ op: "replace", path: "cancelledFlag", value: true },
|
||||
{ op: "replace", path: "quantityCancelled", value: 1 },
|
||||
{
|
||||
op: "replace",
|
||||
path: "cancelledReason",
|
||||
value: "Test cancellation",
|
||||
},
|
||||
]);
|
||||
log("CANCEL", "✓ Cancelled.");
|
||||
} catch (e: any) {
|
||||
log(
|
||||
"CANCEL",
|
||||
`✗ ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5b. Check for auto-created forecast items ─────────────────────────
|
||||
await sleep(300);
|
||||
const midForecast = await cw.get(forecastUrl);
|
||||
const midItems = midForecast.data.forecastItems ?? [];
|
||||
log(
|
||||
"OBSERVE",
|
||||
`Forecast items after procurement creation: ${midItems.length} (was ${items.length})`,
|
||||
);
|
||||
if (midItems.length !== items.length) {
|
||||
log(
|
||||
"OBSERVE",
|
||||
"⚠ Creating procurement products auto-created additional forecast items!",
|
||||
);
|
||||
for (const mi of midItems) {
|
||||
const isOriginal = items.some((i: any) => i.id === mi.id);
|
||||
console.log(
|
||||
` id=${mi.id} desc="${mi.forecastDescription}" ${isOriginal ? "(original)" : "(AUTO-CREATED by procurement)"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot procurement state before reorder
|
||||
const beforeProc = await cw.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
|
||||
);
|
||||
// Build map by description for cross-PUT comparison (IDs will change)
|
||||
const beforeByDesc = new Map<string, any>();
|
||||
log(
|
||||
"SNAPSHOT",
|
||||
`${beforeProc.data.length} procurement products before reorder:`,
|
||||
);
|
||||
for (const p of beforeProc.data) {
|
||||
beforeByDesc.set(p.description, p);
|
||||
console.log(
|
||||
` Proc ${p.id}: forecastDetailId=${p.forecastDetailId} cancelled=${p.cancelledFlag} qty=${p.quantityCancelled} reason="${p.cancelledReason ?? ""}" "${p.description}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Record old procurement IDs for later comparison
|
||||
const oldProcIds = new Set(beforeProc.data.map((p: any) => p.id));
|
||||
|
||||
// ── 6. Reorder: reverse ONLY the original 4 forecast items ──────────────
|
||||
log("REORDER", "Reversing forecast item order via PUT...");
|
||||
|
||||
// Only reorder the original items; keep any auto-created ones in place
|
||||
const originalDescs = new Set(items.map((i: any) => i.forecastDescription));
|
||||
const originals = midItems.filter(
|
||||
(i: any) =>
|
||||
originalDescs.has(i.forecastDescription) &&
|
||||
items.some((o: any) => o.id === i.id),
|
||||
);
|
||||
const extras = midItems.filter(
|
||||
(i: any) => !originals.some((o: any) => o.id === i.id),
|
||||
);
|
||||
|
||||
const reversedOriginals = [...originals].reverse();
|
||||
const reorderedAll = [...reversedOriginals, ...extras];
|
||||
|
||||
const clone = JSON.parse(JSON.stringify(midForecast.data));
|
||||
clone.forecastItems = JSON.parse(JSON.stringify(reorderedAll));
|
||||
|
||||
const putRes = await cw.put(forecastUrl, clone);
|
||||
const newItems: any[] = putRes.data.forecastItems ?? [];
|
||||
|
||||
log("REORDER", `After PUT (${newItems.length} items):`);
|
||||
for (const it of newItems) {
|
||||
console.log(
|
||||
` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build old→new ID map by position (for original items only)
|
||||
const idMap = new Map<number, number>();
|
||||
for (let i = 0; i < reversedOriginals.length && i < newItems.length; i++) {
|
||||
idMap.set(reversedOriginals[i].id, newItems[i].id);
|
||||
}
|
||||
log("ID MAP", "Forecast item Old → New:");
|
||||
for (const [oldId, newId] of idMap) {
|
||||
console.log(` ${oldId} → ${newId}`);
|
||||
}
|
||||
|
||||
// ── 7. Check if procurement products survived PUT ───────────────────────
|
||||
await sleep(300);
|
||||
const afterProc = await cw.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
|
||||
);
|
||||
const newProcIds = new Set(afterProc.data.map((p: any) => p.id));
|
||||
|
||||
log(
|
||||
"PROCUREMENT SURVIVAL",
|
||||
"Checking if procurement product IDs survived PUT...",
|
||||
);
|
||||
const procSurvived = [...oldProcIds].every((id) => newProcIds.has(id));
|
||||
if (procSurvived) {
|
||||
console.log(" ✓ All original procurement product IDs survived PUT.");
|
||||
} else {
|
||||
console.log(" ✗ PUT REGENERATED procurement product IDs!");
|
||||
console.log(` Before: [${[...oldProcIds].join(", ")}]`);
|
||||
console.log(` After: [${[...newProcIds].join(", ")}]`);
|
||||
}
|
||||
|
||||
// Try remap if old IDs still exist
|
||||
let remapOk = true;
|
||||
if (procSurvived) {
|
||||
log("REMAP", "Updating procurement products forecastDetailId...");
|
||||
for (const pp of beforeProc.data) {
|
||||
const oldFdId = pp.forecastDetailId as number;
|
||||
const newFdId = idMap.get(oldFdId);
|
||||
if (!newFdId || newFdId === oldFdId) continue;
|
||||
try {
|
||||
await cw.patch(`/procurement/products/${pp.id}`, [
|
||||
{ op: "replace", path: "forecastDetailId", value: newFdId },
|
||||
]);
|
||||
console.log(
|
||||
` ✓ Proc ${pp.id}: forecastDetailId ${oldFdId} → ${newFdId}`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
remapOk = false;
|
||||
console.log(
|
||||
` ✗ Proc ${pp.id} remap failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
remapOk = false;
|
||||
log(
|
||||
"REMAP",
|
||||
"⚠ SKIPPED — procurement products were regenerated by PUT; old IDs no longer exist.",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 8. Verify ───────────────────────────────────────────────────────────
|
||||
await sleep(300);
|
||||
|
||||
// 8a. Verify order (first 4 items)
|
||||
log("VERIFY ORDER", "Expected reverse: Delta, Charlie, Bravo, Alpha");
|
||||
const expectedOrder = ["Delta", "Charlie", "Bravo", "Alpha"];
|
||||
let orderOk = true;
|
||||
for (let i = 0; i < expectedOrder.length; i++) {
|
||||
const actual = newItems[i]?.forecastDescription;
|
||||
const ok = actual === expectedOrder[i];
|
||||
if (!ok) orderOk = false;
|
||||
console.log(
|
||||
` Position ${i}: ${ok ? "✓" : "✗"} expected "${expectedOrder[i]}", got "${actual}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 8b. Verify prices (by description)
|
||||
log("VERIFY PRICES", "");
|
||||
let pricesOk = true;
|
||||
for (const item of newItems) {
|
||||
const orig = priceSnap.get(item.forecastDescription);
|
||||
if (!orig) continue;
|
||||
if (item.revenue !== orig.rev || item.cost !== orig.cost) {
|
||||
pricesOk = false;
|
||||
console.log(
|
||||
` ✗ "${item.forecastDescription}": rev ${orig.rev}→${item.revenue}, cost ${orig.cost}→${item.cost}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pricesOk) console.log(" ✓ All prices preserved.");
|
||||
|
||||
// 8c. Verify cancellation data — match by description since IDs may have changed
|
||||
let cancelOk = true;
|
||||
if (procProducts.length > 0) {
|
||||
log(
|
||||
"VERIFY CANCELLATION",
|
||||
"Checking cancellation data on procurement products after PUT...",
|
||||
);
|
||||
const finalProc = await cw.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
|
||||
);
|
||||
|
||||
// Track by procIdsToClean for cleanup
|
||||
for (const p of finalProc.data) {
|
||||
if (!procIdsToClean.includes(p.id)) procIdsToClean.push(p.id);
|
||||
}
|
||||
|
||||
for (const pp of finalProc.data) {
|
||||
const orig = beforeByDesc.get(pp.description);
|
||||
if (!orig) {
|
||||
console.log(
|
||||
` ? Proc ${pp.id} "${pp.description}" — no matching pre-PUT record`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cancelledMatch =
|
||||
pp.cancelledFlag === orig.cancelledFlag &&
|
||||
pp.quantityCancelled === orig.quantityCancelled &&
|
||||
(pp.cancelledReason ?? "") === (orig.cancelledReason ?? "");
|
||||
|
||||
if (!cancelledMatch) {
|
||||
cancelOk = false;
|
||||
console.log(
|
||||
` ✗ Proc ${pp.id} "${pp.description}": CANCELLATION DATA CHANGED\n` +
|
||||
` Before: cancelled=${orig.cancelledFlag} qty=${orig.quantityCancelled} reason="${orig.cancelledReason ?? ""}"\n` +
|
||||
` After: cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ✓ Proc ${pp.id} "${pp.description}": cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
log("SUMMARY", "");
|
||||
console.log(
|
||||
" Order correct: ",
|
||||
orderOk ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
console.log(
|
||||
" Prices preserved: ",
|
||||
pricesOk ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
console.log(
|
||||
" Proc IDs survived PUT: ",
|
||||
procSurvived ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
console.log(
|
||||
" Procurement remap: ",
|
||||
remapOk ? "✓ PASS" : "✗ FAIL (skipped or failed)",
|
||||
);
|
||||
console.log(
|
||||
" Cancellation data preserved:",
|
||||
cancelOk ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
|
||||
const allPass = orderOk && pricesOk && procSurvived && remapOk && cancelOk;
|
||||
log("RESULT", allPass ? "✓ ALL TESTS PASSED" : "✗ SOME TESTS FAILED");
|
||||
} finally {
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────────
|
||||
log("CLEANUP", "Deleting procurement products...");
|
||||
for (const id of procIdsToClean) {
|
||||
try {
|
||||
await cw.delete(`/procurement/products/${id}`);
|
||||
} catch {}
|
||||
}
|
||||
log("CLEANUP", `Deleted ${procIdsToClean.length} procurement products.`);
|
||||
|
||||
log("CLEANUP", `Deleting opportunity ${oppId}...`);
|
||||
try {
|
||||
await cw.delete(`/sales/opportunities/${oppId}`);
|
||||
log("CLEANUP", "✓ Done.");
|
||||
} catch (e: any) {
|
||||
log("CLEANUP", `✗ ${e.response?.status ?? e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n[FATAL]", err.response?.data ?? err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -243,9 +243,9 @@ describe("catalogCategories", () => {
|
||||
// -------------------------------------------------------------------
|
||||
describe("matchesEcosystem()", () => {
|
||||
test("matches Ubiquiti Network-Switch to Networking", () => {
|
||||
expect(
|
||||
matchesEcosystem("Networking", "Ubiquiti", "Network-Switch"),
|
||||
).toBe(true);
|
||||
expect(matchesEcosystem("Networking", "Ubiquiti", "Network-Switch")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("matches Uniview Surveillance-CamerasIP to Video Surveillance", () => {
|
||||
|
||||
@@ -35,7 +35,15 @@ describe("memberCache", () => {
|
||||
test("stores and retrieves members", async () => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
members.set("jroberts", buildTestMember());
|
||||
members.set("asmith", buildTestMember({ id: 20, identifier: "asmith", firstName: "Alice", lastName: "Smith" }));
|
||||
members.set(
|
||||
"asmith",
|
||||
buildTestMember({
|
||||
id: 20,
|
||||
identifier: "asmith",
|
||||
firstName: "Alice",
|
||||
lastName: "Smith",
|
||||
}),
|
||||
);
|
||||
|
||||
await setMemberCache(members);
|
||||
const cached = await getMemberCache();
|
||||
@@ -67,7 +75,10 @@ describe("memberCache", () => {
|
||||
|
||||
test("falls back to identifier if name parts are empty", async () => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
members.set("empty", buildTestMember({ identifier: "empty", firstName: "", lastName: "" }));
|
||||
members.set(
|
||||
"empty",
|
||||
buildTestMember({ identifier: "empty", firstName: "", lastName: "" }),
|
||||
);
|
||||
await setMemberCache(members);
|
||||
|
||||
expect(resolveMemberName("empty")).toBe("empty");
|
||||
|
||||
Reference in New Issue
Block a user