node-autotask 0.12.0 → 0.13.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.
Files changed (35) hide show
  1. package/MCP_TOOL_CATALOG.json +4 -3
  2. package/MCP_TOOL_MANIFEST.md +5 -5
  3. package/N8N_MCP_MANIFEST.json +2 -2
  4. package/N8N_MCP_MANIFEST.md +3 -3
  5. package/capabilities.json +1238 -1237
  6. package/dist/capabilities.cjs +2 -2
  7. package/dist/capabilities.js +1 -1
  8. package/dist/{chunk-SRJI5XUZ.cjs → chunk-CJA6L6NR.cjs} +135 -135
  9. package/dist/{chunk-JKZVCFRY.cjs → chunk-CXXNONRP.cjs} +1237 -1236
  10. package/dist/{chunk-3WIDDEHP.js → chunk-DDZXN6LN.js} +2 -2
  11. package/dist/{chunk-GD4JRPYF.js → chunk-IKJLIDK3.js} +1 -1
  12. package/dist/{chunk-5N6DYKXO.cjs → chunk-KB2ANVVR.cjs} +2 -2
  13. package/dist/{chunk-TELBX6HK.cjs → chunk-MTDCLVQQ.cjs} +797 -603
  14. package/dist/{chunk-4A6XLQHE.js → chunk-OZD5FQM4.js} +1237 -1236
  15. package/dist/{chunk-MY7ZSXY3.js → chunk-Y6O5VVQ4.js} +387 -193
  16. package/dist/{index-D8I6taEo.d.cts → index-DbMKWnEX.d.cts} +96 -35
  17. package/dist/{index-uAxAcRol.d.ts → index-TuqNq3Ro.d.ts} +96 -35
  18. package/dist/index.cjs +19 -19
  19. package/dist/index.d.cts +2 -2
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +4 -4
  22. package/dist/operations/index.cjs +103 -103
  23. package/dist/operations/index.d.cts +2 -2
  24. package/dist/operations/index.d.ts +2 -2
  25. package/dist/operations/index.js +2 -2
  26. package/dist/pool.cjs +8 -8
  27. package/dist/pool.d.cts +1 -1
  28. package/dist/pool.d.ts +1 -1
  29. package/dist/pool.js +4 -4
  30. package/dist/resources/index.cjs +4 -4
  31. package/dist/resources/index.d.cts +1 -1
  32. package/dist/resources/index.d.ts +1 -1
  33. package/dist/resources/index.js +3 -3
  34. package/docs/API.md +190 -51
  35. package/package.json +1 -1
@@ -33,10 +33,10 @@ import {
33
33
  unwrapItem,
34
34
  unwrapItems,
35
35
  unwrapOperation
36
- } from "./chunk-GD4JRPYF.js";
36
+ } from "./chunk-IKJLIDK3.js";
37
37
  import {
38
38
  CAPABILITIES
39
- } from "./chunk-4A6XLQHE.js";
39
+ } from "./chunk-OZD5FQM4.js";
40
40
 
41
41
  // src/resources/appointments.ts
42
42
  var AppointmentsResource = class extends BaseResource {
@@ -4549,7 +4549,8 @@ var PROFITABILITY_OPT_KEYS = [
4549
4549
  "groupByActivity",
4550
4550
  "staleAfterDays",
4551
4551
  "maxTicketsForJoin",
4552
- "limit"
4552
+ "limit",
4553
+ "includeRows"
4553
4554
  ];
4554
4555
  var LABOUR_METHOD_SET = [
4555
4556
  "billing_item_our_cost",
@@ -4561,7 +4562,15 @@ var PROFITABILITY_MAX_WINDOW_MONTHS = 36;
4561
4562
  var RECURRING_DRIFT_EPSILON = 5e-3;
4562
4563
  var ESTIMATE_DEVIATION_THRESHOLD_PCT = 10;
4563
4564
  var CLOSED_STATUS_LABEL_RE = /resolved|completed|cancelled|canceled|closed|deleted/i;
4564
- var PROFITABILITY_WIRE_BUDGET = 18;
4565
+ var PROFITABILITY_WIRE_BUDGET = 21;
4566
+ var BLOCK_HOURS_LABEL_RE = /block\s*hours?/i;
4567
+ var TIME_AND_MATERIALS_LABEL_RE = /time.*materials?/i;
4568
+ var NOT_ACTIVE_LABEL_RE = /cancel|inactive/i;
4569
+ var SUBSCRIPTION_NOT_ACTIVE_STATUS_IDS = /* @__PURE__ */ new Set([2, 3]);
4570
+ var PERIOD_TYPE_MONTHLY_FACTOR = { 2: 1, 3: 1 / 3, 4: 1 / 6, 5: 1 / 12 };
4571
+ var PERIOD_TYPE_ONE_TIME = 1;
4572
+ var SERVICE_LINE_FKS = ["contractServiceID", "contractServiceBundleID", "contractServicePeriodID", "contractServiceBundlePeriodID", "contractServiceAdjustmentID", "contractServiceBundleAdjustmentID"];
4573
+ var CHARGE_LINE_FKS = ["contractChargeID", "ticketChargeID", "projectChargeID"];
4565
4574
  var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
4566
4575
  function num2(v) {
4567
4576
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
@@ -4597,17 +4606,81 @@ function monthsBetween(dateFrom, dateTo) {
4597
4606
  }
4598
4607
  return out;
4599
4608
  }
4609
+ function dayAfter(date) {
4610
+ return new Date(Date.parse(date) + 864e5).toISOString().slice(0, 10);
4611
+ }
4600
4612
  function monthBounds(month, dateFrom, dateTo) {
4601
4613
  const y = Number(month.slice(0, 4));
4602
4614
  const m = Number(month.slice(5, 7));
4603
4615
  const first = `${month}-01`;
4604
- const lastOfMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
4605
- const rawEnd = `${month}-${String(lastOfMonth).padStart(2, "0")}`;
4616
+ const nextFirst = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10);
4617
+ const toExcl = dayAfter(dateTo);
4606
4618
  return {
4607
4619
  start: first > dateFrom ? first : dateFrom,
4608
- end: rawEnd < dateTo ? rawEnd : dateTo
4620
+ endExclusive: nextFirst < toExcl ? nextFirst : toExcl
4609
4621
  };
4610
4622
  }
4623
+ function chargeCost(charge) {
4624
+ const ext = charge["extendedCost"];
4625
+ const fromUnits = num2(charge["unitCost"]) * num2(charge["unitQuantity"]);
4626
+ if (typeof ext === "number" && Number.isFinite(ext) && !(ext === 0 && fromUnits !== 0)) return ext;
4627
+ return fromUnits;
4628
+ }
4629
+ function isOneTimePeriod(periodType, label) {
4630
+ return label !== void 0 ? /one[\s-]*time/i.test(label) : periodType === PERIOD_TYPE_ONE_TIME;
4631
+ }
4632
+ function periodMonthlyFactor(periodType, label) {
4633
+ if (label !== void 0) {
4634
+ if (/semi/i.test(label)) return 1 / 6;
4635
+ if (/quarter/i.test(label)) return 1 / 3;
4636
+ if (/annual|year/i.test(label)) return 1 / 12;
4637
+ if (/month/i.test(label)) return 1;
4638
+ return null;
4639
+ }
4640
+ return typeof periodType === "number" ? PERIOD_TYPE_MONTHLY_FACTOR[periodType] ?? null : null;
4641
+ }
4642
+ var BILLING_ITEM_TYPE_KIND = {
4643
+ 1: "labour",
4644
+ 2: "labour",
4645
+ 3: "charges",
4646
+ 4: "expenses",
4647
+ 5: "other",
4648
+ 6: "recurring",
4649
+ 7: "charges",
4650
+ 8: "other"
4651
+ };
4652
+ function billingLineType(item) {
4653
+ const byType = BILLING_ITEM_TYPE_KIND[num2(item["billingItemType"])];
4654
+ if (byType !== void 0) return byType;
4655
+ if (num2(item["timeEntryID"]) > 0) return "labour";
4656
+ if (num2(item["expenseItemID"]) > 0) return "expenses";
4657
+ if (CHARGE_LINE_FKS.some((k) => num2(item[k]) > 0)) return "charges";
4658
+ if (SERVICE_LINE_FKS.some((k) => num2(item[k]) > 0)) return "recurring";
4659
+ return "other";
4660
+ }
4661
+ function internalAmount(row, internalKey, localKey) {
4662
+ const v = row[internalKey];
4663
+ return typeof v === "number" && Number.isFinite(v) ? v : num2(row[localKey]);
4664
+ }
4665
+ function cents(v) {
4666
+ return v === null ? null : Math.round(v * 100) / 100;
4667
+ }
4668
+ async function readPicklists(http, entityPath, operation) {
4669
+ const res = await http.request({ method: "GET", path: `${entityPath}/entityInformation/fields`, operation });
4670
+ const body = typeof res === "object" && res !== null ? res : {};
4671
+ const fields = Array.isArray(body["fields"]) ? body["fields"] : [];
4672
+ const out = /* @__PURE__ */ new Map();
4673
+ for (const f of fields) {
4674
+ if (f["isPickList"] !== true || typeof f["name"] !== "string" || !Array.isArray(f["picklistValues"])) continue;
4675
+ const values = /* @__PURE__ */ new Map();
4676
+ for (const p of f["picklistValues"]) {
4677
+ const id = typeof p["value"] === "string" ? Number.parseInt(p["value"], 10) : Number.NaN;
4678
+ if (Number.isFinite(id)) values.set(id, String(p["label"] ?? ""));
4679
+ }
4680
+ out.set(f["name"], values);
4681
+ }
4682
+ return out;
4683
+ }
4611
4684
  function dateToDay(v) {
4612
4685
  if (typeof v !== "string" || v.length < 10) return null;
4613
4686
  const d = Date.parse(v.slice(0, 10));
@@ -4616,7 +4689,7 @@ function dateToDay(v) {
4616
4689
  function validateProfitabilityOptions(contractId, opts) {
4617
4690
  if (opts === void 0 || (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
4618
4691
  throw new AutotaskConfigError(
4619
- `profitability: opts must be the options object (got ${opts === void 0 ? "undefined" : Array.isArray(opts) ? "an array" : typeof opts})`,
4692
+ `profitability: opts is required \u2014 the options object with dateFrom and dateTo (YYYY-MM-DD) (got ${opts === void 0 ? "undefined" : Array.isArray(opts) ? "an array" : typeof opts})`,
4620
4693
  { operation: "contracts.profitability" }
4621
4694
  );
4622
4695
  }
@@ -4656,7 +4729,7 @@ function validateProfitabilityOptions(contractId, opts) {
4656
4729
  if (method === "provided" && rate === null) {
4657
4730
  problems.push(`labourRatePerHour is required (finite number >= 0) when labourCostMethod is "provided" (got ${String(opts.labourRatePerHour)})`);
4658
4731
  }
4659
- for (const key of ["includeRecurringCosts", "includeUnbilledRevenue", "groupByActivity"]) {
4732
+ for (const key of ["includeRecurringCosts", "includeUnbilledRevenue", "groupByActivity", "includeRows"]) {
4660
4733
  const v = opts[key];
4661
4734
  if (v !== void 0 && typeof v !== "boolean") problems.push(`${key} must be a boolean (got ${String(v)})`);
4662
4735
  }
@@ -4714,7 +4787,8 @@ function validateProfitabilityOptions(contractId, opts) {
4714
4787
  groupByActivity: opts.groupByActivity ?? true,
4715
4788
  staleAfterDays: stale,
4716
4789
  maxTicketsForJoin: mtj,
4717
- limit
4790
+ limit,
4791
+ includeRows: opts.includeRows ?? false
4718
4792
  };
4719
4793
  }
4720
4794
  async function boundedPart2(http, path, conditions, limit, operation, includeFields) {
@@ -4873,7 +4947,7 @@ async function expandByMonth(http, path, fullWindowCond, limit, operation, field
4873
4947
  const cond = [
4874
4948
  ...fullWindowCond.filter((c) => c.field !== dateField),
4875
4949
  { field: dateField, op: "gte", value: b.start },
4876
- { field: dateField, op: "lte", value: b.end }
4950
+ { field: dateField, op: "lt", value: b.endExclusive }
4877
4951
  ];
4878
4952
  const page = await boundedPart2(http, path, cond, limit, operation, fields);
4879
4953
  wires.issued += 1;
@@ -4888,7 +4962,15 @@ async function expandByMonth(http, path, fullWindowCond, limit, operation, field
4888
4962
  }
4889
4963
  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
4964
  wires.note = (wires.note ? wires.note + " | " : "") + partNote;
4891
- return { items: all, capped: cappedMonths.length > 0, hasMore: fullMonths.length > 0, note: partNote, status: "ok" };
4965
+ return { items: all, capped: cappedMonths.length > 0, hasMore: fullMonths.length > 0, note: partNote, status: "ok", truncatedMonths: fullMonths };
4966
+ }
4967
+ async function degradable(read) {
4968
+ try {
4969
+ return await read();
4970
+ } catch (err) {
4971
+ if (!(err instanceof AutotaskError)) throw err;
4972
+ return { items: [], capped: false, hasMore: false, absent: err.code, absentMessage: err.message };
4973
+ }
4892
4974
  }
4893
4975
  function fmtHours(h) {
4894
4976
  const r = Math.round(h * 100) / 100;
@@ -4904,16 +4986,29 @@ function pctOrNa(r) {
4904
4986
  function dedupeSortCap(ids, limit) {
4905
4987
  return [...new Set(ids)].sort((a, b) => Number(a) - Number(b)).slice(0, limit);
4906
4988
  }
4907
- async function profitability(resource, contractId, opts, http) {
4908
- const v = validateProfitabilityOptions(contractId, opts ?? {});
4989
+ async function profitability(resource, contractId, opts, rawHttp) {
4990
+ const v = validateProfitabilityOptions(contractId, opts);
4909
4991
  const OP2 = "contracts.profitability";
4910
4992
  const id = parseIdentifier(contractId, "contractId");
4911
4993
  const idNum = typeof id === "number" ? id : Number(id);
4912
- const wires = { issued: 0, budget: PROFITABILITY_WIRE_BUDGET };
4994
+ const wires = { issued: 0, requests: 0, budget: PROFITABILITY_WIRE_BUDGET };
4913
4995
  const asOf = (/* @__PURE__ */ new Date()).toISOString();
4914
4996
  const asOfMs = Date.parse(asOf);
4997
+ const toExcl = dayAfter(v.dateTo);
4998
+ const windowOn = (field) => [{ field, op: "gte", value: v.dateFrom }, { field, op: "lt", value: toExcl }];
4999
+ const http = { request: (o) => {
5000
+ wires.requests += 1;
5001
+ return rawHttp.request(o);
5002
+ } };
5003
+ const readPart = (path, conditions, fields) => {
5004
+ wires.issued += 1;
5005
+ return degradable(() => boundedPart2(http, path, conditions, v.limit, OP2, fields));
5006
+ };
4915
5007
  const skipPart = (note) => ({ items: [], capped: false, hasMore: false, status: "skipped", note });
4916
5008
  const stampPart = (key, raw, conditions, extraNotes) => {
5009
+ if (raw.absent !== void 0) {
5010
+ 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` };
5011
+ }
4917
5012
  const st = unbilledWorkPartStatus(key, conditions, raw.items);
4918
5013
  return {
4919
5014
  items: raw.items,
@@ -4925,12 +5020,26 @@ async function profitability(resource, contractId, opts, http) {
4925
5020
  emptyReason: raw.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS[key]?.query : void 0
4926
5021
  };
4927
5022
  };
5023
+ const truncatedMonths = {};
5024
+ const windowedPart = async (name, key, path, cond, fields, dateField) => {
5025
+ const first = await readPart(path, cond, fields);
5026
+ if (first.absent !== void 0 || !first.hasMore) return { part: stampPart(key, first, cond), rows: first.items };
5027
+ const wp = await degradable(() => expandByMonth(http, path, cond, v.limit, OP2, fields, dateField, v.dateFrom, v.dateTo, wires));
5028
+ if (!("truncatedMonths" in wp)) return { part: stampPart(key, wp, cond), rows: [] };
5029
+ const { truncatedMonths: tm, ...part } = wp;
5030
+ truncatedMonths[name] = tm;
5031
+ return { part, rows: wp.items };
5032
+ };
5033
+ const contractCached = rawHttp.readCache?.get(`/V1.0/Contracts/${id}`) !== void 0;
4928
5034
  const contract = await resource.get(id);
4929
5035
  wires.issued += 1;
5036
+ if (!contractCached) wires.requests += 1;
4930
5037
  const contractRow = contract;
4931
5038
  const companyID = num2(contractRow["companyID"]);
5039
+ const contractType = num2(contractRow["contractType"]);
4932
5040
  let customer = null;
4933
5041
  let customerStatus = "skipped";
5042
+ let customerAbsent;
4934
5043
  if (companyID > 0) {
4935
5044
  try {
4936
5045
  const res = await http.request({
@@ -4942,49 +5051,61 @@ async function profitability(resource, contractId, opts, http) {
4942
5051
  customer = item ?? null;
4943
5052
  customerStatus = "ok";
4944
5053
  } catch (err) {
4945
- if (!(err instanceof NotFoundError)) throw err;
5054
+ if (!(err instanceof AutotaskError)) throw err;
5055
+ if (!(err instanceof NotFoundError)) {
5056
+ customerStatus = "absent";
5057
+ customerAbsent = err.code;
5058
+ }
4946
5059
  }
4947
5060
  wires.issued += 1;
4948
5061
  }
5062
+ let contractTypeLabel = null;
5063
+ if (contractType > 0) {
5064
+ wires.issued += 1;
5065
+ try {
5066
+ contractTypeLabel = (await readPicklists(http, "/V1.0/Contracts", OP2)).get("contractType")?.get(contractType) ?? null;
5067
+ } catch (err) {
5068
+ if (!(err instanceof AutotaskError)) throw err;
5069
+ }
5070
+ }
5071
+ const contractTypeKnown = contractTypeLabel !== null;
5072
+ const isBlockHours = contractTypeKnown && BLOCK_HOURS_LABEL_RE.test(contractTypeLabel);
5073
+ const isTimeAndMaterials = contractTypeKnown && TIME_AND_MATERIALS_LABEL_RE.test(contractTypeLabel);
4949
5074
  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;
5075
+ const servicesRaw = await readPart("/V1.0/ContractServices/query", servicesCond, ["id", "serviceID", "unitPrice", "unitCost", "internalCurrencyUnitPrice", "internalDescription"]);
5076
+ const unitsCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("startDate")];
5077
+ const units = await windowedPart("contractServiceUnits", "contract_service_units", "/V1.0/ContractServiceUnits/query", unitsCond, ["id", "contractServiceID", "serviceID", "units", "price", "cost", "internalCurrencyPrice", "startDate", "endDate", "approveAndPostDate"], "startDate");
5078
+ const unitRows = units.rows;
4955
5079
  let chargesPart;
4956
5080
  let charges = [];
4957
5081
  let chargesCond = [];
4958
5082
  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;
5083
+ chargesCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("datePurchased")];
5084
+ const raw = await readPart("/V1.0/ContractCharges/query", chargesCond, ["id", "chargeType", "billableAmount", "extendedCost", "unitCost", "unitPrice", "unitQuantity", "datePurchased", "isBilled", "status", "contractServiceID"]);
4962
5085
  charges = raw.items;
4963
5086
  chargesPart = stampPart("contract_charges", raw, chargesCond);
4964
5087
  } else {
4965
5088
  chargesPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
4966
5089
  }
4967
5090
  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;
5091
+ const roleCostsRaw = await readPart("/V1.0/ContractRoleCosts/query", roleCostsCond, ["id", "rate", "resourceID", "roleID"]);
4970
5092
  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 };
5093
+ const ratesRaw = await readPart("/V1.0/ContractRates/query", ratesCond, ["id", "roleID", "contractHourlyRate", "internalCurrencyContractHourlyRate"]);
5094
+ let blocksPart;
5095
+ if (!isBlockHours) {
5096
+ blocksPart = skipPart(
5097
+ 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)`
5098
+ );
4983
5099
  } 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
- }
5100
+ const blocksCond = [{ field: "contractID", op: "eq", value: id }];
5101
+ const raw = await readPart("/V1.0/ContractBlocks/query", blocksCond, ["id", "datePurchased", "startDate", "endDate", "hours", "hoursApproved", "status", "isPaid"]);
5102
+ blocksPart = stampPart("contract_blocks", raw, blocksCond);
5103
+ }
5104
+ const billingCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("itemDate")];
5105
+ const billingFields = ["id", "invoiceID", "timeEntryID", "ticketID", "ourCost", "quantity", "rate", "extendedPrice", "internalCurrencyExtendedPrice", "itemDate", "postedDate", "nonBillable", "billingCodeID", "billingItemType", "expenseItemID", ...SERVICE_LINE_FKS, ...CHARGE_LINE_FKS];
5106
+ const billing = await windowedPart("billingItems", "billing_items", "/V1.0/BillingItems/query", billingCond, billingFields, "itemDate");
5107
+ const billingItems = billing.part;
5108
+ const billingRows = billing.rows;
4988
5109
  const invoiceIds = [...new Set(billingRows.map((b) => num2(b["invoiceID"])).filter((n) => n > 0))];
4989
5110
  let invoicesPart;
4990
5111
  let invoices = [];
@@ -4998,38 +5119,25 @@ async function profitability(resource, contractId, opts, http) {
4998
5119
  invoices = fb.items;
4999
5120
  invoicesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
5000
5121
  }
5001
- const timeCond = () => [{ field: "contractID", op: "eq", value: id }, { field: "dateWorked", op: "gte", value: v.dateFrom }, { field: "dateWorked", op: "lte", value: v.dateTo }];
5122
+ const timeCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("dateWorked")];
5002
5123
  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
- }
5124
+ const time = await windowedPart("timeEntries", "time_entries", "/V1.0/TimeEntries/query", timeCond, timeFields, "dateWorked");
5125
+ const timeEntries = time.part;
5126
+ const entries = time.rows;
5016
5127
  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;
5128
+ const createdInWindow = [{ field: "contractID", op: "eq", value: id }, ...windowOn("createDate")];
5129
+ const refTicketIds = capJoinSet2(entries.map((e) => num2(e["ticketID"])).filter((n) => n > 0), v.maxTicketsForJoin);
5130
+ const ticketsActCond = refTicketIds.ids.length === 0 ? createdInWindow : [{ op: "or", items: [{ op: "and", items: createdInWindow }, { field: "id", op: "in", value: refTicketIds.ids }] }];
5131
+ const ticketsActRaw = await readPart("/V1.0/Tickets/query", ticketsActCond, ticketFields);
5020
5132
  let openStatusSet = null;
5021
5133
  let statusNote;
5134
+ wires.issued += 1;
5022
5135
  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) {
5136
+ const values = (await readPicklists(http, "/V1.0/Tickets", OP2)).get("status");
5137
+ if (values === void 0 || values.size === 0) {
5030
5138
  statusNote = "the tickets.status picklist is not exposed to this API user \u2014 the open-status labels are unavailable";
5031
5139
  } 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));
5140
+ const open = [...values].filter(([, label]) => !CLOSED_STATUS_LABEL_RE.test(label)).map(([v2]) => v2);
5033
5141
  if (open.length === 0) {
5034
5142
  statusNote = "every tickets.status picklist label is closed-class \u2014 no open statuses exist in this tenant";
5035
5143
  } else {
@@ -5037,7 +5145,6 @@ async function profitability(resource, contractId, opts, http) {
5037
5145
  }
5038
5146
  }
5039
5147
  } catch (err) {
5040
- wires.issued += 1;
5041
5148
  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
5149
  }
5043
5150
  let ticketsOpenPart;
@@ -5046,8 +5153,7 @@ async function profitability(resource, contractId, opts, http) {
5046
5153
  ticketsOpenPart = skipPart(`skipped: ${statusNote} (zero wire)`);
5047
5154
  } else {
5048
5155
  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;
5156
+ const raw = await readPart("/V1.0/Tickets/query", openCond, ticketFields);
5051
5157
  openTickets = raw.items;
5052
5158
  ticketsOpenPart = stampPart("tickets", raw, openCond, ["openStatusHeuristic: true (the open set is the tenant picklist minus the closed-class labels)"]);
5053
5159
  }
@@ -5071,16 +5177,16 @@ async function profitability(resource, contractId, opts, http) {
5071
5177
  const k = num2(t["id"]);
5072
5178
  if (k > 0 && !fetchedTickets.has(k)) fetchedTickets.set(k, t);
5073
5179
  }
5074
- const ticketJoin = capJoinSet2([...fetchedTickets.keys()], v.maxTicketsForJoin);
5180
+ const contractTickets = [...fetchedTickets.values()].filter((t) => num2(t["contractID"]) === idNum);
5181
+ const ticketJoin = capJoinSet2(contractTickets.map((t) => num2(t["id"])), v.maxTicketsForJoin);
5075
5182
  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
5183
  let expensesPart;
5077
5184
  let expenses = [];
5078
5185
  if (ticketJoin.ids.length === 0) {
5079
5186
  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
5187
  } 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;
5188
+ const expCond = [{ field: "ticketID", op: "in", value: ticketJoin.ids }, ...windowOn("expenseDate")];
5189
+ const raw = await readPart("/V1.0/ExpenseItems/query", expCond, ["id", "expenseDate", "expenseCategory", "paymentType", "expenseCurrencyExpenseAmount", "internalCurrencyExpenseAmount", "ticketID"]);
5084
5190
  expenses = raw.items;
5085
5191
  expensesPart = stampPart("expense_items", raw, expCond, joinNote === void 0 ? void 0 : [joinNote]);
5086
5192
  }
@@ -5092,45 +5198,69 @@ async function profitability(resource, contractId, opts, http) {
5092
5198
  const probeCond = [
5093
5199
  { field: "ticketID", op: "in", value: ticketJoin.ids },
5094
5200
  { 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 }
5201
+ ...windowOn("dateWorked")
5097
5202
  ];
5098
- const raw = await boundedPart2(http, "/V1.0/TimeEntries/query", probeCond, v.limit, OP2, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
5099
- wires.issued += 1;
5203
+ const raw = await readPart("/V1.0/TimeEntries/query", probeCond, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
5100
5204
  probeRows = raw.items;
5101
5205
  probePart = stampPart("time_entries", raw, probeCond, joinNote === void 0 ? void 0 : [joinNote]);
5102
5206
  }
5103
5207
  let configurationItemsPart;
5104
5208
  let cis = [];
5105
- let ciCond = [];
5106
5209
  if (!v.includeRecurringCosts) {
5107
5210
  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
5211
  } 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;
5212
+ const ciCond = [{ field: "contractID", op: "eq", value: id }];
5213
+ const raw = await readPart("/V1.0/ConfigurationItems/query", ciCond, ["id", "configurationItemName", "status", "contractID"]);
5114
5214
  cis = raw.items;
5115
5215
  configurationItemsPart = stampPart("configuration_items", raw, ciCond);
5116
5216
  }
5117
5217
  let subscriptionsPart;
5118
5218
  let subscriptions = [];
5219
+ const subFactor = /* @__PURE__ */ new Map();
5220
+ const oneTimeSubs = /* @__PURE__ */ new Set();
5221
+ const cancelledSubs = /* @__PURE__ */ new Set();
5119
5222
  if (cis.length === 0) {
5120
5223
  subscriptionsPart = skipPart("skipped: the CI join found no configuration items \u2014 the subscriptions part is not evaluable (zero wire)");
5121
5224
  } else {
5122
5225
  const ciIds = capJoinSet2(cis.map((c) => num2(c["id"])).filter((n) => n > 0), FETCH_BY_IDS_MAX_ITEMS);
5123
5226
  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;
5227
+ const raw = await readPart("/V1.0/Subscriptions/query", subCond, ["id", "subscriptionName", "configurationItemID", "periodCost", "periodPrice", "periodType", "effectiveDate", "expirationDate", "status", "totalCost"]);
5126
5228
  subscriptions = raw.items;
5127
- subscriptionsPart = stampPart("subscriptions", raw, subCond);
5229
+ let labels = null;
5230
+ if (subscriptions.length > 0) {
5231
+ wires.issued += 1;
5232
+ try {
5233
+ labels = await readPicklists(http, "/V1.0/Subscriptions", OP2);
5234
+ } catch (err) {
5235
+ if (!(err instanceof AutotaskError)) throw err;
5236
+ }
5237
+ }
5238
+ for (const x of subscriptions) {
5239
+ const sid = num2(x["id"]);
5240
+ const periodLabel = labels?.get("periodType")?.get(num2(x["periodType"]));
5241
+ subFactor.set(sid, periodMonthlyFactor(x["periodType"], periodLabel));
5242
+ if (isOneTimePeriod(x["periodType"], periodLabel)) oneTimeSubs.add(sid);
5243
+ const statusLabel = labels?.get("status")?.get(num2(x["status"]));
5244
+ const notActive = statusLabel !== void 0 ? NOT_ACTIVE_LABEL_RE.test(statusLabel) : labels === null && SUBSCRIPTION_NOT_ACTIVE_STATUS_IDS.has(num2(x["status"]));
5245
+ if (notActive) cancelledSubs.add(sid);
5246
+ }
5247
+ const unknownPeriods = [...new Set(subscriptions.filter((x) => subFactor.get(num2(x["id"])) === null && !oneTimeSubs.has(num2(x["id"]))).map((x) => String(x["periodType"] ?? "absent")))];
5248
+ subscriptionsPart = stampPart("subscriptions", raw, subCond, [
5249
+ "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)",
5250
+ ...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)"] : [],
5251
+ ...cancelledSubs.size > 0 ? [`${cancelledSubs.size} not-active (cancelled/inactive) subscription(s) excluded from tooling_cost.vendor`] : [],
5252
+ ...oneTimeSubs.size > 0 ? [`${oneTimeSubs.size} one-time subscription(s): periodCost booked whole in the effectiveDate month, never prorated`] : [],
5253
+ ...unknownPeriods.length > 0 ? [`unrecognised periodType ${unknownPeriods.join(", ")} \u2014 those subscriptions are excluded from tooling_cost.vendor (not prorated)`] : []
5254
+ ]);
5128
5255
  }
5129
5256
  const servicesPart = stampPart("contract_services", servicesRaw, servicesCond);
5130
- const unitsPart = stampPart("contract_service_units", unitsRaw, unitsCond);
5257
+ const unitsPart = units.part;
5131
5258
  const roleCostsPart = stampPart("contract_role_costs", roleCostsRaw, roleCostsCond);
5132
5259
  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);
5260
+ const ticketsActivityPart = stampPart("tickets", ticketsActRaw, ticketsActCond, [
5261
+ ...ticketsActRaw.hasMore ? [`${fetchedTickets.size} ticket(s) returned of possibly more \u2014 the checks cover the returned set only`] : [],
5262
+ ...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`] : []
5263
+ ]);
5134
5264
  const billedByEntryId = /* @__PURE__ */ new Map();
5135
5265
  for (const b of billingRows) {
5136
5266
  const te = num2(b["timeEntryID"]);
@@ -5171,56 +5301,92 @@ async function profitability(resource, contractId, opts, http) {
5171
5301
  bucket.hours += hoursOf(e);
5172
5302
  bucket.cost += r.cost ?? 0;
5173
5303
  }
5304
+ for (const b of Object.values(sources)) b.cost = cents(b.cost);
5174
5305
  const unresolvedHours = sources.missing.hours;
5175
5306
  if (unresolvedHours > 0) resolutionNotes.push(`${fmtHours(unresolvedHours)} hours unresolved (missing rate) \u2014 total_cost is a lower bound`);
5176
5307
  const costRateResolution = { method: v.method, sources, unresolvedHours, notes: resolutionNotes };
5177
5308
  const months = monthsBetween(v.dateFrom, v.dateTo);
5178
5309
  const monthOf = (date) => typeof date === "string" && DATE_RE2.test(`${date}`.slice(0, 10)) ? date.slice(0, 7) : null;
5179
5310
  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
- };
5311
+ const unitIn = (unit, m) => inMonth(unit["startDate"], m);
5312
+ const lineRevenue = (b) => internalAmount(b, "internalCurrencyExtendedPrice", "extendedPrice");
5313
+ const expectedBasis = unitsPart.status !== "absent" && (servicesRaw.items.length > 0 || unitRows.length > 0);
5314
+ const expectedIn = (m) => unitRows.filter((u) => unitIn(u, m)).reduce((a, u) => a + internalAmount(u, "internalCurrencyPrice", "price"), 0);
5197
5315
  const recurringIn = (m) => {
5198
5316
  if (!v.includeRecurringCosts) return 0;
5199
5317
  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"]);
5318
+ for (const c of charges) if (inMonth(c["datePurchased"], m)) total += chargeCost(c);
5319
+ for (const u of unitRows) if (unitIn(u, m)) total += num2(u["cost"]);
5202
5320
  return total;
5203
5321
  };
5204
5322
  const invoicesById = new Map(invoices.map((i) => [num2(i["id"]), i]));
5323
+ const billedServiceIn = (m) => {
5324
+ let billed = 0;
5325
+ const ids = [];
5326
+ for (const b of billingRows) {
5327
+ if (!inMonth(b["itemDate"], m) || billingLineType(b) !== "recurring") continue;
5328
+ const invId = num2(b["invoiceID"]);
5329
+ const inv = invId > 0 ? invoicesById.get(invId) : void 0;
5330
+ if (inv === void 0 || inv["isVoided"] === true) continue;
5331
+ billed += lineRevenue(b);
5332
+ ids.push(num2(b["id"]));
5333
+ }
5334
+ return { billed, ids };
5335
+ };
5336
+ const contractRateByRole = /* @__PURE__ */ new Map();
5337
+ for (const r of ratesRaw.items) {
5338
+ const rate = typeof r["internalCurrencyContractHourlyRate"] === "number" ? r["internalCurrencyContractHourlyRate"] : r["contractHourlyRate"];
5339
+ if (num2(r["roleID"]) > 0 && typeof rate === "number" && Number.isFinite(rate)) contractRateByRole.set(num2(r["roleID"]), rate);
5340
+ }
5341
+ const monthlyParts = [
5342
+ ["contractServiceUnits", unitsPart],
5343
+ ["contractCharges", chargesPart],
5344
+ ["contractRoleCosts", roleCostsPart],
5345
+ ["billingItems", billingItems],
5346
+ ["timeEntries", timeEntries],
5347
+ ["expenses", expensesPart],
5348
+ ["subscriptions", subscriptionsPart]
5349
+ ];
5350
+ const coverageGaps = (m) => {
5351
+ const gaps = [];
5352
+ for (const [name, p] of monthlyParts) {
5353
+ if (p.status === "absent") gaps.push(`${name}: absent (${p.absent})`);
5354
+ else if (truncatedMonths[name] !== void 0) {
5355
+ if (truncatedMonths[name].includes(m)) gaps.push(`${name}: truncated at limit ${v.limit}`);
5356
+ } else if (p.hasMore) gaps.push(`${name}: capped at limit ${v.limit}`);
5357
+ }
5358
+ return gaps;
5359
+ };
5205
5360
  const monthly = [];
5206
5361
  for (let mi = 0; mi < months.length; mi++) {
5207
5362
  const m = months[mi];
5208
5363
  const mBilling = billingRows.filter((b) => inMonth(b["itemDate"], m));
5209
5364
  let revenueInvoiced = 0;
5210
5365
  let revenueVoided = 0;
5211
- const billedItemIds = [];
5366
+ let postedUninvoiced = 0;
5367
+ const byType = { recurring: 0, labour: 0, charges: 0, expenses: 0, other: 0 };
5368
+ let unresolvedInvoice = 0;
5369
+ let unresolvedLines = 0;
5212
5370
  for (const b of mBilling) {
5213
5371
  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"]);
5372
+ if (invId <= 0) {
5373
+ postedUninvoiced += lineRevenue(b);
5374
+ continue;
5375
+ }
5376
+ const inv = invoicesById.get(invId);
5377
+ if (inv === void 0) {
5378
+ unresolvedInvoice += lineRevenue(b);
5379
+ unresolvedLines += 1;
5380
+ continue;
5381
+ }
5382
+ if (inv["isVoided"] === true) revenueVoided += lineRevenue(b);
5217
5383
  else {
5218
- revenueInvoiced += num2(b["extendedPrice"]);
5219
- billedItemIds.push(num2(b["id"]));
5384
+ revenueInvoiced += lineRevenue(b);
5385
+ byType[billingLineType(b)] += lineRevenue(b);
5220
5386
  }
5221
5387
  }
5222
5388
  const expected = v.includeUnbilledRevenue && expectedBasis ? expectedIn(m) : null;
5223
- const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - revenueInvoiced);
5389
+ const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - billedServiceIn(m).billed);
5224
5390
  const mEntries = entries.filter((e) => inMonth(e["dateWorked"], m));
5225
5391
  let labourHours = 0;
5226
5392
  let labourTotal = 0;
@@ -5230,7 +5396,7 @@ async function profitability(resource, contractId, opts, http) {
5230
5396
  rateSources.missing = { entries: 0, hours: 0, cost: 0 };
5231
5397
  for (const e of mEntries) {
5232
5398
  const r = entryResolution.get(num2(e["id"]));
5233
- labourHours += num2(e["hoursWorked"]);
5399
+ labourHours += hoursOf(e);
5234
5400
  const bucket = rateSources[r.source];
5235
5401
  bucket.entries += 1;
5236
5402
  bucket.hours += hoursOf(e);
@@ -5238,39 +5404,71 @@ async function profitability(resource, contractId, opts, http) {
5238
5404
  if (r.source === "missing") unresolved += hoursOf(e);
5239
5405
  else labourTotal += r.cost ?? 0;
5240
5406
  }
5241
- let toolingVendor = subscriptionsPart.status === "skipped" ? null : 0;
5407
+ const unposted = mEntries.filter((e) => e["isNonBillable"] !== true && !billedByEntryId.has(num2(e["id"])));
5408
+ const hoursMissing = unposted.some((e) => typeof e["hoursToBill"] !== "number" || !Number.isFinite(e["hoursToBill"]));
5409
+ const hoursPart = hoursMissing ? { entries: unposted.length, hours: null, hoursAbsent: "HOURS_TO_BILL_MISSING" } : { entries: unposted.length, hours: unposted.reduce((a, e) => a + Math.max(0, num2(e["hoursToBill"])), 0) };
5410
+ let unpostedTime;
5411
+ if (!contractTypeKnown) {
5412
+ unpostedTime = { ...hoursPart, value: null, absent: "CONTRACT_TYPE_UNKNOWN" };
5413
+ } else if (!isTimeAndMaterials) {
5414
+ unpostedTime = { ...hoursPart, value: null, absent: "NOT_TIME_AND_MATERIALS_CONTRACT" };
5415
+ } else if (unposted.some((e) => !contractRateByRole.has(num2(e["roleID"])))) {
5416
+ unpostedTime = { ...hoursPart, value: null, absent: "NO_CONTRACT_RATE" };
5417
+ } else if (hoursMissing) {
5418
+ unpostedTime = { ...hoursPart, value: null, absent: "HOURS_TO_BILL_MISSING" };
5419
+ } else {
5420
+ const value = unposted.reduce((a, e) => a + Math.max(0, num2(e["hoursToBill"])) * contractRateByRole.get(num2(e["roleID"])), 0);
5421
+ unpostedTime = { ...hoursPart, value };
5422
+ }
5423
+ let toolingVendor = subscriptionsPart.status === "skipped" || subscriptionsPart.status === "absent" ? null : 0;
5242
5424
  if (toolingVendor !== null) {
5243
5425
  for (const s of subscriptions) {
5244
5426
  const eff = monthOf(s["effectiveDate"]) ?? "0000-00";
5245
5427
  const exp = monthOf(s["expirationDate"]) ?? "9999-99";
5246
- if (eff <= m && m <= exp) toolingVendor += num2(s["periodCost"]);
5428
+ const sid = num2(s["id"]);
5429
+ if (cancelledSubs.has(sid)) continue;
5430
+ if (oneTimeSubs.has(sid)) {
5431
+ if (eff === m) toolingVendor += num2(s["periodCost"]);
5432
+ continue;
5433
+ }
5434
+ const factor = subFactor.get(sid) ?? null;
5435
+ if (factor !== null && eff <= m && m <= exp) toolingVendor += num2(s["periodCost"]) * factor;
5247
5436
  }
5248
5437
  }
5249
5438
  const callerSupplied = v.externalMonthlyCosts[mi] ?? 0;
5250
5439
  const recurring = recurringIn(m);
5251
5440
  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;
5441
+ for (const x of expenses) if (inMonth(x["expenseDate"], m)) expenseCost += num2(x["internalCurrencyExpenseAmount"]);
5442
+ const totalCost = labourTotal + callerSupplied + recurring + expenseCost;
5254
5443
  const profit = revenueInvoiced - totalCost;
5255
5444
  const margin = revenueInvoiced > 0 ? profit / revenueInvoiced : null;
5256
- const fixed = totalCost - labourTotal;
5445
+ const nonLabour = totalCost - labourTotal;
5257
5446
  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;
5447
+ const avgRate = resolvedHours > 0 ? labourTotal / resolvedHours : 0;
5448
+ const breakEven = avgRate > 0 ? Math.max(0, (revenueInvoiced - nonLabour) / avgRate) : null;
5449
+ for (const b of Object.values(rateSources)) b.cost = cents(b.cost);
5450
+ const gaps = coverageGaps(m);
5451
+ if (unresolvedLines > 0) gaps.push(`invoices: ${unresolvedLines} line(s) on unresolved invoices`);
5259
5452
  monthly.push({
5260
5453
  month: m,
5261
- revenue_invoiced: revenueInvoiced,
5262
- revenue_voided_excluded: revenueVoided,
5263
- revenue_expected: expected,
5264
- revenue_expected_unbilled: expectedUnbilled,
5454
+ revenue_invoiced: cents(revenueInvoiced),
5455
+ revenue_by_type: { recurring: cents(byType.recurring), labour: cents(byType.labour), charges: cents(byType.charges), expenses: cents(byType.expenses), other: cents(byType.other) },
5456
+ revenue_voided_excluded: cents(revenueVoided),
5457
+ revenue_posted_uninvoiced: cents(postedUninvoiced),
5458
+ revenue_unresolved_invoice: cents(unresolvedInvoice),
5459
+ revenue_expected: cents(expected),
5460
+ revenue_expected_unbilled: cents(expectedUnbilled),
5461
+ unposted_time: { ...unpostedTime, value: cents(unpostedTime.value) },
5265
5462
  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,
5463
+ labour_cost: { total: cents(labourTotal), unresolvedHours: unresolved, rateSources },
5464
+ tooling_cost: { vendor: cents(toolingVendor), caller_supplied: cents(callerSupplied) },
5465
+ recurring_cost: cents(recurring),
5466
+ expense_cost: cents(expenseCost),
5467
+ total_cost: cents(totalCost),
5468
+ profit: cents(profit),
5272
5469
  margin,
5273
- break_even_hours: breakEven
5470
+ break_even_hours: breakEven,
5471
+ coverage_gaps: gaps
5274
5472
  });
5275
5473
  }
5276
5474
  const warnings = [];
@@ -5334,13 +5532,13 @@ async function profitability(resource, contractId, opts, http) {
5334
5532
  const mA = months[i - 1];
5335
5533
  const mB = months[i];
5336
5534
  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;
5535
+ const cA = inMonth(c["datePurchased"], mA) ? chargeCost(c) : 0;
5536
+ const cB = inMonth(c["datePurchased"], mB) ? chargeCost(c) : 0;
5339
5537
  if (Math.abs(cB - cA) > 0) driftIds.add(num2(c["id"]));
5340
5538
  }
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;
5539
+ for (const u of unitRows) {
5540
+ const cA = unitIn(u, mA) ? num2(u["cost"]) : 0;
5541
+ const cB = unitIn(u, mB) ? num2(u["cost"]) : 0;
5344
5542
  if (Math.abs(cB - cA) > 0) driftIds.add(num2(u["id"]));
5345
5543
  }
5346
5544
  }
@@ -5348,22 +5546,10 @@ async function profitability(resource, contractId, opts, http) {
5348
5546
  warnings.push({ code: "recurring_cost_drift", count: driftCount, sample_ids: dedupeSortCap([...driftIds], v.limit) });
5349
5547
  let expectedUnbilledCount = 0;
5350
5548
  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) {
5549
+ if (v.includeUnbilledRevenue && expectedBasis) {
5550
+ for (const m of months) {
5551
+ const { billed, ids } = billedServiceIn(m);
5552
+ if (expectedIn(m) > billed + 5e-3) {
5367
5553
  expectedUnbilledCount += 1;
5368
5554
  for (const i of ids) expectedUnbilledSamples.push(i);
5369
5555
  }
@@ -5379,7 +5565,7 @@ async function profitability(resource, contractId, opts, http) {
5379
5565
  });
5380
5566
  const stale = [];
5381
5567
  if (openStatusSet !== null) {
5382
- for (const t of fetchedTickets.values()) {
5568
+ for (const t of contractTickets) {
5383
5569
  const status = t["status"];
5384
5570
  if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5385
5571
  const lastDay = dateToDay(t["lastActivityDate"]);
@@ -5431,7 +5617,7 @@ async function profitability(resource, contractId, opts, http) {
5431
5617
  for (const x of expenses) {
5432
5618
  const t = num2(x["ticketID"]);
5433
5619
  if (t <= 0) continue;
5434
- expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num2(x["expenseCurrencyExpenseAmount"]));
5620
+ expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num2(x["internalCurrencyExpenseAmount"]));
5435
5621
  }
5436
5622
  const ticketCost = (t) => {
5437
5623
  const tid = num2(t["id"]);
@@ -5440,7 +5626,7 @@ async function profitability(resource, contractId, opts, http) {
5440
5626
  for (const e of es) cost += entryResolution.get(num2(e["id"])).cost ?? 0;
5441
5627
  return cost;
5442
5628
  };
5443
- const ticketHours = (t) => (entriesByTicket.get(num2(t["id"])) ?? []).reduce((a, e) => a + num2(e["hoursWorked"]), 0);
5629
+ const ticketHours = (t) => (entriesByTicket.get(num2(t["id"])) ?? []).reduce((a, e) => a + hoursOf(e), 0);
5444
5630
  const allTickets = [...fetchedTickets.values()];
5445
5631
  const activityCategories = [];
5446
5632
  if (v.groupByActivity) {
@@ -5458,7 +5644,7 @@ async function profitability(resource, contractId, opts, http) {
5458
5644
  for (const t of tickets) {
5459
5645
  const es = entriesByTicket.get(num2(t["id"])) ?? [];
5460
5646
  entryCount += es.length;
5461
- hours += es.reduce((a, e) => a + num2(e["hoursWorked"]), 0);
5647
+ hours += es.reduce((a, e) => a + hoursOf(e), 0);
5462
5648
  cost += ticketCost(t);
5463
5649
  }
5464
5650
  activityCategories.push({
@@ -5466,7 +5652,7 @@ async function profitability(resource, contractId, opts, http) {
5466
5652
  ticketCount: tickets.length,
5467
5653
  entryCount,
5468
5654
  hours,
5469
- cost,
5655
+ cost: cents(cost),
5470
5656
  sample_ticketNumbers: dedupeSortCap(tickets.map((t) => num2(t["ticketNumber"])).filter((n) => n > 0), v.limit)
5471
5657
  });
5472
5658
  }
@@ -5478,7 +5664,7 @@ async function profitability(resource, contractId, opts, http) {
5478
5664
  title: typeof t["title"] === "string" ? t["title"] : "",
5479
5665
  status: typeof t["status"] === "number" ? t["status"] : null,
5480
5666
  hours: ticketHours(t),
5481
- cost: ticketCost(t),
5667
+ cost: cents(ticketCost(t)),
5482
5668
  lastActivityDate: typeof t["lastActivityDate"] === "string" ? t["lastActivityDate"] : null
5483
5669
  })).sort((a, b) => b.cost - a.cost || a.ticketId - b.ticketId).slice(0, v.limit);
5484
5670
  const miscoding = /* @__PURE__ */ new Map();
@@ -5494,7 +5680,7 @@ async function profitability(resource, contractId, opts, http) {
5494
5680
  const possibleMiscoding = [...miscoding.values()].sort((a, b) => a.entryId - b.entryId).slice(0, v.limit);
5495
5681
  const openRenewal = [];
5496
5682
  if (openStatusSet !== null) {
5497
- for (const t of allTickets) {
5683
+ for (const t of contractTickets) {
5498
5684
  const status = t["status"];
5499
5685
  if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5500
5686
  const last = t["lastActivityDate"];
@@ -5515,7 +5701,7 @@ async function profitability(resource, contractId, opts, http) {
5515
5701
  id: num2(s["id"]),
5516
5702
  label: typeof s["subscriptionName"] === "string" && s["subscriptionName"] !== "" ? s["subscriptionName"] : `subscription ${num2(s["id"])}`,
5517
5703
  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"]))})`
5704
+ 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`})`
5519
5705
  });
5520
5706
  }
5521
5707
  const contractEndDay = dateToDay(contractRow["endDate"]);
@@ -5528,31 +5714,37 @@ async function profitability(resource, contractId, opts, http) {
5528
5714
  detail: `the contract ends ${String(contractRow["endDate"]).slice(0, 10)}`
5529
5715
  });
5530
5716
  }
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();
5717
+ const blocks = blocksPart.items;
5718
+ for (const b of blocks) {
5719
+ const purchased = num2(b["hours"]);
5720
+ const approved = num2(b["hoursApproved"]);
5721
+ const left = purchased - approved;
5541
5722
  openRenewal.push({
5542
5723
  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`})`
5724
+ id: num2(b["id"]),
5725
+ label: `hour block ${num2(b["id"])}`,
5726
+ date: typeof b["endDate"] === "string" ? b["endDate"].slice(0, 10) : null,
5727
+ detail: `${fmtHours(approved)} h approved of ${fmtHours(purchased)} h purchased (${left >= 0 ? `${fmtHours(left)} h left` : `${fmtHours(Math.abs(left))} h over`})`
5547
5728
  });
5548
5729
  }
5730
+ const purchasedHours = blocks.reduce((a, b) => a + num2(b["hours"]), 0);
5731
+ const approvedHours = blocks.reduce((a, b) => a + num2(b["hoursApproved"]), 0);
5732
+ 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 } };
5733
+ const rows = (part) => {
5734
+ if (v.includeRows) return part;
5735
+ const { items, ...stamps } = part;
5736
+ return { ...stamps, rows_fetched: items.length };
5737
+ };
5549
5738
  return {
5550
5739
  contract,
5551
5740
  customer,
5741
+ ...customerAbsent !== void 0 ? { customerAbsent } : {},
5742
+ contract_type: contractTypeKnown ? { id: contractType, label: contractTypeLabel } : { id: contractType > 0 ? contractType : null, label: null, absent: "CONTRACT_TYPE_UNKNOWN" },
5743
+ included_hours: includedHours,
5552
5744
  period: { dateFrom: v.dateFrom, dateTo: v.dateTo, months, asOf },
5553
5745
  currency: {
5554
5746
  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)"
5747
+ 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"
5556
5748
  },
5557
5749
  monthly,
5558
5750
  activity_categories: activityCategories,
@@ -5569,6 +5761,7 @@ async function profitability(resource, contractId, opts, http) {
5569
5761
  contractCharges: chargesPart.status,
5570
5762
  contractRoleCosts: roleCostsPart.status,
5571
5763
  contractRates: ratesPart.status,
5764
+ contractBlocks: blocksPart.status,
5572
5765
  billingItems: billingItems.status,
5573
5766
  invoices: invoicesPart.status,
5574
5767
  timeEntries: timeEntries.status,
@@ -5580,22 +5773,23 @@ async function profitability(resource, contractId, opts, http) {
5580
5773
  configurationItems: configurationItemsPart.status,
5581
5774
  subscriptions: subscriptionsPart.status
5582
5775
  },
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 }
5776
+ contractServices: rows(servicesPart),
5777
+ contractServiceUnits: rows(unitsPart),
5778
+ contractCharges: rows(chargesPart),
5779
+ contractRoleCosts: rows(roleCostsPart),
5780
+ contractRates: rows(ratesPart),
5781
+ contractBlocks: rows(blocksPart),
5782
+ billingItems: rows(billingItems),
5783
+ invoices: rows(invoicesPart),
5784
+ timeEntries: rows(timeEntries),
5785
+ ticketsActivity: rows(ticketsActivityPart),
5786
+ ticketsOpen: rows(ticketsOpenPart),
5787
+ resources: rows(resourcesPart),
5788
+ expenses: rows(expensesPart),
5789
+ miscodingProbe: rows(probePart),
5790
+ configurationItems: rows(configurationItemsPart),
5791
+ subscriptions: rows(subscriptionsPart),
5792
+ wires: { issued: wires.issued, requests: wires.requests, budget: wires.budget, note: wires.note }
5599
5793
  };
5600
5794
  }
5601
5795