@opsrev/acculynx-cli 1.17.0 → 1.18.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 +19 -0
- package/dist/index.js +207 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,6 +140,25 @@ JOB_ID=$(acculynx jobs list --limit 1 | jq -r '.[0].id')
|
|
|
140
140
|
acculynx jobs get "$JOB_ID" | jq .
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### Prospect aging with complete readable rows
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
acculynx jobs scan --report prospect-aging --as-of 2026-10-20 --exclude-trade-type "Maintenance Program"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
This bounded GET-only report ages **current** Prospect inventory by its full
|
|
150
|
+
milestone date in America/New_York, with inclusive 0–30, 31–60, 61–90 and 91+
|
|
151
|
+
calendar-day buckets. It is not a historical inventory snapshot. Exclusions use
|
|
152
|
+
exact case-insensitive trade-type names; missing classification stays unknown.
|
|
153
|
+
Every eligible row includes its full ID, date, age and separate approved/worksheet
|
|
154
|
+
values in exact cents. Missing values remain unavailable rather than zero.
|
|
155
|
+
|
|
156
|
+
Enumeration is strict and capped at 5,000 jobs before per-job financial reads.
|
|
157
|
+
No history, payment, message or write requests are made. Invalid/incomplete
|
|
158
|
+
inventory withholds totals and rows; missing dates or financial evidence make
|
|
159
|
+
the result partial (exit 3). Incompatible filters and output modes fail before
|
|
160
|
+
provider access (exit 2). Read long stdout through bounded process-log pages.
|
|
161
|
+
|
|
143
162
|
### Monitoring scans: payments with strict coverage
|
|
144
163
|
|
|
145
164
|
Scan every job in a set of milestones and fetch each one's received payments in
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,8 @@ import { writeFileSync } from "fs";
|
|
|
60
60
|
// src/ops/scan.ts
|
|
61
61
|
var PAGE_SIZE = 25;
|
|
62
62
|
async function scanJobs(client, filters, options = {}) {
|
|
63
|
+
if (options.maxJobs !== void 0 && (!Number.isSafeInteger(options.maxJobs) || options.maxJobs < 1))
|
|
64
|
+
throw new Error("maxJobs must be a positive integer");
|
|
63
65
|
const params = { includes: "contact", sortBy: "CreatedDate" };
|
|
64
66
|
if (filters.startDate) params.startDate = filters.startDate;
|
|
65
67
|
if (filters.endDate) params.endDate = filters.endDate;
|
|
@@ -97,6 +99,11 @@ async function scanJobs(client, filters, options = {}) {
|
|
|
97
99
|
pageError = "changing_inventory";
|
|
98
100
|
break;
|
|
99
101
|
}
|
|
102
|
+
if (options.maxJobs !== void 0 && page.count > options.maxJobs) {
|
|
103
|
+
serverCount = page.count;
|
|
104
|
+
pageError = "report_job_limit";
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
100
107
|
let invalidId = false;
|
|
101
108
|
for (const item of page.items) {
|
|
102
109
|
const id = item !== null && typeof item === "object" ? item.id : void 0;
|
|
@@ -416,6 +423,172 @@ function marketingEvidence(report, capturedAt = (/* @__PURE__ */ new Date()).toI
|
|
|
416
423
|
};
|
|
417
424
|
}
|
|
418
425
|
|
|
426
|
+
// src/ops/scan-aging.ts
|
|
427
|
+
var AGING_TIME_ZONE = "America/New_York";
|
|
428
|
+
var MAX_AGING_JOBS = 5e3;
|
|
429
|
+
var AGING_BUCKETS = ["0\u201330", "31\u201360", "61\u201390", "91+"];
|
|
430
|
+
var clean = (v, max = 200) => typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f|]/g, " ").replace(/\s+/g, " ").trim().slice(0, max) : "";
|
|
431
|
+
var normalized = (v) => v.trim().replace(/\s+/g, " ").toLowerCase();
|
|
432
|
+
function calendarDay(value) {
|
|
433
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number(value.slice(0, 4)) < 1e3) return null;
|
|
434
|
+
const time = Date.parse(value + "T00:00:00Z");
|
|
435
|
+
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? value : null;
|
|
436
|
+
}
|
|
437
|
+
function milestoneDay(value) {
|
|
438
|
+
if (typeof value !== "string") return null;
|
|
439
|
+
if (calendarDay(value)) return value;
|
|
440
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !calendarDay(value.slice(0, 10))) return null;
|
|
441
|
+
const time = Date.parse(value);
|
|
442
|
+
if (!Number.isFinite(time)) return null;
|
|
443
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
444
|
+
timeZone: AGING_TIME_ZONE,
|
|
445
|
+
year: "numeric",
|
|
446
|
+
month: "2-digit",
|
|
447
|
+
day: "2-digit"
|
|
448
|
+
}).formatToParts(time);
|
|
449
|
+
const part = (type) => parts.find((p) => p.type === type).value;
|
|
450
|
+
return `${part("year")}-${part("month")}-${part("day")}`;
|
|
451
|
+
}
|
|
452
|
+
function agingClassification(job, exclusions) {
|
|
453
|
+
const types = job.tradeTypes;
|
|
454
|
+
if (!Array.isArray(types)) return exclusions.length ? "unknown" : "included";
|
|
455
|
+
const names = types.map((t) => t && typeof t === "object" ? t.name : void 0);
|
|
456
|
+
if (names.some((n) => typeof n === "string" && exclusions.some((e) => normalized(e) === normalized(n)))) return "excluded";
|
|
457
|
+
return exclusions.length && names.some((n) => typeof n !== "string" || !n.trim()) ? "unknown" : "included";
|
|
458
|
+
}
|
|
459
|
+
function amount(value, issues) {
|
|
460
|
+
if (value === null || value === void 0) return null;
|
|
461
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
462
|
+
issues.push("invalid_financial_value");
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
const cents = Math.round(value * 100);
|
|
466
|
+
if (!Number.isSafeInteger(cents) || Math.abs(value * 100 - cents) > 1e-5) {
|
|
467
|
+
issues.push("invalid_financial_value");
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
return cents;
|
|
471
|
+
}
|
|
472
|
+
function row(entry, asOf, exclusions) {
|
|
473
|
+
const job = entry.job, issues = [];
|
|
474
|
+
const classification = agingClassification(job, exclusions);
|
|
475
|
+
const address = job.locationAddress && typeof job.locationAddress === "object" ? job.locationAddress : {};
|
|
476
|
+
const contacts = Array.isArray(job.contacts) ? job.contacts : [];
|
|
477
|
+
const primary = contacts.find((c) => c && c.isPrimary === true)?.contact;
|
|
478
|
+
const name = primary ? [clean(primary.firstName), clean(primary.lastName)].filter(Boolean).join(" ") : clean(job.jobName);
|
|
479
|
+
const result = {
|
|
480
|
+
id: String(job.id),
|
|
481
|
+
name: name || "\u2014",
|
|
482
|
+
address: [clean(address.street1), clean(address.city)].filter(Boolean).join(", ") || "\u2014",
|
|
483
|
+
milestoneDate: null,
|
|
484
|
+
ageDays: null,
|
|
485
|
+
bucket: null,
|
|
486
|
+
classification,
|
|
487
|
+
approvedCents: null,
|
|
488
|
+
worksheetCents: null,
|
|
489
|
+
issues
|
|
490
|
+
};
|
|
491
|
+
if (classification === "excluded") return result;
|
|
492
|
+
if (classification === "unknown") {
|
|
493
|
+
issues.push("unknown_trade_classification");
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
if (normalized(String(job.currentMilestone ?? "")) !== "prospect") issues.push("unexpected_milestone");
|
|
497
|
+
const day = milestoneDay(job.milestoneDate);
|
|
498
|
+
if (!day) issues.push("missing_or_invalid_milestone_date");
|
|
499
|
+
else if (day > asOf) issues.push("future_milestone_date");
|
|
500
|
+
else if (!issues.length) {
|
|
501
|
+
result.milestoneDate = day;
|
|
502
|
+
result.ageDays = Math.round((Date.parse(asOf + "T00:00:00Z") - Date.parse(day + "T00:00:00Z")) / 864e5);
|
|
503
|
+
const age = result.ageDays;
|
|
504
|
+
result.bucket = age <= 30 ? "0\u201330" : age <= 60 ? "31\u201360" : age <= 90 ? "61\u201390" : "91+";
|
|
505
|
+
}
|
|
506
|
+
if (entry.errors.some((e) => e.source === "financials") || !entry.financials) issues.push("financials_unavailable");
|
|
507
|
+
else {
|
|
508
|
+
result.approvedCents = amount(entry.financials.approvedJobValue, issues);
|
|
509
|
+
result.worksheetCents = amount(entry.financials.worksheetTotal, issues);
|
|
510
|
+
if (result.approvedCents === null && result.worksheetCents === null) issues.push("financial_values_unavailable");
|
|
511
|
+
}
|
|
512
|
+
result.issues = [...new Set(issues)];
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
function prospectAging(report, asOf, exclusions) {
|
|
516
|
+
if (!calendarDay(asOf)) throw new Error("--as-of must be a real YYYY-MM-DD date");
|
|
517
|
+
if (exclusions.length > 10 || exclusions.some((e) => !e.trim() || e.length > 100 || /[\r\n\u0000]/.test(e)))
|
|
518
|
+
throw new Error("Use at most ten nonempty trade-type exclusions of at most 100 characters");
|
|
519
|
+
const result = {
|
|
520
|
+
schema: "acculynx-prospect-aging/v1",
|
|
521
|
+
asOf,
|
|
522
|
+
timeZone: AGING_TIME_ZONE,
|
|
523
|
+
exclusions,
|
|
524
|
+
status: "partial",
|
|
525
|
+
scanned: report.scanned,
|
|
526
|
+
serverCount: report.serverCount ?? null,
|
|
527
|
+
issues: [],
|
|
528
|
+
counts: null,
|
|
529
|
+
rows: []
|
|
530
|
+
};
|
|
531
|
+
const ids = /* @__PURE__ */ new Set();
|
|
532
|
+
for (const { job } of report.jobs) {
|
|
533
|
+
if (typeof job.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(job.id) || ids.has(job.id))
|
|
534
|
+
result.issues.push("duplicate_or_missing_job_id");
|
|
535
|
+
else ids.add(job.id);
|
|
536
|
+
}
|
|
537
|
+
if (!report.complete || report.pageError || !Number.isSafeInteger(report.serverCount) || report.serverCount !== report.scanned || report.scanned !== report.jobs.length)
|
|
538
|
+
result.issues.push("incomplete_inventory");
|
|
539
|
+
if (report.jobs.length > MAX_AGING_JOBS) result.issues.push("report_job_limit");
|
|
540
|
+
if (result.issues.length) {
|
|
541
|
+
result.issues = [...new Set(result.issues)];
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
result.rows = report.jobs.map((e) => row(e, asOf, exclusions));
|
|
545
|
+
const counts = {
|
|
546
|
+
excluded: 0,
|
|
547
|
+
eligible: 0,
|
|
548
|
+
unknownClassification: 0,
|
|
549
|
+
unknownAge: 0,
|
|
550
|
+
buckets: Object.fromEntries(AGING_BUCKETS.map((b) => [b, 0]))
|
|
551
|
+
};
|
|
552
|
+
for (const r of result.rows) {
|
|
553
|
+
if (r.classification === "excluded") counts.excluded++;
|
|
554
|
+
else if (r.classification === "unknown") counts.unknownClassification++;
|
|
555
|
+
else {
|
|
556
|
+
counts.eligible++;
|
|
557
|
+
if (r.bucket) counts.buckets[r.bucket]++;
|
|
558
|
+
else counts.unknownAge++;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
result.counts = counts;
|
|
562
|
+
result.status = result.rows.some((r) => r.issues.length) ? "partial" : "complete";
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
function formatProspectAging(report) {
|
|
566
|
+
const lines = [
|
|
567
|
+
`PROSPECT AGING ${report.status.toUpperCase()} \xB7 age as of ${report.asOf} (${report.timeZone})`,
|
|
568
|
+
"Current Prospect inventory; this is not a historical inventory snapshot.",
|
|
569
|
+
`jobs ${report.scanned}/${report.serverCount ?? "?"} (server count ${report.serverCount ?? "?"})`,
|
|
570
|
+
`Excluded trade types (exact names): ${report.exclusions.map((e) => clean(e)).join(", ") || "none"}`
|
|
571
|
+
];
|
|
572
|
+
const c = report.counts;
|
|
573
|
+
if (!c) return [...lines, `Query unavailable or incomplete: ${report.issues.join(", ")}. No aging totals or rows are asserted.`].join("\n");
|
|
574
|
+
lines.push(
|
|
575
|
+
`Eligible ${c.eligible} \xB7 excluded ${c.excluded} \xB7 unknown classification ${c.unknownClassification}`,
|
|
576
|
+
...AGING_BUCKETS.map((b) => `${b} days: ${c.buckets[b]}`),
|
|
577
|
+
`Unknown age: ${c.unknownAge}`,
|
|
578
|
+
"Approved recorded values and worksheet estimates are separate; neither is collected cash."
|
|
579
|
+
);
|
|
580
|
+
const money2 = (c2) => c2 === null ? "unavailable" : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(c2 / 100);
|
|
581
|
+
for (const group of [...AGING_BUCKETS, "Unknown"]) {
|
|
582
|
+
const rows = report.rows.filter((r) => r.classification !== "excluded" && (group === "Unknown" ? !r.bucket : r.bucket === group));
|
|
583
|
+
if (!rows.length) continue;
|
|
584
|
+
lines.push(`
|
|
585
|
+
${group} (${rows.length})`);
|
|
586
|
+
for (const r of rows) lines.push(`${r.id} | ${r.name} | ${r.address} | Prospect date ${r.milestoneDate ?? "unknown"} | age ${r.ageDays ?? "unknown"} | approved ${money2(r.approvedCents)} | estimate ${money2(r.worksheetCents)}${r.issues.length ? " | " + r.issues.join(", ") : ""}`);
|
|
587
|
+
}
|
|
588
|
+
lines.push(`Rows shown ${report.rows.filter((r) => r.classification !== "excluded").length}; excluded rows are omitted.`);
|
|
589
|
+
return lines.join("\n");
|
|
590
|
+
}
|
|
591
|
+
|
|
419
592
|
// src/commands/jobs.ts
|
|
420
593
|
function collect(value, previous) {
|
|
421
594
|
return [...previous, value];
|
|
@@ -437,7 +610,40 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
437
610
|
});
|
|
438
611
|
console.log(JSON.stringify(result));
|
|
439
612
|
});
|
|
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) => {
|
|
613
|
+
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').option("--report <name>", "Bounded readable report: prospect-aging").option("--as-of <date>", "Aging calendar date (YYYY-MM-DD), America/New_York").option("--exclude-trade-type <name>", "Exact trade-type name to exclude from prospect-aging (repeatable)", collect, []).action(async (opts) => {
|
|
614
|
+
if (opts.report || opts.asOf || opts.excludeTradeType.length) {
|
|
615
|
+
const enrichInput = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
616
|
+
if (opts.report !== "prospect-aging" || !calendarDay(String(opts.asOf ?? "")) || opts.out || opts.format !== "digest" || opts.tradeType.length || opts.startDate || opts.endDate || opts.dateFilterType || opts.assignment || opts.milestones && opts.milestones.toLowerCase() !== "prospect" || enrichInput.some((e) => !["financials"].includes(e))) {
|
|
617
|
+
console.error("prospect-aging requires --as-of YYYY-MM-DD and current Prospect inventory; format/out, date/assignment/include-trade filters and other enrichers are unavailable");
|
|
618
|
+
process.exitCode = 2;
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
prospectAging({ filters: {}, enrich: [], jobs: [], scanned: 0, serverCount: 0, complete: true }, opts.asOf, opts.excludeTradeType);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
console.error(error instanceof Error ? error.message : "Invalid aging options");
|
|
625
|
+
process.exitCode = 2;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const filters2 = { milestones: "Prospect" };
|
|
629
|
+
const client = getClient2();
|
|
630
|
+
const result2 = await scanJobs(client, filters2, { strict: true, maxJobs: MAX_AGING_JOBS });
|
|
631
|
+
const selected = result2.complete ? result2.jobs.filter((j) => agingClassification(j, opts.excludeTradeType) === "included") : [];
|
|
632
|
+
const enriched = await enrichJobs(client, selected, ["financials"]);
|
|
633
|
+
const byId = new Map(enriched.map((e) => [e.job.id, e]));
|
|
634
|
+
const report2 = prospectAging({
|
|
635
|
+
filters: filters2,
|
|
636
|
+
enrich: ["financials"],
|
|
637
|
+
jobs: result2.jobs.map((job) => byId.get(job.id) ?? { job, errors: [] }),
|
|
638
|
+
scanned: result2.scanned,
|
|
639
|
+
serverCount: result2.serverCount,
|
|
640
|
+
complete: result2.complete,
|
|
641
|
+
pageError: result2.pageError
|
|
642
|
+
}, opts.asOf, opts.excludeTradeType);
|
|
643
|
+
console.log(formatProspectAging(report2));
|
|
644
|
+
if (report2.status !== "complete") process.exitCode = 3;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
441
647
|
const enrich = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
442
648
|
const badEnricher = enrich.find((e) => !ENRICHERS.includes(e));
|
|
443
649
|
if (badEnricher) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsrev/acculynx-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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",
|