@opsrev/acculynx-cli 1.13.1 → 1.15.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
@@ -121,6 +121,7 @@ acculynx ping # verify API key
121
121
  acculynx jobs list --limit 5 # 25 results by default; --all to fetch everything
122
122
  acculynx jobs get <jobId>
123
123
  acculynx jobs search --query "smith"
124
+ acculynx jobs scan --milestones Approved # scan all jobs → digest + coverage receipt
124
125
  acculynx contacts list --limit 10
125
126
  acculynx estimates get <estimateId>
126
127
  ```
@@ -384,6 +384,7 @@ export {
384
384
  getConfig,
385
385
  createApiClient,
386
386
  ping,
387
+ paginate,
387
388
  readStdin,
388
389
  jobsList,
389
390
  jobGet,
package/dist/index.js CHANGED
@@ -34,12 +34,13 @@ import {
34
34
  jobSetWorkType,
35
35
  jobUploadDocument,
36
36
  jobsList,
37
+ paginate,
37
38
  ping,
38
39
  readStdin,
39
40
  resolveTradeTypeIds,
40
41
  resolveWorkTypeId,
41
42
  usersList
42
- } from "./chunk-AKUPC2EI.js";
43
+ } from "./chunk-MQDU6DIH.js";
43
44
 
44
45
  // src/index.ts
45
46
  import { createRequire } from "module";
@@ -53,6 +54,249 @@ function registerPingCommand(parentCmd, getClient2) {
53
54
  });
54
55
  }
55
56
 
57
+ // src/commands/jobs.ts
58
+ import { writeFileSync } from "fs";
59
+
60
+ // src/ops/scan.ts
61
+ var PAGE_SIZE = 25;
62
+ async function scanJobs(client, filters) {
63
+ const params = { includes: "contact", sortBy: "CreatedDate" };
64
+ if (filters.startDate) params.startDate = filters.startDate;
65
+ if (filters.endDate) params.endDate = filters.endDate;
66
+ if (filters.dateFilterType) params.dateFilterType = filters.dateFilterType;
67
+ if (filters.milestones) params.milestones = filters.milestones;
68
+ if (filters.assignment) params.assignment = filters.assignment;
69
+ const fetched = [];
70
+ let serverCount;
71
+ let pageError;
72
+ let pageStartIndex = 0;
73
+ while (true) {
74
+ let data;
75
+ try {
76
+ data = await client.get("/jobs", {
77
+ ...params,
78
+ pageSize: String(PAGE_SIZE),
79
+ pageStartIndex: String(pageStartIndex)
80
+ });
81
+ } catch (error) {
82
+ pageError = error instanceof Error ? error.message : String(error);
83
+ break;
84
+ }
85
+ if (serverCount === void 0 && typeof data.count === "number") serverCount = data.count;
86
+ const items = Array.isArray(data.items) ? data.items : [];
87
+ fetched.push(...items);
88
+ if (items.length < PAGE_SIZE) break;
89
+ pageStartIndex += PAGE_SIZE;
90
+ }
91
+ const complete = pageError === void 0 && (serverCount === void 0 || fetched.length === serverCount);
92
+ const wanted = (filters.tradeType ?? []).map((t) => t.toLowerCase());
93
+ const jobs = wanted.length === 0 ? fetched : fetched.filter((j) => {
94
+ const types = j.tradeTypes;
95
+ if (!Array.isArray(types)) return false;
96
+ return types.some((t) => {
97
+ const name = t !== null && typeof t === "object" ? t.name : void 0;
98
+ if (typeof name !== "string") return false;
99
+ const lower = name.toLowerCase();
100
+ return wanted.some((w) => lower.includes(w));
101
+ });
102
+ });
103
+ return { jobs, scanned: fetched.length, serverCount, complete, ...pageError ? { pageError } : {} };
104
+ }
105
+ var ENRICHERS = ["financials", "reps", "dates", "messages"];
106
+ var asRecord = (v) => v !== null && typeof v === "object" ? v : {};
107
+ async function defaultLoadMessagesTool() {
108
+ const { createRequire: createRequire2 } = await import("module");
109
+ const require3 = createRequire2(import.meta.url);
110
+ const resolved = require3.resolve("@opsrev/acculynx-cli-unofficial");
111
+ const mod = await import(resolved);
112
+ return mod.handleExtendedToolCall;
113
+ }
114
+ async function enrichJobs(client, jobs, enrich, concurrency = 5, deps = {}) {
115
+ let userNames = /* @__PURE__ */ new Map();
116
+ if (enrich.includes("reps")) {
117
+ try {
118
+ const users = await paginate(client, "/users", {}, Infinity);
119
+ userNames = new Map(users.map((u) => [String(u.id), String(u.displayName ?? "")]));
120
+ } catch {
121
+ }
122
+ }
123
+ const nameOf = (id) => userNames.get(String(id)) || String(id ?? "").slice(0, 8);
124
+ let messagesToolPromise = null;
125
+ const getMessagesTool = () => messagesToolPromise ??= (deps.loadMessagesTool ?? defaultLoadMessagesTool)();
126
+ const out = jobs.map((job) => ({ job, errors: [] }));
127
+ let next = 0;
128
+ async function enrichOne(entry) {
129
+ const jobId = String(entry.job.id ?? "");
130
+ for (const source of enrich) {
131
+ try {
132
+ if (source === "financials") {
133
+ const f = asRecord(await jobFinancials(client, jobId));
134
+ const totals = asRecord(f.worksheetSectionTotals);
135
+ entry.financials = {
136
+ approvedJobValue: f.approvedJobValue,
137
+ balanceDue: f.balanceDue,
138
+ worksheetTotal: totals.worksheetTotal
139
+ };
140
+ } else if (source === "reps") {
141
+ const r = asRecord(await jobReps(client, jobId));
142
+ const reps = {};
143
+ for (const item of r.items ?? []) {
144
+ const userId = asRecord(item.user).id;
145
+ if (item.type === "SalesOwner") reps.salesOwner = nameOf(userId);
146
+ else if (item.type === "CompanyRepresentative") reps.company = nameOf(userId);
147
+ }
148
+ entry.reps = reps;
149
+ } else if (source === "dates") {
150
+ const m = asRecord(await jobMilestones(client, jobId));
151
+ entry.dates = (m.items ?? []).map((i) => ({
152
+ name: String(i.name ?? ""),
153
+ date: String(i.date ?? "")
154
+ }));
155
+ } else if (source === "messages") {
156
+ const tool = await getMessagesTool();
157
+ const res = await tool({ name: "acculynx_jobs_messages", args: { jobId } });
158
+ const text = String(res.content?.[0]?.text ?? "");
159
+ if (res.isError) throw new Error(text.slice(0, 200) || "messages tool error");
160
+ const data = asRecord(JSON.parse(text || "{}"));
161
+ entry.messages = (data.messages ?? []).slice(0, 3).map((msg) => ({
162
+ date: String(msg.createdDate ?? ""),
163
+ by: String(msg.createdBy ?? ""),
164
+ text: String(msg.message ?? "").replace(/\s+/g, " ").trim().slice(0, 200)
165
+ }));
166
+ }
167
+ } catch (error) {
168
+ entry.errors.push({ jobId, source, message: error instanceof Error ? error.message : String(error) });
169
+ }
170
+ }
171
+ }
172
+ async function worker() {
173
+ while (next < out.length) await enrichOne(out[next++]);
174
+ }
175
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
176
+ return out;
177
+ }
178
+
179
+ // src/ops/scan-digest.ts
180
+ function money(n) {
181
+ if (n === void 0 || n === null || Number.isNaN(n)) return "-";
182
+ return "$" + Math.round(n).toLocaleString("en-US");
183
+ }
184
+ function mmdd(iso) {
185
+ if (!iso) return "-";
186
+ const d = new Date(iso);
187
+ if (Number.isNaN(d.getTime())) return "-";
188
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
189
+ const dd = String(d.getUTCDate()).padStart(2, "0");
190
+ return `${mm}-${dd}`;
191
+ }
192
+ function customerName(job) {
193
+ const raw = job.contacts;
194
+ const contacts = Array.isArray(raw) ? raw : [];
195
+ const primary = contacts.find((c) => c !== null && typeof c === "object" && c.isPrimary === true);
196
+ if (primary) {
197
+ const contact = primary.contact ?? {};
198
+ const full = `${String(contact.firstName ?? "").trim()} ${String(contact.lastName ?? "").trim()}`.trim();
199
+ if (full) return full;
200
+ }
201
+ return String(job.jobName ?? "");
202
+ }
203
+ function headerLine1(report) {
204
+ const prefix = report.complete ? "SCAN" : "SCAN PARTIAL";
205
+ const f = report.filters;
206
+ const parts = [];
207
+ if (f.milestones) parts.push(`milestones=${f.milestones}`);
208
+ if (f.startDate) parts.push(`since=${f.startDate}`);
209
+ if (f.endDate) parts.push(`until=${f.endDate}`);
210
+ if (f.dateFilterType) parts.push(`dateField=${f.dateFilterType}`);
211
+ if (f.assignment) parts.push(`assignment=${f.assignment}`);
212
+ if (f.tradeType && f.tradeType.length > 0) parts.push(`trade=${f.tradeType.join(",")}`);
213
+ if (report.enrich.length > 0) parts.push(`enrich=${report.enrich.join(",")}`);
214
+ return parts.length > 0 ? `${prefix} ${parts.join(" ")}` : prefix;
215
+ }
216
+ function headerLine2(report) {
217
+ const scanned = report.scanned ?? report.jobs.length;
218
+ const totalLabel = report.serverCount !== void 0 ? String(report.serverCount) : "?";
219
+ let line = `jobs ${scanned}/${totalLabel}`;
220
+ if (report.serverCount !== void 0) line += ` (server count ${report.serverCount})`;
221
+ const tradeFiltered = (report.filters.tradeType ?? []).length > 0;
222
+ if (tradeFiltered && report.jobs.length !== scanned) line += ` \xB7 trade matched ${report.jobs.length}`;
223
+ if (report.enrich.length > 0) {
224
+ const enrichedCount = report.jobs.filter((j) => j.errors.length === 0).length;
225
+ const errorCount = report.jobs.reduce((sum, j) => sum + j.errors.length, 0);
226
+ line += ` \xB7 enriched ${enrichedCount} \xB7 errors ${errorCount}`;
227
+ }
228
+ if (!report.complete) {
229
+ const note = report.pageError ?? `fetched ${scanned} of ${totalLabel}`;
230
+ line += ` \xB7 PARTIAL: ${note}`;
231
+ }
232
+ return line;
233
+ }
234
+ function messagesLine(entry, enrich) {
235
+ if (!enrich.includes("messages")) return null;
236
+ if (entry.errors.some((e) => e.source === "messages")) return " note ERR:messages";
237
+ const m = entry.messages?.[0];
238
+ if (!m) return " note: -";
239
+ const text = m.text.slice(0, 110) || "-";
240
+ return ` note ${mmdd(m.date)} ${m.by || "-"}: ${text}`;
241
+ }
242
+ function jobLine(entry, enrich) {
243
+ const job = entry.job;
244
+ const id8 = String(job.id ?? "").slice(0, 8);
245
+ const name = customerName(job);
246
+ const addr = job.locationAddress ?? {};
247
+ const street = String(addr.street1 || "-");
248
+ const city = String(addr.city || "-");
249
+ const milestone = String(job.currentMilestone ?? "-");
250
+ const date = mmdd(job.milestoneDate);
251
+ let line = `${id8} | ${name} \u2014 ${street}, ${city} | ${milestone} ${date}`;
252
+ const erroredSources = new Set(entry.errors.map((e) => e.source));
253
+ for (const source of ["reps", "financials", "dates"]) {
254
+ if (!enrich.includes(source)) continue;
255
+ if (erroredSources.has(source)) {
256
+ line += ` | ERR:${source}`;
257
+ continue;
258
+ }
259
+ if (source === "reps") {
260
+ const salesOwner = entry.reps?.salesOwner || "-";
261
+ const company = entry.reps?.company || "";
262
+ line += ` | rep ${salesOwner}`;
263
+ if (company && company !== entry.reps?.salesOwner) line += ` (co ${company})`;
264
+ } else if (source === "financials") {
265
+ const f = entry.financials;
266
+ line += ` | wk ${money(f?.worksheetTotal)} | appr ${money(f?.approvedJobValue)} | bal ${money(f?.balanceDue)}`;
267
+ } else if (source === "dates") {
268
+ const approved = entry.dates?.find((d) => d.name === "Approved");
269
+ line += ` | appr\u2019d ${approved ? mmdd(approved.date) : "-"}`;
270
+ }
271
+ }
272
+ return line;
273
+ }
274
+ function formatScanDigest(report) {
275
+ const lines = [headerLine1(report), headerLine2(report)];
276
+ for (const entry of report.jobs) {
277
+ lines.push(jobLine(entry, report.enrich));
278
+ const note = messagesLine(entry, report.enrich);
279
+ if (note !== null) lines.push(note);
280
+ }
281
+ const allErrors = report.jobs.flatMap((j) => j.errors);
282
+ if (allErrors.length > 0) {
283
+ lines.push("errors:");
284
+ const groups = /* @__PURE__ */ new Map();
285
+ for (const e of allErrors) {
286
+ const key = `${e.source}: ${e.message}`;
287
+ const ids = groups.get(key) ?? [];
288
+ ids.push(e.jobId.slice(0, 8));
289
+ groups.set(key, ids);
290
+ }
291
+ for (const [key, ids] of groups)
292
+ lines.push(ids.length > 3 ? ` ${key} (${ids.length} jobs)` : ` ${ids.join(",")} ${key}`);
293
+ }
294
+ return lines.join("\n");
295
+ }
296
+ function formatScanJsonl(report) {
297
+ return report.jobs.map((j) => JSON.stringify(j)).join("\n");
298
+ }
299
+
56
300
  // src/commands/jobs.ts
57
301
  function collect(value, previous) {
58
302
  return [...previous, value];
@@ -74,6 +318,40 @@ function registerJobsCommands(parentCmd, getClient2) {
74
318
  });
75
319
  console.log(JSON.stringify(result));
76
320
  });
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) => {
322
+ const enrich = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
323
+ const badEnricher = enrich.find((e) => !ENRICHERS.includes(e));
324
+ if (badEnricher) {
325
+ console.error(`Unknown enricher "${badEnricher}" (valid: ${ENRICHERS.join(", ")})`);
326
+ process.exitCode = 2;
327
+ return;
328
+ }
329
+ if (opts.format !== "digest" && opts.format !== "jsonl") {
330
+ console.error("Unknown --format (valid: digest, jsonl)");
331
+ process.exitCode = 2;
332
+ return;
333
+ }
334
+ const filters = {
335
+ startDate: opts.startDate,
336
+ endDate: opts.endDate,
337
+ dateFilterType: opts.dateFilterType,
338
+ milestones: opts.milestones,
339
+ assignment: opts.assignment,
340
+ tradeType: opts.tradeType.length ? opts.tradeType : void 0
341
+ };
342
+ const result = await scanJobs(getClient2(), filters);
343
+ const enrichedJobs = await enrichJobs(getClient2(), result.jobs, enrich);
344
+ 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;
347
+ if (opts.out) {
348
+ try {
349
+ writeFileSync(opts.out, formatScanJsonl(report) + "\n");
350
+ } catch (error) {
351
+ console.error(`out: FAILED - ${error instanceof Error ? error.message : String(error)}`);
352
+ }
353
+ }
354
+ });
77
355
  jobs.command("get").argument("<jobId>", "Job ID").description("Get job details").action(async (jobId) => {
78
356
  console.log(JSON.stringify(await jobGet(getClient2(), jobId)));
79
357
  });
package/dist/mcp.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  resolveTradeTypeIds,
39
39
  resolveWorkTypeId,
40
40
  usersList
41
- } from "./chunk-AKUPC2EI.js";
41
+ } from "./chunk-MQDU6DIH.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.13.1",
3
+ "version": "1.15.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",