/** * Test Script: CW Forecast Item Edit & Partial Cancellation * * This script performs read-write operations against the ConnectWise API: * * 1. Search all open opportunities for a forecast item with description * matching "labor Special Order" (case-insensitive). * 2. Report the current state of that item (price, cost, qty, etc.). * 3. PATCH the item: revenue → 72,000 | cost → 8,500 | quantity → 67 * 4. Verify the update by re-fetching the forecast. * 5. Cancel 13 units via the linked procurement product * (partial cancellation: quantityCancelled = 13). * 6. Verify the cancellation by re-fetching procurement data. * 7. Report on every step. * * Usage: bun run test-cw-edit-item.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", }, timeout: 30_000, }); // ── Helpers ─────────────────────────────────────────────────────────────────── const log = (label: string, ...args: unknown[]) => console.log(`\n[${label}]`, ...args); const divider = () => console.log("─".repeat(72)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const fmt = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); // ── Types (minimal, for this script) ────────────────────────────────────────── interface ForecastItem { id: number; forecastDescription: string; productDescription: string; quantity: number; revenue: number; cost: number; margin: number; forecastType: string; sequenceNumber: number; catalogItem?: { id: number; identifier: string }; status?: { id: number; name: string }; opportunity?: { id: number; name: string }; [key: string]: unknown; } interface Forecast { id: number; forecastItems: ForecastItem[]; [key: string]: unknown; } interface ProcurementProduct { id: number; forecastDetailId: number; description: string; quantity: number; price: number; cost: number; cancelledFlag: boolean; quantityCancelled: number; cancelledReason: string | null; cancelledBy: string | null; cancelledDate: string | null; opportunity?: { id: number }; [key: string]: unknown; } // ── Main ────────────────────────────────────────────────────────────────────── async function main() { divider(); log("START", "CW Forecast Item Edit & Cancellation Test"); log("START", `Timestamp: ${new Date().toISOString()}`); divider(); // ── Step 1: Find the "labor Special Order" forecast item ──────────────── const OPP_ID = 5150; log( "SEARCH", `Looking for forecast item matching "labor Special Order" on opportunity ${OPP_ID}...`, ); // Fetch the forecast for opportunity 5150 directly let targetOppId: number = OPP_ID; let targetItem: ForecastItem | null = null; let targetForecast: Forecast | null = null; const forecastRes = await cw.get(`/sales/opportunities/${OPP_ID}/forecast`); targetForecast = forecastRes.data as Forecast; const match = (targetForecast.forecastItems ?? []).find( (fi: ForecastItem) => fi.forecastDescription?.toLowerCase().includes("special order") || fi.productDescription?.toLowerCase().includes("special order"), ); if (match) { targetItem = match; log("SEARCH", `✓ FOUND forecast item on opportunity ${OPP_ID}`); } if (!targetItem || !targetForecast) { log( "SEARCH", `✗ No "labor Special Order" item found on opportunity ${OPP_ID}.`, ); log("SEARCH", "All forecast items on this opportunity:"); for (const fi of targetForecast.forecastItems ?? []) { console.log( ` id=${fi.id} "${fi.forecastDescription}" / "${fi.productDescription}"`, ); } log("SEARCH", "Aborting."); process.exit(1); } // ── Step 2: Report current state ──────────────────────────────────────── divider(); log("CURRENT STATE", "Forecast item details BEFORE edit:"); console.log(` Opportunity ID: ${targetOppId}`); console.log(` Forecast Item ID: ${targetItem.id}`); console.log(` Forecast Description: ${targetItem.forecastDescription}`); console.log(` Product Description: ${targetItem.productDescription}`); console.log( ` Catalog Item: ${targetItem.catalogItem?.identifier ?? "(none)"} (cwId=${targetItem.catalogItem?.id ?? "N/A"})`, ); console.log(` Forecast Type: ${targetItem.forecastType}`); console.log( ` Status: ${targetItem.status?.name ?? "?"} (id=${targetItem.status?.id ?? "?"})`, ); console.log(` Sequence Number: ${targetItem.sequenceNumber}`); console.log(` ──────────────────────────────────`); console.log(` Quantity: ${targetItem.quantity}`); console.log(` Revenue (Price): $${fmt(targetItem.revenue)}`); console.log(` Cost: $${fmt(targetItem.cost)}`); console.log(` Margin: $${fmt(targetItem.margin)}`); // Also report all items on this opportunity for context const allItems = targetForecast.forecastItems ?? []; log( "CONTEXT", `Total forecast items on this opportunity: ${allItems.length}`, ); for (const fi of allItems) { const marker = fi.id === targetItem.id ? " ◀ TARGET" : ""; console.log( ` [${fi.sequenceNumber}] id=${fi.id} "${fi.forecastDescription}" ` + `qty=${fi.quantity} rev=$${fmt(fi.revenue)} cost=$${fmt(fi.cost)}${marker}`, ); } // ── Step 3: PATCH the forecast item ───────────────────────────────────── divider(); const UNIT_PRICE = 72_000; const UNIT_COST = 8_500; const QTY = 67; const TOTAL_REVENUE = UNIT_PRICE * QTY; // $4,824,000 const TOTAL_COST = UNIT_COST * QTY; // $569,500 log("EDIT", "Patching forecast item..."); log( "EDIT", ` Unit price: $${fmt(UNIT_PRICE)} × ${QTY} = $${fmt(TOTAL_REVENUE)} (revenue)`, ); log( "EDIT", ` Unit cost: $${fmt(UNIT_COST)} × ${QTY} = $${fmt(TOTAL_COST)} (cost)`, ); log("EDIT", ` Quantity: ${QTY}`); // Find the index of our target item in the forecast array const forecastItems = targetForecast.forecastItems ?? []; const targetIdx = forecastItems.findIndex((fi) => fi.id === targetItem!.id); if (targetIdx === -1) { log( "EDIT", "✗ Could not find target item index in forecast array. Aborting.", ); process.exit(1); } log("EDIT", `Target item is at index ${targetIdx} in forecastItems array.`); const patchOps = [ { op: "replace", path: `/forecastItems/${targetIdx}/revenue`, value: TOTAL_REVENUE, }, { op: "replace", path: `/forecastItems/${targetIdx}/cost`, value: TOTAL_COST, }, { op: "replace", path: `/forecastItems/${targetIdx}/quantity`, value: QTY }, ]; log("EDIT", "Patch operations:"); for (const op of patchOps) { console.log(` ${op.op} ${op.path} → ${op.value}`); } try { const patchRes = await cw.patch( `/sales/opportunities/${targetOppId}/forecast`, patchOps, ); const updatedForecast: Forecast = patchRes.data; const updatedItem = (updatedForecast.forecastItems ?? [])[targetIdx]; if (!updatedItem) { log("EDIT", "✗ Item not found at expected index after PATCH."); } else { log("EDIT", "✓ PATCH successful. Updated item:"); console.log(` Forecast Item ID: ${updatedItem.id}`); console.log(` Forecast Description: ${updatedItem.forecastDescription}`); console.log(` Quantity: ${updatedItem.quantity}`); console.log(` Revenue (Price): $${fmt(updatedItem.revenue)}`); console.log(` Cost: $${fmt(updatedItem.cost)}`); console.log(` Margin: $${fmt(updatedItem.margin)}`); // Verify values match what we set const checks = [ { field: "revenue", expected: TOTAL_REVENUE, actual: updatedItem.revenue, }, { field: "cost", expected: TOTAL_COST, actual: updatedItem.cost }, { field: "quantity", expected: QTY, actual: updatedItem.quantity }, ]; log("VERIFY EDIT", "Checking values match requested:"); for (const check of checks) { const ok = check.actual === check.expected; console.log( ` ${ok ? "✓" : "✗"} ${check.field}: expected=${check.expected}, actual=${check.actual}`, ); } // Update our reference for the cancellation step targetItem = updatedItem; } } catch (err: any) { log("EDIT", `✗ PATCH failed: ${err.response?.status ?? err.message}`); if (err.response?.data) { console.log(" Response:", JSON.stringify(err.response.data, null, 2)); } // If quantity PATCH failed (read-only), try without quantity if (err.response?.status === 400 || err.response?.status === 422) { log( "EDIT", "Retrying without quantity (may be read-only on forecast items)...", ); const retryOps = patchOps.filter((op) => !op.path.endsWith("/quantity")); try { const retryRes = await cw.patch( `/sales/opportunities/${targetOppId}/forecast`, retryOps, ); const retryForecast: Forecast = retryRes.data; const retryItem = (retryForecast.forecastItems ?? [])[targetIdx]; if (retryItem) { log( "EDIT", "✓ Retry PATCH successful (without quantity). Updated item:", ); console.log( ` Quantity: ${retryItem.quantity} (unchanged — read-only)`, ); console.log(` Revenue (Price): $${fmt(retryItem.revenue)}`); console.log(` Cost: $${fmt(retryItem.cost)}`); console.log(` Margin: $${fmt(retryItem.margin)}`); targetItem = retryItem; } } catch (retryErr: any) { log( "EDIT", `✗ Retry also failed: ${retryErr.response?.status ?? retryErr.message}`, ); if (retryErr.response?.data) { console.log( " Response:", JSON.stringify(retryErr.response.data, null, 2), ); } } } } // ── Step 4: Re-fetch and confirm final forecast state ─────────────────── divider(); log("RE-FETCH", "Fetching forecast to confirm final state..."); await sleep(500); const confirmRes = await cw.get( `/sales/opportunities/${targetOppId}/forecast`, ); const confirmedForecast: Forecast = confirmRes.data; const confirmedItem = (confirmedForecast.forecastItems ?? []).find( (fi) => fi.id === targetItem!.id, ); if (confirmedItem) { log("CONFIRMED STATE", "Forecast item after edit:"); console.log(` Forecast Item ID: ${confirmedItem.id}`); console.log(` Forecast Description: ${confirmedItem.forecastDescription}`); console.log(` Quantity: ${confirmedItem.quantity}`); console.log(` Revenue (Price): $${fmt(confirmedItem.revenue)}`); console.log(` Cost: $${fmt(confirmedItem.cost)}`); console.log(` Margin: $${fmt(confirmedItem.margin)}`); } else { log( "CONFIRMED STATE", "⚠ Could not find item by original ID — it may have been regenerated.", ); log("CONFIRMED STATE", "All current forecast items:"); for (const fi of confirmedForecast.forecastItems ?? []) { console.log( ` id=${fi.id} "${fi.forecastDescription}" qty=${fi.quantity} rev=$${fmt(fi.revenue)} cost=$${fmt(fi.cost)}`, ); } } // ── Step 5: Cancel 13 items via procurement product ───────────────────── divider(); log("CANCEL", "Cancelling 13 units on this item via procurement product..."); // First, find existing procurement products linked to this opportunity const procRes = await cw.get( `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${targetOppId}`)}&pageSize=1000`, ); const procProducts: ProcurementProduct[] = procRes.data; log( "CANCEL", `Found ${procProducts.length} procurement product(s) on this opportunity.`, ); if (procProducts.length > 0) { for (const pp of procProducts) { console.log( ` Proc id=${pp.id} forecastDetailId=${pp.forecastDetailId} ` + `"${pp.description}" qty=${pp.quantity} price=$${fmt(pp.price ?? 0)} ` + `cancelled=${pp.cancelledFlag} qtyCancelled=${pp.quantityCancelled}`, ); } } // Find the procurement product linked to our forecast item const linkedProc = procProducts.find( (pp) => pp.forecastDetailId === targetItem!.id, ); if (linkedProc) { log("CANCEL", `Found linked procurement product: id=${linkedProc.id}`); log( "CANCEL", `Current state: cancelled=${linkedProc.cancelledFlag}, quantityCancelled=${linkedProc.quantityCancelled}`, ); log("CANCEL", "Patching: quantityCancelled → 13, cancelledFlag → true"); try { const cancelRes = await cw.patch( `/procurement/products/${linkedProc.id}`, [ { op: "replace", path: "cancelledFlag", value: true }, { op: "replace", path: "quantityCancelled", value: 13 }, { op: "replace", path: "cancelledReason", value: "Test cancellation — 13 units", }, ], ); log("CANCEL", "✓ Cancellation PATCH successful."); console.log(` cancelledFlag: ${cancelRes.data.cancelledFlag}`); console.log(` quantityCancelled: ${cancelRes.data.quantityCancelled}`); console.log(` cancelledReason: ${cancelRes.data.cancelledReason}`); console.log( ` cancelledBy: ${cancelRes.data.cancelledBy ?? "N/A"}`, ); console.log( ` cancelledDate: ${cancelRes.data.cancelledDate ?? "N/A"}`, ); } catch (err: any) { log( "CANCEL", `✗ Cancellation PATCH failed: ${err.response?.status ?? err.message}`, ); if (err.response?.data) { console.log(" Response:", JSON.stringify(err.response.data, null, 2)); } } } else { log( "CANCEL", `No procurement product linked to forecast item id=${targetItem!.id}.`, ); log( "CANCEL", "Creating a procurement product first, then cancelling 13...", ); try { // Create a procurement product linked to this forecast item const createProcRes = await cw.post("/procurement/products", { catalogItem: targetItem!.catalogItem?.id ? { id: targetItem!.catalogItem.id } : undefined, description: targetItem!.forecastDescription || targetItem!.productDescription, quantity: targetItem!.quantity || 67, price: targetItem!.revenue || 72_000, cost: targetItem!.cost || 8_500, billableOption: "Billable", opportunity: { id: targetOppId }, forecastDetailId: targetItem!.id, }); const newProc = createProcRes.data; log("CANCEL", `✓ Created procurement product id=${newProc.id}`); console.log(` forecastDetailId: ${newProc.forecastDetailId}`); console.log(` description: ${newProc.description}`); console.log(` quantity: ${newProc.quantity}`); console.log(` price: $${fmt(newProc.price ?? 0)}`); console.log(` cost: $${fmt(newProc.cost ?? 0)}`); // Now cancel 13 units log("CANCEL", "Patching procurement product: quantityCancelled → 13..."); const cancelRes = await cw.patch(`/procurement/products/${newProc.id}`, [ { op: "replace", path: "cancelledFlag", value: true }, { op: "replace", path: "quantityCancelled", value: 13 }, { op: "replace", path: "cancelledReason", value: "Test cancellation — 13 units", }, ]); log("CANCEL", "✓ Cancellation PATCH successful."); console.log(` cancelledFlag: ${cancelRes.data.cancelledFlag}`); console.log(` quantityCancelled: ${cancelRes.data.quantityCancelled}`); console.log(` cancelledReason: ${cancelRes.data.cancelledReason}`); console.log( ` cancelledBy: ${cancelRes.data.cancelledBy ?? "N/A"}`, ); console.log( ` cancelledDate: ${cancelRes.data.cancelledDate ?? "N/A"}`, ); } catch (err: any) { log("CANCEL", `✗ Failed: ${err.response?.status ?? err.message}`); if (err.response?.data) { console.log(" Response:", JSON.stringify(err.response.data, null, 2)); } } } // ── Step 6: Final verification ────────────────────────────────────────── divider(); log("FINAL VERIFY", "Re-fetching all data for final report..."); await sleep(500); // Re-fetch forecast const finalForecastRes = await cw.get( `/sales/opportunities/${targetOppId}/forecast`, ); const finalForecast: Forecast = finalForecastRes.data; const finalItem = (finalForecast.forecastItems ?? []).find( (fi) => fi.id === targetItem!.id, ) ?? (finalForecast.forecastItems ?? []).find( (fi) => fi.forecastDescription?.toLowerCase().includes("special order") || fi.productDescription?.toLowerCase().includes("special order"), ); // Re-fetch procurement const finalProcRes = await cw.get( `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${targetOppId}`)}&pageSize=1000`, ); const finalProcs: ProcurementProduct[] = finalProcRes.data; log("FINAL STATE — FORECAST ITEM", ""); if (finalItem) { console.log(` Forecast Item ID: ${finalItem.id}`); console.log(` Forecast Description: ${finalItem.forecastDescription}`); console.log(` Quantity: ${finalItem.quantity}`); console.log(` Revenue (Price): $${fmt(finalItem.revenue)}`); console.log(` Cost: $${fmt(finalItem.cost)}`); console.log(` Margin: $${fmt(finalItem.margin)}`); } else { console.log(" ⚠ Target item not found in final forecast."); } log("FINAL STATE — PROCUREMENT", `${finalProcs.length} product(s):`); for (const pp of finalProcs) { console.log( ` id=${pp.id} forecastDetailId=${pp.forecastDetailId} ` + `"${pp.description}" qty=${pp.quantity} cancelled=${pp.cancelledFlag} ` + `qtyCancelled=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`, ); } // ── Summary ───────────────────────────────────────────────────────────── divider(); log("SUMMARY", ""); // After cancelling 13 of 67, CW recalculates totals for remaining 54 units const expectedFinalRevenue = Math.round(UNIT_PRICE * (QTY - 13) * 100) / 100; const expectedFinalCost = Math.round(UNIT_COST * (QTY - 13) * 100) / 100; const editOk = finalItem ? Math.abs(finalItem.revenue - expectedFinalRevenue) < 1 && Math.abs(finalItem.cost - expectedFinalCost) < 1 : false; const qtyOk = finalItem ? finalItem.quantity === QTY : false; if (finalItem) { console.log( ` Expected final revenue ($${fmt(UNIT_PRICE)} × ${QTY - 13}): $${fmt(expectedFinalRevenue)}`, ); console.log( ` Actual final revenue: $${fmt(finalItem.revenue)}`, ); console.log( ` Expected final cost ($${fmt(UNIT_COST)} × ${QTY - 13}): $${fmt(expectedFinalCost)}`, ); console.log( ` Actual final cost: $${fmt(finalItem.cost)}`, ); } const cancelOk = finalProcs.some( (pp) => pp.forecastDetailId === targetItem!.id && pp.cancelledFlag === true && pp.quantityCancelled === 13, ); console.log( ` Unit price $${fmt(UNIT_PRICE)}/ea: `, editOk ? "✓ PASS" : "✗ FAIL", ); console.log( ` Unit cost $${fmt(UNIT_COST)}/ea: `, editOk ? "✓ PASS" : "✗ FAIL", ); console.log( ` Quantity set to ${QTY}: `, qtyOk ? "✓ PASS" : "✗ FAIL (may be read-only)", ); console.log( " 13 units cancelled: ", cancelOk ? "✓ PASS" : "✗ FAIL", ); const allPass = editOk && qtyOk && cancelOk; divider(); log( "RESULT", allPass ? "✓ ALL CHECKS PASSED" : "⚠ SOME CHECKS DID NOT PASS — review output above", ); divider(); } main().catch((err) => { console.error("\n[FATAL]", err.response?.data ?? err.message); process.exit(1); });