node-autotask 0.11.0 → 0.12.1

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.
Files changed (41) hide show
  1. package/MCP_TOOL_CATALOG.json +26 -8
  2. package/MCP_TOOL_MANIFEST.md +12 -11
  3. package/N8N_MCP_MANIFEST.json +3 -3
  4. package/N8N_MCP_MANIFEST.md +4 -4
  5. package/README.md +1 -1
  6. package/capabilities.json +1352 -1233
  7. package/dist/capabilities.cjs +2 -2
  8. package/dist/capabilities.js +1 -1
  9. package/dist/{chunk-QAX222R6.cjs → chunk-36PG2IU2.cjs} +1647 -394
  10. package/dist/{chunk-T5FLSJOD.cjs → chunk-CSRCMBZ2.cjs} +1351 -1232
  11. package/dist/{chunk-YKEX4SK7.cjs → chunk-IHRXVIUU.cjs} +519 -39
  12. package/dist/{chunk-7L23FDQW.js → chunk-NACI2NDF.js} +1351 -1232
  13. package/dist/{chunk-CJONZQ7E.js → chunk-P35GFG2H.js} +1259 -6
  14. package/dist/{chunk-25ZUKHS4.cjs → chunk-PGQIGE63.cjs} +135 -135
  15. package/dist/{chunk-SXHPUQMB.js → chunk-PPTAMCFA.js} +2 -2
  16. package/dist/{chunk-LNRN45XS.js → chunk-ZOA5HUFX.js} +498 -18
  17. package/dist/{index-B_xToSpq.d.ts → index-DZfd9Gt7.d.cts} +251 -2
  18. package/dist/{index-Dv_GSZaN.d.cts → index-UilX3PFL.d.ts} +251 -2
  19. package/dist/index.cjs +20 -20
  20. package/dist/index.d.cts +4 -4
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +5 -5
  23. package/dist/operations/index.cjs +123 -588
  24. package/dist/operations/index.d.cts +4 -4
  25. package/dist/operations/index.d.ts +4 -4
  26. package/dist/operations/index.js +10 -475
  27. package/dist/pool.cjs +8 -8
  28. package/dist/pool.d.cts +2 -2
  29. package/dist/pool.d.ts +2 -2
  30. package/dist/pool.js +4 -4
  31. package/dist/resources/index.cjs +4 -4
  32. package/dist/resources/index.d.cts +2 -2
  33. package/dist/resources/index.d.ts +2 -2
  34. package/dist/resources/index.js +3 -3
  35. package/dist/types/index.d.cts +1 -1
  36. package/dist/types/index.d.ts +1 -1
  37. package/docs/API.md +301 -2
  38. package/docs/N8N-ADAPTER.md +1 -1
  39. package/package.json +1 -1
  40. package/dist/{index-cQ6CRexR.d.cts → index-BXtKN1M5.d.cts} +201 -201
  41. 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-LNRN45XS.js";
36
+ } from "./chunk-ZOA5HUFX.js";
34
37
  import {
35
38
  CAPABILITIES
36
- } from "./chunk-7L23FDQW.js";
39
+ } from "./chunk-NACI2NDF.js";
37
40
 
38
41
  // src/resources/appointments.ts
39
42
  var AppointmentsResource = class extends BaseResource {
@@ -4534,6 +4537,1252 @@ 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 = 21;
4565
+ var BLOCK_HOURS_LABEL_RE = /block\s*hours?/i;
4566
+ var TIME_AND_MATERIALS_LABEL_RE = /time.*materials?/i;
4567
+ var NOT_ACTIVE_LABEL_RE = /cancel|inactive/i;
4568
+ var SUBSCRIPTION_NOT_ACTIVE_STATUS_IDS = /* @__PURE__ */ new Set([2, 3]);
4569
+ var PERIOD_TYPE_MONTHLY_FACTOR = { 2: 1, 3: 1 / 3, 4: 1 / 6, 5: 1 / 12 };
4570
+ var PERIOD_TYPE_ONE_TIME = 1;
4571
+ var SERVICE_LINE_FKS = ["contractServiceID", "contractServiceBundleID", "contractServicePeriodID", "contractServiceBundlePeriodID", "contractServiceAdjustmentID", "contractServiceBundleAdjustmentID"];
4572
+ var CHARGE_LINE_FKS = ["contractChargeID", "ticketChargeID", "projectChargeID"];
4573
+ var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
4574
+ function num2(v) {
4575
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
4576
+ }
4577
+ function isDateYMD2(v) {
4578
+ return typeof v === "string" && DATE_RE2.test(v) && !Number.isNaN(Date.parse(v));
4579
+ }
4580
+ function monthsInWindow(dateFrom, dateTo) {
4581
+ const a = `${dateFrom.slice(0, 7)}`;
4582
+ const b = `${dateTo.slice(0, 7)}`;
4583
+ if (a > b) return 0;
4584
+ const ay = Number(a.slice(0, 4));
4585
+ const am = Number(a.slice(5, 7));
4586
+ const by = Number(b.slice(0, 4));
4587
+ const bm = Number(b.slice(5, 7));
4588
+ return (by - ay) * 12 + (bm - am) + 1;
4589
+ }
4590
+ function monthsBetween(dateFrom, dateTo) {
4591
+ const out = [];
4592
+ let y = Number(dateFrom.slice(0, 4));
4593
+ let m = Number(dateFrom.slice(5, 7));
4594
+ const end = dateTo.slice(0, 7);
4595
+ for (; ; ) {
4596
+ const tag = `${y}-${String(m).padStart(2, "0")}`;
4597
+ out.push(tag);
4598
+ if (tag === end) break;
4599
+ m += 1;
4600
+ if (m === 13) {
4601
+ m = 1;
4602
+ y += 1;
4603
+ }
4604
+ if (out.length > 500) break;
4605
+ }
4606
+ return out;
4607
+ }
4608
+ function dayAfter(date) {
4609
+ return new Date(Date.parse(date) + 864e5).toISOString().slice(0, 10);
4610
+ }
4611
+ function monthBounds(month, dateFrom, dateTo) {
4612
+ const y = Number(month.slice(0, 4));
4613
+ const m = Number(month.slice(5, 7));
4614
+ const first = `${month}-01`;
4615
+ const nextFirst = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10);
4616
+ const toExcl = dayAfter(dateTo);
4617
+ return {
4618
+ start: first > dateFrom ? first : dateFrom,
4619
+ endExclusive: nextFirst < toExcl ? nextFirst : toExcl
4620
+ };
4621
+ }
4622
+ function chargeCost(charge) {
4623
+ const ext = charge["extendedCost"];
4624
+ const fromUnits = num2(charge["unitCost"]) * num2(charge["unitQuantity"]);
4625
+ if (typeof ext === "number" && Number.isFinite(ext) && !(ext === 0 && fromUnits !== 0)) return ext;
4626
+ return fromUnits;
4627
+ }
4628
+ function isOneTimePeriod(periodType, label) {
4629
+ return label !== void 0 ? /one[\s-]*time/i.test(label) : periodType === PERIOD_TYPE_ONE_TIME;
4630
+ }
4631
+ function periodMonthlyFactor(periodType, label) {
4632
+ if (label !== void 0) {
4633
+ if (/semi/i.test(label)) return 1 / 6;
4634
+ if (/quarter/i.test(label)) return 1 / 3;
4635
+ if (/annual|year/i.test(label)) return 1 / 12;
4636
+ if (/month/i.test(label)) return 1;
4637
+ return null;
4638
+ }
4639
+ return typeof periodType === "number" ? PERIOD_TYPE_MONTHLY_FACTOR[periodType] ?? null : null;
4640
+ }
4641
+ var BILLING_ITEM_TYPE_KIND = {
4642
+ 1: "labour",
4643
+ 2: "labour",
4644
+ 3: "charges",
4645
+ 4: "expenses",
4646
+ 5: "other",
4647
+ 6: "recurring",
4648
+ 7: "charges",
4649
+ 8: "other"
4650
+ };
4651
+ function billingLineType(item) {
4652
+ const byType = BILLING_ITEM_TYPE_KIND[num2(item["billingItemType"])];
4653
+ if (byType !== void 0) return byType;
4654
+ if (num2(item["timeEntryID"]) > 0) return "labour";
4655
+ if (num2(item["expenseItemID"]) > 0) return "expenses";
4656
+ if (CHARGE_LINE_FKS.some((k) => num2(item[k]) > 0)) return "charges";
4657
+ if (SERVICE_LINE_FKS.some((k) => num2(item[k]) > 0)) return "recurring";
4658
+ return "other";
4659
+ }
4660
+ function internalAmount(row, internalKey, localKey) {
4661
+ const v = row[internalKey];
4662
+ return typeof v === "number" && Number.isFinite(v) ? v : num2(row[localKey]);
4663
+ }
4664
+ function cents(v) {
4665
+ return v === null ? null : Math.round(v * 100) / 100;
4666
+ }
4667
+ async function readPicklists(http, entityPath, operation) {
4668
+ const res = await http.request({ method: "GET", path: `${entityPath}/entityInformation/fields`, operation });
4669
+ const body = typeof res === "object" && res !== null ? res : {};
4670
+ const fields = Array.isArray(body["fields"]) ? body["fields"] : [];
4671
+ const out = /* @__PURE__ */ new Map();
4672
+ for (const f of fields) {
4673
+ if (f["isPickList"] !== true || typeof f["name"] !== "string" || !Array.isArray(f["picklistValues"])) continue;
4674
+ const values = /* @__PURE__ */ new Map();
4675
+ for (const p of f["picklistValues"]) {
4676
+ const id = typeof p["value"] === "string" ? Number.parseInt(p["value"], 10) : Number.NaN;
4677
+ if (Number.isFinite(id)) values.set(id, String(p["label"] ?? ""));
4678
+ }
4679
+ out.set(f["name"], values);
4680
+ }
4681
+ return out;
4682
+ }
4683
+ function dateToDay(v) {
4684
+ if (typeof v !== "string" || v.length < 10) return null;
4685
+ const d = Date.parse(v.slice(0, 10));
4686
+ return Number.isNaN(d) ? null : Math.floor(d / 864e5);
4687
+ }
4688
+ function validateProfitabilityOptions(contractId, opts) {
4689
+ if (opts === void 0 || (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
4690
+ throw new AutotaskConfigError(
4691
+ `profitability: opts must be the options object (got ${opts === void 0 ? "undefined" : Array.isArray(opts) ? "an array" : typeof opts})`,
4692
+ { operation: "contracts.profitability" }
4693
+ );
4694
+ }
4695
+ const problems = [];
4696
+ for (const key of Object.keys(opts)) {
4697
+ if (!PROFITABILITY_OPT_KEYS.includes(key)) {
4698
+ problems.push(
4699
+ `unknown opts key "${key}" \u2014 the bag is closed: ${PROFITABILITY_OPT_KEYS.join(", ")}`
4700
+ );
4701
+ }
4702
+ }
4703
+ if (!isDateYMD2(opts.dateFrom)) problems.push(`dateFrom is required (YYYY-MM-DD) (got ${String(opts.dateFrom)})`);
4704
+ if (!isDateYMD2(opts.dateTo)) problems.push(`dateTo is required (YYYY-MM-DD) (got ${String(opts.dateTo)})`);
4705
+ if (isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo < opts.dateFrom) {
4706
+ problems.push(`dateTo must be >= dateFrom (got ${opts.dateTo})`);
4707
+ }
4708
+ if (isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo >= opts.dateFrom) {
4709
+ const months = monthsInWindow(opts.dateFrom, opts.dateTo);
4710
+ if (months > PROFITABILITY_MAX_WINDOW_MONTHS) {
4711
+ problems.push(
4712
+ `window must span at most ${PROFITABILITY_MAX_WINDOW_MONTHS} months (the per-month windowing budget stays bounded; got ${months} months)`
4713
+ );
4714
+ }
4715
+ }
4716
+ const method = opts.labourCostMethod ?? "billing_item_our_cost";
4717
+ if (!LABOUR_METHOD_SET.includes(method)) {
4718
+ problems.push(`labourCostMethod must be one of: ${LABOUR_METHOD_SET.join(", ")} (got ${String(opts.labourCostMethod)})`);
4719
+ }
4720
+ let rate = null;
4721
+ if (opts.labourRatePerHour !== void 0) {
4722
+ if (typeof opts.labourRatePerHour !== "number" || !Number.isFinite(opts.labourRatePerHour) || opts.labourRatePerHour < 0) {
4723
+ problems.push(`labourRatePerHour must be a finite number >= 0 (got ${String(opts.labourRatePerHour)})`);
4724
+ } else {
4725
+ rate = opts.labourRatePerHour;
4726
+ }
4727
+ }
4728
+ if (method === "provided" && rate === null) {
4729
+ problems.push(`labourRatePerHour is required (finite number >= 0) when labourCostMethod is "provided" (got ${String(opts.labourRatePerHour)})`);
4730
+ }
4731
+ for (const key of ["includeRecurringCosts", "includeUnbilledRevenue", "groupByActivity"]) {
4732
+ const v = opts[key];
4733
+ if (v !== void 0 && typeof v !== "boolean") problems.push(`${key} must be a boolean (got ${String(v)})`);
4734
+ }
4735
+ if (opts.externalMonthlyCosts !== void 0) {
4736
+ const emc = opts.externalMonthlyCosts;
4737
+ if (!Array.isArray(emc)) {
4738
+ problems.push("externalMonthlyCosts must be an array of finite numbers >= 0, indexed by month from dateFrom (got a non-array)");
4739
+ } else {
4740
+ const max = isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo >= opts.dateFrom ? monthsInWindow(opts.dateFrom, opts.dateTo) : Infinity;
4741
+ for (let i = 0; i < emc.length; i++) {
4742
+ const v = emc[i];
4743
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
4744
+ problems.push(`externalMonthlyCosts[${i}] must be a finite number >= 0 (got ${String(v)})`);
4745
+ }
4746
+ }
4747
+ if (emc.length > max) {
4748
+ problems.push(`externalMonthlyCosts must be an array of finite numbers >= 0, indexed by month from dateFrom, with at most ${max} entries (got ${emc.length})`);
4749
+ }
4750
+ }
4751
+ }
4752
+ const stale = opts.staleAfterDays ?? 30;
4753
+ if (!Number.isInteger(stale) || stale < 1) problems.push(`staleAfterDays must be an integer >= 1 (got ${String(opts.staleAfterDays)})`);
4754
+ const mtj = opts.maxTicketsForJoin ?? 500;
4755
+ if (!Number.isInteger(mtj) || mtj < 1 || mtj > 500) {
4756
+ problems.push(`maxTicketsForJoin must be an integer 1..500 (the in-filter cap; got ${String(opts.maxTicketsForJoin)})`);
4757
+ }
4758
+ let limit = 50;
4759
+ if (opts.limit !== void 0) {
4760
+ if (!Number.isInteger(opts.limit) || opts.limit < 1) {
4761
+ problems.push(`limit must be an integer >= 1 (got ${String(opts.limit)})`);
4762
+ } else {
4763
+ limit = Math.min(opts.limit, 500);
4764
+ }
4765
+ }
4766
+ try {
4767
+ parseIdentifier(contractId, "contractId");
4768
+ } catch (err) {
4769
+ problems.push(err instanceof AutotaskError ? err.message : "contractId must be a vendor numeric id");
4770
+ }
4771
+ if (problems.length > 0) {
4772
+ throw new AutotaskConfigError(
4773
+ `profitability: ${problems.length} problem(s) found pre-request (zero wire was issued)
4774
+ - ` + problems.join("\n- "),
4775
+ { operation: "contracts.profitability" }
4776
+ );
4777
+ }
4778
+ return {
4779
+ dateFrom: opts.dateFrom,
4780
+ dateTo: opts.dateTo,
4781
+ method,
4782
+ labourRatePerHour: rate,
4783
+ includeRecurringCosts: opts.includeRecurringCosts ?? true,
4784
+ includeUnbilledRevenue: opts.includeUnbilledRevenue ?? true,
4785
+ externalMonthlyCosts: Array.isArray(opts.externalMonthlyCosts) ? [...opts.externalMonthlyCosts] : [],
4786
+ groupByActivity: opts.groupByActivity ?? true,
4787
+ staleAfterDays: stale,
4788
+ maxTicketsForJoin: mtj,
4789
+ limit
4790
+ };
4791
+ }
4792
+ async function boundedPart2(http, path, conditions, limit, operation, includeFields) {
4793
+ const body = { MaxRecords: limit, filter: conditions };
4794
+ if (includeFields !== void 0) body.IncludeFields = includeFields;
4795
+ const res = await http.request({
4796
+ method: "POST",
4797
+ idempotent: true,
4798
+ path,
4799
+ body,
4800
+ operation
4801
+ });
4802
+ const { items, pageDetails } = unwrapItems(res, operation);
4803
+ const capped = items.length >= limit;
4804
+ return { items, capped, hasMore: capped && pageDetails.nextPageUrl != null };
4805
+ }
4806
+ function capJoinSet2(ids, cap) {
4807
+ const sorted = [...new Set(ids)].sort((a, b) => a - b);
4808
+ if (sorted.length <= cap) return { ids: sorted, truncated: false, total: sorted.length };
4809
+ return { ids: sorted.slice(0, cap), truncated: true, total: sorted.length };
4810
+ }
4811
+ async function fetchByIdsPart(http, entityPath, ids, fields, operation) {
4812
+ const unique = [...new Set(ids)].sort((a, b) => a - b);
4813
+ const byKey = /* @__PURE__ */ new Map();
4814
+ const misses = [];
4815
+ const notes = [];
4816
+ for (let c = 0; c < unique.length; c += FETCH_BY_IDS_CHUNK_SIZE) {
4817
+ const chunk = unique.slice(c, c + FETCH_BY_IDS_CHUNK_SIZE);
4818
+ const chunkKeys = new Set(chunk);
4819
+ try {
4820
+ const body = {
4821
+ MaxRecords: FETCH_BY_IDS_CHUNK_SIZE,
4822
+ filter: [{ field: "id", op: "in", value: chunk }],
4823
+ IncludeFields: fields
4824
+ };
4825
+ const res = await http.request({ method: "POST", idempotent: true, path: `${entityPath}/query`, body, operation });
4826
+ const { items: rows } = unwrapItems(res, operation);
4827
+ for (const row of rows) {
4828
+ const k = num2(row.id);
4829
+ if (k > 0 && chunkKeys.has(k)) byKey.set(k, row);
4830
+ }
4831
+ } catch (err) {
4832
+ const code = err instanceof AutotaskError ? err.code : "SERVER_ERROR";
4833
+ for (const k of chunk) {
4834
+ if (!byKey.has(k) && !misses.some((m) => m.id === k)) misses.push({ id: k, code, message: err instanceof Error ? err.message : String(err) });
4835
+ }
4836
+ }
4837
+ }
4838
+ const queryMissed = unique.filter((k) => !byKey.has(k) && !misses.some((m) => m.id === k));
4839
+ for (const k of queryMissed) {
4840
+ try {
4841
+ const res = await http.request({ method: "GET", path: `${entityPath}/${k}`, operation });
4842
+ byKey.set(k, unwrapItem(res, k, operation));
4843
+ } catch (err) {
4844
+ if (err instanceof NotFoundError) {
4845
+ misses.push({ id: k, code: "NOT_FOUND", message: err.message });
4846
+ } else if (err instanceof AutotaskError) {
4847
+ misses.push({ id: k, code: err.code, message: err.message });
4848
+ } else {
4849
+ misses.push({ id: k, code: "SERVER_ERROR", message: err instanceof Error ? err.message : String(err) });
4850
+ }
4851
+ }
4852
+ }
4853
+ const items = unique.map((k) => byKey.get(k)).filter((r) => r !== void 0);
4854
+ const gotByQuery = unique.length - queryMissed.length;
4855
+ if (queryMissed.length > 0 && gotByQuery === 0 && byKey.size > 0) {
4856
+ notes.push(
4857
+ `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)`
4858
+ );
4859
+ }
4860
+ const foundIds = new Set(byKey.keys());
4861
+ if (misses.length > 0) {
4862
+ const byCode = /* @__PURE__ */ new Map();
4863
+ for (const m of misses) byCode.set(m.code, (byCode.get(m.code) ?? 0) + 1);
4864
+ notes.push(`${misses.length} id(s) unreadable: ${[...byCode.entries()].map(([code, n]) => `${n} \xD7 ${code}`).join(", ")} (per-item typed misses \u2014 the rest resolved)`);
4865
+ }
4866
+ return { items, foundIds, misses, notes };
4867
+ }
4868
+ function ladderFrom(ctx) {
4869
+ const rungs = [];
4870
+ const push = (s) => rungs.push(s);
4871
+ switch (ctx.method) {
4872
+ case "billing_item_our_cost":
4873
+ push("billing_item.ourCost");
4874
+ push("contract_role_costs.rate");
4875
+ push("resource.internalCost");
4876
+ break;
4877
+ case "contract_role_cost":
4878
+ push("contract_role_costs.rate");
4879
+ push("resource.internalCost");
4880
+ break;
4881
+ case "resource_internal_cost":
4882
+ push("resource.internalCost");
4883
+ break;
4884
+ case "provided":
4885
+ break;
4886
+ }
4887
+ if (ctx.rate !== null) push("provided");
4888
+ return rungs;
4889
+ }
4890
+ function hoursOf(entry) {
4891
+ const h = entry["hoursWorked"];
4892
+ return typeof h === "number" && Number.isFinite(h) && h > 0 ? h : 0;
4893
+ }
4894
+ function resolveLabour(entry, ctx) {
4895
+ const id = num2(entry["id"]);
4896
+ const hours = hoursOf(entry);
4897
+ for (const rung of ladderFrom(ctx)) {
4898
+ if (rung === "billing_item.ourCost") {
4899
+ const items = ctx.billedByEntryId.get(id) ?? [];
4900
+ const costs = items.map((i) => i["ourCost"]).filter((v) => typeof v === "number" && Number.isFinite(v) && v >= 0);
4901
+ if (costs.length > 0) {
4902
+ return {
4903
+ source: "billing_item.ourCost",
4904
+ cost: costs.reduce((a, b) => a + b, 0),
4905
+ rate: null,
4906
+ evidence: { billingItemIds: items.map((i) => num2(i["id"])) }
4907
+ };
4908
+ }
4909
+ continue;
4910
+ }
4911
+ if (rung === "contract_role_costs.rate") {
4912
+ const resId = num2(entry["resourceID"]);
4913
+ const roleId = num2(entry["roleID"]);
4914
+ const row = resId > 0 && roleId > 0 ? ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) ?? ctx.roleCostByRole.get(roleId) : roleId > 0 ? ctx.roleCostByRole.get(roleId) : void 0;
4915
+ const rate = row ? row["rate"] : void 0;
4916
+ if (row !== void 0 && typeof rate === "number" && Number.isFinite(rate) && rate >= 0) {
4917
+ const matched = row !== void 0 && resId > 0 && roleId > 0 && ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) !== void 0 ? "resource+role" : "role";
4918
+ return { source: "contract_role_costs.rate", cost: rate * hours, rate, evidence: { contractRoleCostId: num2(row["id"]), matched } };
4919
+ }
4920
+ continue;
4921
+ }
4922
+ if (rung === "resource.internalCost") {
4923
+ const resId = num2(entry["resourceID"]);
4924
+ const r = resId > 0 ? ctx.resourcesById.get(resId) : void 0;
4925
+ const ic = r ? r["internalCost"] : void 0;
4926
+ if (r !== void 0 && typeof ic === "number" && Number.isFinite(ic) && ic >= 0) {
4927
+ return { source: "resource.internalCost", cost: ic * hours, rate: ic, evidence: { resourceId: resId } };
4928
+ }
4929
+ continue;
4930
+ }
4931
+ if (rung === "provided" && ctx.rate !== null) {
4932
+ return { source: "provided", cost: ctx.rate * hours, rate: ctx.rate, evidence: {} };
4933
+ }
4934
+ }
4935
+ return { source: "missing", cost: null, rate: null, evidence: {} };
4936
+ }
4937
+ async function expandByMonth(http, path, fullWindowCond, limit, operation, fields, dateField, dateFrom, dateTo, wires) {
4938
+ const months = monthsBetween(dateFrom, dateTo);
4939
+ const seen = /* @__PURE__ */ new Set();
4940
+ const all = [];
4941
+ const cappedMonths = [];
4942
+ const fullMonths = [];
4943
+ for (const m of months) {
4944
+ const b = monthBounds(m, dateFrom, dateTo);
4945
+ const cond = [
4946
+ ...fullWindowCond.filter((c) => c.field !== dateField),
4947
+ { field: dateField, op: "gte", value: b.start },
4948
+ { field: dateField, op: "lt", value: b.endExclusive }
4949
+ ];
4950
+ const page = await boundedPart2(http, path, cond, limit, operation, fields);
4951
+ wires.issued += 1;
4952
+ for (const row of page.items) {
4953
+ const k = num2(row["id"]);
4954
+ if (seen.has(k)) continue;
4955
+ seen.add(k);
4956
+ all.push(row);
4957
+ }
4958
+ if (page.capped) cappedMonths.push(m);
4959
+ if (page.capped && page.hasMore) fullMonths.push(m);
4960
+ }
4961
+ 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` : "");
4962
+ wires.note = (wires.note ? wires.note + " | " : "") + partNote;
4963
+ return { items: all, capped: cappedMonths.length > 0, hasMore: fullMonths.length > 0, note: partNote, status: "ok", truncatedMonths: fullMonths };
4964
+ }
4965
+ async function degradable(read) {
4966
+ try {
4967
+ return await read();
4968
+ } catch (err) {
4969
+ if (!(err instanceof AutotaskError)) throw err;
4970
+ return { items: [], capped: false, hasMore: false, absent: err.code, absentMessage: err.message };
4971
+ }
4972
+ }
4973
+ function fmtHours(h) {
4974
+ const r = Math.round(h * 100) / 100;
4975
+ return String(r);
4976
+ }
4977
+ function fmtNum(v) {
4978
+ const r = Math.round(v * 100) / 100;
4979
+ return String(r);
4980
+ }
4981
+ function pctOrNa(r) {
4982
+ return r === null ? "n/a" : `${Math.round(r * 1e3) / 10}%`;
4983
+ }
4984
+ function dedupeSortCap(ids, limit) {
4985
+ return [...new Set(ids)].sort((a, b) => Number(a) - Number(b)).slice(0, limit);
4986
+ }
4987
+ async function profitability(resource, contractId, opts, rawHttp) {
4988
+ const v = validateProfitabilityOptions(contractId, opts ?? {});
4989
+ const OP2 = "contracts.profitability";
4990
+ const id = parseIdentifier(contractId, "contractId");
4991
+ const idNum = typeof id === "number" ? id : Number(id);
4992
+ const wires = { issued: 0, requests: 0, budget: PROFITABILITY_WIRE_BUDGET };
4993
+ const asOf = (/* @__PURE__ */ new Date()).toISOString();
4994
+ const asOfMs = Date.parse(asOf);
4995
+ const toExcl = dayAfter(v.dateTo);
4996
+ const windowOn = (field) => [{ field, op: "gte", value: v.dateFrom }, { field, op: "lt", value: toExcl }];
4997
+ const http = { request: (o) => {
4998
+ wires.requests += 1;
4999
+ return rawHttp.request(o);
5000
+ } };
5001
+ const readPart = (path, conditions, fields) => {
5002
+ wires.issued += 1;
5003
+ return degradable(() => boundedPart2(http, path, conditions, v.limit, OP2, fields));
5004
+ };
5005
+ const skipPart = (note) => ({ items: [], capped: false, hasMore: false, status: "skipped", note });
5006
+ const stampPart = (key, raw, conditions, extraNotes) => {
5007
+ if (raw.absent !== void 0) {
5008
+ return { items: [], capped: false, hasMore: false, status: "absent", absent: raw.absent, note: `absent: the read failed (${raw.absent}: ${raw.absentMessage ?? ""}) \u2014 the monthly figures it feeds carry it in coverage_gaps` };
5009
+ }
5010
+ const st = unbilledWorkPartStatus(key, conditions, raw.items);
5011
+ return {
5012
+ items: raw.items,
5013
+ capped: raw.capped,
5014
+ hasMore: raw.hasMore,
5015
+ status: st.status,
5016
+ blindSurface: st.blindSurface,
5017
+ note: [st.note, ...extraNotes ?? []].filter(Boolean).join(" | ") || void 0,
5018
+ emptyReason: raw.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS[key]?.query : void 0
5019
+ };
5020
+ };
5021
+ const truncatedMonths = {};
5022
+ const windowedPart = async (name, key, path, cond, fields, dateField) => {
5023
+ const first = await readPart(path, cond, fields);
5024
+ if (first.absent !== void 0 || !first.hasMore) return { part: stampPart(key, first, cond), rows: first.items };
5025
+ const wp = await degradable(() => expandByMonth(http, path, cond, v.limit, OP2, fields, dateField, v.dateFrom, v.dateTo, wires));
5026
+ if (!("truncatedMonths" in wp)) return { part: stampPart(key, wp, cond), rows: [] };
5027
+ const { truncatedMonths: tm, ...part } = wp;
5028
+ truncatedMonths[name] = tm;
5029
+ return { part, rows: wp.items };
5030
+ };
5031
+ const contractCached = rawHttp.readCache?.get(`/V1.0/Contracts/${id}`) !== void 0;
5032
+ const contract = await resource.get(id);
5033
+ wires.issued += 1;
5034
+ if (!contractCached) wires.requests += 1;
5035
+ const contractRow = contract;
5036
+ const companyID = num2(contractRow["companyID"]);
5037
+ const contractType = num2(contractRow["contractType"]);
5038
+ let customer = null;
5039
+ let customerStatus = "skipped";
5040
+ let customerAbsent;
5041
+ if (companyID > 0) {
5042
+ try {
5043
+ const res = await http.request({
5044
+ method: "GET",
5045
+ path: `/V1.0/Companies/${companyID}`,
5046
+ operation: OP2
5047
+ });
5048
+ const item = unwrapItem(res, companyID, OP2);
5049
+ customer = item ?? null;
5050
+ customerStatus = "ok";
5051
+ } catch (err) {
5052
+ if (!(err instanceof AutotaskError)) throw err;
5053
+ if (!(err instanceof NotFoundError)) {
5054
+ customerStatus = "absent";
5055
+ customerAbsent = err.code;
5056
+ }
5057
+ }
5058
+ wires.issued += 1;
5059
+ }
5060
+ let contractTypeLabel = null;
5061
+ if (contractType > 0) {
5062
+ wires.issued += 1;
5063
+ try {
5064
+ contractTypeLabel = (await readPicklists(http, "/V1.0/Contracts", OP2)).get("contractType")?.get(contractType) ?? null;
5065
+ } catch (err) {
5066
+ if (!(err instanceof AutotaskError)) throw err;
5067
+ }
5068
+ }
5069
+ const contractTypeKnown = contractTypeLabel !== null;
5070
+ const isBlockHours = contractTypeKnown && BLOCK_HOURS_LABEL_RE.test(contractTypeLabel);
5071
+ const isTimeAndMaterials = contractTypeKnown && TIME_AND_MATERIALS_LABEL_RE.test(contractTypeLabel);
5072
+ const servicesCond = [{ field: "contractID", op: "eq", value: id }];
5073
+ const servicesRaw = await readPart("/V1.0/ContractServices/query", servicesCond, ["id", "serviceID", "unitPrice", "unitCost", "internalCurrencyUnitPrice", "internalDescription"]);
5074
+ const unitsCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("startDate")];
5075
+ const units = await windowedPart("contractServiceUnits", "contract_service_units", "/V1.0/ContractServiceUnits/query", unitsCond, ["id", "contractServiceID", "serviceID", "units", "price", "cost", "internalCurrencyPrice", "startDate", "endDate", "approveAndPostDate"], "startDate");
5076
+ const unitRows = units.rows;
5077
+ let chargesPart;
5078
+ let charges = [];
5079
+ let chargesCond = [];
5080
+ if (v.includeRecurringCosts) {
5081
+ chargesCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("datePurchased")];
5082
+ const raw = await readPart("/V1.0/ContractCharges/query", chargesCond, ["id", "chargeType", "billableAmount", "extendedCost", "unitCost", "unitPrice", "unitQuantity", "datePurchased", "isBilled", "status", "contractServiceID"]);
5083
+ charges = raw.items;
5084
+ chargesPart = stampPart("contract_charges", raw, chargesCond);
5085
+ } else {
5086
+ chargesPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
5087
+ }
5088
+ const roleCostsCond = [{ field: "contractID", op: "eq", value: id }];
5089
+ const roleCostsRaw = await readPart("/V1.0/ContractRoleCosts/query", roleCostsCond, ["id", "rate", "resourceID", "roleID"]);
5090
+ const ratesCond = [{ field: "contractID", op: "eq", value: id }];
5091
+ const ratesRaw = await readPart("/V1.0/ContractRates/query", ratesCond, ["id", "roleID", "contractHourlyRate", "internalCurrencyContractHourlyRate"]);
5092
+ let blocksPart;
5093
+ if (!isBlockHours) {
5094
+ blocksPart = skipPart(
5095
+ contractTypeKnown ? `skipped: not a Block Hours contract (contractType ${contractType} "${contractTypeLabel}") \u2014 included hours apply to hour blocks only (zero wire)` : `skipped: the contract type is unknown (contractType ${contractType || "absent"}; label unresolved) \u2014 included hours are not evaluable (zero wire)`
5096
+ );
5097
+ } else {
5098
+ const blocksCond = [{ field: "contractID", op: "eq", value: id }];
5099
+ const raw = await readPart("/V1.0/ContractBlocks/query", blocksCond, ["id", "datePurchased", "startDate", "endDate", "hours", "hoursApproved", "status", "isPaid"]);
5100
+ blocksPart = stampPart("contract_blocks", raw, blocksCond);
5101
+ }
5102
+ const billingCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("itemDate")];
5103
+ const billingFields = ["id", "invoiceID", "timeEntryID", "ticketID", "ourCost", "quantity", "rate", "extendedPrice", "internalCurrencyExtendedPrice", "itemDate", "postedDate", "nonBillable", "billingCodeID", "billingItemType", "expenseItemID", ...SERVICE_LINE_FKS, ...CHARGE_LINE_FKS];
5104
+ const billing = await windowedPart("billingItems", "billing_items", "/V1.0/BillingItems/query", billingCond, billingFields, "itemDate");
5105
+ const billingItems = billing.part;
5106
+ const billingRows = billing.rows;
5107
+ const invoiceIds = [...new Set(billingRows.map((b) => num2(b["invoiceID"])).filter((n) => n > 0))];
5108
+ let invoicesPart;
5109
+ let invoices = [];
5110
+ if (invoiceIds.length === 0) {
5111
+ invoicesPart = skipPart("skipped: the billing part has no invoiceIDs (zero wire)");
5112
+ } else {
5113
+ const capped = capJoinSet2(invoiceIds, FETCH_BY_IDS_MAX_ITEMS);
5114
+ 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`;
5115
+ const fb = await fetchByIdsPart(http, "/V1.0/Invoices", capped.ids, ["id", "invoiceNumber", "invoiceTotal", "paidDate", "isVoided", "dueDate"], OP2);
5116
+ wires.issued += 1;
5117
+ invoices = fb.items;
5118
+ invoicesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
5119
+ }
5120
+ const timeCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("dateWorked")];
5121
+ const timeFields = ["id", "ticketID", "taskID", "resourceID", "roleID", "hoursWorked", "hoursToBill", "dateWorked", "startDateTime", "endDateTime", "isNonBillable", "timeEntryType", "billingCodeID", "createDateTime"];
5122
+ const time = await windowedPart("timeEntries", "time_entries", "/V1.0/TimeEntries/query", timeCond, timeFields, "dateWorked");
5123
+ const timeEntries = time.part;
5124
+ const entries = time.rows;
5125
+ const ticketFields = ["id", "ticketNumber", "title", "status", "ticketCategory", "contractID", "createDate", "resolvedDateTime", "completedDate", "lastActivityDate", "dueDateTime"];
5126
+ const createdInWindow = [{ field: "contractID", op: "eq", value: id }, ...windowOn("createDate")];
5127
+ const refTicketIds = capJoinSet2(entries.map((e) => num2(e["ticketID"])).filter((n) => n > 0), v.maxTicketsForJoin);
5128
+ const ticketsActCond = refTicketIds.ids.length === 0 ? createdInWindow : [{ op: "or", items: [{ op: "and", items: createdInWindow }, { field: "id", op: "in", value: refTicketIds.ids }] }];
5129
+ const ticketsActRaw = await readPart("/V1.0/Tickets/query", ticketsActCond, ticketFields);
5130
+ let openStatusSet = null;
5131
+ let statusNote;
5132
+ wires.issued += 1;
5133
+ try {
5134
+ const values = (await readPicklists(http, "/V1.0/Tickets", OP2)).get("status");
5135
+ if (values === void 0 || values.size === 0) {
5136
+ statusNote = "the tickets.status picklist is not exposed to this API user \u2014 the open-status labels are unavailable";
5137
+ } else {
5138
+ const open = [...values].filter(([, label]) => !CLOSED_STATUS_LABEL_RE.test(label)).map(([v2]) => v2);
5139
+ if (open.length === 0) {
5140
+ statusNote = "every tickets.status picklist label is closed-class \u2014 no open statuses exist in this tenant";
5141
+ } else {
5142
+ openStatusSet = new Set(open);
5143
+ }
5144
+ }
5145
+ } catch (err) {
5146
+ 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`;
5147
+ }
5148
+ let ticketsOpenPart;
5149
+ let openTickets = [];
5150
+ if (openStatusSet === null) {
5151
+ ticketsOpenPart = skipPart(`skipped: ${statusNote} (zero wire)`);
5152
+ } else {
5153
+ const openCond = [{ field: "contractID", op: "eq", value: id }, { field: "status", op: "in", value: [...openStatusSet] }];
5154
+ const raw = await readPart("/V1.0/Tickets/query", openCond, ticketFields);
5155
+ openTickets = raw.items;
5156
+ ticketsOpenPart = stampPart("tickets", raw, openCond, ["openStatusHeuristic: true (the open set is the tenant picklist minus the closed-class labels)"]);
5157
+ }
5158
+ const resourceIds = [...new Set(entries.map((t) => num2(t["resourceID"])).filter((n) => n > 0))];
5159
+ let resourcesPart;
5160
+ let resources = [];
5161
+ const resourceMissIds = /* @__PURE__ */ new Set();
5162
+ if (resourceIds.length === 0) {
5163
+ resourcesPart = skipPart("skipped: the time part has no resourceIDs (zero wire)");
5164
+ } else {
5165
+ const capped = capJoinSet2(resourceIds, FETCH_BY_IDS_MAX_ITEMS);
5166
+ 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`;
5167
+ const fb = await fetchByIdsPart(http, "/V1.0/Resources", capped.ids, ["id", "internalCost", "firstName", "lastName", "isActive", "userType"], OP2);
5168
+ wires.issued += 1;
5169
+ resources = fb.items;
5170
+ for (const m of fb.misses) if (m.code === "NOT_FOUND") resourceMissIds.add(m.id);
5171
+ resourcesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
5172
+ }
5173
+ const fetchedTickets = /* @__PURE__ */ new Map();
5174
+ for (const t of [...ticketsActRaw.items, ...openTickets]) {
5175
+ const k = num2(t["id"]);
5176
+ if (k > 0 && !fetchedTickets.has(k)) fetchedTickets.set(k, t);
5177
+ }
5178
+ const contractTickets = [...fetchedTickets.values()].filter((t) => num2(t["contractID"]) === idNum);
5179
+ const ticketJoin = capJoinSet2(contractTickets.map((t) => num2(t["id"])), v.maxTicketsForJoin);
5180
+ 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;
5181
+ let expensesPart;
5182
+ let expenses = [];
5183
+ if (ticketJoin.ids.length === 0) {
5184
+ 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)");
5185
+ } else {
5186
+ const expCond = [{ field: "ticketID", op: "in", value: ticketJoin.ids }, ...windowOn("expenseDate")];
5187
+ const raw = await readPart("/V1.0/ExpenseItems/query", expCond, ["id", "expenseDate", "expenseCategory", "paymentType", "expenseCurrencyExpenseAmount", "internalCurrencyExpenseAmount", "ticketID"]);
5188
+ expenses = raw.items;
5189
+ expensesPart = stampPart("expense_items", raw, expCond, joinNote === void 0 ? void 0 : [joinNote]);
5190
+ }
5191
+ let probePart;
5192
+ let probeRows = [];
5193
+ if (ticketJoin.ids.length === 0) {
5194
+ probePart = skipPart("skipped: the contract has no fetched tickets to probe against (zero wire) \u2014 check 1 (miscoded_time_out) is not evaluable");
5195
+ } else {
5196
+ const probeCond = [
5197
+ { field: "ticketID", op: "in", value: ticketJoin.ids },
5198
+ { op: "or", items: [{ field: "contractID", op: "noteq", value: id }, { field: "contractID", op: "notExist" }] },
5199
+ ...windowOn("dateWorked")
5200
+ ];
5201
+ const raw = await readPart("/V1.0/TimeEntries/query", probeCond, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
5202
+ probeRows = raw.items;
5203
+ probePart = stampPart("time_entries", raw, probeCond, joinNote === void 0 ? void 0 : [joinNote]);
5204
+ }
5205
+ let configurationItemsPart;
5206
+ let cis = [];
5207
+ if (!v.includeRecurringCosts) {
5208
+ configurationItemsPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
5209
+ } else {
5210
+ const ciCond = [{ field: "contractID", op: "eq", value: id }];
5211
+ const raw = await readPart("/V1.0/ConfigurationItems/query", ciCond, ["id", "configurationItemName", "status", "contractID"]);
5212
+ cis = raw.items;
5213
+ configurationItemsPart = stampPart("configuration_items", raw, ciCond);
5214
+ }
5215
+ let subscriptionsPart;
5216
+ let subscriptions = [];
5217
+ const subFactor = /* @__PURE__ */ new Map();
5218
+ const oneTimeSubs = /* @__PURE__ */ new Set();
5219
+ const cancelledSubs = /* @__PURE__ */ new Set();
5220
+ if (cis.length === 0) {
5221
+ subscriptionsPart = skipPart("skipped: the CI join found no configuration items \u2014 the subscriptions part is not evaluable (zero wire)");
5222
+ } else {
5223
+ const ciIds = capJoinSet2(cis.map((c) => num2(c["id"])).filter((n) => n > 0), FETCH_BY_IDS_MAX_ITEMS);
5224
+ const subCond = [{ field: "configurationItemID", op: "in", value: ciIds.ids }];
5225
+ const raw = await readPart("/V1.0/Subscriptions/query", subCond, ["id", "subscriptionName", "configurationItemID", "periodCost", "periodPrice", "periodType", "effectiveDate", "expirationDate", "status", "totalCost"]);
5226
+ subscriptions = raw.items;
5227
+ let labels = null;
5228
+ if (subscriptions.length > 0) {
5229
+ wires.issued += 1;
5230
+ try {
5231
+ labels = await readPicklists(http, "/V1.0/Subscriptions", OP2);
5232
+ } catch (err) {
5233
+ if (!(err instanceof AutotaskError)) throw err;
5234
+ }
5235
+ }
5236
+ for (const x of subscriptions) {
5237
+ const sid = num2(x["id"]);
5238
+ const periodLabel = labels?.get("periodType")?.get(num2(x["periodType"]));
5239
+ subFactor.set(sid, periodMonthlyFactor(x["periodType"], periodLabel));
5240
+ if (isOneTimePeriod(x["periodType"], periodLabel)) oneTimeSubs.add(sid);
5241
+ const statusLabel = labels?.get("status")?.get(num2(x["status"]));
5242
+ const notActive = statusLabel !== void 0 ? NOT_ACTIVE_LABEL_RE.test(statusLabel) : labels === null && SUBSCRIPTION_NOT_ACTIVE_STATUS_IDS.has(num2(x["status"]));
5243
+ if (notActive) cancelledSubs.add(sid);
5244
+ }
5245
+ const unknownPeriods = [...new Set(subscriptions.filter((x) => subFactor.get(num2(x["id"])) === null && !oneTimeSubs.has(num2(x["id"]))).map((x) => String(x["periodType"] ?? "absent")))];
5246
+ subscriptionsPart = stampPart("subscriptions", raw, subCond, [
5247
+ "scope: the subscriptions on the CIs this contract covers \u2014 periodCost prorated to a month by periodType into tooling_cost.vendor, REPORTED but NOT folded into total_cost/profit/margin (subscriptions bill outside the contract)",
5248
+ ...subscriptions.length > 0 && labels === null ? ["the Subscriptions field catalog read failed \u2014 the verified id maps apply (periodType 1 One-Time, 2 Monthly, 3 Quarterly, 4 Semi-Annual, 5 Yearly; status 2 Cancelled / 3 Inactive excluded)"] : [],
5249
+ ...cancelledSubs.size > 0 ? [`${cancelledSubs.size} not-active (cancelled/inactive) subscription(s) excluded from tooling_cost.vendor`] : [],
5250
+ ...oneTimeSubs.size > 0 ? [`${oneTimeSubs.size} one-time subscription(s): periodCost booked whole in the effectiveDate month, never prorated`] : [],
5251
+ ...unknownPeriods.length > 0 ? [`unrecognised periodType ${unknownPeriods.join(", ")} \u2014 those subscriptions are excluded from tooling_cost.vendor (not prorated)`] : []
5252
+ ]);
5253
+ }
5254
+ const servicesPart = stampPart("contract_services", servicesRaw, servicesCond);
5255
+ const unitsPart = units.part;
5256
+ const roleCostsPart = stampPart("contract_role_costs", roleCostsRaw, roleCostsCond);
5257
+ const ratesPart = stampPart("contract_rates", ratesRaw, ratesCond);
5258
+ const ticketsActivityPart = stampPart("tickets", ticketsActRaw, ticketsActCond, [
5259
+ ...ticketsActRaw.hasMore ? [`${fetchedTickets.size} ticket(s) returned of possibly more \u2014 the checks cover the returned set only`] : [],
5260
+ ...refTicketIds.truncated ? [`the time-referenced ticket ids are capped at ${v.maxTicketsForJoin} of ${refTicketIds.total} \u2014 the rest read as ticket_not_in_fetched_set`] : []
5261
+ ]);
5262
+ const billedByEntryId = /* @__PURE__ */ new Map();
5263
+ for (const b of billingRows) {
5264
+ const te = num2(b["timeEntryID"]);
5265
+ if (te <= 0) continue;
5266
+ const arr = billedByEntryId.get(te) ?? [];
5267
+ arr.push(b);
5268
+ billedByEntryId.set(te, arr);
5269
+ }
5270
+ const roleCostByResourceRole = /* @__PURE__ */ new Map();
5271
+ const roleCostByRole = /* @__PURE__ */ new Map();
5272
+ for (const rc of roleCostsRaw.items) {
5273
+ const resId = num2(rc["resourceID"]);
5274
+ const roleId = num2(rc["roleID"]);
5275
+ if (resId > 0 && roleId > 0) roleCostByResourceRole.set(`${resId}:${roleId}`, rc);
5276
+ else if (roleId > 0) roleCostByRole.set(roleId, rc);
5277
+ }
5278
+ const resourcesById = new Map(resources.map((r) => [num2(r["id"]), r]));
5279
+ const ladderCtx = { method: v.method, rate: v.labourRatePerHour, billedByEntryId, roleCostByResourceRole, roleCostByRole, resourcesById, resourceMissIds };
5280
+ const entryResolution = /* @__PURE__ */ new Map();
5281
+ for (const e of entries) entryResolution.set(num2(e["id"]), resolveLabour(e, ladderCtx));
5282
+ const activeRungs = ladderFrom(ladderCtx);
5283
+ const sources = {};
5284
+ for (const rung of activeRungs) sources[rung] = { entries: 0, hours: 0, cost: 0 };
5285
+ sources.missing = { entries: 0, hours: 0, cost: 0 };
5286
+ const resolutionNotes = [];
5287
+ if (v.method === "billing_item_our_cost") {
5288
+ const noItems = entries.filter((e) => !billedByEntryId.has(num2(e["id"]))).length;
5289
+ if (noItems > 0) resolutionNotes.push(`${noItems} ${noItems === 1 ? "entry" : "entries"} had no billing item (non-billable or unposted) \u2014 rung 1 not usable`);
5290
+ }
5291
+ for (const resId of [...resourceMissIds].sort((a, b) => a - b)) {
5292
+ const n = entries.filter((e) => num2(e["resourceID"]) === resId).length;
5293
+ resolutionNotes.push(`resource ${resId} unreadable (fetchByIds NOT_FOUND) \u2014 rung 3 unavailable for ${n} ${n === 1 ? "entry" : "entries"}`);
5294
+ }
5295
+ for (const e of entries) {
5296
+ const r = entryResolution.get(num2(e["id"]));
5297
+ const bucket = sources[r.source] ?? sources.missing;
5298
+ bucket.entries += 1;
5299
+ bucket.hours += hoursOf(e);
5300
+ bucket.cost += r.cost ?? 0;
5301
+ }
5302
+ for (const b of Object.values(sources)) b.cost = cents(b.cost);
5303
+ const unresolvedHours = sources.missing.hours;
5304
+ if (unresolvedHours > 0) resolutionNotes.push(`${fmtHours(unresolvedHours)} hours unresolved (missing rate) \u2014 total_cost is a lower bound`);
5305
+ const costRateResolution = { method: v.method, sources, unresolvedHours, notes: resolutionNotes };
5306
+ const months = monthsBetween(v.dateFrom, v.dateTo);
5307
+ const monthOf = (date) => typeof date === "string" && DATE_RE2.test(`${date}`.slice(0, 10)) ? date.slice(0, 7) : null;
5308
+ const inMonth = (date, m) => monthOf(date) === m;
5309
+ const unitIn = (unit, m) => inMonth(unit["startDate"], m);
5310
+ const lineRevenue = (b) => internalAmount(b, "internalCurrencyExtendedPrice", "extendedPrice");
5311
+ const expectedBasis = unitsPart.status !== "absent" && (servicesRaw.items.length > 0 || unitRows.length > 0);
5312
+ const expectedIn = (m) => unitRows.filter((u) => unitIn(u, m)).reduce((a, u) => a + internalAmount(u, "internalCurrencyPrice", "price"), 0);
5313
+ const recurringIn = (m) => {
5314
+ if (!v.includeRecurringCosts) return 0;
5315
+ let total = 0;
5316
+ for (const c of charges) if (inMonth(c["datePurchased"], m)) total += chargeCost(c);
5317
+ for (const u of unitRows) if (unitIn(u, m)) total += num2(u["cost"]);
5318
+ return total;
5319
+ };
5320
+ const invoicesById = new Map(invoices.map((i) => [num2(i["id"]), i]));
5321
+ const billedServiceIn = (m) => {
5322
+ let billed = 0;
5323
+ const ids = [];
5324
+ for (const b of billingRows) {
5325
+ if (!inMonth(b["itemDate"], m) || billingLineType(b) !== "recurring") continue;
5326
+ const invId = num2(b["invoiceID"]);
5327
+ const inv = invId > 0 ? invoicesById.get(invId) : void 0;
5328
+ if (inv === void 0 || inv["isVoided"] === true) continue;
5329
+ billed += lineRevenue(b);
5330
+ ids.push(num2(b["id"]));
5331
+ }
5332
+ return { billed, ids };
5333
+ };
5334
+ const contractRateByRole = /* @__PURE__ */ new Map();
5335
+ for (const r of ratesRaw.items) {
5336
+ const rate = typeof r["internalCurrencyContractHourlyRate"] === "number" ? r["internalCurrencyContractHourlyRate"] : r["contractHourlyRate"];
5337
+ if (num2(r["roleID"]) > 0 && typeof rate === "number" && Number.isFinite(rate)) contractRateByRole.set(num2(r["roleID"]), rate);
5338
+ }
5339
+ const monthlyParts = [
5340
+ ["contractServiceUnits", unitsPart],
5341
+ ["contractCharges", chargesPart],
5342
+ ["contractRoleCosts", roleCostsPart],
5343
+ ["billingItems", billingItems],
5344
+ ["timeEntries", timeEntries],
5345
+ ["expenses", expensesPart],
5346
+ ["subscriptions", subscriptionsPart]
5347
+ ];
5348
+ const coverageGaps = (m) => {
5349
+ const gaps = [];
5350
+ for (const [name, p] of monthlyParts) {
5351
+ if (p.status === "absent") gaps.push(`${name}: absent (${p.absent})`);
5352
+ else if (truncatedMonths[name] !== void 0) {
5353
+ if (truncatedMonths[name].includes(m)) gaps.push(`${name}: truncated at limit ${v.limit}`);
5354
+ } else if (p.hasMore) gaps.push(`${name}: capped at limit ${v.limit}`);
5355
+ }
5356
+ return gaps;
5357
+ };
5358
+ const monthly = [];
5359
+ for (let mi = 0; mi < months.length; mi++) {
5360
+ const m = months[mi];
5361
+ const mBilling = billingRows.filter((b) => inMonth(b["itemDate"], m));
5362
+ let revenueInvoiced = 0;
5363
+ let revenueVoided = 0;
5364
+ let postedUninvoiced = 0;
5365
+ const byType = { recurring: 0, labour: 0, charges: 0, expenses: 0, other: 0 };
5366
+ let unresolvedInvoice = 0;
5367
+ let unresolvedLines = 0;
5368
+ for (const b of mBilling) {
5369
+ const invId = num2(b["invoiceID"]);
5370
+ if (invId <= 0) {
5371
+ postedUninvoiced += lineRevenue(b);
5372
+ continue;
5373
+ }
5374
+ const inv = invoicesById.get(invId);
5375
+ if (inv === void 0) {
5376
+ unresolvedInvoice += lineRevenue(b);
5377
+ unresolvedLines += 1;
5378
+ continue;
5379
+ }
5380
+ if (inv["isVoided"] === true) revenueVoided += lineRevenue(b);
5381
+ else {
5382
+ revenueInvoiced += lineRevenue(b);
5383
+ byType[billingLineType(b)] += lineRevenue(b);
5384
+ }
5385
+ }
5386
+ const expected = v.includeUnbilledRevenue && expectedBasis ? expectedIn(m) : null;
5387
+ const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - billedServiceIn(m).billed);
5388
+ const mEntries = entries.filter((e) => inMonth(e["dateWorked"], m));
5389
+ let labourHours = 0;
5390
+ let labourTotal = 0;
5391
+ let unresolved = 0;
5392
+ const rateSources = {};
5393
+ for (const rung of activeRungs) rateSources[rung] = { entries: 0, hours: 0, cost: 0 };
5394
+ rateSources.missing = { entries: 0, hours: 0, cost: 0 };
5395
+ for (const e of mEntries) {
5396
+ const r = entryResolution.get(num2(e["id"]));
5397
+ labourHours += hoursOf(e);
5398
+ const bucket = rateSources[r.source];
5399
+ bucket.entries += 1;
5400
+ bucket.hours += hoursOf(e);
5401
+ bucket.cost += r.cost ?? 0;
5402
+ if (r.source === "missing") unresolved += hoursOf(e);
5403
+ else labourTotal += r.cost ?? 0;
5404
+ }
5405
+ const unposted = mEntries.filter((e) => e["isNonBillable"] !== true && !billedByEntryId.has(num2(e["id"])));
5406
+ const unpostedHours = unposted.reduce((a, e) => a + Math.max(0, num2(e["hoursToBill"])), 0);
5407
+ let unpostedTime;
5408
+ if (!contractTypeKnown) {
5409
+ unpostedTime = { entries: unposted.length, hours: unpostedHours, value: null, absent: "CONTRACT_TYPE_UNKNOWN" };
5410
+ } else if (!isTimeAndMaterials) {
5411
+ unpostedTime = { entries: unposted.length, hours: unpostedHours, value: null, absent: "NOT_TIME_AND_MATERIALS_CONTRACT" };
5412
+ } else if (unposted.some((e) => !contractRateByRole.has(num2(e["roleID"])))) {
5413
+ unpostedTime = { entries: unposted.length, hours: unpostedHours, value: null, absent: "NO_CONTRACT_RATE" };
5414
+ } else {
5415
+ const value = unposted.reduce((a, e) => a + Math.max(0, num2(e["hoursToBill"])) * contractRateByRole.get(num2(e["roleID"])), 0);
5416
+ unpostedTime = { entries: unposted.length, hours: unpostedHours, value };
5417
+ }
5418
+ let toolingVendor = subscriptionsPart.status === "skipped" || subscriptionsPart.status === "absent" ? null : 0;
5419
+ if (toolingVendor !== null) {
5420
+ for (const s of subscriptions) {
5421
+ const eff = monthOf(s["effectiveDate"]) ?? "0000-00";
5422
+ const exp = monthOf(s["expirationDate"]) ?? "9999-99";
5423
+ const sid = num2(s["id"]);
5424
+ if (cancelledSubs.has(sid)) continue;
5425
+ if (oneTimeSubs.has(sid)) {
5426
+ if (eff === m) toolingVendor += num2(s["periodCost"]);
5427
+ continue;
5428
+ }
5429
+ const factor = subFactor.get(sid) ?? null;
5430
+ if (factor !== null && eff <= m && m <= exp) toolingVendor += num2(s["periodCost"]) * factor;
5431
+ }
5432
+ }
5433
+ const callerSupplied = v.externalMonthlyCosts[mi] ?? 0;
5434
+ const recurring = recurringIn(m);
5435
+ let expenseCost = 0;
5436
+ for (const x of expenses) if (inMonth(x["expenseDate"], m)) expenseCost += num2(x["internalCurrencyExpenseAmount"]);
5437
+ const totalCost = labourTotal + callerSupplied + recurring + expenseCost;
5438
+ const profit = revenueInvoiced - totalCost;
5439
+ const margin = revenueInvoiced > 0 ? profit / revenueInvoiced : null;
5440
+ const nonLabour = totalCost - labourTotal;
5441
+ const resolvedHours = mEntries.filter((e) => entryResolution.get(num2(e["id"])).source !== "missing").reduce((a, e) => a + hoursOf(e), 0);
5442
+ const avgRate = resolvedHours > 0 ? labourTotal / resolvedHours : 0;
5443
+ const breakEven = avgRate > 0 ? Math.max(0, (revenueInvoiced - nonLabour) / avgRate) : null;
5444
+ for (const b of Object.values(rateSources)) b.cost = cents(b.cost);
5445
+ const gaps = coverageGaps(m);
5446
+ if (unresolvedLines > 0) gaps.push(`invoices: ${unresolvedLines} line(s) on unresolved invoices`);
5447
+ monthly.push({
5448
+ month: m,
5449
+ revenue_invoiced: cents(revenueInvoiced),
5450
+ revenue_by_type: { recurring: cents(byType.recurring), labour: cents(byType.labour), charges: cents(byType.charges), expenses: cents(byType.expenses), other: cents(byType.other) },
5451
+ revenue_voided_excluded: cents(revenueVoided),
5452
+ revenue_posted_uninvoiced: cents(postedUninvoiced),
5453
+ revenue_unresolved_invoice: cents(unresolvedInvoice),
5454
+ revenue_expected: cents(expected),
5455
+ revenue_expected_unbilled: cents(expectedUnbilled),
5456
+ unposted_time: { ...unpostedTime, value: cents(unpostedTime.value) },
5457
+ labour_hours: labourHours,
5458
+ labour_cost: { total: cents(labourTotal), unresolvedHours: unresolved, rateSources },
5459
+ tooling_cost: { vendor: cents(toolingVendor), caller_supplied: cents(callerSupplied) },
5460
+ recurring_cost: cents(recurring),
5461
+ expense_cost: cents(expenseCost),
5462
+ total_cost: cents(totalCost),
5463
+ profit: cents(profit),
5464
+ margin,
5465
+ break_even_hours: breakEven,
5466
+ coverage_gaps: gaps
5467
+ });
5468
+ }
5469
+ const warnings = [];
5470
+ warnings.push({
5471
+ code: "miscoded_time_out",
5472
+ count: probeRows.length,
5473
+ sample_ids: dedupeSortCap(probeRows.map((p) => num2(p["id"])), v.limit)
5474
+ });
5475
+ const crossTimeEntries = entries.filter((e) => {
5476
+ const t = num2(e["ticketID"]);
5477
+ if (t <= 0) return false;
5478
+ const ticket = fetchedTickets.get(t);
5479
+ return ticket !== void 0 && num2(ticket["contractID"]) !== idNum;
5480
+ });
5481
+ warnings.push({ code: "cross_contract_ticket_time", count: crossTimeEntries.length, sample_ids: dedupeSortCap(crossTimeEntries.map((e) => num2(e["id"])), v.limit) });
5482
+ const tupleGroups = /* @__PURE__ */ new Map();
5483
+ for (const e of entries) {
5484
+ const key = [String(num2(e["resourceID"])), String(e["dateWorked"] ?? ""), String(num2(e["ticketID"])), String(e["hoursWorked"] ?? "")].join("|");
5485
+ const arr = tupleGroups.get(key) ?? [];
5486
+ arr.push(e);
5487
+ tupleGroups.set(key, arr);
5488
+ }
5489
+ const dupAll = [];
5490
+ const dupSample = [];
5491
+ for (const group of tupleGroups.values()) {
5492
+ if (group.length < 2) continue;
5493
+ const sorted = [...group].sort((a, b) => num2(a["id"]) - num2(b["id"]));
5494
+ for (const e of sorted) dupAll.push(num2(e["id"]));
5495
+ for (const e of sorted.slice(1)) dupSample.push(num2(e["id"]));
5496
+ }
5497
+ warnings.push({ code: "duplicate_entries", count: dupAll.length, sample_ids: dedupeSortCap(dupSample, v.limit) });
5498
+ const zeroHours = entries.filter((e) => {
5499
+ const h = e["hoursWorked"];
5500
+ return !(typeof h === "number" && Number.isFinite(h) && h > 0);
5501
+ });
5502
+ warnings.push({ code: "zero_negative_hours", count: zeroHours.length, sample_ids: dedupeSortCap(zeroHours.map((e) => num2(e["id"])), v.limit) });
5503
+ const outPeriod = entries.filter((e) => {
5504
+ const t = num2(e["ticketID"]);
5505
+ if (t <= 0) return false;
5506
+ const ticket = fetchedTickets.get(t);
5507
+ if (ticket === void 0) return false;
5508
+ const dW = dateToDay(e["dateWorked"]);
5509
+ if (dW === null) return false;
5510
+ const created = dateToDay(ticket["createDate"]);
5511
+ if (created !== null && dW < created) return true;
5512
+ const ends = [dateToDay(ticket["resolvedDateTime"]), dateToDay(ticket["completedDate"])].filter((d) => d !== null);
5513
+ if (ends.length > 0 && dW > Math.min(...ends)) return true;
5514
+ return false;
5515
+ });
5516
+ warnings.push({ code: "time_outside_ticket_period", count: outPeriod.length, sample_ids: dedupeSortCap(outPeriod.map((e) => num2(e["id"])), v.limit) });
5517
+ const missingRate = entries.filter((e) => entryResolution.get(num2(e["id"])).source === "missing");
5518
+ warnings.push({ code: "missing_cost_rate", count: missingRate.length, sample_ids: dedupeSortCap(missingRate.map((e) => num2(e["id"])), v.limit) });
5519
+ let driftCount = 0;
5520
+ const driftIds = /* @__PURE__ */ new Set();
5521
+ if (v.includeRecurringCosts) {
5522
+ for (let i = 1; i < months.length; i++) {
5523
+ const r1 = recurringIn(months[i]);
5524
+ const r0 = recurringIn(months[i - 1]);
5525
+ if (r1 <= 0 || r0 <= 0 || Math.abs(r1 - r0) <= RECURRING_DRIFT_EPSILON) continue;
5526
+ driftCount += 1;
5527
+ const mA = months[i - 1];
5528
+ const mB = months[i];
5529
+ for (const c of charges) {
5530
+ const cA = inMonth(c["datePurchased"], mA) ? chargeCost(c) : 0;
5531
+ const cB = inMonth(c["datePurchased"], mB) ? chargeCost(c) : 0;
5532
+ if (Math.abs(cB - cA) > 0) driftIds.add(num2(c["id"]));
5533
+ }
5534
+ for (const u of unitRows) {
5535
+ const cA = unitIn(u, mA) ? num2(u["cost"]) : 0;
5536
+ const cB = unitIn(u, mB) ? num2(u["cost"]) : 0;
5537
+ if (Math.abs(cB - cA) > 0) driftIds.add(num2(u["id"]));
5538
+ }
5539
+ }
5540
+ }
5541
+ warnings.push({ code: "recurring_cost_drift", count: driftCount, sample_ids: dedupeSortCap([...driftIds], v.limit) });
5542
+ let expectedUnbilledCount = 0;
5543
+ const expectedUnbilledSamples = [];
5544
+ if (v.includeUnbilledRevenue && expectedBasis) {
5545
+ for (const m of months) {
5546
+ const { billed, ids } = billedServiceIn(m);
5547
+ if (expectedIn(m) > billed + 5e-3) {
5548
+ expectedUnbilledCount += 1;
5549
+ for (const i of ids) expectedUnbilledSamples.push(i);
5550
+ }
5551
+ }
5552
+ }
5553
+ warnings.push({ code: "expected_unbilled_revenue", count: expectedUnbilledCount, sample_ids: dedupeSortCap(expectedUnbilledSamples, v.limit) });
5554
+ const driftTickets = [...fetchedTickets.values()].filter((t) => num2(t["contractID"]) !== idNum);
5555
+ const unresolvedRefs = entries.filter((e) => num2(e["ticketID"]) > 0 && !fetchedTickets.has(num2(e["ticketID"])));
5556
+ warnings.push({
5557
+ code: "cross_contract_tickets",
5558
+ count: driftTickets.length + unresolvedRefs.length,
5559
+ sample_ids: dedupeSortCap([...driftTickets.map((t) => num2(t["id"])), ...unresolvedRefs.map((e) => num2(e["id"]))], v.limit)
5560
+ });
5561
+ const stale = [];
5562
+ if (openStatusSet !== null) {
5563
+ for (const t of contractTickets) {
5564
+ const status = t["status"];
5565
+ if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5566
+ const lastDay = dateToDay(t["lastActivityDate"]);
5567
+ if (lastDay === null) continue;
5568
+ const days = asOfMs / 864e5 - lastDay;
5569
+ if (days > v.staleAfterDays) stale.push(num2(t["id"]));
5570
+ }
5571
+ }
5572
+ warnings.push({ code: "open_stale_tickets", count: stale.length, sample_ids: dedupeSortCap(stale, v.limit) });
5573
+ let estimateCount = 0;
5574
+ const estimateSample = [];
5575
+ let estimateNote;
5576
+ {
5577
+ const actualRevenue = monthly.reduce((a, r) => a + r.revenue_invoiced, 0);
5578
+ const actualCost = monthly.reduce((a, r) => a + r.total_cost, 0);
5579
+ const termStart = dateToDay(contractRow["startDate"]);
5580
+ const termEnd = dateToDay(contractRow["endDate"]);
5581
+ const fromDay = dateToDay(v.dateFrom);
5582
+ const toDay = dateToDay(v.dateTo);
5583
+ if (termStart !== null && termEnd !== null && fromDay !== null && toDay !== null) {
5584
+ const overlapStart = Math.max(termStart, fromDay);
5585
+ const overlapEnd = Math.min(termEnd, toDay);
5586
+ const termDays = termEnd - termStart + 1;
5587
+ const overlapDays = overlapEnd >= overlapStart ? overlapEnd - overlapStart + 1 : 0;
5588
+ if (termDays > 0 && overlapDays > 0) {
5589
+ const frac = overlapDays / termDays;
5590
+ const estRev = num2(contractRow["estimatedRevenue"]);
5591
+ const estCost = num2(contractRow["estimatedCost"]);
5592
+ const devRev = estRev > 0 ? Math.abs(actualRevenue - estRev * frac) / (estRev * frac) : null;
5593
+ const devCost = estCost > 0 ? Math.abs(actualCost - estCost * frac) / (estCost * frac) : null;
5594
+ if (devRev !== null && devRev > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100 || devCost !== null && devCost > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100) {
5595
+ estimateCount = 1;
5596
+ estimateSample.push(idNum);
5597
+ }
5598
+ 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)})`;
5599
+ }
5600
+ }
5601
+ }
5602
+ warnings.push({ code: "estimate_vs_actual", count: estimateCount, sample_ids: estimateSample, ...estimateCount === 1 && estimateNote !== void 0 ? { note: estimateNote } : {} });
5603
+ const entriesByTicket = /* @__PURE__ */ new Map();
5604
+ for (const e of entries) {
5605
+ const t = num2(e["ticketID"]);
5606
+ if (t <= 0) continue;
5607
+ const arr = entriesByTicket.get(t) ?? [];
5608
+ arr.push(e);
5609
+ entriesByTicket.set(t, arr);
5610
+ }
5611
+ const expenseByTicket = /* @__PURE__ */ new Map();
5612
+ for (const x of expenses) {
5613
+ const t = num2(x["ticketID"]);
5614
+ if (t <= 0) continue;
5615
+ expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num2(x["internalCurrencyExpenseAmount"]));
5616
+ }
5617
+ const ticketCost = (t) => {
5618
+ const tid = num2(t["id"]);
5619
+ const es = entriesByTicket.get(tid) ?? [];
5620
+ let cost = expenseByTicket.get(tid) ?? 0;
5621
+ for (const e of es) cost += entryResolution.get(num2(e["id"])).cost ?? 0;
5622
+ return cost;
5623
+ };
5624
+ const ticketHours = (t) => (entriesByTicket.get(num2(t["id"])) ?? []).reduce((a, e) => a + hoursOf(e), 0);
5625
+ const allTickets = [...fetchedTickets.values()];
5626
+ const activityCategories = [];
5627
+ if (v.groupByActivity) {
5628
+ const byCat = /* @__PURE__ */ new Map();
5629
+ for (const t of allTickets) {
5630
+ const cat = num2(t["ticketCategory"]) > 0 ? num2(t["ticketCategory"]) : null;
5631
+ const arr = byCat.get(cat) ?? [];
5632
+ arr.push(t);
5633
+ byCat.set(cat, arr);
5634
+ }
5635
+ for (const [cat, tickets] of byCat) {
5636
+ let entryCount = 0;
5637
+ let hours = 0;
5638
+ let cost = 0;
5639
+ for (const t of tickets) {
5640
+ const es = entriesByTicket.get(num2(t["id"])) ?? [];
5641
+ entryCount += es.length;
5642
+ hours += es.reduce((a, e) => a + hoursOf(e), 0);
5643
+ cost += ticketCost(t);
5644
+ }
5645
+ activityCategories.push({
5646
+ ticketCategory: cat,
5647
+ ticketCount: tickets.length,
5648
+ entryCount,
5649
+ hours,
5650
+ cost: cents(cost),
5651
+ sample_ticketNumbers: dedupeSortCap(tickets.map((t) => num2(t["ticketNumber"])).filter((n) => n > 0), v.limit)
5652
+ });
5653
+ }
5654
+ activityCategories.sort((a, b) => b.cost - a.cost || (a.ticketCategory === null ? 1 : b.ticketCategory === null ? -1 : (a.ticketCategory ?? 0) - (b.ticketCategory ?? 0)));
5655
+ }
5656
+ const highestCostTickets = allTickets.map((t) => ({
5657
+ ticketId: num2(t["id"]),
5658
+ ticketNumber: num2(t["ticketNumber"]) > 0 ? num2(t["ticketNumber"]) : null,
5659
+ title: typeof t["title"] === "string" ? t["title"] : "",
5660
+ status: typeof t["status"] === "number" ? t["status"] : null,
5661
+ hours: ticketHours(t),
5662
+ cost: cents(ticketCost(t)),
5663
+ lastActivityDate: typeof t["lastActivityDate"] === "string" ? t["lastActivityDate"] : null
5664
+ })).sort((a, b) => b.cost - a.cost || a.ticketId - b.ticketId).slice(0, v.limit);
5665
+ const miscoding = /* @__PURE__ */ new Map();
5666
+ const putMiscoding = (e, reason) => {
5667
+ const eid = num2(e["id"]);
5668
+ if (eid <= 0 || miscoding.has(eid)) return;
5669
+ const c = num2(e["contractID"]);
5670
+ miscoding.set(eid, { entryId: eid, reason, ticketId: num2(e["ticketID"]) > 0 ? num2(e["ticketID"]) : null, contractId: c > 0 ? c : null, hours: num2(e["hoursWorked"]) });
5671
+ };
5672
+ for (const p of probeRows) putMiscoding(p, num2(p["contractID"]) > 0 ? "ticket_of_other_contract" : "no_contract_on_entry");
5673
+ for (const e of crossTimeEntries) putMiscoding(e, "ticket_of_other_contract");
5674
+ for (const e of unresolvedRefs) putMiscoding(e, "ticket_not_in_fetched_set");
5675
+ const possibleMiscoding = [...miscoding.values()].sort((a, b) => a.entryId - b.entryId).slice(0, v.limit);
5676
+ const openRenewal = [];
5677
+ if (openStatusSet !== null) {
5678
+ for (const t of contractTickets) {
5679
+ const status = t["status"];
5680
+ if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5681
+ const last = t["lastActivityDate"];
5682
+ const lastDay = typeof last === "string" ? (asOfMs - Date.parse(last.slice(0, 10))) / 864e5 : null;
5683
+ const staleDays = lastDay !== null && lastDay > v.staleAfterDays ? Math.floor(lastDay) : null;
5684
+ openRenewal.push({
5685
+ kind: "ticket",
5686
+ id: num2(t["id"]),
5687
+ label: typeof t["title"] === "string" && t["title"] !== "" ? t["title"] : `ticket ${num2(t["ticketNumber"]) > 0 ? num2(t["ticketNumber"]) : num2(t["id"])}`,
5688
+ date: typeof last === "string" ? last.slice(0, 10) : null,
5689
+ detail: staleDays !== null ? `open \u2014 stale ${staleDays} days (last activity)` : "open"
5690
+ });
5691
+ }
5692
+ }
5693
+ for (const s of subscriptions) {
5694
+ openRenewal.push({
5695
+ kind: "subscription",
5696
+ id: num2(s["id"]),
5697
+ label: typeof s["subscriptionName"] === "string" && s["subscriptionName"] !== "" ? s["subscriptionName"] : `subscription ${num2(s["id"])}`,
5698
+ date: typeof s["expirationDate"] === "string" ? s["expirationDate"].slice(0, 10) : null,
5699
+ detail: `periodType: ${typeof s["periodType"] === "number" ? s["periodType"] : "unknown"} (period cost ${fmtNum(num2(s["periodCost"]))}${cancelledSubs.has(num2(s["id"])) ? " \u2014 not active, not in tooling_cost.vendor" : oneTimeSubs.has(num2(s["id"])) ? " \u2014 one-time, booked in its effective month" : subFactor.get(num2(s["id"])) == null ? " \u2014 unrecognised period, not in tooling_cost.vendor" : `, ${fmtNum(num2(s["periodCost"]) * subFactor.get(num2(s["id"])))}/month`})`
5700
+ });
5701
+ }
5702
+ const contractEndDay = dateToDay(contractRow["endDate"]);
5703
+ if (contractEndDay !== null) {
5704
+ openRenewal.push({
5705
+ kind: "contract_end",
5706
+ id: idNum,
5707
+ label: typeof contractRow["contractName"] === "string" && contractRow["contractName"] !== "" ? contractRow["contractName"] : `contract ${idNum}`,
5708
+ date: String(contractRow["endDate"]).slice(0, 10),
5709
+ detail: `the contract ends ${String(contractRow["endDate"]).slice(0, 10)}`
5710
+ });
5711
+ }
5712
+ const blocks = blocksPart.items;
5713
+ for (const b of blocks) {
5714
+ const purchased = num2(b["hours"]);
5715
+ const approved = num2(b["hoursApproved"]);
5716
+ const left = purchased - approved;
5717
+ openRenewal.push({
5718
+ kind: "included_hours_burn",
5719
+ id: num2(b["id"]),
5720
+ label: `hour block ${num2(b["id"])}`,
5721
+ date: typeof b["endDate"] === "string" ? b["endDate"].slice(0, 10) : null,
5722
+ detail: `${fmtHours(approved)} h approved of ${fmtHours(purchased)} h purchased (${left >= 0 ? `${fmtHours(left)} h left` : `${fmtHours(Math.abs(left))} h over`})`
5723
+ });
5724
+ }
5725
+ const purchasedHours = blocks.reduce((a, b) => a + num2(b["hours"]), 0);
5726
+ const approvedHours = blocks.reduce((a, b) => a + num2(b["hoursApproved"]), 0);
5727
+ const includedHours = !contractTypeKnown ? { value: null, absent: "CONTRACT_TYPE_UNKNOWN" } : !isBlockHours ? { value: null, absent: "NOT_BLOCK_HOURS_CONTRACT" } : blocksPart.status === "absent" ? { value: null, absent: blocksPart.absent } : { value: { purchased: purchasedHours, approved: approvedHours, remaining: purchasedHours - approvedHours } };
5728
+ return {
5729
+ contract,
5730
+ customer,
5731
+ ...customerAbsent !== void 0 ? { customerAbsent } : {},
5732
+ contract_type: contractTypeKnown ? { id: contractType, label: contractTypeLabel } : { id: contractType > 0 ? contractType : null, label: null, absent: "CONTRACT_TYPE_UNKNOWN" },
5733
+ included_hours: includedHours,
5734
+ period: { dateFrom: v.dateFrom, dateTo: v.dateTo, months, asOf },
5735
+ currency: {
5736
+ companyCurrencyID: customer && typeof customer["currencyID"] === "number" ? customer["currencyID"] : null,
5737
+ note: "single-currency P&L in the INTERNAL currency: revenue is billing items internalCurrencyExtendedPrice and service units internalCurrencyPrice (each falling back to the local field when absent), unposted value the internalCurrencyContractHourlyRate; costs are internal (ourCost/role cost/internalCost, charge extendedCost, unit cost, internalCurrencyExpenseAmount). companyCurrencyID is the customer context stamp only"
5738
+ },
5739
+ monthly,
5740
+ activity_categories: activityCategories,
5741
+ highest_cost_tickets: highestCostTickets,
5742
+ possible_miscoding: possibleMiscoding,
5743
+ open_renewal_items: openRenewal,
5744
+ data_quality_warnings: warnings,
5745
+ costRateResolution,
5746
+ parts: {
5747
+ contract: "ok",
5748
+ customer: customerStatus,
5749
+ contractServices: servicesPart.status,
5750
+ contractServiceUnits: unitsPart.status,
5751
+ contractCharges: chargesPart.status,
5752
+ contractRoleCosts: roleCostsPart.status,
5753
+ contractRates: ratesPart.status,
5754
+ contractBlocks: blocksPart.status,
5755
+ billingItems: billingItems.status,
5756
+ invoices: invoicesPart.status,
5757
+ timeEntries: timeEntries.status,
5758
+ ticketsActivity: ticketsActivityPart.status,
5759
+ ticketsOpen: ticketsOpenPart.status,
5760
+ resources: resourcesPart.status,
5761
+ expenses: expensesPart.status,
5762
+ miscodingProbe: probePart.status,
5763
+ configurationItems: configurationItemsPart.status,
5764
+ subscriptions: subscriptionsPart.status
5765
+ },
5766
+ contractServices: servicesPart,
5767
+ contractServiceUnits: unitsPart,
5768
+ contractCharges: chargesPart,
5769
+ contractRoleCosts: roleCostsPart,
5770
+ contractRates: ratesPart,
5771
+ contractBlocks: blocksPart,
5772
+ billingItems,
5773
+ invoices: invoicesPart,
5774
+ timeEntries,
5775
+ ticketsActivity: ticketsActivityPart,
5776
+ ticketsOpen: ticketsOpenPart,
5777
+ resources: resourcesPart,
5778
+ expenses: expensesPart,
5779
+ miscodingProbe: probePart,
5780
+ configurationItems: configurationItemsPart,
5781
+ subscriptions: subscriptionsPart,
5782
+ wires: { issued: wires.issued, requests: wires.requests, budget: wires.budget, note: wires.note }
5783
+ };
5784
+ }
5785
+
4537
5786
  // src/resources/contracts.ts
4538
5787
  var ContractsResource = class extends BaseResource {
4539
5788
  constructor(http, pageSize) {
@@ -4599,6 +5848,10 @@ var ContractsResource = class extends BaseResource {
4599
5848
  async search(query, opts) {
4600
5849
  return this.searchBase(query, opts);
4601
5850
  }
5851
+ /** Node-parity special op (hand-written in src/resources/special/contracts.ts). */
5852
+ profitability(id, opts) {
5853
+ return profitability(this, id, opts, this.http);
5854
+ }
4602
5855
  };
4603
5856
 
4604
5857
  // src/resources/contract_ticket_purchases.ts
@@ -9083,11 +10336,11 @@ async function slaHealthCheck(_resource, id, opts = {}, http) {
9083
10336
  );
9084
10337
  const slas = rows.map((row) => {
9085
10338
  const bool = (v) => typeof v === "boolean" ? v : null;
9086
- const num2 = (v) => typeof v === "number" ? v : null;
10339
+ const num3 = (v) => typeof v === "number" ? v : null;
9087
10340
  const milestones = [
9088
- { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num2(row.firstResponseElapsedHours) },
9089
- { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num2(row.resolutionElapsedHours) },
9090
- { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num2(row.resolutionPlanElapsedHours) }
10341
+ { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num3(row.firstResponseElapsedHours) },
10342
+ { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num3(row.resolutionElapsedHours) },
10343
+ { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num3(row.resolutionPlanElapsedHours) }
9091
10344
  ];
9092
10345
  return { id: Number(row.id), name: row.serviceLevelAgreementName ?? null, milestones };
9093
10346
  });