@opsrev/acculynx-cli 1.13.0 → 1.14.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
  ```
@@ -251,12 +251,10 @@ function jobRepsAssign(client, jobId, opts) {
251
251
  return client.post(`/jobs/${jobId}/representatives/${type}`, { id: opts.userId });
252
252
  }
253
253
  function jobSetWorkType(client, jobId, workTypeId) {
254
- return client.put(`/jobs/${jobId}/work-type`, { id: workTypeId });
254
+ return client.put(`/jobs/${jobId}/work-type`, { workTypeId });
255
255
  }
256
256
  function jobSetTradeTypes(client, jobId, tradeTypeIds) {
257
- return client.put(`/jobs/${jobId}/trade-types`, {
258
- items: tradeTypeIds.map((id) => ({ id }))
259
- });
257
+ return client.put(`/jobs/${jobId}/trade-types`, { tradeTypeIds });
260
258
  }
261
259
  function companyWorkTypes(client) {
262
260
  return paginate(client, "/company-settings/job-file-settings/work-types", {}, Infinity);
@@ -298,7 +296,7 @@ async function resolveWorkTypeId(client, value) {
298
296
  async function resolveTradeTypeIds(client, values) {
299
297
  const items = await companyTradeTypes(client);
300
298
  return values.map(
301
- (value) => resolveNamed(items, value, { label: "trade type", idKey: "tradeId", idIsNumeric: false })
299
+ (value) => resolveNamed(items, value, { label: "trade type", idKey: "id", idIsNumeric: false })
302
300
  );
303
301
  }
304
302
  function documentFolders(client, opts = {}) {
@@ -386,6 +384,7 @@ export {
386
384
  getConfig,
387
385
  createApiClient,
388
386
  ping,
387
+ paginate,
389
388
  readStdin,
390
389
  jobsList,
391
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-CX647QGX.js";
43
+ } from "./chunk-MQDU6DIH.js";
43
44
 
44
45
  // src/index.ts
45
46
  import { createRequire } from "module";
@@ -53,6 +54,209 @@ 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"];
106
+ var asRecord = (v) => v !== null && typeof v === "object" ? v : {};
107
+ async function enrichJobs(client, jobs, enrich, concurrency = 5) {
108
+ let userNames = /* @__PURE__ */ new Map();
109
+ if (enrich.includes("reps")) {
110
+ try {
111
+ const users = await paginate(client, "/users", {}, Infinity);
112
+ userNames = new Map(users.map((u) => [String(u.id), String(u.displayName ?? "")]));
113
+ } catch {
114
+ }
115
+ }
116
+ const nameOf = (id) => userNames.get(String(id)) || String(id ?? "").slice(0, 8);
117
+ const out = jobs.map((job) => ({ job, errors: [] }));
118
+ let next = 0;
119
+ async function enrichOne(entry) {
120
+ const jobId = String(entry.job.id ?? "");
121
+ for (const source of enrich) {
122
+ try {
123
+ if (source === "financials") {
124
+ const f = asRecord(await jobFinancials(client, jobId));
125
+ const totals = asRecord(f.worksheetSectionTotals);
126
+ entry.financials = {
127
+ approvedJobValue: f.approvedJobValue,
128
+ balanceDue: f.balanceDue,
129
+ worksheetTotal: totals.worksheetTotal
130
+ };
131
+ } else if (source === "reps") {
132
+ const r = asRecord(await jobReps(client, jobId));
133
+ const reps = {};
134
+ for (const item of r.items ?? []) {
135
+ const userId = asRecord(item.user).id;
136
+ if (item.type === "SalesOwner") reps.salesOwner = nameOf(userId);
137
+ else if (item.type === "CompanyRepresentative") reps.company = nameOf(userId);
138
+ }
139
+ entry.reps = reps;
140
+ } else if (source === "dates") {
141
+ const m = asRecord(await jobMilestones(client, jobId));
142
+ entry.dates = (m.items ?? []).map((i) => ({
143
+ name: String(i.name ?? ""),
144
+ date: String(i.date ?? "")
145
+ }));
146
+ }
147
+ } catch (error) {
148
+ entry.errors.push({ jobId, source, message: error instanceof Error ? error.message : String(error) });
149
+ }
150
+ }
151
+ }
152
+ async function worker() {
153
+ while (next < out.length) await enrichOne(out[next++]);
154
+ }
155
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
156
+ return out;
157
+ }
158
+
159
+ // src/ops/scan-digest.ts
160
+ function money(n) {
161
+ if (n === void 0 || n === null || Number.isNaN(n)) return "-";
162
+ return "$" + Math.round(n).toLocaleString("en-US");
163
+ }
164
+ function mmdd(iso) {
165
+ if (!iso) return "-";
166
+ const d = new Date(iso);
167
+ if (Number.isNaN(d.getTime())) return "-";
168
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
169
+ const dd = String(d.getUTCDate()).padStart(2, "0");
170
+ return `${mm}-${dd}`;
171
+ }
172
+ function customerName(job) {
173
+ const raw = job.contacts;
174
+ const contacts = Array.isArray(raw) ? raw : [];
175
+ const primary = contacts.find((c) => c !== null && typeof c === "object" && c.isPrimary === true);
176
+ if (primary) {
177
+ const contact = primary.contact ?? {};
178
+ const full = `${String(contact.firstName ?? "").trim()} ${String(contact.lastName ?? "").trim()}`.trim();
179
+ if (full) return full;
180
+ }
181
+ return String(job.jobName ?? "");
182
+ }
183
+ function headerLine1(report) {
184
+ const prefix = report.complete ? "SCAN" : "SCAN PARTIAL";
185
+ const f = report.filters;
186
+ const parts = [];
187
+ if (f.milestones) parts.push(`milestones=${f.milestones}`);
188
+ if (f.startDate) parts.push(`since=${f.startDate}`);
189
+ if (f.endDate) parts.push(`until=${f.endDate}`);
190
+ if (f.dateFilterType) parts.push(`dateField=${f.dateFilterType}`);
191
+ if (f.assignment) parts.push(`assignment=${f.assignment}`);
192
+ if (f.tradeType && f.tradeType.length > 0) parts.push(`trade=${f.tradeType.join(",")}`);
193
+ if (report.enrich.length > 0) parts.push(`enrich=${report.enrich.join(",")}`);
194
+ return parts.length > 0 ? `${prefix} ${parts.join(" ")}` : prefix;
195
+ }
196
+ function headerLine2(report) {
197
+ const scanned = report.scanned ?? report.jobs.length;
198
+ const totalLabel = report.serverCount !== void 0 ? String(report.serverCount) : "?";
199
+ let line = `jobs ${scanned}/${totalLabel}`;
200
+ if (report.serverCount !== void 0) line += ` (server count ${report.serverCount})`;
201
+ const tradeFiltered = (report.filters.tradeType ?? []).length > 0;
202
+ if (tradeFiltered && report.jobs.length !== scanned) line += ` \xB7 trade matched ${report.jobs.length}`;
203
+ if (report.enrich.length > 0) {
204
+ const enrichedCount = report.jobs.filter((j) => j.errors.length === 0).length;
205
+ const errorCount = report.jobs.reduce((sum, j) => sum + j.errors.length, 0);
206
+ line += ` \xB7 enriched ${enrichedCount} \xB7 errors ${errorCount}`;
207
+ }
208
+ if (!report.complete) {
209
+ const note = report.pageError ?? `fetched ${scanned} of ${totalLabel}`;
210
+ line += ` \xB7 PARTIAL: ${note}`;
211
+ }
212
+ return line;
213
+ }
214
+ function jobLine(entry, enrich) {
215
+ const job = entry.job;
216
+ const id8 = String(job.id ?? "").slice(0, 8);
217
+ const name = customerName(job);
218
+ const addr = job.locationAddress ?? {};
219
+ const street = String(addr.street1 || "-");
220
+ const city = String(addr.city || "-");
221
+ const milestone = String(job.currentMilestone ?? "-");
222
+ const date = mmdd(job.milestoneDate);
223
+ let line = `${id8} | ${name} \u2014 ${street}, ${city} | ${milestone} ${date}`;
224
+ const erroredSources = new Set(entry.errors.map((e) => e.source));
225
+ for (const source of ["reps", "financials", "dates"]) {
226
+ if (!enrich.includes(source)) continue;
227
+ if (erroredSources.has(source)) {
228
+ line += ` | ERR:${source}`;
229
+ continue;
230
+ }
231
+ if (source === "reps") {
232
+ const salesOwner = entry.reps?.salesOwner || "-";
233
+ const company = entry.reps?.company || "";
234
+ line += ` | rep ${salesOwner}`;
235
+ if (company && company !== entry.reps?.salesOwner) line += ` (co ${company})`;
236
+ } else if (source === "financials") {
237
+ const f = entry.financials;
238
+ line += ` | wk ${money(f?.worksheetTotal)} | appr ${money(f?.approvedJobValue)} | bal ${money(f?.balanceDue)}`;
239
+ } else if (source === "dates") {
240
+ const approved = entry.dates?.find((d) => d.name === "Approved");
241
+ line += ` | appr\u2019d ${approved ? mmdd(approved.date) : "-"}`;
242
+ }
243
+ }
244
+ return line;
245
+ }
246
+ function formatScanDigest(report) {
247
+ const lines = [headerLine1(report), headerLine2(report)];
248
+ for (const entry of report.jobs) lines.push(jobLine(entry, report.enrich));
249
+ const allErrors = report.jobs.flatMap((j) => j.errors);
250
+ if (allErrors.length > 0) {
251
+ lines.push("errors:");
252
+ for (const e of allErrors) lines.push(` ${e.jobId.slice(0, 8)} ${e.source}: ${e.message}`);
253
+ }
254
+ return lines.join("\n");
255
+ }
256
+ function formatScanJsonl(report) {
257
+ return report.jobs.map((j) => JSON.stringify(j)).join("\n");
258
+ }
259
+
56
260
  // src/commands/jobs.ts
57
261
  function collect(value, previous) {
58
262
  return [...previous, value];
@@ -74,6 +278,40 @@ function registerJobsCommands(parentCmd, getClient2) {
74
278
  });
75
279
  console.log(JSON.stringify(result));
76
280
  });
281
+ 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").option("--format <fmt>", "digest or jsonl", "digest").option("--out <path>", "Also write full jsonl to this file").action(async (opts) => {
282
+ const enrich = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
283
+ const badEnricher = enrich.find((e) => !ENRICHERS.includes(e));
284
+ if (badEnricher) {
285
+ console.error(`Unknown enricher "${badEnricher}" (valid: ${ENRICHERS.join(", ")})`);
286
+ process.exitCode = 2;
287
+ return;
288
+ }
289
+ if (opts.format !== "digest" && opts.format !== "jsonl") {
290
+ console.error("Unknown --format (valid: digest, jsonl)");
291
+ process.exitCode = 2;
292
+ return;
293
+ }
294
+ const filters = {
295
+ startDate: opts.startDate,
296
+ endDate: opts.endDate,
297
+ dateFilterType: opts.dateFilterType,
298
+ milestones: opts.milestones,
299
+ assignment: opts.assignment,
300
+ tradeType: opts.tradeType.length ? opts.tradeType : void 0
301
+ };
302
+ const result = await scanJobs(getClient2(), filters);
303
+ const enrichedJobs = await enrichJobs(getClient2(), result.jobs, enrich);
304
+ const report = { filters, enrich, jobs: enrichedJobs, scanned: result.scanned, serverCount: result.serverCount, complete: result.complete, ...result.pageError ? { pageError: result.pageError } : {} };
305
+ console.log(opts.format === "jsonl" ? formatScanJsonl(report) : formatScanDigest(report));
306
+ if (!result.complete) process.exitCode = 3;
307
+ if (opts.out) {
308
+ try {
309
+ writeFileSync(opts.out, formatScanJsonl(report) + "\n");
310
+ } catch (error) {
311
+ console.error(`out: FAILED - ${error instanceof Error ? error.message : String(error)}`);
312
+ }
313
+ }
314
+ });
77
315
  jobs.command("get").argument("<jobId>", "Job ID").description("Get job details").action(async (jobId) => {
78
316
  console.log(JSON.stringify(await jobGet(getClient2(), jobId)));
79
317
  });
package/dist/mcp.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  resolveTradeTypeIds,
39
39
  resolveWorkTypeId,
40
40
  usersList
41
- } from "./chunk-CX647QGX.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.0",
3
+ "version": "1.14.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",