/** * 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( 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(); 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(); 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); });