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:
2026-03-01 18:01:02 -06:00
parent d7b374f8ab
commit 30b408e0db
19 changed files with 1030 additions and 107 deletions
@@ -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>;