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:
2026-03-01 18:01:02 -06:00
parent d7b374f8ab
commit 30b408e0db
19 changed files with 1030 additions and 107 deletions
+442
View File
@@ -0,0 +1,442 @@
/**
* 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);
});