feat: add CW callback route and optimize cache refresh workflows

This commit is contained in:
2026-03-03 19:46:48 -06:00
parent 6d935e7180
commit a048e1e824
28 changed files with 3268 additions and 294 deletions
+600
View File
@@ -0,0 +1,600 @@
/**
* 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);
});