node-autotask 0.11.0 → 0.12.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/MCP_TOOL_CATALOG.json +26 -8
- package/MCP_TOOL_MANIFEST.md +12 -11
- package/N8N_MCP_MANIFEST.json +3 -3
- package/N8N_MCP_MANIFEST.md +4 -4
- package/README.md +1 -1
- package/capabilities.json +1352 -1233
- package/dist/capabilities.cjs +2 -2
- package/dist/capabilities.js +1 -1
- package/dist/{chunk-SXHPUQMB.js → chunk-3WIDDEHP.js} +2 -2
- package/dist/{chunk-7L23FDQW.js → chunk-4A6XLQHE.js} +1351 -1232
- package/dist/{chunk-YKEX4SK7.cjs → chunk-5N6DYKXO.cjs} +519 -39
- package/dist/{chunk-LNRN45XS.js → chunk-GD4JRPYF.js} +498 -18
- package/dist/{chunk-T5FLSJOD.cjs → chunk-JKZVCFRY.cjs} +1351 -1232
- package/dist/{chunk-CJONZQ7E.js → chunk-MY7ZSXY3.js} +1075 -6
- package/dist/{chunk-25ZUKHS4.cjs → chunk-SRJI5XUZ.cjs} +135 -135
- package/dist/{chunk-QAX222R6.cjs → chunk-TELBX6HK.cjs} +1462 -393
- package/dist/{index-B_xToSpq.d.ts → index-D8I6taEo.d.cts} +200 -2
- package/dist/{index-Dv_GSZaN.d.cts → index-uAxAcRol.d.ts} +200 -2
- package/dist/index.cjs +19 -19
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/operations/index.cjs +123 -588
- package/dist/operations/index.d.cts +4 -4
- package/dist/operations/index.d.ts +4 -4
- package/dist/operations/index.js +10 -475
- package/dist/pool.cjs +8 -8
- package/dist/pool.d.cts +2 -2
- package/dist/pool.d.ts +2 -2
- package/dist/pool.js +4 -4
- package/dist/resources/index.cjs +4 -4
- package/dist/resources/index.d.cts +2 -2
- package/dist/resources/index.d.ts +2 -2
- package/dist/resources/index.js +3 -3
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/API.md +192 -2
- package/docs/N8N-ADAPTER.md +1 -1
- package/package.json +1 -1
- package/dist/{index-cQ6CRexR.d.cts → index-BXtKN1M5.d.cts} +201 -201
- package/dist/{index-cQ6CRexR.d.ts → index-BXtKN1M5.d.ts} +201 -201
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
BLIND_SURFACE_STAMPS,
|
|
5
5
|
BaseResource,
|
|
6
6
|
ConflictError,
|
|
7
|
+
EMPTY_REASON_STAMPS,
|
|
8
|
+
FETCH_BY_IDS_CHUNK_SIZE,
|
|
9
|
+
FETCH_BY_IDS_MAX_ITEMS,
|
|
7
10
|
NotFoundError,
|
|
8
11
|
RELATIONSHIP_MAP,
|
|
9
12
|
ResolutionError,
|
|
@@ -30,10 +33,10 @@ import {
|
|
|
30
33
|
unwrapItem,
|
|
31
34
|
unwrapItems,
|
|
32
35
|
unwrapOperation
|
|
33
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-GD4JRPYF.js";
|
|
34
37
|
import {
|
|
35
38
|
CAPABILITIES
|
|
36
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-4A6XLQHE.js";
|
|
37
40
|
|
|
38
41
|
// src/resources/appointments.ts
|
|
39
42
|
var AppointmentsResource = class extends BaseResource {
|
|
@@ -4534,6 +4537,1068 @@ var ContractServiceUnitsResource = class extends BaseResource {
|
|
|
4534
4537
|
}
|
|
4535
4538
|
};
|
|
4536
4539
|
|
|
4540
|
+
// src/resources/special/contracts.ts
|
|
4541
|
+
var PROFITABILITY_OPT_KEYS = [
|
|
4542
|
+
"dateFrom",
|
|
4543
|
+
"dateTo",
|
|
4544
|
+
"labourCostMethod",
|
|
4545
|
+
"labourRatePerHour",
|
|
4546
|
+
"includeRecurringCosts",
|
|
4547
|
+
"includeUnbilledRevenue",
|
|
4548
|
+
"externalMonthlyCosts",
|
|
4549
|
+
"groupByActivity",
|
|
4550
|
+
"staleAfterDays",
|
|
4551
|
+
"maxTicketsForJoin",
|
|
4552
|
+
"limit"
|
|
4553
|
+
];
|
|
4554
|
+
var LABOUR_METHOD_SET = [
|
|
4555
|
+
"billing_item_our_cost",
|
|
4556
|
+
"contract_role_cost",
|
|
4557
|
+
"resource_internal_cost",
|
|
4558
|
+
"provided"
|
|
4559
|
+
];
|
|
4560
|
+
var PROFITABILITY_MAX_WINDOW_MONTHS = 36;
|
|
4561
|
+
var RECURRING_DRIFT_EPSILON = 5e-3;
|
|
4562
|
+
var ESTIMATE_DEVIATION_THRESHOLD_PCT = 10;
|
|
4563
|
+
var CLOSED_STATUS_LABEL_RE = /resolved|completed|cancelled|canceled|closed|deleted/i;
|
|
4564
|
+
var PROFITABILITY_WIRE_BUDGET = 18;
|
|
4565
|
+
var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
4566
|
+
function num2(v) {
|
|
4567
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
4568
|
+
}
|
|
4569
|
+
function isDateYMD2(v) {
|
|
4570
|
+
return typeof v === "string" && DATE_RE2.test(v) && !Number.isNaN(Date.parse(v));
|
|
4571
|
+
}
|
|
4572
|
+
function monthsInWindow(dateFrom, dateTo) {
|
|
4573
|
+
const a = `${dateFrom.slice(0, 7)}`;
|
|
4574
|
+
const b = `${dateTo.slice(0, 7)}`;
|
|
4575
|
+
if (a > b) return 0;
|
|
4576
|
+
const ay = Number(a.slice(0, 4));
|
|
4577
|
+
const am = Number(a.slice(5, 7));
|
|
4578
|
+
const by = Number(b.slice(0, 4));
|
|
4579
|
+
const bm = Number(b.slice(5, 7));
|
|
4580
|
+
return (by - ay) * 12 + (bm - am) + 1;
|
|
4581
|
+
}
|
|
4582
|
+
function monthsBetween(dateFrom, dateTo) {
|
|
4583
|
+
const out = [];
|
|
4584
|
+
let y = Number(dateFrom.slice(0, 4));
|
|
4585
|
+
let m = Number(dateFrom.slice(5, 7));
|
|
4586
|
+
const end = dateTo.slice(0, 7);
|
|
4587
|
+
for (; ; ) {
|
|
4588
|
+
const tag = `${y}-${String(m).padStart(2, "0")}`;
|
|
4589
|
+
out.push(tag);
|
|
4590
|
+
if (tag === end) break;
|
|
4591
|
+
m += 1;
|
|
4592
|
+
if (m === 13) {
|
|
4593
|
+
m = 1;
|
|
4594
|
+
y += 1;
|
|
4595
|
+
}
|
|
4596
|
+
if (out.length > 500) break;
|
|
4597
|
+
}
|
|
4598
|
+
return out;
|
|
4599
|
+
}
|
|
4600
|
+
function monthBounds(month, dateFrom, dateTo) {
|
|
4601
|
+
const y = Number(month.slice(0, 4));
|
|
4602
|
+
const m = Number(month.slice(5, 7));
|
|
4603
|
+
const first = `${month}-01`;
|
|
4604
|
+
const lastOfMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
4605
|
+
const rawEnd = `${month}-${String(lastOfMonth).padStart(2, "0")}`;
|
|
4606
|
+
return {
|
|
4607
|
+
start: first > dateFrom ? first : dateFrom,
|
|
4608
|
+
end: rawEnd < dateTo ? rawEnd : dateTo
|
|
4609
|
+
};
|
|
4610
|
+
}
|
|
4611
|
+
function dateToDay(v) {
|
|
4612
|
+
if (typeof v !== "string" || v.length < 10) return null;
|
|
4613
|
+
const d = Date.parse(v.slice(0, 10));
|
|
4614
|
+
return Number.isNaN(d) ? null : Math.floor(d / 864e5);
|
|
4615
|
+
}
|
|
4616
|
+
function validateProfitabilityOptions(contractId, opts) {
|
|
4617
|
+
if (opts === void 0 || (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
|
|
4618
|
+
throw new AutotaskConfigError(
|
|
4619
|
+
`profitability: opts must be the options object (got ${opts === void 0 ? "undefined" : Array.isArray(opts) ? "an array" : typeof opts})`,
|
|
4620
|
+
{ operation: "contracts.profitability" }
|
|
4621
|
+
);
|
|
4622
|
+
}
|
|
4623
|
+
const problems = [];
|
|
4624
|
+
for (const key of Object.keys(opts)) {
|
|
4625
|
+
if (!PROFITABILITY_OPT_KEYS.includes(key)) {
|
|
4626
|
+
problems.push(
|
|
4627
|
+
`unknown opts key "${key}" \u2014 the bag is closed: ${PROFITABILITY_OPT_KEYS.join(", ")}`
|
|
4628
|
+
);
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
if (!isDateYMD2(opts.dateFrom)) problems.push(`dateFrom is required (YYYY-MM-DD) (got ${String(opts.dateFrom)})`);
|
|
4632
|
+
if (!isDateYMD2(opts.dateTo)) problems.push(`dateTo is required (YYYY-MM-DD) (got ${String(opts.dateTo)})`);
|
|
4633
|
+
if (isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo < opts.dateFrom) {
|
|
4634
|
+
problems.push(`dateTo must be >= dateFrom (got ${opts.dateTo})`);
|
|
4635
|
+
}
|
|
4636
|
+
if (isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo >= opts.dateFrom) {
|
|
4637
|
+
const months = monthsInWindow(opts.dateFrom, opts.dateTo);
|
|
4638
|
+
if (months > PROFITABILITY_MAX_WINDOW_MONTHS) {
|
|
4639
|
+
problems.push(
|
|
4640
|
+
`window must span at most ${PROFITABILITY_MAX_WINDOW_MONTHS} months (the per-month windowing budget stays bounded; got ${months} months)`
|
|
4641
|
+
);
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
const method = opts.labourCostMethod ?? "billing_item_our_cost";
|
|
4645
|
+
if (!LABOUR_METHOD_SET.includes(method)) {
|
|
4646
|
+
problems.push(`labourCostMethod must be one of: ${LABOUR_METHOD_SET.join(", ")} (got ${String(opts.labourCostMethod)})`);
|
|
4647
|
+
}
|
|
4648
|
+
let rate = null;
|
|
4649
|
+
if (opts.labourRatePerHour !== void 0) {
|
|
4650
|
+
if (typeof opts.labourRatePerHour !== "number" || !Number.isFinite(opts.labourRatePerHour) || opts.labourRatePerHour < 0) {
|
|
4651
|
+
problems.push(`labourRatePerHour must be a finite number >= 0 (got ${String(opts.labourRatePerHour)})`);
|
|
4652
|
+
} else {
|
|
4653
|
+
rate = opts.labourRatePerHour;
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
if (method === "provided" && rate === null) {
|
|
4657
|
+
problems.push(`labourRatePerHour is required (finite number >= 0) when labourCostMethod is "provided" (got ${String(opts.labourRatePerHour)})`);
|
|
4658
|
+
}
|
|
4659
|
+
for (const key of ["includeRecurringCosts", "includeUnbilledRevenue", "groupByActivity"]) {
|
|
4660
|
+
const v = opts[key];
|
|
4661
|
+
if (v !== void 0 && typeof v !== "boolean") problems.push(`${key} must be a boolean (got ${String(v)})`);
|
|
4662
|
+
}
|
|
4663
|
+
if (opts.externalMonthlyCosts !== void 0) {
|
|
4664
|
+
const emc = opts.externalMonthlyCosts;
|
|
4665
|
+
if (!Array.isArray(emc)) {
|
|
4666
|
+
problems.push("externalMonthlyCosts must be an array of finite numbers >= 0, indexed by month from dateFrom (got a non-array)");
|
|
4667
|
+
} else {
|
|
4668
|
+
const max = isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo >= opts.dateFrom ? monthsInWindow(opts.dateFrom, opts.dateTo) : Infinity;
|
|
4669
|
+
for (let i = 0; i < emc.length; i++) {
|
|
4670
|
+
const v = emc[i];
|
|
4671
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
4672
|
+
problems.push(`externalMonthlyCosts[${i}] must be a finite number >= 0 (got ${String(v)})`);
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
if (emc.length > max) {
|
|
4676
|
+
problems.push(`externalMonthlyCosts must be an array of finite numbers >= 0, indexed by month from dateFrom, with at most ${max} entries (got ${emc.length})`);
|
|
4677
|
+
}
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4680
|
+
const stale = opts.staleAfterDays ?? 30;
|
|
4681
|
+
if (!Number.isInteger(stale) || stale < 1) problems.push(`staleAfterDays must be an integer >= 1 (got ${String(opts.staleAfterDays)})`);
|
|
4682
|
+
const mtj = opts.maxTicketsForJoin ?? 500;
|
|
4683
|
+
if (!Number.isInteger(mtj) || mtj < 1 || mtj > 500) {
|
|
4684
|
+
problems.push(`maxTicketsForJoin must be an integer 1..500 (the in-filter cap; got ${String(opts.maxTicketsForJoin)})`);
|
|
4685
|
+
}
|
|
4686
|
+
let limit = 50;
|
|
4687
|
+
if (opts.limit !== void 0) {
|
|
4688
|
+
if (!Number.isInteger(opts.limit) || opts.limit < 1) {
|
|
4689
|
+
problems.push(`limit must be an integer >= 1 (got ${String(opts.limit)})`);
|
|
4690
|
+
} else {
|
|
4691
|
+
limit = Math.min(opts.limit, 500);
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
try {
|
|
4695
|
+
parseIdentifier(contractId, "contractId");
|
|
4696
|
+
} catch (err) {
|
|
4697
|
+
problems.push(err instanceof AutotaskError ? err.message : "contractId must be a vendor numeric id");
|
|
4698
|
+
}
|
|
4699
|
+
if (problems.length > 0) {
|
|
4700
|
+
throw new AutotaskConfigError(
|
|
4701
|
+
`profitability: ${problems.length} problem(s) found pre-request (zero wire was issued)
|
|
4702
|
+
- ` + problems.join("\n- "),
|
|
4703
|
+
{ operation: "contracts.profitability" }
|
|
4704
|
+
);
|
|
4705
|
+
}
|
|
4706
|
+
return {
|
|
4707
|
+
dateFrom: opts.dateFrom,
|
|
4708
|
+
dateTo: opts.dateTo,
|
|
4709
|
+
method,
|
|
4710
|
+
labourRatePerHour: rate,
|
|
4711
|
+
includeRecurringCosts: opts.includeRecurringCosts ?? true,
|
|
4712
|
+
includeUnbilledRevenue: opts.includeUnbilledRevenue ?? true,
|
|
4713
|
+
externalMonthlyCosts: Array.isArray(opts.externalMonthlyCosts) ? [...opts.externalMonthlyCosts] : [],
|
|
4714
|
+
groupByActivity: opts.groupByActivity ?? true,
|
|
4715
|
+
staleAfterDays: stale,
|
|
4716
|
+
maxTicketsForJoin: mtj,
|
|
4717
|
+
limit
|
|
4718
|
+
};
|
|
4719
|
+
}
|
|
4720
|
+
async function boundedPart2(http, path, conditions, limit, operation, includeFields) {
|
|
4721
|
+
const body = { MaxRecords: limit, filter: conditions };
|
|
4722
|
+
if (includeFields !== void 0) body.IncludeFields = includeFields;
|
|
4723
|
+
const res = await http.request({
|
|
4724
|
+
method: "POST",
|
|
4725
|
+
idempotent: true,
|
|
4726
|
+
path,
|
|
4727
|
+
body,
|
|
4728
|
+
operation
|
|
4729
|
+
});
|
|
4730
|
+
const { items, pageDetails } = unwrapItems(res, operation);
|
|
4731
|
+
const capped = items.length >= limit;
|
|
4732
|
+
return { items, capped, hasMore: capped && pageDetails.nextPageUrl != null };
|
|
4733
|
+
}
|
|
4734
|
+
function capJoinSet2(ids, cap) {
|
|
4735
|
+
const sorted = [...new Set(ids)].sort((a, b) => a - b);
|
|
4736
|
+
if (sorted.length <= cap) return { ids: sorted, truncated: false, total: sorted.length };
|
|
4737
|
+
return { ids: sorted.slice(0, cap), truncated: true, total: sorted.length };
|
|
4738
|
+
}
|
|
4739
|
+
async function fetchByIdsPart(http, entityPath, ids, fields, operation) {
|
|
4740
|
+
const unique = [...new Set(ids)].sort((a, b) => a - b);
|
|
4741
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
4742
|
+
const misses = [];
|
|
4743
|
+
const notes = [];
|
|
4744
|
+
for (let c = 0; c < unique.length; c += FETCH_BY_IDS_CHUNK_SIZE) {
|
|
4745
|
+
const chunk = unique.slice(c, c + FETCH_BY_IDS_CHUNK_SIZE);
|
|
4746
|
+
const chunkKeys = new Set(chunk);
|
|
4747
|
+
try {
|
|
4748
|
+
const body = {
|
|
4749
|
+
MaxRecords: FETCH_BY_IDS_CHUNK_SIZE,
|
|
4750
|
+
filter: [{ field: "id", op: "in", value: chunk }],
|
|
4751
|
+
IncludeFields: fields
|
|
4752
|
+
};
|
|
4753
|
+
const res = await http.request({ method: "POST", idempotent: true, path: `${entityPath}/query`, body, operation });
|
|
4754
|
+
const { items: rows } = unwrapItems(res, operation);
|
|
4755
|
+
for (const row of rows) {
|
|
4756
|
+
const k = num2(row.id);
|
|
4757
|
+
if (k > 0 && chunkKeys.has(k)) byKey.set(k, row);
|
|
4758
|
+
}
|
|
4759
|
+
} catch (err) {
|
|
4760
|
+
const code = err instanceof AutotaskError ? err.code : "SERVER_ERROR";
|
|
4761
|
+
for (const k of chunk) {
|
|
4762
|
+
if (!byKey.has(k) && !misses.some((m) => m.id === k)) misses.push({ id: k, code, message: err instanceof Error ? err.message : String(err) });
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
const queryMissed = unique.filter((k) => !byKey.has(k) && !misses.some((m) => m.id === k));
|
|
4767
|
+
for (const k of queryMissed) {
|
|
4768
|
+
try {
|
|
4769
|
+
const res = await http.request({ method: "GET", path: `${entityPath}/${k}`, operation });
|
|
4770
|
+
byKey.set(k, unwrapItem(res, k, operation));
|
|
4771
|
+
} catch (err) {
|
|
4772
|
+
if (err instanceof NotFoundError) {
|
|
4773
|
+
misses.push({ id: k, code: "NOT_FOUND", message: err.message });
|
|
4774
|
+
} else if (err instanceof AutotaskError) {
|
|
4775
|
+
misses.push({ id: k, code: err.code, message: err.message });
|
|
4776
|
+
} else {
|
|
4777
|
+
misses.push({ id: k, code: "SERVER_ERROR", message: err instanceof Error ? err.message : String(err) });
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
}
|
|
4781
|
+
const items = unique.map((k) => byKey.get(k)).filter((r) => r !== void 0);
|
|
4782
|
+
const gotByQuery = unique.length - queryMissed.length;
|
|
4783
|
+
if (queryMissed.length > 0 && gotByQuery === 0 && byKey.size > 0) {
|
|
4784
|
+
notes.push(
|
|
4785
|
+
`the chunk \`id in\` query returned 0 rows for ${byKey.size} id(s) \u2014 the per-id get fallback resolved them (the list-scoping quirk: get-by-id is the authority)`
|
|
4786
|
+
);
|
|
4787
|
+
}
|
|
4788
|
+
const foundIds = new Set(byKey.keys());
|
|
4789
|
+
if (misses.length > 0) {
|
|
4790
|
+
const byCode = /* @__PURE__ */ new Map();
|
|
4791
|
+
for (const m of misses) byCode.set(m.code, (byCode.get(m.code) ?? 0) + 1);
|
|
4792
|
+
notes.push(`${misses.length} id(s) unreadable: ${[...byCode.entries()].map(([code, n]) => `${n} \xD7 ${code}`).join(", ")} (per-item typed misses \u2014 the rest resolved)`);
|
|
4793
|
+
}
|
|
4794
|
+
return { items, foundIds, misses, notes };
|
|
4795
|
+
}
|
|
4796
|
+
function ladderFrom(ctx) {
|
|
4797
|
+
const rungs = [];
|
|
4798
|
+
const push = (s) => rungs.push(s);
|
|
4799
|
+
switch (ctx.method) {
|
|
4800
|
+
case "billing_item_our_cost":
|
|
4801
|
+
push("billing_item.ourCost");
|
|
4802
|
+
push("contract_role_costs.rate");
|
|
4803
|
+
push("resource.internalCost");
|
|
4804
|
+
break;
|
|
4805
|
+
case "contract_role_cost":
|
|
4806
|
+
push("contract_role_costs.rate");
|
|
4807
|
+
push("resource.internalCost");
|
|
4808
|
+
break;
|
|
4809
|
+
case "resource_internal_cost":
|
|
4810
|
+
push("resource.internalCost");
|
|
4811
|
+
break;
|
|
4812
|
+
case "provided":
|
|
4813
|
+
break;
|
|
4814
|
+
}
|
|
4815
|
+
if (ctx.rate !== null) push("provided");
|
|
4816
|
+
return rungs;
|
|
4817
|
+
}
|
|
4818
|
+
function hoursOf(entry) {
|
|
4819
|
+
const h = entry["hoursWorked"];
|
|
4820
|
+
return typeof h === "number" && Number.isFinite(h) && h > 0 ? h : 0;
|
|
4821
|
+
}
|
|
4822
|
+
function resolveLabour(entry, ctx) {
|
|
4823
|
+
const id = num2(entry["id"]);
|
|
4824
|
+
const hours = hoursOf(entry);
|
|
4825
|
+
for (const rung of ladderFrom(ctx)) {
|
|
4826
|
+
if (rung === "billing_item.ourCost") {
|
|
4827
|
+
const items = ctx.billedByEntryId.get(id) ?? [];
|
|
4828
|
+
const costs = items.map((i) => i["ourCost"]).filter((v) => typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
4829
|
+
if (costs.length > 0) {
|
|
4830
|
+
return {
|
|
4831
|
+
source: "billing_item.ourCost",
|
|
4832
|
+
cost: costs.reduce((a, b) => a + b, 0),
|
|
4833
|
+
rate: null,
|
|
4834
|
+
evidence: { billingItemIds: items.map((i) => num2(i["id"])) }
|
|
4835
|
+
};
|
|
4836
|
+
}
|
|
4837
|
+
continue;
|
|
4838
|
+
}
|
|
4839
|
+
if (rung === "contract_role_costs.rate") {
|
|
4840
|
+
const resId = num2(entry["resourceID"]);
|
|
4841
|
+
const roleId = num2(entry["roleID"]);
|
|
4842
|
+
const row = resId > 0 && roleId > 0 ? ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) ?? ctx.roleCostByRole.get(roleId) : roleId > 0 ? ctx.roleCostByRole.get(roleId) : void 0;
|
|
4843
|
+
const rate = row ? row["rate"] : void 0;
|
|
4844
|
+
if (row !== void 0 && typeof rate === "number" && Number.isFinite(rate) && rate >= 0) {
|
|
4845
|
+
const matched = row !== void 0 && resId > 0 && roleId > 0 && ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) !== void 0 ? "resource+role" : "role";
|
|
4846
|
+
return { source: "contract_role_costs.rate", cost: rate * hours, rate, evidence: { contractRoleCostId: num2(row["id"]), matched } };
|
|
4847
|
+
}
|
|
4848
|
+
continue;
|
|
4849
|
+
}
|
|
4850
|
+
if (rung === "resource.internalCost") {
|
|
4851
|
+
const resId = num2(entry["resourceID"]);
|
|
4852
|
+
const r = resId > 0 ? ctx.resourcesById.get(resId) : void 0;
|
|
4853
|
+
const ic = r ? r["internalCost"] : void 0;
|
|
4854
|
+
if (r !== void 0 && typeof ic === "number" && Number.isFinite(ic) && ic >= 0) {
|
|
4855
|
+
return { source: "resource.internalCost", cost: ic * hours, rate: ic, evidence: { resourceId: resId } };
|
|
4856
|
+
}
|
|
4857
|
+
continue;
|
|
4858
|
+
}
|
|
4859
|
+
if (rung === "provided" && ctx.rate !== null) {
|
|
4860
|
+
return { source: "provided", cost: ctx.rate * hours, rate: ctx.rate, evidence: {} };
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
return { source: "missing", cost: null, rate: null, evidence: {} };
|
|
4864
|
+
}
|
|
4865
|
+
async function expandByMonth(http, path, fullWindowCond, limit, operation, fields, dateField, dateFrom, dateTo, wires) {
|
|
4866
|
+
const months = monthsBetween(dateFrom, dateTo);
|
|
4867
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4868
|
+
const all = [];
|
|
4869
|
+
const cappedMonths = [];
|
|
4870
|
+
const fullMonths = [];
|
|
4871
|
+
for (const m of months) {
|
|
4872
|
+
const b = monthBounds(m, dateFrom, dateTo);
|
|
4873
|
+
const cond = [
|
|
4874
|
+
...fullWindowCond.filter((c) => c.field !== dateField),
|
|
4875
|
+
{ field: dateField, op: "gte", value: b.start },
|
|
4876
|
+
{ field: dateField, op: "lte", value: b.end }
|
|
4877
|
+
];
|
|
4878
|
+
const page = await boundedPart2(http, path, cond, limit, operation, fields);
|
|
4879
|
+
wires.issued += 1;
|
|
4880
|
+
for (const row of page.items) {
|
|
4881
|
+
const k = num2(row["id"]);
|
|
4882
|
+
if (seen.has(k)) continue;
|
|
4883
|
+
seen.add(k);
|
|
4884
|
+
all.push(row);
|
|
4885
|
+
}
|
|
4886
|
+
if (page.capped) cappedMonths.push(m);
|
|
4887
|
+
if (page.capped && page.hasMore) fullMonths.push(m);
|
|
4888
|
+
}
|
|
4889
|
+
const partNote = `per-month windowing fired: ${months.length} month read(s) added after the full-window page carried a live cursor (no cursor walk \u2014 the single-page house rule)` + (fullMonths.length > 0 ? ` | month ${fullMonths.join(", ")} hit the limit of ${limit} \u2014 figures cover the returned rows only; widen with a narrower window or higher limit` : "");
|
|
4890
|
+
wires.note = (wires.note ? wires.note + " | " : "") + partNote;
|
|
4891
|
+
return { items: all, capped: cappedMonths.length > 0, hasMore: fullMonths.length > 0, note: partNote, status: "ok" };
|
|
4892
|
+
}
|
|
4893
|
+
function fmtHours(h) {
|
|
4894
|
+
const r = Math.round(h * 100) / 100;
|
|
4895
|
+
return String(r);
|
|
4896
|
+
}
|
|
4897
|
+
function fmtNum(v) {
|
|
4898
|
+
const r = Math.round(v * 100) / 100;
|
|
4899
|
+
return String(r);
|
|
4900
|
+
}
|
|
4901
|
+
function pctOrNa(r) {
|
|
4902
|
+
return r === null ? "n/a" : `${Math.round(r * 1e3) / 10}%`;
|
|
4903
|
+
}
|
|
4904
|
+
function dedupeSortCap(ids, limit) {
|
|
4905
|
+
return [...new Set(ids)].sort((a, b) => Number(a) - Number(b)).slice(0, limit);
|
|
4906
|
+
}
|
|
4907
|
+
async function profitability(resource, contractId, opts, http) {
|
|
4908
|
+
const v = validateProfitabilityOptions(contractId, opts ?? {});
|
|
4909
|
+
const OP2 = "contracts.profitability";
|
|
4910
|
+
const id = parseIdentifier(contractId, "contractId");
|
|
4911
|
+
const idNum = typeof id === "number" ? id : Number(id);
|
|
4912
|
+
const wires = { issued: 0, budget: PROFITABILITY_WIRE_BUDGET };
|
|
4913
|
+
const asOf = (/* @__PURE__ */ new Date()).toISOString();
|
|
4914
|
+
const asOfMs = Date.parse(asOf);
|
|
4915
|
+
const skipPart = (note) => ({ items: [], capped: false, hasMore: false, status: "skipped", note });
|
|
4916
|
+
const stampPart = (key, raw, conditions, extraNotes) => {
|
|
4917
|
+
const st = unbilledWorkPartStatus(key, conditions, raw.items);
|
|
4918
|
+
return {
|
|
4919
|
+
items: raw.items,
|
|
4920
|
+
capped: raw.capped,
|
|
4921
|
+
hasMore: raw.hasMore,
|
|
4922
|
+
status: st.status,
|
|
4923
|
+
blindSurface: st.blindSurface,
|
|
4924
|
+
note: [st.note, ...extraNotes ?? []].filter(Boolean).join(" | ") || void 0,
|
|
4925
|
+
emptyReason: raw.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS[key]?.query : void 0
|
|
4926
|
+
};
|
|
4927
|
+
};
|
|
4928
|
+
const contract = await resource.get(id);
|
|
4929
|
+
wires.issued += 1;
|
|
4930
|
+
const contractRow = contract;
|
|
4931
|
+
const companyID = num2(contractRow["companyID"]);
|
|
4932
|
+
let customer = null;
|
|
4933
|
+
let customerStatus = "skipped";
|
|
4934
|
+
if (companyID > 0) {
|
|
4935
|
+
try {
|
|
4936
|
+
const res = await http.request({
|
|
4937
|
+
method: "GET",
|
|
4938
|
+
path: `/V1.0/Companies/${companyID}`,
|
|
4939
|
+
operation: OP2
|
|
4940
|
+
});
|
|
4941
|
+
const item = unwrapItem(res, companyID, OP2);
|
|
4942
|
+
customer = item ?? null;
|
|
4943
|
+
customerStatus = "ok";
|
|
4944
|
+
} catch (err) {
|
|
4945
|
+
if (!(err instanceof NotFoundError)) throw err;
|
|
4946
|
+
}
|
|
4947
|
+
wires.issued += 1;
|
|
4948
|
+
}
|
|
4949
|
+
const servicesCond = [{ field: "contractID", op: "eq", value: id }];
|
|
4950
|
+
const servicesRaw = await boundedPart2(http, "/V1.0/ContractServices/query", servicesCond, v.limit, OP2, ["id", "serviceID", "unitPrice", "unitCost", "internalCurrencyUnitPrice", "internalDescription"]);
|
|
4951
|
+
wires.issued += 1;
|
|
4952
|
+
const unitsCond = [{ field: "contractID", op: "eq", value: id }];
|
|
4953
|
+
const unitsRaw = await boundedPart2(http, "/V1.0/ContractServiceUnits/query", unitsCond, v.limit, OP2, ["id", "contractServiceID", "serviceID", "units", "price", "cost", "internalCurrencyPrice", "startDate", "endDate", "approveAndPostDate"]);
|
|
4954
|
+
wires.issued += 1;
|
|
4955
|
+
let chargesPart;
|
|
4956
|
+
let charges = [];
|
|
4957
|
+
let chargesCond = [];
|
|
4958
|
+
if (v.includeRecurringCosts) {
|
|
4959
|
+
chargesCond = [{ field: "contractID", op: "eq", value: id }, { field: "datePurchased", op: "gte", value: v.dateFrom }, { field: "datePurchased", op: "lte", value: v.dateTo }];
|
|
4960
|
+
const raw = await boundedPart2(http, "/V1.0/ContractCharges/query", chargesCond, v.limit, OP2, ["id", "chargeType", "billableAmount", "extendedCost", "unitCost", "unitPrice", "unitQuantity", "datePurchased", "isBilled", "status", "contractServiceID"]);
|
|
4961
|
+
wires.issued += 1;
|
|
4962
|
+
charges = raw.items;
|
|
4963
|
+
chargesPart = stampPart("contract_charges", raw, chargesCond);
|
|
4964
|
+
} else {
|
|
4965
|
+
chargesPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
|
|
4966
|
+
}
|
|
4967
|
+
const roleCostsCond = [{ field: "contractID", op: "eq", value: id }];
|
|
4968
|
+
const roleCostsRaw = await boundedPart2(http, "/V1.0/ContractRoleCosts/query", roleCostsCond, v.limit, OP2, ["id", "rate", "resourceID", "roleID"]);
|
|
4969
|
+
wires.issued += 1;
|
|
4970
|
+
const ratesCond = [{ field: "contractID", op: "eq", value: id }];
|
|
4971
|
+
const ratesRaw = await boundedPart2(http, "/V1.0/ContractRates/query", ratesCond, v.limit, OP2, ["id", "roleID", "contractHourlyRate", "internalCurrencyContractHourlyRate"]);
|
|
4972
|
+
wires.issued += 1;
|
|
4973
|
+
const billingCond = () => [{ field: "contractID", op: "eq", value: id }, { field: "itemDate", op: "gte", value: v.dateFrom }, { field: "itemDate", op: "lte", value: v.dateTo }];
|
|
4974
|
+
const billingFields = ["id", "invoiceID", "timeEntryID", "ticketID", "ourCost", "quantity", "rate", "extendedPrice", "itemDate", "postedDate", "nonBillable", "billingCodeID", "billingItemType"];
|
|
4975
|
+
const billingFirst = await boundedPart2(http, "/V1.0/BillingItems/query", billingCond(), v.limit, OP2, billingFields);
|
|
4976
|
+
wires.issued += 1;
|
|
4977
|
+
let billingItems;
|
|
4978
|
+
let billingRows = [];
|
|
4979
|
+
if (!billingFirst.hasMore) {
|
|
4980
|
+
const st = unbilledWorkPartStatus("billing_items", billingCond(), billingFirst.items);
|
|
4981
|
+
billingRows = billingFirst.items;
|
|
4982
|
+
billingItems = { items: billingFirst.items, capped: billingFirst.capped, hasMore: false, status: st.status, blindSurface: st.blindSurface, note: st.note, emptyReason: billingFirst.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS.billing_items?.query : void 0 };
|
|
4983
|
+
} else {
|
|
4984
|
+
const wp = await expandByMonth(http, "/V1.0/BillingItems/query", billingCond(), v.limit, OP2, billingFields, "itemDate", v.dateFrom, v.dateTo, wires);
|
|
4985
|
+
billingRows = wp.items;
|
|
4986
|
+
billingItems = { ...wp, items: wp.items, note: wp.note };
|
|
4987
|
+
}
|
|
4988
|
+
const invoiceIds = [...new Set(billingRows.map((b) => num2(b["invoiceID"])).filter((n) => n > 0))];
|
|
4989
|
+
let invoicesPart;
|
|
4990
|
+
let invoices = [];
|
|
4991
|
+
if (invoiceIds.length === 0) {
|
|
4992
|
+
invoicesPart = skipPart("skipped: the billing part has no invoiceIDs (zero wire)");
|
|
4993
|
+
} else {
|
|
4994
|
+
const capped = capJoinSet2(invoiceIds, FETCH_BY_IDS_MAX_ITEMS);
|
|
4995
|
+
if (capped.truncated) wires.note = (wires.note ? wires.note + " | " : "") + `invoiceJoinTruncated: the join set is capped at ${FETCH_BY_IDS_MAX_ITEMS} of ${capped.total} invoice ids (the fetch-by-ids cap) \u2014 the figures cover the returned set only`;
|
|
4996
|
+
const fb = await fetchByIdsPart(http, "/V1.0/Invoices", capped.ids, ["id", "invoiceNumber", "invoiceTotal", "paidDate", "isVoided", "dueDate"], OP2);
|
|
4997
|
+
wires.issued += 1;
|
|
4998
|
+
invoices = fb.items;
|
|
4999
|
+
invoicesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
|
|
5000
|
+
}
|
|
5001
|
+
const timeCond = () => [{ field: "contractID", op: "eq", value: id }, { field: "dateWorked", op: "gte", value: v.dateFrom }, { field: "dateWorked", op: "lte", value: v.dateTo }];
|
|
5002
|
+
const timeFields = ["id", "ticketID", "taskID", "resourceID", "roleID", "hoursWorked", "hoursToBill", "dateWorked", "startDateTime", "endDateTime", "isNonBillable", "timeEntryType", "billingCodeID", "createDateTime"];
|
|
5003
|
+
const timeFirst = await boundedPart2(http, "/V1.0/TimeEntries/query", timeCond(), v.limit, OP2, timeFields);
|
|
5004
|
+
wires.issued += 1;
|
|
5005
|
+
let timeEntries;
|
|
5006
|
+
let entries = [];
|
|
5007
|
+
if (!timeFirst.hasMore) {
|
|
5008
|
+
const st = unbilledWorkPartStatus("time_entries", timeCond(), timeFirst.items);
|
|
5009
|
+
entries = timeFirst.items;
|
|
5010
|
+
timeEntries = { items: timeFirst.items, capped: timeFirst.capped, hasMore: false, status: st.status, blindSurface: st.blindSurface, note: st.note, emptyReason: timeFirst.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS.time_entries?.query : void 0 };
|
|
5011
|
+
} else {
|
|
5012
|
+
const wp = await expandByMonth(http, "/V1.0/TimeEntries/query", timeCond(), v.limit, OP2, timeFields, "dateWorked", v.dateFrom, v.dateTo, wires);
|
|
5013
|
+
entries = wp.items;
|
|
5014
|
+
timeEntries = { ...wp, items: wp.items, note: wp.note };
|
|
5015
|
+
}
|
|
5016
|
+
const ticketFields = ["id", "ticketNumber", "title", "status", "ticketCategory", "contractID", "createDate", "resolvedDateTime", "completedDate", "lastActivityDate", "dueDateTime"];
|
|
5017
|
+
const ticketsActCond = [{ field: "contractID", op: "eq", value: id }, { field: "createDate", op: "gte", value: v.dateFrom }, { field: "createDate", op: "lte", value: v.dateTo }];
|
|
5018
|
+
const ticketsActRaw = await boundedPart2(http, "/V1.0/Tickets/query", ticketsActCond, v.limit, OP2, ticketFields);
|
|
5019
|
+
wires.issued += 1;
|
|
5020
|
+
let openStatusSet = null;
|
|
5021
|
+
let statusNote;
|
|
5022
|
+
try {
|
|
5023
|
+
const res = await http.request({ method: "GET", path: "/V1.0/Tickets/entityInformation/fields", operation: OP2 });
|
|
5024
|
+
wires.issued += 1;
|
|
5025
|
+
const body = typeof res === "object" && res !== null ? res : {};
|
|
5026
|
+
const fields = Array.isArray(body["fields"]) ? body["fields"] : [];
|
|
5027
|
+
const statusRow = fields.find((f) => f["name"] === "status" && f["isPickList"] === true);
|
|
5028
|
+
const values = statusRow && Array.isArray(statusRow["picklistValues"]) ? statusRow["picklistValues"] : [];
|
|
5029
|
+
if (values.length === 0) {
|
|
5030
|
+
statusNote = "the tickets.status picklist is not exposed to this API user \u2014 the open-status labels are unavailable";
|
|
5031
|
+
} else {
|
|
5032
|
+
const open = values.filter((p) => typeof p["value"] === "string" && !CLOSED_STATUS_LABEL_RE.test(String(p["label"] ?? ""))).map((p) => Number.parseInt(String(p["value"]), 10)).filter((n) => Number.isFinite(n));
|
|
5033
|
+
if (open.length === 0) {
|
|
5034
|
+
statusNote = "every tickets.status picklist label is closed-class \u2014 no open statuses exist in this tenant";
|
|
5035
|
+
} else {
|
|
5036
|
+
openStatusSet = new Set(open);
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
5039
|
+
} catch (err) {
|
|
5040
|
+
wires.issued += 1;
|
|
5041
|
+
statusNote = `the tickets.status field catalog read failed (${err instanceof AutotaskError ? err.code : "SERVER_ERROR"}: ${err instanceof Error ? err.message : String(err)}) \u2014 the open-status labels are unavailable`;
|
|
5042
|
+
}
|
|
5043
|
+
let ticketsOpenPart;
|
|
5044
|
+
let openTickets = [];
|
|
5045
|
+
if (openStatusSet === null) {
|
|
5046
|
+
ticketsOpenPart = skipPart(`skipped: ${statusNote} (zero wire)`);
|
|
5047
|
+
} else {
|
|
5048
|
+
const openCond = [{ field: "contractID", op: "eq", value: id }, { field: "status", op: "in", value: [...openStatusSet] }];
|
|
5049
|
+
const raw = await boundedPart2(http, "/V1.0/Tickets/query", openCond, v.limit, OP2, ticketFields);
|
|
5050
|
+
wires.issued += 1;
|
|
5051
|
+
openTickets = raw.items;
|
|
5052
|
+
ticketsOpenPart = stampPart("tickets", raw, openCond, ["openStatusHeuristic: true (the open set is the tenant picklist minus the closed-class labels)"]);
|
|
5053
|
+
}
|
|
5054
|
+
const resourceIds = [...new Set(entries.map((t) => num2(t["resourceID"])).filter((n) => n > 0))];
|
|
5055
|
+
let resourcesPart;
|
|
5056
|
+
let resources = [];
|
|
5057
|
+
const resourceMissIds = /* @__PURE__ */ new Set();
|
|
5058
|
+
if (resourceIds.length === 0) {
|
|
5059
|
+
resourcesPart = skipPart("skipped: the time part has no resourceIDs (zero wire)");
|
|
5060
|
+
} else {
|
|
5061
|
+
const capped = capJoinSet2(resourceIds, FETCH_BY_IDS_MAX_ITEMS);
|
|
5062
|
+
if (capped.truncated) wires.note = (wires.note ? wires.note + " | " : "") + `resourceJoinTruncated: the join set is capped at ${FETCH_BY_IDS_MAX_ITEMS} of ${capped.total} resource ids (the fetch-by-ids cap) \u2014 the figures cover the returned set only`;
|
|
5063
|
+
const fb = await fetchByIdsPart(http, "/V1.0/Resources", capped.ids, ["id", "internalCost", "firstName", "lastName", "isActive", "userType"], OP2);
|
|
5064
|
+
wires.issued += 1;
|
|
5065
|
+
resources = fb.items;
|
|
5066
|
+
for (const m of fb.misses) if (m.code === "NOT_FOUND") resourceMissIds.add(m.id);
|
|
5067
|
+
resourcesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
|
|
5068
|
+
}
|
|
5069
|
+
const fetchedTickets = /* @__PURE__ */ new Map();
|
|
5070
|
+
for (const t of [...ticketsActRaw.items, ...openTickets]) {
|
|
5071
|
+
const k = num2(t["id"]);
|
|
5072
|
+
if (k > 0 && !fetchedTickets.has(k)) fetchedTickets.set(k, t);
|
|
5073
|
+
}
|
|
5074
|
+
const ticketJoin = capJoinSet2([...fetchedTickets.keys()], v.maxTicketsForJoin);
|
|
5075
|
+
const joinNote = ticketJoin.truncated ? `the ticket join set is capped at ${v.maxTicketsForJoin} of ${ticketJoin.total} ids \u2014 the expenses + the miscoding probe cover the returned join set only` : void 0;
|
|
5076
|
+
let expensesPart;
|
|
5077
|
+
let expenses = [];
|
|
5078
|
+
if (ticketJoin.ids.length === 0) {
|
|
5079
|
+
expensesPart = skipPart("skipped: the contract has no fetched tickets to join against (an empty join set is NOT evidence of no expenses \u2014 zero wire)");
|
|
5080
|
+
} else {
|
|
5081
|
+
const expCond = [{ field: "ticketID", op: "in", value: ticketJoin.ids }, { field: "expenseDate", op: "gte", value: v.dateFrom }, { field: "expenseDate", op: "lte", value: v.dateTo }];
|
|
5082
|
+
const raw = await boundedPart2(http, "/V1.0/ExpenseItems/query", expCond, v.limit, OP2, ["id", "expenseDate", "expenseCategory", "paymentType", "expenseCurrencyExpenseAmount", "internalCurrencyExpenseAmount", "ticketID"]);
|
|
5083
|
+
wires.issued += 1;
|
|
5084
|
+
expenses = raw.items;
|
|
5085
|
+
expensesPart = stampPart("expense_items", raw, expCond, joinNote === void 0 ? void 0 : [joinNote]);
|
|
5086
|
+
}
|
|
5087
|
+
let probePart;
|
|
5088
|
+
let probeRows = [];
|
|
5089
|
+
if (ticketJoin.ids.length === 0) {
|
|
5090
|
+
probePart = skipPart("skipped: the contract has no fetched tickets to probe against (zero wire) \u2014 check 1 (miscoded_time_out) is not evaluable");
|
|
5091
|
+
} else {
|
|
5092
|
+
const probeCond = [
|
|
5093
|
+
{ field: "ticketID", op: "in", value: ticketJoin.ids },
|
|
5094
|
+
{ op: "or", items: [{ field: "contractID", op: "noteq", value: id }, { field: "contractID", op: "notExist" }] },
|
|
5095
|
+
{ field: "dateWorked", op: "gte", value: v.dateFrom },
|
|
5096
|
+
{ field: "dateWorked", op: "lte", value: v.dateTo }
|
|
5097
|
+
];
|
|
5098
|
+
const raw = await boundedPart2(http, "/V1.0/TimeEntries/query", probeCond, v.limit, OP2, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
|
|
5099
|
+
wires.issued += 1;
|
|
5100
|
+
probeRows = raw.items;
|
|
5101
|
+
probePart = stampPart("time_entries", raw, probeCond, joinNote === void 0 ? void 0 : [joinNote]);
|
|
5102
|
+
}
|
|
5103
|
+
let configurationItemsPart;
|
|
5104
|
+
let cis = [];
|
|
5105
|
+
let ciCond = [];
|
|
5106
|
+
if (!v.includeRecurringCosts) {
|
|
5107
|
+
configurationItemsPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
|
|
5108
|
+
} else if (companyID <= 0) {
|
|
5109
|
+
configurationItemsPart = skipPart("skipped: the contract carries no companyID \u2014 the CI join has no root (zero wire)");
|
|
5110
|
+
} else {
|
|
5111
|
+
ciCond = [{ field: "companyID", op: "eq", value: companyID }];
|
|
5112
|
+
const raw = await boundedPart2(http, "/V1.0/ConfigurationItems/query", ciCond, v.limit, OP2, ["id", "configurationItemName", "status"]);
|
|
5113
|
+
wires.issued += 1;
|
|
5114
|
+
cis = raw.items;
|
|
5115
|
+
configurationItemsPart = stampPart("configuration_items", raw, ciCond);
|
|
5116
|
+
}
|
|
5117
|
+
let subscriptionsPart;
|
|
5118
|
+
let subscriptions = [];
|
|
5119
|
+
if (cis.length === 0) {
|
|
5120
|
+
subscriptionsPart = skipPart("skipped: the CI join found no configuration items \u2014 the subscriptions part is not evaluable (zero wire)");
|
|
5121
|
+
} else {
|
|
5122
|
+
const ciIds = capJoinSet2(cis.map((c) => num2(c["id"])).filter((n) => n > 0), FETCH_BY_IDS_MAX_ITEMS);
|
|
5123
|
+
const subCond = [{ field: "configurationItemID", op: "in", value: ciIds.ids }];
|
|
5124
|
+
const raw = await boundedPart2(http, "/V1.0/Subscriptions/query", subCond, v.limit, OP2, ["id", "subscriptionName", "configurationItemID", "periodCost", "periodPrice", "periodType", "effectiveDate", "expirationDate", "status", "totalCost"]);
|
|
5125
|
+
wires.issued += 1;
|
|
5126
|
+
subscriptions = raw.items;
|
|
5127
|
+
subscriptionsPart = stampPart("subscriptions", raw, subCond);
|
|
5128
|
+
}
|
|
5129
|
+
const servicesPart = stampPart("contract_services", servicesRaw, servicesCond);
|
|
5130
|
+
const unitsPart = stampPart("contract_service_units", unitsRaw, unitsCond);
|
|
5131
|
+
const roleCostsPart = stampPart("contract_role_costs", roleCostsRaw, roleCostsCond);
|
|
5132
|
+
const ratesPart = stampPart("contract_rates", ratesRaw, ratesCond);
|
|
5133
|
+
const ticketsActivityPart = stampPart("tickets", ticketsActRaw, ticketsActCond, ticketsActRaw.hasMore ? [`${fetchedTickets.size} ticket(s) returned of possibly more \u2014 the checks cover the returned set only`] : void 0);
|
|
5134
|
+
const billedByEntryId = /* @__PURE__ */ new Map();
|
|
5135
|
+
for (const b of billingRows) {
|
|
5136
|
+
const te = num2(b["timeEntryID"]);
|
|
5137
|
+
if (te <= 0) continue;
|
|
5138
|
+
const arr = billedByEntryId.get(te) ?? [];
|
|
5139
|
+
arr.push(b);
|
|
5140
|
+
billedByEntryId.set(te, arr);
|
|
5141
|
+
}
|
|
5142
|
+
const roleCostByResourceRole = /* @__PURE__ */ new Map();
|
|
5143
|
+
const roleCostByRole = /* @__PURE__ */ new Map();
|
|
5144
|
+
for (const rc of roleCostsRaw.items) {
|
|
5145
|
+
const resId = num2(rc["resourceID"]);
|
|
5146
|
+
const roleId = num2(rc["roleID"]);
|
|
5147
|
+
if (resId > 0 && roleId > 0) roleCostByResourceRole.set(`${resId}:${roleId}`, rc);
|
|
5148
|
+
else if (roleId > 0) roleCostByRole.set(roleId, rc);
|
|
5149
|
+
}
|
|
5150
|
+
const resourcesById = new Map(resources.map((r) => [num2(r["id"]), r]));
|
|
5151
|
+
const ladderCtx = { method: v.method, rate: v.labourRatePerHour, billedByEntryId, roleCostByResourceRole, roleCostByRole, resourcesById, resourceMissIds };
|
|
5152
|
+
const entryResolution = /* @__PURE__ */ new Map();
|
|
5153
|
+
for (const e of entries) entryResolution.set(num2(e["id"]), resolveLabour(e, ladderCtx));
|
|
5154
|
+
const activeRungs = ladderFrom(ladderCtx);
|
|
5155
|
+
const sources = {};
|
|
5156
|
+
for (const rung of activeRungs) sources[rung] = { entries: 0, hours: 0, cost: 0 };
|
|
5157
|
+
sources.missing = { entries: 0, hours: 0, cost: 0 };
|
|
5158
|
+
const resolutionNotes = [];
|
|
5159
|
+
if (v.method === "billing_item_our_cost") {
|
|
5160
|
+
const noItems = entries.filter((e) => !billedByEntryId.has(num2(e["id"]))).length;
|
|
5161
|
+
if (noItems > 0) resolutionNotes.push(`${noItems} ${noItems === 1 ? "entry" : "entries"} had no billing item (non-billable or unposted) \u2014 rung 1 not usable`);
|
|
5162
|
+
}
|
|
5163
|
+
for (const resId of [...resourceMissIds].sort((a, b) => a - b)) {
|
|
5164
|
+
const n = entries.filter((e) => num2(e["resourceID"]) === resId).length;
|
|
5165
|
+
resolutionNotes.push(`resource ${resId} unreadable (fetchByIds NOT_FOUND) \u2014 rung 3 unavailable for ${n} ${n === 1 ? "entry" : "entries"}`);
|
|
5166
|
+
}
|
|
5167
|
+
for (const e of entries) {
|
|
5168
|
+
const r = entryResolution.get(num2(e["id"]));
|
|
5169
|
+
const bucket = sources[r.source] ?? sources.missing;
|
|
5170
|
+
bucket.entries += 1;
|
|
5171
|
+
bucket.hours += hoursOf(e);
|
|
5172
|
+
bucket.cost += r.cost ?? 0;
|
|
5173
|
+
}
|
|
5174
|
+
const unresolvedHours = sources.missing.hours;
|
|
5175
|
+
if (unresolvedHours > 0) resolutionNotes.push(`${fmtHours(unresolvedHours)} hours unresolved (missing rate) \u2014 total_cost is a lower bound`);
|
|
5176
|
+
const costRateResolution = { method: v.method, sources, unresolvedHours, notes: resolutionNotes };
|
|
5177
|
+
const months = monthsBetween(v.dateFrom, v.dateTo);
|
|
5178
|
+
const monthOf = (date) => typeof date === "string" && DATE_RE2.test(`${date}`.slice(0, 10)) ? date.slice(0, 7) : null;
|
|
5179
|
+
const inMonth = (date, m) => monthOf(date) === m;
|
|
5180
|
+
const unitEffectiveIn = (unit, m) => {
|
|
5181
|
+
const s = monthOf(unit["startDate"]);
|
|
5182
|
+
const e = monthOf(unit["endDate"]);
|
|
5183
|
+
return (s === null || s <= m) && (e === null || e >= m);
|
|
5184
|
+
};
|
|
5185
|
+
const serviceById = new Map(servicesRaw.items.map((s) => [num2(s["id"]), s]));
|
|
5186
|
+
const expectedBasis = servicesRaw.items.length > 0 || unitsRaw.items.length > 0;
|
|
5187
|
+
const expectedIn = (m) => {
|
|
5188
|
+
let total = 0;
|
|
5189
|
+
for (const u of unitsRaw.items) {
|
|
5190
|
+
if (!unitEffectiveIn(u, m)) continue;
|
|
5191
|
+
const svc = serviceById.get(num2(u["contractServiceID"]));
|
|
5192
|
+
if (svc === void 0) continue;
|
|
5193
|
+
total += num2(svc["unitPrice"]) * num2(u["units"]);
|
|
5194
|
+
}
|
|
5195
|
+
return total;
|
|
5196
|
+
};
|
|
5197
|
+
const recurringIn = (m) => {
|
|
5198
|
+
if (!v.includeRecurringCosts) return 0;
|
|
5199
|
+
let total = 0;
|
|
5200
|
+
for (const c of charges) if (inMonth(c["datePurchased"], m)) total += num2(c["billableAmount"]);
|
|
5201
|
+
for (const u of unitsRaw.items) if (unitEffectiveIn(u, m)) total += num2(u["cost"]);
|
|
5202
|
+
return total;
|
|
5203
|
+
};
|
|
5204
|
+
const invoicesById = new Map(invoices.map((i) => [num2(i["id"]), i]));
|
|
5205
|
+
const monthly = [];
|
|
5206
|
+
for (let mi = 0; mi < months.length; mi++) {
|
|
5207
|
+
const m = months[mi];
|
|
5208
|
+
const mBilling = billingRows.filter((b) => inMonth(b["itemDate"], m));
|
|
5209
|
+
let revenueInvoiced = 0;
|
|
5210
|
+
let revenueVoided = 0;
|
|
5211
|
+
const billedItemIds = [];
|
|
5212
|
+
for (const b of mBilling) {
|
|
5213
|
+
const invId = num2(b["invoiceID"]);
|
|
5214
|
+
const inv = invId > 0 ? invoicesById.get(invId) : void 0;
|
|
5215
|
+
if (inv === void 0) continue;
|
|
5216
|
+
if (inv["isVoided"] === true) revenueVoided += num2(b["extendedPrice"]);
|
|
5217
|
+
else {
|
|
5218
|
+
revenueInvoiced += num2(b["extendedPrice"]);
|
|
5219
|
+
billedItemIds.push(num2(b["id"]));
|
|
5220
|
+
}
|
|
5221
|
+
}
|
|
5222
|
+
const expected = v.includeUnbilledRevenue && expectedBasis ? expectedIn(m) : null;
|
|
5223
|
+
const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - revenueInvoiced);
|
|
5224
|
+
const mEntries = entries.filter((e) => inMonth(e["dateWorked"], m));
|
|
5225
|
+
let labourHours = 0;
|
|
5226
|
+
let labourTotal = 0;
|
|
5227
|
+
let unresolved = 0;
|
|
5228
|
+
const rateSources = {};
|
|
5229
|
+
for (const rung of activeRungs) rateSources[rung] = { entries: 0, hours: 0, cost: 0 };
|
|
5230
|
+
rateSources.missing = { entries: 0, hours: 0, cost: 0 };
|
|
5231
|
+
for (const e of mEntries) {
|
|
5232
|
+
const r = entryResolution.get(num2(e["id"]));
|
|
5233
|
+
labourHours += num2(e["hoursWorked"]);
|
|
5234
|
+
const bucket = rateSources[r.source];
|
|
5235
|
+
bucket.entries += 1;
|
|
5236
|
+
bucket.hours += hoursOf(e);
|
|
5237
|
+
bucket.cost += r.cost ?? 0;
|
|
5238
|
+
if (r.source === "missing") unresolved += hoursOf(e);
|
|
5239
|
+
else labourTotal += r.cost ?? 0;
|
|
5240
|
+
}
|
|
5241
|
+
let toolingVendor = subscriptionsPart.status === "skipped" ? null : 0;
|
|
5242
|
+
if (toolingVendor !== null) {
|
|
5243
|
+
for (const s of subscriptions) {
|
|
5244
|
+
const eff = monthOf(s["effectiveDate"]) ?? "0000-00";
|
|
5245
|
+
const exp = monthOf(s["expirationDate"]) ?? "9999-99";
|
|
5246
|
+
if (eff <= m && m <= exp) toolingVendor += num2(s["periodCost"]);
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
const callerSupplied = v.externalMonthlyCosts[mi] ?? 0;
|
|
5250
|
+
const recurring = recurringIn(m);
|
|
5251
|
+
let expenseCost = 0;
|
|
5252
|
+
for (const x of expenses) if (inMonth(x["expenseDate"], m)) expenseCost += num2(x["expenseCurrencyExpenseAmount"]);
|
|
5253
|
+
const totalCost = labourTotal + (toolingVendor ?? 0) + callerSupplied + recurring + expenseCost;
|
|
5254
|
+
const profit = revenueInvoiced - totalCost;
|
|
5255
|
+
const margin = revenueInvoiced > 0 ? profit / revenueInvoiced : null;
|
|
5256
|
+
const fixed = totalCost - labourTotal;
|
|
5257
|
+
const resolvedHours = mEntries.filter((e) => entryResolution.get(num2(e["id"])).source !== "missing").reduce((a, e) => a + hoursOf(e), 0);
|
|
5258
|
+
const breakEven = resolvedHours > 0 ? fixed <= 0 ? 0 : fixed / (labourTotal / resolvedHours) : null;
|
|
5259
|
+
monthly.push({
|
|
5260
|
+
month: m,
|
|
5261
|
+
revenue_invoiced: revenueInvoiced,
|
|
5262
|
+
revenue_voided_excluded: revenueVoided,
|
|
5263
|
+
revenue_expected: expected,
|
|
5264
|
+
revenue_expected_unbilled: expectedUnbilled,
|
|
5265
|
+
labour_hours: labourHours,
|
|
5266
|
+
labour_cost: { total: labourTotal, unresolvedHours: unresolved, rateSources },
|
|
5267
|
+
tooling_cost: { vendor: toolingVendor, caller_supplied: callerSupplied },
|
|
5268
|
+
recurring_cost: recurring,
|
|
5269
|
+
expense_cost: expenseCost,
|
|
5270
|
+
total_cost: totalCost,
|
|
5271
|
+
profit,
|
|
5272
|
+
margin,
|
|
5273
|
+
break_even_hours: breakEven
|
|
5274
|
+
});
|
|
5275
|
+
}
|
|
5276
|
+
const warnings = [];
|
|
5277
|
+
warnings.push({
|
|
5278
|
+
code: "miscoded_time_out",
|
|
5279
|
+
count: probeRows.length,
|
|
5280
|
+
sample_ids: dedupeSortCap(probeRows.map((p) => num2(p["id"])), v.limit)
|
|
5281
|
+
});
|
|
5282
|
+
const crossTimeEntries = entries.filter((e) => {
|
|
5283
|
+
const t = num2(e["ticketID"]);
|
|
5284
|
+
if (t <= 0) return false;
|
|
5285
|
+
const ticket = fetchedTickets.get(t);
|
|
5286
|
+
return ticket !== void 0 && num2(ticket["contractID"]) !== idNum;
|
|
5287
|
+
});
|
|
5288
|
+
warnings.push({ code: "cross_contract_ticket_time", count: crossTimeEntries.length, sample_ids: dedupeSortCap(crossTimeEntries.map((e) => num2(e["id"])), v.limit) });
|
|
5289
|
+
const tupleGroups = /* @__PURE__ */ new Map();
|
|
5290
|
+
for (const e of entries) {
|
|
5291
|
+
const key = [String(num2(e["resourceID"])), String(e["dateWorked"] ?? ""), String(num2(e["ticketID"])), String(e["hoursWorked"] ?? "")].join("|");
|
|
5292
|
+
const arr = tupleGroups.get(key) ?? [];
|
|
5293
|
+
arr.push(e);
|
|
5294
|
+
tupleGroups.set(key, arr);
|
|
5295
|
+
}
|
|
5296
|
+
const dupAll = [];
|
|
5297
|
+
const dupSample = [];
|
|
5298
|
+
for (const group of tupleGroups.values()) {
|
|
5299
|
+
if (group.length < 2) continue;
|
|
5300
|
+
const sorted = [...group].sort((a, b) => num2(a["id"]) - num2(b["id"]));
|
|
5301
|
+
for (const e of sorted) dupAll.push(num2(e["id"]));
|
|
5302
|
+
for (const e of sorted.slice(1)) dupSample.push(num2(e["id"]));
|
|
5303
|
+
}
|
|
5304
|
+
warnings.push({ code: "duplicate_entries", count: dupAll.length, sample_ids: dedupeSortCap(dupSample, v.limit) });
|
|
5305
|
+
const zeroHours = entries.filter((e) => {
|
|
5306
|
+
const h = e["hoursWorked"];
|
|
5307
|
+
return !(typeof h === "number" && Number.isFinite(h) && h > 0);
|
|
5308
|
+
});
|
|
5309
|
+
warnings.push({ code: "zero_negative_hours", count: zeroHours.length, sample_ids: dedupeSortCap(zeroHours.map((e) => num2(e["id"])), v.limit) });
|
|
5310
|
+
const outPeriod = entries.filter((e) => {
|
|
5311
|
+
const t = num2(e["ticketID"]);
|
|
5312
|
+
if (t <= 0) return false;
|
|
5313
|
+
const ticket = fetchedTickets.get(t);
|
|
5314
|
+
if (ticket === void 0) return false;
|
|
5315
|
+
const dW = dateToDay(e["dateWorked"]);
|
|
5316
|
+
if (dW === null) return false;
|
|
5317
|
+
const created = dateToDay(ticket["createDate"]);
|
|
5318
|
+
if (created !== null && dW < created) return true;
|
|
5319
|
+
const ends = [dateToDay(ticket["resolvedDateTime"]), dateToDay(ticket["completedDate"])].filter((d) => d !== null);
|
|
5320
|
+
if (ends.length > 0 && dW > Math.min(...ends)) return true;
|
|
5321
|
+
return false;
|
|
5322
|
+
});
|
|
5323
|
+
warnings.push({ code: "time_outside_ticket_period", count: outPeriod.length, sample_ids: dedupeSortCap(outPeriod.map((e) => num2(e["id"])), v.limit) });
|
|
5324
|
+
const missingRate = entries.filter((e) => entryResolution.get(num2(e["id"])).source === "missing");
|
|
5325
|
+
warnings.push({ code: "missing_cost_rate", count: missingRate.length, sample_ids: dedupeSortCap(missingRate.map((e) => num2(e["id"])), v.limit) });
|
|
5326
|
+
let driftCount = 0;
|
|
5327
|
+
const driftIds = /* @__PURE__ */ new Set();
|
|
5328
|
+
if (v.includeRecurringCosts) {
|
|
5329
|
+
for (let i = 1; i < months.length; i++) {
|
|
5330
|
+
const r1 = recurringIn(months[i]);
|
|
5331
|
+
const r0 = recurringIn(months[i - 1]);
|
|
5332
|
+
if (r1 <= 0 || r0 <= 0 || Math.abs(r1 - r0) <= RECURRING_DRIFT_EPSILON) continue;
|
|
5333
|
+
driftCount += 1;
|
|
5334
|
+
const mA = months[i - 1];
|
|
5335
|
+
const mB = months[i];
|
|
5336
|
+
for (const c of charges) {
|
|
5337
|
+
const cA = inMonth(c["datePurchased"], mA) ? num2(c["billableAmount"]) : 0;
|
|
5338
|
+
const cB = inMonth(c["datePurchased"], mB) ? num2(c["billableAmount"]) : 0;
|
|
5339
|
+
if (Math.abs(cB - cA) > 0) driftIds.add(num2(c["id"]));
|
|
5340
|
+
}
|
|
5341
|
+
for (const u of unitsRaw.items) {
|
|
5342
|
+
const cA = unitEffectiveIn(u, mA) ? num2(u["cost"]) : 0;
|
|
5343
|
+
const cB = unitEffectiveIn(u, mB) ? num2(u["cost"]) : 0;
|
|
5344
|
+
if (Math.abs(cB - cA) > 0) driftIds.add(num2(u["id"]));
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
5348
|
+
warnings.push({ code: "recurring_cost_drift", count: driftCount, sample_ids: dedupeSortCap([...driftIds], v.limit) });
|
|
5349
|
+
let expectedUnbilledCount = 0;
|
|
5350
|
+
const expectedUnbilledSamples = [];
|
|
5351
|
+
if (v.includeUnbilledRevenue) {
|
|
5352
|
+
for (let mi = 0; mi < months.length; mi++) {
|
|
5353
|
+
const m = months[mi];
|
|
5354
|
+
const expected = expectedBasis ? expectedIn(m) : null;
|
|
5355
|
+
if (expected === null) continue;
|
|
5356
|
+
const mBilling = billingRows.filter((b) => inMonth(b["itemDate"], m));
|
|
5357
|
+
let billed = 0;
|
|
5358
|
+
const ids = [];
|
|
5359
|
+
for (const b of mBilling) {
|
|
5360
|
+
const invId = num2(b["invoiceID"]);
|
|
5361
|
+
const inv = invId > 0 ? invoicesById.get(invId) : void 0;
|
|
5362
|
+
if (inv === void 0 || inv["isVoided"] === true) continue;
|
|
5363
|
+
billed += num2(b["extendedPrice"]);
|
|
5364
|
+
ids.push(num2(b["id"]));
|
|
5365
|
+
}
|
|
5366
|
+
if (expected > billed + 5e-3) {
|
|
5367
|
+
expectedUnbilledCount += 1;
|
|
5368
|
+
for (const i of ids) expectedUnbilledSamples.push(i);
|
|
5369
|
+
}
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5372
|
+
warnings.push({ code: "expected_unbilled_revenue", count: expectedUnbilledCount, sample_ids: dedupeSortCap(expectedUnbilledSamples, v.limit) });
|
|
5373
|
+
const driftTickets = [...fetchedTickets.values()].filter((t) => num2(t["contractID"]) !== idNum);
|
|
5374
|
+
const unresolvedRefs = entries.filter((e) => num2(e["ticketID"]) > 0 && !fetchedTickets.has(num2(e["ticketID"])));
|
|
5375
|
+
warnings.push({
|
|
5376
|
+
code: "cross_contract_tickets",
|
|
5377
|
+
count: driftTickets.length + unresolvedRefs.length,
|
|
5378
|
+
sample_ids: dedupeSortCap([...driftTickets.map((t) => num2(t["id"])), ...unresolvedRefs.map((e) => num2(e["id"]))], v.limit)
|
|
5379
|
+
});
|
|
5380
|
+
const stale = [];
|
|
5381
|
+
if (openStatusSet !== null) {
|
|
5382
|
+
for (const t of fetchedTickets.values()) {
|
|
5383
|
+
const status = t["status"];
|
|
5384
|
+
if (typeof status !== "number" || !openStatusSet.has(status)) continue;
|
|
5385
|
+
const lastDay = dateToDay(t["lastActivityDate"]);
|
|
5386
|
+
if (lastDay === null) continue;
|
|
5387
|
+
const days = asOfMs / 864e5 - lastDay;
|
|
5388
|
+
if (days > v.staleAfterDays) stale.push(num2(t["id"]));
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
warnings.push({ code: "open_stale_tickets", count: stale.length, sample_ids: dedupeSortCap(stale, v.limit) });
|
|
5392
|
+
let estimateCount = 0;
|
|
5393
|
+
const estimateSample = [];
|
|
5394
|
+
let estimateNote;
|
|
5395
|
+
{
|
|
5396
|
+
const actualRevenue = monthly.reduce((a, r) => a + r.revenue_invoiced, 0);
|
|
5397
|
+
const actualCost = monthly.reduce((a, r) => a + r.total_cost, 0);
|
|
5398
|
+
const termStart = dateToDay(contractRow["startDate"]);
|
|
5399
|
+
const termEnd = dateToDay(contractRow["endDate"]);
|
|
5400
|
+
const fromDay = dateToDay(v.dateFrom);
|
|
5401
|
+
const toDay = dateToDay(v.dateTo);
|
|
5402
|
+
if (termStart !== null && termEnd !== null && fromDay !== null && toDay !== null) {
|
|
5403
|
+
const overlapStart = Math.max(termStart, fromDay);
|
|
5404
|
+
const overlapEnd = Math.min(termEnd, toDay);
|
|
5405
|
+
const termDays = termEnd - termStart + 1;
|
|
5406
|
+
const overlapDays = overlapEnd >= overlapStart ? overlapEnd - overlapStart + 1 : 0;
|
|
5407
|
+
if (termDays > 0 && overlapDays > 0) {
|
|
5408
|
+
const frac = overlapDays / termDays;
|
|
5409
|
+
const estRev = num2(contractRow["estimatedRevenue"]);
|
|
5410
|
+
const estCost = num2(contractRow["estimatedCost"]);
|
|
5411
|
+
const devRev = estRev > 0 ? Math.abs(actualRevenue - estRev * frac) / (estRev * frac) : null;
|
|
5412
|
+
const devCost = estCost > 0 ? Math.abs(actualCost - estCost * frac) / (estCost * frac) : null;
|
|
5413
|
+
if (devRev !== null && devRev > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100 || devCost !== null && devCost > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100) {
|
|
5414
|
+
estimateCount = 1;
|
|
5415
|
+
estimateSample.push(idNum);
|
|
5416
|
+
}
|
|
5417
|
+
estimateNote = `estimate vs actual (window-prorated ${overlapDays}/${termDays} days): revenue ${pctOrNa(devRev)} deviation (actual ${fmtNum(actualRevenue)} vs prorated ${fmtNum(estRev * frac)}), cost ${pctOrNa(devCost)} deviation (actual ${fmtNum(actualCost)} vs prorated ${fmtNum(estCost * frac)})`;
|
|
5418
|
+
}
|
|
5419
|
+
}
|
|
5420
|
+
}
|
|
5421
|
+
warnings.push({ code: "estimate_vs_actual", count: estimateCount, sample_ids: estimateSample, ...estimateCount === 1 && estimateNote !== void 0 ? { note: estimateNote } : {} });
|
|
5422
|
+
const entriesByTicket = /* @__PURE__ */ new Map();
|
|
5423
|
+
for (const e of entries) {
|
|
5424
|
+
const t = num2(e["ticketID"]);
|
|
5425
|
+
if (t <= 0) continue;
|
|
5426
|
+
const arr = entriesByTicket.get(t) ?? [];
|
|
5427
|
+
arr.push(e);
|
|
5428
|
+
entriesByTicket.set(t, arr);
|
|
5429
|
+
}
|
|
5430
|
+
const expenseByTicket = /* @__PURE__ */ new Map();
|
|
5431
|
+
for (const x of expenses) {
|
|
5432
|
+
const t = num2(x["ticketID"]);
|
|
5433
|
+
if (t <= 0) continue;
|
|
5434
|
+
expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num2(x["expenseCurrencyExpenseAmount"]));
|
|
5435
|
+
}
|
|
5436
|
+
const ticketCost = (t) => {
|
|
5437
|
+
const tid = num2(t["id"]);
|
|
5438
|
+
const es = entriesByTicket.get(tid) ?? [];
|
|
5439
|
+
let cost = expenseByTicket.get(tid) ?? 0;
|
|
5440
|
+
for (const e of es) cost += entryResolution.get(num2(e["id"])).cost ?? 0;
|
|
5441
|
+
return cost;
|
|
5442
|
+
};
|
|
5443
|
+
const ticketHours = (t) => (entriesByTicket.get(num2(t["id"])) ?? []).reduce((a, e) => a + num2(e["hoursWorked"]), 0);
|
|
5444
|
+
const allTickets = [...fetchedTickets.values()];
|
|
5445
|
+
const activityCategories = [];
|
|
5446
|
+
if (v.groupByActivity) {
|
|
5447
|
+
const byCat = /* @__PURE__ */ new Map();
|
|
5448
|
+
for (const t of allTickets) {
|
|
5449
|
+
const cat = num2(t["ticketCategory"]) > 0 ? num2(t["ticketCategory"]) : null;
|
|
5450
|
+
const arr = byCat.get(cat) ?? [];
|
|
5451
|
+
arr.push(t);
|
|
5452
|
+
byCat.set(cat, arr);
|
|
5453
|
+
}
|
|
5454
|
+
for (const [cat, tickets] of byCat) {
|
|
5455
|
+
let entryCount = 0;
|
|
5456
|
+
let hours = 0;
|
|
5457
|
+
let cost = 0;
|
|
5458
|
+
for (const t of tickets) {
|
|
5459
|
+
const es = entriesByTicket.get(num2(t["id"])) ?? [];
|
|
5460
|
+
entryCount += es.length;
|
|
5461
|
+
hours += es.reduce((a, e) => a + num2(e["hoursWorked"]), 0);
|
|
5462
|
+
cost += ticketCost(t);
|
|
5463
|
+
}
|
|
5464
|
+
activityCategories.push({
|
|
5465
|
+
ticketCategory: cat,
|
|
5466
|
+
ticketCount: tickets.length,
|
|
5467
|
+
entryCount,
|
|
5468
|
+
hours,
|
|
5469
|
+
cost,
|
|
5470
|
+
sample_ticketNumbers: dedupeSortCap(tickets.map((t) => num2(t["ticketNumber"])).filter((n) => n > 0), v.limit)
|
|
5471
|
+
});
|
|
5472
|
+
}
|
|
5473
|
+
activityCategories.sort((a, b) => b.cost - a.cost || (a.ticketCategory === null ? 1 : b.ticketCategory === null ? -1 : (a.ticketCategory ?? 0) - (b.ticketCategory ?? 0)));
|
|
5474
|
+
}
|
|
5475
|
+
const highestCostTickets = allTickets.map((t) => ({
|
|
5476
|
+
ticketId: num2(t["id"]),
|
|
5477
|
+
ticketNumber: num2(t["ticketNumber"]) > 0 ? num2(t["ticketNumber"]) : null,
|
|
5478
|
+
title: typeof t["title"] === "string" ? t["title"] : "",
|
|
5479
|
+
status: typeof t["status"] === "number" ? t["status"] : null,
|
|
5480
|
+
hours: ticketHours(t),
|
|
5481
|
+
cost: ticketCost(t),
|
|
5482
|
+
lastActivityDate: typeof t["lastActivityDate"] === "string" ? t["lastActivityDate"] : null
|
|
5483
|
+
})).sort((a, b) => b.cost - a.cost || a.ticketId - b.ticketId).slice(0, v.limit);
|
|
5484
|
+
const miscoding = /* @__PURE__ */ new Map();
|
|
5485
|
+
const putMiscoding = (e, reason) => {
|
|
5486
|
+
const eid = num2(e["id"]);
|
|
5487
|
+
if (eid <= 0 || miscoding.has(eid)) return;
|
|
5488
|
+
const c = num2(e["contractID"]);
|
|
5489
|
+
miscoding.set(eid, { entryId: eid, reason, ticketId: num2(e["ticketID"]) > 0 ? num2(e["ticketID"]) : null, contractId: c > 0 ? c : null, hours: num2(e["hoursWorked"]) });
|
|
5490
|
+
};
|
|
5491
|
+
for (const p of probeRows) putMiscoding(p, num2(p["contractID"]) > 0 ? "ticket_of_other_contract" : "no_contract_on_entry");
|
|
5492
|
+
for (const e of crossTimeEntries) putMiscoding(e, "ticket_of_other_contract");
|
|
5493
|
+
for (const e of unresolvedRefs) putMiscoding(e, "ticket_not_in_fetched_set");
|
|
5494
|
+
const possibleMiscoding = [...miscoding.values()].sort((a, b) => a.entryId - b.entryId).slice(0, v.limit);
|
|
5495
|
+
const openRenewal = [];
|
|
5496
|
+
if (openStatusSet !== null) {
|
|
5497
|
+
for (const t of allTickets) {
|
|
5498
|
+
const status = t["status"];
|
|
5499
|
+
if (typeof status !== "number" || !openStatusSet.has(status)) continue;
|
|
5500
|
+
const last = t["lastActivityDate"];
|
|
5501
|
+
const lastDay = typeof last === "string" ? (asOfMs - Date.parse(last.slice(0, 10))) / 864e5 : null;
|
|
5502
|
+
const staleDays = lastDay !== null && lastDay > v.staleAfterDays ? Math.floor(lastDay) : null;
|
|
5503
|
+
openRenewal.push({
|
|
5504
|
+
kind: "ticket",
|
|
5505
|
+
id: num2(t["id"]),
|
|
5506
|
+
label: typeof t["title"] === "string" && t["title"] !== "" ? t["title"] : `ticket ${num2(t["ticketNumber"]) > 0 ? num2(t["ticketNumber"]) : num2(t["id"])}`,
|
|
5507
|
+
date: typeof last === "string" ? last.slice(0, 10) : null,
|
|
5508
|
+
detail: staleDays !== null ? `open \u2014 stale ${staleDays} days (last activity)` : "open"
|
|
5509
|
+
});
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
for (const s of subscriptions) {
|
|
5513
|
+
openRenewal.push({
|
|
5514
|
+
kind: "subscription",
|
|
5515
|
+
id: num2(s["id"]),
|
|
5516
|
+
label: typeof s["subscriptionName"] === "string" && s["subscriptionName"] !== "" ? s["subscriptionName"] : `subscription ${num2(s["id"])}`,
|
|
5517
|
+
date: typeof s["expirationDate"] === "string" ? s["expirationDate"].slice(0, 10) : null,
|
|
5518
|
+
detail: `periodType: ${typeof s["periodType"] === "string" && s["periodType"] !== "" ? s["periodType"] : "unknown"} (period cost ${fmtNum(num2(s["periodCost"]))})`
|
|
5519
|
+
});
|
|
5520
|
+
}
|
|
5521
|
+
const contractEndDay = dateToDay(contractRow["endDate"]);
|
|
5522
|
+
if (contractEndDay !== null) {
|
|
5523
|
+
openRenewal.push({
|
|
5524
|
+
kind: "contract_end",
|
|
5525
|
+
id: idNum,
|
|
5526
|
+
label: typeof contractRow["contractName"] === "string" && contractRow["contractName"] !== "" ? contractRow["contractName"] : `contract ${idNum}`,
|
|
5527
|
+
date: String(contractRow["endDate"]).slice(0, 10),
|
|
5528
|
+
detail: `the contract ends ${String(contractRow["endDate"]).slice(0, 10)}`
|
|
5529
|
+
});
|
|
5530
|
+
}
|
|
5531
|
+
for (let mi = 0; mi < months.length; mi++) {
|
|
5532
|
+
const m = months[mi];
|
|
5533
|
+
let included = 0;
|
|
5534
|
+
for (const u of unitsRaw.items) if (unitEffectiveIn(u, m)) included += num2(u["units"]);
|
|
5535
|
+
const worked = monthly[mi].labour_hours;
|
|
5536
|
+
if (included === 0 && worked === 0) continue;
|
|
5537
|
+
const left = included - worked;
|
|
5538
|
+
const my = Number(m.slice(0, 4));
|
|
5539
|
+
const mm = Number(m.slice(5, 7));
|
|
5540
|
+
const lastDay = new Date(Date.UTC(my, mm, 0)).getUTCDate();
|
|
5541
|
+
openRenewal.push({
|
|
5542
|
+
kind: "included_hours_burn",
|
|
5543
|
+
id: null,
|
|
5544
|
+
label: `included hours ${m}`,
|
|
5545
|
+
date: `${m}-${String(lastDay).padStart(2, "0")}`,
|
|
5546
|
+
detail: `${fmtHours(worked)} h worked vs ${fmtHours(included)} h included (${left >= 0 ? `${fmtHours(left)} h left` : `${fmtHours(Math.abs(left))} h over`})`
|
|
5547
|
+
});
|
|
5548
|
+
}
|
|
5549
|
+
return {
|
|
5550
|
+
contract,
|
|
5551
|
+
customer,
|
|
5552
|
+
period: { dateFrom: v.dateFrom, dateTo: v.dateTo, months, asOf },
|
|
5553
|
+
currency: {
|
|
5554
|
+
companyCurrencyID: customer && typeof customer["currencyID"] === "number" ? customer["currencyID"] : null,
|
|
5555
|
+
note: "amounts are the vendor LOCAL-currency fields (the customer company currency is the context stamp); the internalCurrency* variants are NOT mixed in (v1 \u2014 a cross-currency tenant needs a follow-up op or bag key)"
|
|
5556
|
+
},
|
|
5557
|
+
monthly,
|
|
5558
|
+
activity_categories: activityCategories,
|
|
5559
|
+
highest_cost_tickets: highestCostTickets,
|
|
5560
|
+
possible_miscoding: possibleMiscoding,
|
|
5561
|
+
open_renewal_items: openRenewal,
|
|
5562
|
+
data_quality_warnings: warnings,
|
|
5563
|
+
costRateResolution,
|
|
5564
|
+
parts: {
|
|
5565
|
+
contract: "ok",
|
|
5566
|
+
customer: customerStatus,
|
|
5567
|
+
contractServices: servicesPart.status,
|
|
5568
|
+
contractServiceUnits: unitsPart.status,
|
|
5569
|
+
contractCharges: chargesPart.status,
|
|
5570
|
+
contractRoleCosts: roleCostsPart.status,
|
|
5571
|
+
contractRates: ratesPart.status,
|
|
5572
|
+
billingItems: billingItems.status,
|
|
5573
|
+
invoices: invoicesPart.status,
|
|
5574
|
+
timeEntries: timeEntries.status,
|
|
5575
|
+
ticketsActivity: ticketsActivityPart.status,
|
|
5576
|
+
ticketsOpen: ticketsOpenPart.status,
|
|
5577
|
+
resources: resourcesPart.status,
|
|
5578
|
+
expenses: expensesPart.status,
|
|
5579
|
+
miscodingProbe: probePart.status,
|
|
5580
|
+
configurationItems: configurationItemsPart.status,
|
|
5581
|
+
subscriptions: subscriptionsPart.status
|
|
5582
|
+
},
|
|
5583
|
+
contractServices: servicesPart,
|
|
5584
|
+
contractServiceUnits: unitsPart,
|
|
5585
|
+
contractCharges: chargesPart,
|
|
5586
|
+
contractRoleCosts: roleCostsPart,
|
|
5587
|
+
contractRates: ratesPart,
|
|
5588
|
+
billingItems,
|
|
5589
|
+
invoices: invoicesPart,
|
|
5590
|
+
timeEntries,
|
|
5591
|
+
ticketsActivity: ticketsActivityPart,
|
|
5592
|
+
ticketsOpen: ticketsOpenPart,
|
|
5593
|
+
resources: resourcesPart,
|
|
5594
|
+
expenses: expensesPart,
|
|
5595
|
+
miscodingProbe: probePart,
|
|
5596
|
+
configurationItems: configurationItemsPart,
|
|
5597
|
+
subscriptions: subscriptionsPart,
|
|
5598
|
+
wires: { issued: wires.issued, budget: wires.budget, note: wires.note }
|
|
5599
|
+
};
|
|
5600
|
+
}
|
|
5601
|
+
|
|
4537
5602
|
// src/resources/contracts.ts
|
|
4538
5603
|
var ContractsResource = class extends BaseResource {
|
|
4539
5604
|
constructor(http, pageSize) {
|
|
@@ -4599,6 +5664,10 @@ var ContractsResource = class extends BaseResource {
|
|
|
4599
5664
|
async search(query, opts) {
|
|
4600
5665
|
return this.searchBase(query, opts);
|
|
4601
5666
|
}
|
|
5667
|
+
/** Node-parity special op (hand-written in src/resources/special/contracts.ts). */
|
|
5668
|
+
profitability(id, opts) {
|
|
5669
|
+
return profitability(this, id, opts, this.http);
|
|
5670
|
+
}
|
|
4602
5671
|
};
|
|
4603
5672
|
|
|
4604
5673
|
// src/resources/contract_ticket_purchases.ts
|
|
@@ -9083,11 +10152,11 @@ async function slaHealthCheck(_resource, id, opts = {}, http) {
|
|
|
9083
10152
|
);
|
|
9084
10153
|
const slas = rows.map((row) => {
|
|
9085
10154
|
const bool = (v) => typeof v === "boolean" ? v : null;
|
|
9086
|
-
const
|
|
10155
|
+
const num3 = (v) => typeof v === "number" ? v : null;
|
|
9087
10156
|
const milestones = [
|
|
9088
|
-
{ name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours:
|
|
9089
|
-
{ name: "resolution", met: bool(row.isResolutionMet), elapsedHours:
|
|
9090
|
-
{ name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours:
|
|
10157
|
+
{ name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num3(row.firstResponseElapsedHours) },
|
|
10158
|
+
{ name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num3(row.resolutionElapsedHours) },
|
|
10159
|
+
{ name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num3(row.resolutionPlanElapsedHours) }
|
|
9091
10160
|
];
|
|
9092
10161
|
return { id: Number(row.id), name: row.serviceLevelAgreementName ?? null, milestones };
|
|
9093
10162
|
});
|