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:
@@ -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]!);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user