node-autotask 0.12.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 (35) hide show
  1. package/MCP_TOOL_CATALOG.json +1 -1
  2. package/MCP_TOOL_MANIFEST.md +2 -2
  3. package/N8N_MCP_MANIFEST.json +2 -2
  4. package/N8N_MCP_MANIFEST.md +3 -3
  5. package/capabilities.json +5 -5
  6. package/dist/capabilities.cjs +2 -2
  7. package/dist/capabilities.js +1 -1
  8. package/dist/{chunk-TELBX6HK.cjs → chunk-36PG2IU2.cjs} +767 -583
  9. package/dist/{chunk-JKZVCFRY.cjs → chunk-CSRCMBZ2.cjs} +4 -4
  10. package/dist/{chunk-5N6DYKXO.cjs → chunk-IHRXVIUU.cjs} +2 -2
  11. package/dist/{chunk-4A6XLQHE.js → chunk-NACI2NDF.js} +4 -4
  12. package/dist/{chunk-MY7ZSXY3.js → chunk-P35GFG2H.js} +357 -173
  13. package/dist/{chunk-SRJI5XUZ.cjs → chunk-PGQIGE63.cjs} +135 -135
  14. package/dist/{chunk-3WIDDEHP.js → chunk-PPTAMCFA.js} +2 -2
  15. package/dist/{chunk-GD4JRPYF.js → chunk-ZOA5HUFX.js} +1 -1
  16. package/dist/{index-D8I6taEo.d.cts → index-DZfd9Gt7.d.cts} +67 -16
  17. package/dist/{index-uAxAcRol.d.ts → index-UilX3PFL.d.ts} +67 -16
  18. package/dist/index.cjs +20 -20
  19. package/dist/index.d.cts +2 -2
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +5 -5
  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 +157 -48
  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-ZOA5HUFX.js";
37
37
  import {
38
38
  CAPABILITIES
39
- } from "./chunk-4A6XLQHE.js";
39
+ } from "./chunk-NACI2NDF.js";
40
40
 
41
41
  // src/resources/appointments.ts
42
42
  var AppointmentsResource = class extends BaseResource {
@@ -4561,7 +4561,15 @@ var PROFITABILITY_MAX_WINDOW_MONTHS = 36;
4561
4561
  var RECURRING_DRIFT_EPSILON = 5e-3;
4562
4562
  var ESTIMATE_DEVIATION_THRESHOLD_PCT = 10;
4563
4563
  var CLOSED_STATUS_LABEL_RE = /resolved|completed|cancelled|canceled|closed|deleted/i;
4564
- var PROFITABILITY_WIRE_BUDGET = 18;
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"];
4565
4573
  var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
4566
4574
  function num2(v) {
4567
4575
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
@@ -4597,17 +4605,81 @@ function monthsBetween(dateFrom, dateTo) {
4597
4605
  }
4598
4606
  return out;
4599
4607
  }
4608
+ function dayAfter(date) {
4609
+ return new Date(Date.parse(date) + 864e5).toISOString().slice(0, 10);
4610
+ }
4600
4611
  function monthBounds(month, dateFrom, dateTo) {
4601
4612
  const y = Number(month.slice(0, 4));
4602
4613
  const m = Number(month.slice(5, 7));
4603
4614
  const first = `${month}-01`;
4604
- const lastOfMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
4605
- const rawEnd = `${month}-${String(lastOfMonth).padStart(2, "0")}`;
4615
+ const nextFirst = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10);
4616
+ const toExcl = dayAfter(dateTo);
4606
4617
  return {
4607
4618
  start: first > dateFrom ? first : dateFrom,
4608
- end: rawEnd < dateTo ? rawEnd : dateTo
4619
+ endExclusive: nextFirst < toExcl ? nextFirst : toExcl
4609
4620
  };
4610
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
+ }
4611
4683
  function dateToDay(v) {
4612
4684
  if (typeof v !== "string" || v.length < 10) return null;
4613
4685
  const d = Date.parse(v.slice(0, 10));
@@ -4873,7 +4945,7 @@ async function expandByMonth(http, path, fullWindowCond, limit, operation, field
4873
4945
  const cond = [
4874
4946
  ...fullWindowCond.filter((c) => c.field !== dateField),
4875
4947
  { field: dateField, op: "gte", value: b.start },
4876
- { field: dateField, op: "lte", value: b.end }
4948
+ { field: dateField, op: "lt", value: b.endExclusive }
4877
4949
  ];
4878
4950
  const page = await boundedPart2(http, path, cond, limit, operation, fields);
4879
4951
  wires.issued += 1;
@@ -4888,7 +4960,15 @@ async function expandByMonth(http, path, fullWindowCond, limit, operation, field
4888
4960
  }
4889
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` : "");
4890
4962
  wires.note = (wires.note ? wires.note + " | " : "") + partNote;
4891
- return { items: all, capped: cappedMonths.length > 0, hasMore: fullMonths.length > 0, note: partNote, status: "ok" };
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
+ }
4892
4972
  }
4893
4973
  function fmtHours(h) {
4894
4974
  const r = Math.round(h * 100) / 100;
@@ -4904,16 +4984,29 @@ function pctOrNa(r) {
4904
4984
  function dedupeSortCap(ids, limit) {
4905
4985
  return [...new Set(ids)].sort((a, b) => Number(a) - Number(b)).slice(0, limit);
4906
4986
  }
4907
- async function profitability(resource, contractId, opts, http) {
4987
+ async function profitability(resource, contractId, opts, rawHttp) {
4908
4988
  const v = validateProfitabilityOptions(contractId, opts ?? {});
4909
4989
  const OP2 = "contracts.profitability";
4910
4990
  const id = parseIdentifier(contractId, "contractId");
4911
4991
  const idNum = typeof id === "number" ? id : Number(id);
4912
- const wires = { issued: 0, budget: PROFITABILITY_WIRE_BUDGET };
4992
+ const wires = { issued: 0, requests: 0, budget: PROFITABILITY_WIRE_BUDGET };
4913
4993
  const asOf = (/* @__PURE__ */ new Date()).toISOString();
4914
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
+ };
4915
5005
  const skipPart = (note) => ({ items: [], capped: false, hasMore: false, status: "skipped", note });
4916
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
+ }
4917
5010
  const st = unbilledWorkPartStatus(key, conditions, raw.items);
4918
5011
  return {
4919
5012
  items: raw.items,
@@ -4925,12 +5018,26 @@ async function profitability(resource, contractId, opts, http) {
4925
5018
  emptyReason: raw.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS[key]?.query : void 0
4926
5019
  };
4927
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;
4928
5032
  const contract = await resource.get(id);
4929
5033
  wires.issued += 1;
5034
+ if (!contractCached) wires.requests += 1;
4930
5035
  const contractRow = contract;
4931
5036
  const companyID = num2(contractRow["companyID"]);
5037
+ const contractType = num2(contractRow["contractType"]);
4932
5038
  let customer = null;
4933
5039
  let customerStatus = "skipped";
5040
+ let customerAbsent;
4934
5041
  if (companyID > 0) {
4935
5042
  try {
4936
5043
  const res = await http.request({
@@ -4942,49 +5049,61 @@ async function profitability(resource, contractId, opts, http) {
4942
5049
  customer = item ?? null;
4943
5050
  customerStatus = "ok";
4944
5051
  } catch (err) {
4945
- if (!(err instanceof NotFoundError)) throw err;
5052
+ if (!(err instanceof AutotaskError)) throw err;
5053
+ if (!(err instanceof NotFoundError)) {
5054
+ customerStatus = "absent";
5055
+ customerAbsent = err.code;
5056
+ }
4946
5057
  }
4947
5058
  wires.issued += 1;
4948
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);
4949
5072
  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;
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;
4955
5077
  let chargesPart;
4956
5078
  let charges = [];
4957
5079
  let chargesCond = [];
4958
5080
  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;
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"]);
4962
5083
  charges = raw.items;
4963
5084
  chargesPart = stampPart("contract_charges", raw, chargesCond);
4964
5085
  } else {
4965
5086
  chargesPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
4966
5087
  }
4967
5088
  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;
5089
+ const roleCostsRaw = await readPart("/V1.0/ContractRoleCosts/query", roleCostsCond, ["id", "rate", "resourceID", "roleID"]);
4970
5090
  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 };
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
+ );
4983
5097
  } 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
- }
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;
4988
5107
  const invoiceIds = [...new Set(billingRows.map((b) => num2(b["invoiceID"])).filter((n) => n > 0))];
4989
5108
  let invoicesPart;
4990
5109
  let invoices = [];
@@ -4998,38 +5117,25 @@ async function profitability(resource, contractId, opts, http) {
4998
5117
  invoices = fb.items;
4999
5118
  invoicesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
5000
5119
  }
5001
- const timeCond = () => [{ field: "contractID", op: "eq", value: id }, { field: "dateWorked", op: "gte", value: v.dateFrom }, { field: "dateWorked", op: "lte", value: v.dateTo }];
5120
+ const timeCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("dateWorked")];
5002
5121
  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
- }
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;
5016
5125
  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;
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);
5020
5130
  let openStatusSet = null;
5021
5131
  let statusNote;
5132
+ wires.issued += 1;
5022
5133
  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) {
5134
+ const values = (await readPicklists(http, "/V1.0/Tickets", OP2)).get("status");
5135
+ if (values === void 0 || values.size === 0) {
5030
5136
  statusNote = "the tickets.status picklist is not exposed to this API user \u2014 the open-status labels are unavailable";
5031
5137
  } 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));
5138
+ const open = [...values].filter(([, label]) => !CLOSED_STATUS_LABEL_RE.test(label)).map(([v2]) => v2);
5033
5139
  if (open.length === 0) {
5034
5140
  statusNote = "every tickets.status picklist label is closed-class \u2014 no open statuses exist in this tenant";
5035
5141
  } else {
@@ -5037,7 +5143,6 @@ async function profitability(resource, contractId, opts, http) {
5037
5143
  }
5038
5144
  }
5039
5145
  } catch (err) {
5040
- wires.issued += 1;
5041
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`;
5042
5147
  }
5043
5148
  let ticketsOpenPart;
@@ -5046,8 +5151,7 @@ async function profitability(resource, contractId, opts, http) {
5046
5151
  ticketsOpenPart = skipPart(`skipped: ${statusNote} (zero wire)`);
5047
5152
  } else {
5048
5153
  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;
5154
+ const raw = await readPart("/V1.0/Tickets/query", openCond, ticketFields);
5051
5155
  openTickets = raw.items;
5052
5156
  ticketsOpenPart = stampPart("tickets", raw, openCond, ["openStatusHeuristic: true (the open set is the tenant picklist minus the closed-class labels)"]);
5053
5157
  }
@@ -5071,16 +5175,16 @@ async function profitability(resource, contractId, opts, http) {
5071
5175
  const k = num2(t["id"]);
5072
5176
  if (k > 0 && !fetchedTickets.has(k)) fetchedTickets.set(k, t);
5073
5177
  }
5074
- const ticketJoin = capJoinSet2([...fetchedTickets.keys()], v.maxTicketsForJoin);
5178
+ const contractTickets = [...fetchedTickets.values()].filter((t) => num2(t["contractID"]) === idNum);
5179
+ const ticketJoin = capJoinSet2(contractTickets.map((t) => num2(t["id"])), v.maxTicketsForJoin);
5075
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;
5076
5181
  let expensesPart;
5077
5182
  let expenses = [];
5078
5183
  if (ticketJoin.ids.length === 0) {
5079
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)");
5080
5185
  } 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;
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"]);
5084
5188
  expenses = raw.items;
5085
5189
  expensesPart = stampPart("expense_items", raw, expCond, joinNote === void 0 ? void 0 : [joinNote]);
5086
5190
  }
@@ -5092,45 +5196,69 @@ async function profitability(resource, contractId, opts, http) {
5092
5196
  const probeCond = [
5093
5197
  { field: "ticketID", op: "in", value: ticketJoin.ids },
5094
5198
  { 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 }
5199
+ ...windowOn("dateWorked")
5097
5200
  ];
5098
- const raw = await boundedPart2(http, "/V1.0/TimeEntries/query", probeCond, v.limit, OP2, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
5099
- wires.issued += 1;
5201
+ const raw = await readPart("/V1.0/TimeEntries/query", probeCond, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
5100
5202
  probeRows = raw.items;
5101
5203
  probePart = stampPart("time_entries", raw, probeCond, joinNote === void 0 ? void 0 : [joinNote]);
5102
5204
  }
5103
5205
  let configurationItemsPart;
5104
5206
  let cis = [];
5105
- let ciCond = [];
5106
5207
  if (!v.includeRecurringCosts) {
5107
5208
  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
5209
  } 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;
5210
+ const ciCond = [{ field: "contractID", op: "eq", value: id }];
5211
+ const raw = await readPart("/V1.0/ConfigurationItems/query", ciCond, ["id", "configurationItemName", "status", "contractID"]);
5114
5212
  cis = raw.items;
5115
5213
  configurationItemsPart = stampPart("configuration_items", raw, ciCond);
5116
5214
  }
5117
5215
  let subscriptionsPart;
5118
5216
  let subscriptions = [];
5217
+ const subFactor = /* @__PURE__ */ new Map();
5218
+ const oneTimeSubs = /* @__PURE__ */ new Set();
5219
+ const cancelledSubs = /* @__PURE__ */ new Set();
5119
5220
  if (cis.length === 0) {
5120
5221
  subscriptionsPart = skipPart("skipped: the CI join found no configuration items \u2014 the subscriptions part is not evaluable (zero wire)");
5121
5222
  } else {
5122
5223
  const ciIds = capJoinSet2(cis.map((c) => num2(c["id"])).filter((n) => n > 0), FETCH_BY_IDS_MAX_ITEMS);
5123
5224
  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;
5225
+ const raw = await readPart("/V1.0/Subscriptions/query", subCond, ["id", "subscriptionName", "configurationItemID", "periodCost", "periodPrice", "periodType", "effectiveDate", "expirationDate", "status", "totalCost"]);
5126
5226
  subscriptions = raw.items;
5127
- subscriptionsPart = stampPart("subscriptions", raw, subCond);
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
+ ]);
5128
5253
  }
5129
5254
  const servicesPart = stampPart("contract_services", servicesRaw, servicesCond);
5130
- const unitsPart = stampPart("contract_service_units", unitsRaw, unitsCond);
5255
+ const unitsPart = units.part;
5131
5256
  const roleCostsPart = stampPart("contract_role_costs", roleCostsRaw, roleCostsCond);
5132
5257
  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);
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
+ ]);
5134
5262
  const billedByEntryId = /* @__PURE__ */ new Map();
5135
5263
  for (const b of billingRows) {
5136
5264
  const te = num2(b["timeEntryID"]);
@@ -5171,56 +5299,92 @@ async function profitability(resource, contractId, opts, http) {
5171
5299
  bucket.hours += hoursOf(e);
5172
5300
  bucket.cost += r.cost ?? 0;
5173
5301
  }
5302
+ for (const b of Object.values(sources)) b.cost = cents(b.cost);
5174
5303
  const unresolvedHours = sources.missing.hours;
5175
5304
  if (unresolvedHours > 0) resolutionNotes.push(`${fmtHours(unresolvedHours)} hours unresolved (missing rate) \u2014 total_cost is a lower bound`);
5176
5305
  const costRateResolution = { method: v.method, sources, unresolvedHours, notes: resolutionNotes };
5177
5306
  const months = monthsBetween(v.dateFrom, v.dateTo);
5178
5307
  const monthOf = (date) => typeof date === "string" && DATE_RE2.test(`${date}`.slice(0, 10)) ? date.slice(0, 7) : null;
5179
5308
  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
- };
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);
5197
5313
  const recurringIn = (m) => {
5198
5314
  if (!v.includeRecurringCosts) return 0;
5199
5315
  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"]);
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"]);
5202
5318
  return total;
5203
5319
  };
5204
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
+ };
5205
5358
  const monthly = [];
5206
5359
  for (let mi = 0; mi < months.length; mi++) {
5207
5360
  const m = months[mi];
5208
5361
  const mBilling = billingRows.filter((b) => inMonth(b["itemDate"], m));
5209
5362
  let revenueInvoiced = 0;
5210
5363
  let revenueVoided = 0;
5211
- const billedItemIds = [];
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;
5212
5368
  for (const b of mBilling) {
5213
5369
  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"]);
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);
5217
5381
  else {
5218
- revenueInvoiced += num2(b["extendedPrice"]);
5219
- billedItemIds.push(num2(b["id"]));
5382
+ revenueInvoiced += lineRevenue(b);
5383
+ byType[billingLineType(b)] += lineRevenue(b);
5220
5384
  }
5221
5385
  }
5222
5386
  const expected = v.includeUnbilledRevenue && expectedBasis ? expectedIn(m) : null;
5223
- const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - revenueInvoiced);
5387
+ const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - billedServiceIn(m).billed);
5224
5388
  const mEntries = entries.filter((e) => inMonth(e["dateWorked"], m));
5225
5389
  let labourHours = 0;
5226
5390
  let labourTotal = 0;
@@ -5230,7 +5394,7 @@ async function profitability(resource, contractId, opts, http) {
5230
5394
  rateSources.missing = { entries: 0, hours: 0, cost: 0 };
5231
5395
  for (const e of mEntries) {
5232
5396
  const r = entryResolution.get(num2(e["id"]));
5233
- labourHours += num2(e["hoursWorked"]);
5397
+ labourHours += hoursOf(e);
5234
5398
  const bucket = rateSources[r.source];
5235
5399
  bucket.entries += 1;
5236
5400
  bucket.hours += hoursOf(e);
@@ -5238,39 +5402,68 @@ async function profitability(resource, contractId, opts, http) {
5238
5402
  if (r.source === "missing") unresolved += hoursOf(e);
5239
5403
  else labourTotal += r.cost ?? 0;
5240
5404
  }
5241
- let toolingVendor = subscriptionsPart.status === "skipped" ? null : 0;
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;
5242
5419
  if (toolingVendor !== null) {
5243
5420
  for (const s of subscriptions) {
5244
5421
  const eff = monthOf(s["effectiveDate"]) ?? "0000-00";
5245
5422
  const exp = monthOf(s["expirationDate"]) ?? "9999-99";
5246
- if (eff <= m && m <= exp) toolingVendor += num2(s["periodCost"]);
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;
5247
5431
  }
5248
5432
  }
5249
5433
  const callerSupplied = v.externalMonthlyCosts[mi] ?? 0;
5250
5434
  const recurring = recurringIn(m);
5251
5435
  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;
5436
+ for (const x of expenses) if (inMonth(x["expenseDate"], m)) expenseCost += num2(x["internalCurrencyExpenseAmount"]);
5437
+ const totalCost = labourTotal + callerSupplied + recurring + expenseCost;
5254
5438
  const profit = revenueInvoiced - totalCost;
5255
5439
  const margin = revenueInvoiced > 0 ? profit / revenueInvoiced : null;
5256
- const fixed = totalCost - labourTotal;
5440
+ const nonLabour = totalCost - labourTotal;
5257
5441
  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;
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`);
5259
5447
  monthly.push({
5260
5448
  month: m,
5261
- revenue_invoiced: revenueInvoiced,
5262
- revenue_voided_excluded: revenueVoided,
5263
- revenue_expected: expected,
5264
- revenue_expected_unbilled: expectedUnbilled,
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) },
5265
5457
  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,
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),
5272
5464
  margin,
5273
- break_even_hours: breakEven
5465
+ break_even_hours: breakEven,
5466
+ coverage_gaps: gaps
5274
5467
  });
5275
5468
  }
5276
5469
  const warnings = [];
@@ -5334,13 +5527,13 @@ async function profitability(resource, contractId, opts, http) {
5334
5527
  const mA = months[i - 1];
5335
5528
  const mB = months[i];
5336
5529
  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;
5530
+ const cA = inMonth(c["datePurchased"], mA) ? chargeCost(c) : 0;
5531
+ const cB = inMonth(c["datePurchased"], mB) ? chargeCost(c) : 0;
5339
5532
  if (Math.abs(cB - cA) > 0) driftIds.add(num2(c["id"]));
5340
5533
  }
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;
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;
5344
5537
  if (Math.abs(cB - cA) > 0) driftIds.add(num2(u["id"]));
5345
5538
  }
5346
5539
  }
@@ -5348,22 +5541,10 @@ async function profitability(resource, contractId, opts, http) {
5348
5541
  warnings.push({ code: "recurring_cost_drift", count: driftCount, sample_ids: dedupeSortCap([...driftIds], v.limit) });
5349
5542
  let expectedUnbilledCount = 0;
5350
5543
  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) {
5544
+ if (v.includeUnbilledRevenue && expectedBasis) {
5545
+ for (const m of months) {
5546
+ const { billed, ids } = billedServiceIn(m);
5547
+ if (expectedIn(m) > billed + 5e-3) {
5367
5548
  expectedUnbilledCount += 1;
5368
5549
  for (const i of ids) expectedUnbilledSamples.push(i);
5369
5550
  }
@@ -5379,7 +5560,7 @@ async function profitability(resource, contractId, opts, http) {
5379
5560
  });
5380
5561
  const stale = [];
5381
5562
  if (openStatusSet !== null) {
5382
- for (const t of fetchedTickets.values()) {
5563
+ for (const t of contractTickets) {
5383
5564
  const status = t["status"];
5384
5565
  if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5385
5566
  const lastDay = dateToDay(t["lastActivityDate"]);
@@ -5431,7 +5612,7 @@ async function profitability(resource, contractId, opts, http) {
5431
5612
  for (const x of expenses) {
5432
5613
  const t = num2(x["ticketID"]);
5433
5614
  if (t <= 0) continue;
5434
- expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num2(x["expenseCurrencyExpenseAmount"]));
5615
+ expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num2(x["internalCurrencyExpenseAmount"]));
5435
5616
  }
5436
5617
  const ticketCost = (t) => {
5437
5618
  const tid = num2(t["id"]);
@@ -5440,7 +5621,7 @@ async function profitability(resource, contractId, opts, http) {
5440
5621
  for (const e of es) cost += entryResolution.get(num2(e["id"])).cost ?? 0;
5441
5622
  return cost;
5442
5623
  };
5443
- const ticketHours = (t) => (entriesByTicket.get(num2(t["id"])) ?? []).reduce((a, e) => a + num2(e["hoursWorked"]), 0);
5624
+ const ticketHours = (t) => (entriesByTicket.get(num2(t["id"])) ?? []).reduce((a, e) => a + hoursOf(e), 0);
5444
5625
  const allTickets = [...fetchedTickets.values()];
5445
5626
  const activityCategories = [];
5446
5627
  if (v.groupByActivity) {
@@ -5458,7 +5639,7 @@ async function profitability(resource, contractId, opts, http) {
5458
5639
  for (const t of tickets) {
5459
5640
  const es = entriesByTicket.get(num2(t["id"])) ?? [];
5460
5641
  entryCount += es.length;
5461
- hours += es.reduce((a, e) => a + num2(e["hoursWorked"]), 0);
5642
+ hours += es.reduce((a, e) => a + hoursOf(e), 0);
5462
5643
  cost += ticketCost(t);
5463
5644
  }
5464
5645
  activityCategories.push({
@@ -5466,7 +5647,7 @@ async function profitability(resource, contractId, opts, http) {
5466
5647
  ticketCount: tickets.length,
5467
5648
  entryCount,
5468
5649
  hours,
5469
- cost,
5650
+ cost: cents(cost),
5470
5651
  sample_ticketNumbers: dedupeSortCap(tickets.map((t) => num2(t["ticketNumber"])).filter((n) => n > 0), v.limit)
5471
5652
  });
5472
5653
  }
@@ -5478,7 +5659,7 @@ async function profitability(resource, contractId, opts, http) {
5478
5659
  title: typeof t["title"] === "string" ? t["title"] : "",
5479
5660
  status: typeof t["status"] === "number" ? t["status"] : null,
5480
5661
  hours: ticketHours(t),
5481
- cost: ticketCost(t),
5662
+ cost: cents(ticketCost(t)),
5482
5663
  lastActivityDate: typeof t["lastActivityDate"] === "string" ? t["lastActivityDate"] : null
5483
5664
  })).sort((a, b) => b.cost - a.cost || a.ticketId - b.ticketId).slice(0, v.limit);
5484
5665
  const miscoding = /* @__PURE__ */ new Map();
@@ -5494,7 +5675,7 @@ async function profitability(resource, contractId, opts, http) {
5494
5675
  const possibleMiscoding = [...miscoding.values()].sort((a, b) => a.entryId - b.entryId).slice(0, v.limit);
5495
5676
  const openRenewal = [];
5496
5677
  if (openStatusSet !== null) {
5497
- for (const t of allTickets) {
5678
+ for (const t of contractTickets) {
5498
5679
  const status = t["status"];
5499
5680
  if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5500
5681
  const last = t["lastActivityDate"];
@@ -5515,7 +5696,7 @@ async function profitability(resource, contractId, opts, http) {
5515
5696
  id: num2(s["id"]),
5516
5697
  label: typeof s["subscriptionName"] === "string" && s["subscriptionName"] !== "" ? s["subscriptionName"] : `subscription ${num2(s["id"])}`,
5517
5698
  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"]))})`
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`})`
5519
5700
  });
5520
5701
  }
5521
5702
  const contractEndDay = dateToDay(contractRow["endDate"]);
@@ -5528,31 +5709,32 @@ async function profitability(resource, contractId, opts, http) {
5528
5709
  detail: `the contract ends ${String(contractRow["endDate"]).slice(0, 10)}`
5529
5710
  });
5530
5711
  }
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();
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;
5541
5717
  openRenewal.push({
5542
5718
  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`})`
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`})`
5547
5723
  });
5548
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 } };
5549
5728
  return {
5550
5729
  contract,
5551
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,
5552
5734
  period: { dateFrom: v.dateFrom, dateTo: v.dateTo, months, asOf },
5553
5735
  currency: {
5554
5736
  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)"
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"
5556
5738
  },
5557
5739
  monthly,
5558
5740
  activity_categories: activityCategories,
@@ -5569,6 +5751,7 @@ async function profitability(resource, contractId, opts, http) {
5569
5751
  contractCharges: chargesPart.status,
5570
5752
  contractRoleCosts: roleCostsPart.status,
5571
5753
  contractRates: ratesPart.status,
5754
+ contractBlocks: blocksPart.status,
5572
5755
  billingItems: billingItems.status,
5573
5756
  invoices: invoicesPart.status,
5574
5757
  timeEntries: timeEntries.status,
@@ -5585,6 +5768,7 @@ async function profitability(resource, contractId, opts, http) {
5585
5768
  contractCharges: chargesPart,
5586
5769
  contractRoleCosts: roleCostsPart,
5587
5770
  contractRates: ratesPart,
5771
+ contractBlocks: blocksPart,
5588
5772
  billingItems,
5589
5773
  invoices: invoicesPart,
5590
5774
  timeEntries,
@@ -5595,7 +5779,7 @@ async function profitability(resource, contractId, opts, http) {
5595
5779
  miscodingProbe: probePart,
5596
5780
  configurationItems: configurationItemsPart,
5597
5781
  subscriptions: subscriptionsPart,
5598
- wires: { issued: wires.issued, budget: wires.budget, note: wires.note }
5782
+ wires: { issued: wires.issued, requests: wires.requests, budget: wires.budget, note: wires.note }
5599
5783
  };
5600
5784
  }
5601
5785