@opsrev/acculynx-cli 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,6 +140,34 @@ JOB_ID=$(acculynx jobs list --limit 1 | jq -r '.[0].id')
140
140
  acculynx jobs get "$JOB_ID" | jq .
141
141
  ```
142
142
 
143
+ ### Monitoring scans: payments with strict coverage
144
+
145
+ Scan every job in a set of milestones and fetch each one's received payments in
146
+ one process, with a coverage line a script can check:
147
+
148
+ ```bash
149
+ acculynx jobs scan --milestones Approved,Completed,Invoiced \
150
+ --enrich payments --strict --format jsonl --out payments.jsonl
151
+ ```
152
+
153
+ Each job line carries `payments`: the job's full received-payment objects
154
+ (`id`, `from`, `amount`, `paymentDate`, `checkNumber`, `notes`, …). The last
155
+ line is the coverage receipt:
156
+
157
+ ```json
158
+ {"type":"coverage","scanned":299,"serverCount":299,"complete":true,"pageError":null,"enrichErrors":0}
159
+ ```
160
+
161
+ - `--strict` (implied by `--enrich payments`) treats any pagination anomaly
162
+ (changing total, duplicate job IDs, overfull or malformed pages) as a partial
163
+ scan. Partial scans exit 3 and report `complete: false`. Never treat them as
164
+ a full inventory.
165
+ - A per-job payments failure doesn't abort the scan. It appears in that job's
166
+ `errors` (`{"jobId", "source": "payments", "message"}`) and counts toward
167
+ `enrichErrors`, so retry just those jobs with `acculynx jobs payments <id>`.
168
+ - Without `--strict` and without `payments`, `jsonl` output is unchanged: job
169
+ lines only.
170
+
143
171
  ## Development
144
172
 
145
173
  ```bash
@@ -159,3 +187,36 @@ npm run dev -- jobs list --limit 5
159
187
  ## License
160
188
 
161
189
  MIT
190
+
191
+ ### Private marketing evidence from a native scan
192
+
193
+ Operators preparing approval-date reconciliation can preserve exact source
194
+ fields without parsing the rounded human digest:
195
+
196
+ ```bash
197
+ acculynx jobs scan --start-date 2026-07-01 --end-date 2026-09-04 --date-filter-type ModifiedDate --enrich dates,financials --format marketing-evidence
198
+ ```
199
+
200
+ This emits one `acculynx-marketing-evidence/v1` JSON object. It is **private
201
+ operator evidence** containing full job IDs and individual values; do not post
202
+ it in a vendor/customer channel. It excludes contacts, addresses, names,
203
+ notes, documents, unrelated financials and raw provider error bodies.
204
+ `--out` and other enrichers are rejected before reading. Redirect stdout only
205
+ into an appropriately protected operator workspace if retaining the artifact.
206
+ This format does not alter downstream agents' command allowlists or authorize
207
+ them to export records.
208
+
209
+ The receipt distinguishes query completeness from enrichment completeness.
210
+ Strict pagination requires a numeric stable total, advancing offsets and unique
211
+ job IDs. Exit 3 means partial evidence; never publish it as a complete result.
212
+ Full source dates are preserved without timezone conversion, repeated approval
213
+ milestones remain separate, and current approved value is represented in exact
214
+ cents. Missing/invalid values and dates remain null with issue codes.
215
+
216
+ This is not an approval cohort, an original-acquisition attribution, a complete
217
+ lead-delivery inventory or historical revenue. ModifiedDate is a candidate
218
+ filter; confirm historical and unassigned/dead-lead coverage separately. Review
219
+ source date semantics, repeated approvals, transfers and current-versus-original
220
+ contract value before producing normalized results. A large historical scan
221
+ reads history and financials for each candidate; bootstrap it separately from
222
+ short daily marketing jobs, then validate an incremental refresh strategy.
@@ -179,6 +179,9 @@ async function paginate(client, path, params, limit = DEFAULT_LIMIT) {
179
179
  if (data.items.length < DEFAULT_PAGE_SIZE) {
180
180
  break;
181
181
  }
182
+ if (typeof data.count === "number" && pageStartIndex + DEFAULT_PAGE_SIZE >= data.count) {
183
+ break;
184
+ }
182
185
  pageStartIndex += DEFAULT_PAGE_SIZE;
183
186
  }
184
187
  return results;
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  resolveTradeTypeIds,
41
41
  resolveWorkTypeId,
42
42
  usersList
43
- } from "./chunk-MQDU6DIH.js";
43
+ } from "./chunk-STEYTGZE.js";
44
44
 
45
45
  // src/index.ts
46
46
  import { createRequire } from "module";
@@ -59,7 +59,7 @@ import { writeFileSync } from "fs";
59
59
 
60
60
  // src/ops/scan.ts
61
61
  var PAGE_SIZE = 25;
62
- async function scanJobs(client, filters) {
62
+ async function scanJobs(client, filters, options = {}) {
63
63
  const params = { includes: "contact", sortBy: "CreatedDate" };
64
64
  if (filters.startDate) params.startDate = filters.startDate;
65
65
  if (filters.endDate) params.endDate = filters.endDate;
@@ -70,6 +70,7 @@ async function scanJobs(client, filters) {
70
70
  let serverCount;
71
71
  let pageError;
72
72
  let pageStartIndex = 0;
73
+ const seen = /* @__PURE__ */ new Set();
73
74
  while (true) {
74
75
  let data;
75
76
  try {
@@ -82,6 +83,42 @@ async function scanJobs(client, filters) {
82
83
  pageError = error instanceof Error ? error.message : String(error);
83
84
  break;
84
85
  }
86
+ if (options.strict) {
87
+ const page = data;
88
+ if (page === null || typeof page !== "object") {
89
+ pageError = "invalid_page";
90
+ break;
91
+ }
92
+ if (!Number.isSafeInteger(page.count) || page.count < 0 || !Array.isArray(page.items) || page.items.length > PAGE_SIZE || page.pageStartIndex !== pageStartIndex) {
93
+ pageError = "invalid_page";
94
+ break;
95
+ }
96
+ if (serverCount !== void 0 && serverCount !== page.count) {
97
+ pageError = "changing_inventory";
98
+ break;
99
+ }
100
+ let invalidId = false;
101
+ for (const item of page.items) {
102
+ const id = item !== null && typeof item === "object" ? item.id : void 0;
103
+ if (typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(id) || seen.has(id)) {
104
+ invalidId = true;
105
+ break;
106
+ }
107
+ seen.add(id);
108
+ }
109
+ if (invalidId) {
110
+ pageError = "duplicate_or_missing_job_id";
111
+ break;
112
+ }
113
+ if (fetched.length + page.items.length > page.count) {
114
+ pageError = "count_mismatch";
115
+ break;
116
+ }
117
+ if (pageStartIndex >= 5e4) {
118
+ pageError = "page_limit";
119
+ break;
120
+ }
121
+ }
85
122
  if (serverCount === void 0 && typeof data.count === "number") serverCount = data.count;
86
123
  const items = Array.isArray(data.items) ? data.items : [];
87
124
  fetched.push(...items);
@@ -102,7 +139,7 @@ async function scanJobs(client, filters) {
102
139
  });
103
140
  return { jobs, scanned: fetched.length, serverCount, complete, ...pageError ? { pageError } : {} };
104
141
  }
105
- var ENRICHERS = ["financials", "reps", "dates", "messages"];
142
+ var ENRICHERS = ["financials", "reps", "dates", "messages", "payments"];
106
143
  var asRecord = (v) => v !== null && typeof v === "object" ? v : {};
107
144
  async function defaultLoadMessagesTool() {
108
145
  const { createRequire: createRequire2 } = await import("module");
@@ -148,7 +185,8 @@ async function enrichJobs(client, jobs, enrich, concurrency = 5, deps = {}) {
148
185
  entry.reps = reps;
149
186
  } else if (source === "dates") {
150
187
  const m = asRecord(await jobMilestones(client, jobId));
151
- entry.dates = (m.items ?? []).map((i) => ({
188
+ if (!Array.isArray(m.items) || m.items.some((i) => i === null || typeof i !== "object")) throw new Error("invalid_milestone_history");
189
+ entry.dates = m.items.map((i) => ({
152
190
  name: String(i.name ?? ""),
153
191
  date: String(i.date ?? "")
154
192
  }));
@@ -163,6 +201,12 @@ async function enrichJobs(client, jobs, enrich, concurrency = 5, deps = {}) {
163
201
  by: String(msg.createdBy ?? ""),
164
202
  text: String(msg.message ?? "").replace(/\s+/g, " ").trim().slice(0, 200)
165
203
  }));
204
+ } else if (source === "payments") {
205
+ const received = asRecord(asRecord(await jobPayments(client, jobId)).receivedPayments);
206
+ if (!Array.isArray(received.payments) || received.payments.some((p) => p === null || typeof p !== "object")) {
207
+ throw new Error("invalid_payments");
208
+ }
209
+ entry.payments = received.payments;
166
210
  }
167
211
  } catch (error) {
168
212
  entry.errors.push({ jobId, source, message: error instanceof Error ? error.message : String(error) });
@@ -250,7 +294,7 @@ function jobLine(entry, enrich) {
250
294
  const date = mmdd(job.milestoneDate);
251
295
  let line = `${id8} | ${name} \u2014 ${street}, ${city} | ${milestone} ${date}`;
252
296
  const erroredSources = new Set(entry.errors.map((e) => e.source));
253
- for (const source of ["reps", "financials", "dates"]) {
297
+ for (const source of ["reps", "financials", "dates", "payments"]) {
254
298
  if (!enrich.includes(source)) continue;
255
299
  if (erroredSources.has(source)) {
256
300
  line += ` | ERR:${source}`;
@@ -267,6 +311,10 @@ function jobLine(entry, enrich) {
267
311
  } else if (source === "dates") {
268
312
  const approved = entry.dates?.find((d) => d.name === "Approved");
269
313
  line += ` | appr\u2019d ${approved ? mmdd(approved.date) : "-"}`;
314
+ } else if (source === "payments") {
315
+ const payments = entry.payments ?? [];
316
+ const total = payments.reduce((sum, p) => sum + (typeof p.amount === "number" ? p.amount : 0), 0);
317
+ line += ` | paid ${money(total)} (${payments.length})`;
270
318
  }
271
319
  }
272
320
  return line;
@@ -293,8 +341,79 @@ function formatScanDigest(report) {
293
341
  }
294
342
  return lines.join("\n");
295
343
  }
296
- function formatScanJsonl(report) {
297
- return report.jobs.map((j) => JSON.stringify(j)).join("\n");
344
+ function coverageLine(report) {
345
+ return JSON.stringify({
346
+ type: "coverage",
347
+ scanned: report.scanned,
348
+ serverCount: report.serverCount ?? null,
349
+ complete: report.complete,
350
+ pageError: report.pageError ?? null,
351
+ enrichErrors: report.jobs.reduce((sum, j) => sum + j.errors.length, 0)
352
+ });
353
+ }
354
+ function formatScanJsonl(report, options = {}) {
355
+ const lines = report.jobs.map((j) => JSON.stringify(j));
356
+ if (options.receipt) lines.push(coverageLine(report));
357
+ return lines.join("\n");
358
+ }
359
+
360
+ // src/ops/scan-marketing.ts
361
+ function marketingEvidence(report, capturedAt = (/* @__PURE__ */ new Date()).toISOString()) {
362
+ const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
363
+ const ids = /* @__PURE__ */ new Set();
364
+ let uniqueIds = true;
365
+ const jobs = report.jobs.map((entry) => {
366
+ const issues = [];
367
+ const id = typeof entry.job.id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(entry.job.id) ? entry.job.id : null;
368
+ if (!id) issues.push("invalid_job_id");
369
+ else if (ids.has(id)) {
370
+ uniqueIds = false;
371
+ issues.push("duplicate_job_id");
372
+ } else ids.add(id);
373
+ const date = (value2, field) => {
374
+ if (typeof value2 === "string" && /^\d{4}-\d{2}-\d{2}(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:0\d|1[0-4]):[0-5]\d))?$/.test(value2) && Number.isFinite(Date.parse(value2)) && (/* @__PURE__ */ new Date(value2.slice(0, 10) + "T00:00:00Z")).toISOString().slice(0, 10) === value2.slice(0, 10)) return value2;
375
+ issues.push("invalid_" + field);
376
+ return null;
377
+ };
378
+ const label = (value2) => typeof value2 === "string" && value2.length > 0 && value2.length <= 200 ? value2 : null;
379
+ const failed = new Set(entry.errors.map((e) => e.source));
380
+ for (const source of ["financials", "dates"]) if (failed.has(source)) issues.push(source + "_read_failed");
381
+ let approvedValueCents = null;
382
+ const value = entry.financials?.approvedJobValue;
383
+ if (!failed.has("financials") && typeof value === "number" && Number.isFinite(value) && value >= 0 && Number.isSafeInteger(Math.round(value * 100)) && Math.abs(value * 100 - Math.round(value * 100)) <= 1e-6) approvedValueCents = Math.round(value * 100);
384
+ else issues.push("approved_value_unavailable");
385
+ let milestones = null;
386
+ if (!failed.has("dates") && Array.isArray(entry.dates)) {
387
+ milestones = entry.dates.map((d) => {
388
+ const name = label(d.name);
389
+ if (!name) issues.push("invalid_milestone_name");
390
+ return { name, sourceDate: date(d.date, "milestone_date") };
391
+ });
392
+ } else issues.push("milestone_history_unavailable");
393
+ return {
394
+ id,
395
+ createdDate: date(entry.job.createdDate, "created_date"),
396
+ modifiedDate: date(entry.job.modifiedDate, "modified_date"),
397
+ currentMilestone: label(entry.job.currentMilestone),
398
+ workType: label(record(entry.job.workType).name),
399
+ tradeTypes: Array.isArray(entry.job.tradeTypes) ? entry.job.tradeTypes.map((t) => label(record(t).name)).filter((v) => v !== null) : [],
400
+ milestones,
401
+ currentApprovedValueCents: approvedValueCents,
402
+ issues: [...new Set(issues)]
403
+ };
404
+ });
405
+ const queryComplete = report.complete && Number.isSafeInteger(report.serverCount) && report.serverCount >= 0 && report.scanned === report.serverCount && uniqueIds && jobs.every((j) => j.id !== null);
406
+ const enrichmentComplete = report.enrich.includes("dates") && report.enrich.includes("financials") && jobs.every((j) => j.issues.length === 0);
407
+ return {
408
+ schemaVersion: "acculynx-marketing-evidence/v1",
409
+ capturedAt,
410
+ filters: report.filters,
411
+ coverage: { scanned: report.scanned, serverCount: report.serverCount ?? null, emitted: jobs.length, queryComplete, enrichmentComplete, pageReadFailed: Boolean(report.pageError) },
412
+ // Query coverage is never a claim about historical approval or acquisition.
413
+ isApprovalCohort: false,
414
+ isFullLeadInventory: false,
415
+ jobs
416
+ };
298
417
  }
299
418
 
300
419
  // src/commands/jobs.ts
@@ -318,7 +437,7 @@ function registerJobsCommands(parentCmd, getClient2) {
318
437
  });
319
438
  console.log(JSON.stringify(result));
320
439
  });
321
- jobs.command("scan").description("Fetch ALL matching jobs and print a digest with a coverage receipt").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones (comma-separated)").option("--assignment <type>", "Filter by assignment: assigned, unassigned").option("--trade-type <name>", "Client-side trade-type filter (repeatable)", collect, []).option("--enrich <list>", "Comma-separated: financials,reps,dates,messages").option("--format <fmt>", "digest or jsonl", "digest").option("--out <path>", "Also write full jsonl to this file").action(async (opts) => {
440
+ jobs.command("scan").description("Fetch ALL matching jobs and print a digest with a coverage receipt").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones (comma-separated)").option("--assignment <type>", "Filter by assignment: assigned, unassigned").option("--trade-type <name>", "Client-side trade-type filter (repeatable)", collect, []).option("--enrich <list>", "Comma-separated: financials,reps,dates,messages,payments").option("--format <fmt>", "digest, jsonl, or marketing-evidence (private operator data)", "digest").option("--out <path>", "Also write full jsonl to this file").option("--strict", 'Strict coverage: any pagination anomaly makes the scan partial (exit 3), and jsonl output ends with a {"type":"coverage"} line. Implied by --enrich payments').action(async (opts) => {
322
441
  const enrich = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
323
442
  const badEnricher = enrich.find((e) => !ENRICHERS.includes(e));
324
443
  if (badEnricher) {
@@ -326,8 +445,13 @@ function registerJobsCommands(parentCmd, getClient2) {
326
445
  process.exitCode = 2;
327
446
  return;
328
447
  }
329
- if (opts.format !== "digest" && opts.format !== "jsonl") {
330
- console.error("Unknown --format (valid: digest, jsonl)");
448
+ if (!["digest", "jsonl", "marketing-evidence"].includes(opts.format)) {
449
+ console.error("Unknown --format (valid: digest, jsonl, marketing-evidence)");
450
+ process.exitCode = 2;
451
+ return;
452
+ }
453
+ if (opts.format === "marketing-evidence" && (opts.out || !enrich.includes("dates") || !enrich.includes("financials") || enrich.some((e) => e !== "dates" && e !== "financials"))) {
454
+ console.error("marketing-evidence requires --enrich dates,financials and forbids --out or other enrichers");
331
455
  process.exitCode = 2;
332
456
  return;
333
457
  }
@@ -339,14 +463,17 @@ function registerJobsCommands(parentCmd, getClient2) {
339
463
  assignment: opts.assignment,
340
464
  tradeType: opts.tradeType.length ? opts.tradeType : void 0
341
465
  };
342
- const result = await scanJobs(getClient2(), filters);
466
+ const strict = Boolean(opts.strict) || opts.format === "marketing-evidence" || enrich.includes("payments");
467
+ const result = await scanJobs(getClient2(), filters, { strict });
343
468
  const enrichedJobs = await enrichJobs(getClient2(), result.jobs, enrich);
344
469
  const report = { filters, enrich, jobs: enrichedJobs, scanned: result.scanned, serverCount: result.serverCount, complete: result.complete, ...result.pageError ? { pageError: result.pageError } : {} };
345
- console.log(opts.format === "jsonl" ? formatScanJsonl(report) : formatScanDigest(report));
346
- if (!result.complete) process.exitCode = 3;
470
+ const evidence = opts.format === "marketing-evidence" ? marketingEvidence(report) : null;
471
+ const jsonl = formatScanJsonl(report, { receipt: strict });
472
+ console.log(evidence ? JSON.stringify(evidence) : opts.format === "jsonl" ? jsonl : formatScanDigest(report));
473
+ if (!result.complete || evidence && (!evidence.coverage.queryComplete || !evidence.coverage.enrichmentComplete)) process.exitCode = 3;
347
474
  if (opts.out) {
348
475
  try {
349
- writeFileSync(opts.out, formatScanJsonl(report) + "\n");
476
+ writeFileSync(opts.out, jsonl + "\n");
350
477
  } catch (error) {
351
478
  console.error(`out: FAILED - ${error instanceof Error ? error.message : String(error)}`);
352
479
  }
package/dist/mcp.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  resolveTradeTypeIds,
39
39
  resolveWorkTypeId,
40
40
  usersList
41
- } from "./chunk-MQDU6DIH.js";
41
+ } from "./chunk-STEYTGZE.js";
42
42
 
43
43
  // src/mcp.ts
44
44
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsrev/acculynx-cli",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "AccuLynx MCP server & CLI — use the AccuLynx roofing CRM in Claude Desktop, Cursor, and any MCP client. Also a JSON CLI for scripts and AI agents.",
5
5
  "repository": {
6
6
  "type": "git",