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.
- package/MCP_TOOL_CATALOG.json +1 -1
- package/MCP_TOOL_MANIFEST.md +2 -2
- package/N8N_MCP_MANIFEST.json +2 -2
- package/N8N_MCP_MANIFEST.md +3 -3
- package/capabilities.json +5 -5
- package/dist/capabilities.cjs +2 -2
- package/dist/capabilities.js +1 -1
- package/dist/{chunk-TELBX6HK.cjs → chunk-36PG2IU2.cjs} +767 -583
- package/dist/{chunk-JKZVCFRY.cjs → chunk-CSRCMBZ2.cjs} +4 -4
- package/dist/{chunk-5N6DYKXO.cjs → chunk-IHRXVIUU.cjs} +2 -2
- package/dist/{chunk-4A6XLQHE.js → chunk-NACI2NDF.js} +4 -4
- package/dist/{chunk-MY7ZSXY3.js → chunk-P35GFG2H.js} +357 -173
- package/dist/{chunk-SRJI5XUZ.cjs → chunk-PGQIGE63.cjs} +135 -135
- package/dist/{chunk-3WIDDEHP.js → chunk-PPTAMCFA.js} +2 -2
- package/dist/{chunk-GD4JRPYF.js → chunk-ZOA5HUFX.js} +1 -1
- package/dist/{index-D8I6taEo.d.cts → index-DZfd9Gt7.d.cts} +67 -16
- package/dist/{index-uAxAcRol.d.ts → index-UilX3PFL.d.ts} +67 -16
- package/dist/index.cjs +20 -20
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -5
- package/dist/operations/index.cjs +103 -103
- package/dist/operations/index.d.cts +2 -2
- package/dist/operations/index.d.ts +2 -2
- package/dist/operations/index.js +2 -2
- package/dist/pool.cjs +8 -8
- package/dist/pool.d.cts +1 -1
- package/dist/pool.d.ts +1 -1
- package/dist/pool.js +4 -4
- package/dist/resources/index.cjs +4 -4
- package/dist/resources/index.d.cts +1 -1
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/index.js +3 -3
- package/docs/API.md +157 -48
- package/package.json +1 -1
|
@@ -33,10 +33,10 @@ import {
|
|
|
33
33
|
unwrapItem,
|
|
34
34
|
unwrapItems,
|
|
35
35
|
unwrapOperation
|
|
36
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-ZOA5HUFX.js";
|
|
37
37
|
import {
|
|
38
38
|
CAPABILITIES
|
|
39
|
-
} from "./chunk-
|
|
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 =
|
|
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
|
|
4605
|
-
const
|
|
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
|
-
|
|
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: "
|
|
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,
|
|
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
|
|
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
|
|
4951
|
-
|
|
4952
|
-
const
|
|
4953
|
-
const
|
|
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 },
|
|
4960
|
-
const raw = await
|
|
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
|
|
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
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
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
|
|
4985
|
-
|
|
4986
|
-
|
|
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 =
|
|
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
|
|
5004
|
-
|
|
5005
|
-
|
|
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
|
|
5018
|
-
const
|
|
5019
|
-
|
|
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
|
|
5024
|
-
|
|
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((
|
|
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
|
|
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
|
|
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 },
|
|
5082
|
-
const raw = await
|
|
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
|
-
|
|
5096
|
-
{ field: "dateWorked", op: "lte", value: v.dateTo }
|
|
5199
|
+
...windowOn("dateWorked")
|
|
5097
5200
|
];
|
|
5098
|
-
const raw = await
|
|
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: "
|
|
5112
|
-
const raw = await
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
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 +=
|
|
5201
|
-
for (const u of
|
|
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
|
-
|
|
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
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
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 +=
|
|
5219
|
-
|
|
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 -
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
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["
|
|
5253
|
-
const totalCost = labourTotal +
|
|
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
|
|
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
|
|
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
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
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) ?
|
|
5338
|
-
const cB = inMonth(c["datePurchased"], mB) ?
|
|
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
|
|
5342
|
-
const cA =
|
|
5343
|
-
const cB =
|
|
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 (
|
|
5353
|
-
const
|
|
5354
|
-
|
|
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
|
|
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["
|
|
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 +
|
|
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 +
|
|
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
|
|
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"] === "
|
|
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
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
const
|
|
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:
|
|
5544
|
-
label: `
|
|
5545
|
-
date:
|
|
5546
|
-
detail: `${fmtHours(
|
|
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: "
|
|
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
|
|