Skip to content
SlashClone
Made by@revopslab

Pipeline hygiene

@revopslab/pipeline-hygiene

Check open deals in a CRM export (HubSpot, Salesforce, or any CSV) for past close dates, deals stuck in a stage, no recent activity, missing required fields (also by stage), zero amounts, and close dates too far out. Writes a hygiene score and a fix list per rep you can send out.

SkillRevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// pipeline-hygiene: check open opportunities in a CRM export for past close dates,
  3. 3// stale stages, no recent activity, missing fields, zero amounts, and close dates
  4. 4// too far out. Writes a per-rep fix list. Node 22, built-ins only.
  5. 5//
  6. 6// Usage:
  7. 7// node scripts/hygiene.mjs <opportunities.csv> [--config hygiene.json] [--today YYYY-MM-DD]
  8. 8// [--out out] [--name hygiene] [--map field=Column Name]...
  9. 9
  10. 10import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
  11. 11import { join } from "node:path";
  12. 13function fail(msg) {
  13. 14 console.error(`pipeline-hygiene: ${msg}`);
  14. 15 process.exit(1);
  15. 18const args = process.argv.slice(2);
  16. 19const opts = { out: "out", name: "hygiene", config: null, today: null, map: {} };
  17. 20const pos = [];
  18. 21for (let i = 0; i < args.length; i++) {
  19. 22 const a = args[i];
  20. 23 if (a === "--out") opts.out = args[++i];
  21. 24 else if (a === "--name") opts.name = args[++i];
  22. 25 else if (a === "--config") opts.config = args[++i];
  23. 26 else if (a === "--today") opts.today = args[++i];
  24. 27 else if (a === "--map") {
  25. 28 const [k, ...v] = String(args[++i]).split("=");
  26. 29 opts.map[k.trim()] = v.join("=").trim();
  27. 30 } else if (a === "--help" || a === "-h") {
  28. 31 console.log("Usage: node scripts/hygiene.mjs <opportunities.csv> [--config hygiene.json] [--today YYYY-MM-DD] [--out out] [--name hygiene] [--map field=Column]");
  29. 32 process.exit(0);
  30. 33 } else pos.push(a);
  31. 35if (pos.length !== 1) fail("give one opportunity export CSV. Try --help.");
  32. 37const DEFAULTS = {
  33. 38 stale_days: 30,
  34. 39 activity_days: 14,
  35. 40 far_future_days: 365,
  36. 41 required: ["amount", "close_date", "owner", "next_step"],
  37. 42 required_by_stage: {},
  38. 43 closed_stages: ["closed won", "closed lost", "won", "lost", "closed"],
  39. 44};
  40. 45let config = { ...DEFAULTS };
  41. 46if (opts.config) {
  42. 47 try {
  43. 48 config = { ...DEFAULTS, ...JSON.parse(readFileSync(opts.config, "utf8")) };
  44. 49 } catch (e) {
  45. 50 fail(`cannot read config ${opts.config}: ${e.message}`);
  46. 51 }
  47. 54function parseCsv(text) {
  48. 55 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
  49. 56 const rows = [];
  50. 57 let row = [];
  51. 58 let field = "";
  52. 59 let q = false;
  53. 60 for (let i = 0; i < text.length; i++) {
  54. 61 const c = text[i];
  55. 62 if (q) {
  56. 63 if (c === '"') {
  57. 64 if (text[i + 1] === '"') {
  58. 65 field += '"';
  59. 66 i++;
  60. 67 } else q = false;
  61. 68 } else field += c;
  62. 69 } else if (c === '"') q = true;
  63. 70 else if (c === ",") {
  64. 71 row.push(field);
  65. 72 field = "";
  66. 73 } else if (c === "\n" || c === "\r") {
  67. 74 if (c === "\r" && text[i + 1] === "\n") i++;
  68. 75 row.push(field);
  69. 76 rows.push(row);
  70. 77 row = [];
  71. 78 field = "";
  72. 79 } else field += c;
  73. 80 }
  74. 81 if (field !== "" || row.length) {
  75. 82 row.push(field);
  76. 83 rows.push(row);
  77. 84 }
  78. 85 const data = rows.filter((r) => r.some((v) => v.trim() !== ""));
  79. 86 if (!data.length) return { header: [], records: [] };
  80. 87 const header = data.shift().map((h) => h.trim());
  81. 88 return { header, records: data.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? "").trim()]))) };
  82. 90const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
  83. 91const ALIASES = {
  84. 92 id: ["id", "opportunity_id", "deal_id", "record_id", "hs_object_id"],
  85. 93 name: ["name", "opportunity_name", "deal_name", "dealname"],
  86. 94 owner: ["owner", "opportunity_owner", "deal_owner", "owner_name", "hubspot_owner", "rep"],
  87. 95 amount: ["amount", "amount_converted", "deal_amount", "amount_usd"],
  88. 96 close_date: ["close_date", "closedate", "expected_close_date"],
  89. 97 stage: ["stage", "stage_name", "deal_stage", "dealstage"],
  90. 98 next_step: ["next_step", "nextstep", "next_steps", "hs_next_step"],
  91. 99 stage_entered: ["stage_entered", "stage_entered_date", "date_entered_current_stage", "last_stage_change_date", "stage_change_date"],
  92. 100 stage_days: ["stage_duration", "stage_duration_days", "days_in_stage", "time_in_stage_days"],
  93. 101 last_activity: ["last_activity", "last_activity_date", "last_activity_date_time", "notes_last_updated", "last_contacted"],
  94. 102};
  95. 104let text;
  96. 105try {
  97. 106 text = readFileSync(pos[0], "utf8");
  98. 107} catch {
  99. 108 fail(`cannot read ${pos[0]}`);
  100. 110const { header, records } = parseCsv(text);
  101. 111const byNorm = new Map(header.map((h) => [norm(h), h]));
  102. 112const cols = {};
  103. 113for (const [f, list] of Object.entries(ALIASES)) {
  104. 114 if (opts.map[f]) {
  105. 115 if (!header.includes(opts.map[f])) fail(`--map ${f}=${opts.map[f]} names a column that does not exist`);
  106. 116 cols[f] = opts.map[f];
  107. 117 } else {
  108. 118 const hit = list.find((a) => byNorm.has(a));
  109. 119 if (hit) cols[f] = byNorm.get(hit);
  110. 120 }
  111. 122for (const req of ["id", "stage"]) if (!cols[req]) fail(`no column found for "${req}". Columns are: ${header.join(", ")}. Use --map ${req}=<column>.`);
  112. 124function parseDate(s) {
  113. 125 if (!s) return null;
  114. 126 let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  115. 127 if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3]);
  116. 128 m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
  117. 129 if (m) return Date.UTC(+m[3], +m[1] - 1, +m[2]);
  118. 130 return null;
  119. 132const todayStr = opts.today || new Date().toISOString().slice(0, 10);
  120. 133const TODAY = parseDate(todayStr);
  121. 134if (TODAY === null) fail("--today must be YYYY-MM-DD");
  122. 135const days = (t) => Math.round((TODAY - t) / 86400000);
  123. 136const iso = (t) => new Date(t).toISOString().slice(0, 10);
  124. 138const closed = new Set(config.closed_stages.map((s) => s.toLowerCase()));
  125. 139const valueOf = (r, field) => (cols[field] ? r[cols[field]] : r[field] ?? r[Object.keys(r).find((k) => norm(k) === norm(field))] ?? "");
  126. 141const deals = [];
  127. 142let skippedClosed = 0;
  128. 143for (const r of records) {
  129. 144 const stage = r[cols.stage] || "";
  130. 145 if (closed.has(stage.toLowerCase())) {
  131. 146 skippedClosed++;
  132. 147 continue;
  133. 148 }
  134. 149 const issues = [];
  135. 150 const add = (code, detail) => issues.push({ code, detail });
  136. 151 const close = parseDate(valueOf(r, "close_date"));
  137. 152 const amountRaw = valueOf(r, "amount");
  138. 153 const amount = Number(String(amountRaw).replace(/[$,\s]/g, ""));
  139. 155 if (close !== null && close < TODAY) add("past_close_date", `close date ${iso(close)} is ${days(close)} days ago`);
  140. 156 if (close !== null && -days(close) > config.far_future_days) add("far_future_close", `close date ${iso(close)} is more than ${config.far_future_days} days out`);
  141. 157 if (amountRaw !== "" && Number.isFinite(amount) && amount <= 0) add("zero_amount", "amount is 0");
  142. 159 let inStage = null;
  143. 160 if (cols.stage_days && r[cols.stage_days] !== "") inStage = Number(r[cols.stage_days]);
  144. 161 else if (cols.stage_entered) {
  145. 162 const t = parseDate(r[cols.stage_entered]);
  146. 163 if (t !== null) inStage = days(t);
  147. 164 }
  148. 165 if (inStage !== null && Number.isFinite(inStage) && inStage > config.stale_days) add("stale_stage", `${inStage} days in ${stage}`);
  149. 167 if (cols.last_activity) {
  150. 168 const t = parseDate(r[cols.last_activity]);
  151. 169 if (t === null) add("no_activity_logged", "no activity date");
  152. 170 else if (days(t) > config.activity_days) add("no_recent_activity", `last activity ${days(t)} days ago`);
  153. 171 }
  154. 173 const required = [...config.required, ...(Object.entries(config.required_by_stage).find(([s]) => s.toLowerCase() === stage.toLowerCase())?.[1] || [])];
  155. 174 const missing = required.filter((f) => String(valueOf(r, f)).trim() === "");
  156. 175 if (missing.length) add("missing_fields", missing.join(", "));
  157. 177 deals.push({
  158. 178 id: r[cols.id],
  159. 179 name: cols.name ? r[cols.name] : r[cols.id],
  160. 180 owner: (cols.owner && r[cols.owner]) || "(no owner)",
  161. 181 stage,
  162. 182 amount: Number.isFinite(amount) ? amount : 0,
  163. 183 close_date: close !== null ? iso(close) : null,
  164. 184 issues,
  165. 185 });
  166. 188const CODES = ["past_close_date", "stale_stage", "no_recent_activity", "no_activity_logged", "missing_fields", "zero_amount", "far_future_close"];
  167. 189const issueCounts = Object.fromEntries(CODES.map((c) => [c, deals.filter((d) => d.issues.some((i) => i.code === c)).length]));
  168. 190const owners = {};
  169. 191for (const d of deals) {
  170. 192 owners[d.owner] ||= { open: 0, clean: 0, with_issues: 0, amount_with_issues: 0 };
  171. 193 const o = owners[d.owner];
  172. 194 o.open++;
  173. 195 if (d.issues.length) {
  174. 196 o.with_issues++;
  175. 197 o.amount_with_issues += d.amount;
  176. 198 } else o.clean++;
  177. 200const clean = deals.filter((d) => !d.issues.length).length;
  178. 201const result = {
  179. 202 today: todayStr,
  180. 203 config: { stale_days: config.stale_days, activity_days: config.activity_days, far_future_days: config.far_future_days, required: config.required },
  181. 204 columns_used: cols,
  182. 205 open_deals: deals.length,
  183. 206 closed_skipped: skippedClosed,
  184. 207 clean_deals: clean,
  185. 208 hygiene_score: deals.length ? Math.round((clean / deals.length) * 100) : 100,
  186. 209 issue_counts: issueCounts,
  187. 210 by_owner: Object.fromEntries(Object.entries(owners).sort((a, b) => b[1].with_issues - a[1].with_issues)),
  188. 211 deals: deals.filter((d) => d.issues.length).sort((a, b) => b.issues.length - a.issues.length || b.amount - a.amount),
  189. 212 checks_skipped: [
  190. 213 ...(cols.stage_entered || cols.stage_days ? [] : ["stale_stage (no stage-entered date or days-in-stage column)"]),
  191. 214 ...(cols.last_activity ? [] : ["no_recent_activity (no last activity column)"]),
  192. 215 ],
  193. 216};
  194. 218const money = (n) => "$" + Math.round(n).toLocaleString("en-US");
  195. 219const LABEL = {
  196. 220 past_close_date: "Close date in the past",
  197. 221 stale_stage: `Stuck in stage over ${config.stale_days} days`,
  198. 222 no_recent_activity: `No activity in ${config.activity_days}+ days`,
  199. 223 no_activity_logged: "No activity logged",
  200. 224 missing_fields: "Missing required fields",
  201. 225 zero_amount: "Zero amount",
  202. 226 far_future_close: `Close date over ${config.far_future_days} days out`,
  203. 227};
  204. 228const md = [`# Pipeline hygiene, ${todayStr}`, ""];
  205. 229md.push(`**Hygiene score ${result.hygiene_score}%**: ${clean} of ${deals.length} open deals are clean.`, "");
  206. 230md.push("| Issue | Deals |", "| --- | ---: |");
  207. 231for (const c of CODES) if (issueCounts[c]) md.push(`| ${LABEL[c]} | ${issueCounts[c]} |`);
  208. 232md.push("", "## By rep", "", "| Rep | Open | With issues | Amount with issues |", "| --- | ---: | ---: | ---: |");
  209. 233for (const [o, v] of Object.entries(result.by_owner)) md.push(`| ${o} | ${v.open} | ${v.with_issues} | ${money(v.amount_with_issues)} |`);
  210. 234md.push("", "## Fix list");
  211. 235let lastOwner = null;
  212. 236for (const d of [...result.deals].sort((a, b) => a.owner.localeCompare(b.owner))) {
  213. 237 if (d.owner !== lastOwner) {
  214. 238 md.push("", `### ${d.owner}`, "");
  215. 239 lastOwner = d.owner;
  216. 240 }
  217. 241 md.push(`- **${d.name}** (${d.stage}, ${money(d.amount)}): ${d.issues.map((i) => `${LABEL[i.code]}: ${i.detail}`).join("; ")}`);
  218. 243if (result.checks_skipped.length) md.push("", "## Checks skipped", "", ...result.checks_skipped.map((s) => `- ${s}`));
  219. 244md.push("");
  220. 246const csvCell = (s) => (/[",\n]/.test(String(s)) ? `"${String(s).replace(/"/g, '""')}"` : String(s));
  221. 247const csv = ["id,name,owner,stage,amount,close_date,issues"]
  222. 248 .concat(result.deals.map((d) => [d.id, d.name, d.owner, d.stage, d.amount, d.close_date ?? "", d.issues.map((i) => `${i.code}: ${i.detail}`).join(" | ")].map(csvCell).join(",")))
  223. 249 .join("\n");
  224. 251mkdirSync(opts.out, { recursive: true });
  225. 252writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
  226. 253writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
  227. 254writeFileSync(join(opts.out, `${opts.name}-fix-list.csv`), csv + "\n");
  228. 255console.log(`Hygiene score ${result.hygiene_score}% (${clean}/${deals.length} clean). Wrote ${join(opts.out, opts.name)}.{md,json} and ${opts.name}-fix-list.csv`);