443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
/**
|
||
* 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<string, { rev: number; cost: number }>(
|
||
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<string, any>();
|
||
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<number, number>();
|
||
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);
|
||
});
|