node-autotask 0.20.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +44 -21
- package/CHANGELOG.md +42 -0
- package/MCP_TOOL_CATALOG.json +1458 -12
- package/MCP_TOOL_MANIFEST.md +23 -20
- package/MCP_TOOL_OVERRIDES.json +7 -7
- package/N8N_MCP_MANIFEST.json +20 -4
- package/N8N_MCP_MANIFEST.md +6 -5
- package/README.md +9 -9
- package/capabilities.json +12362 -7886
- package/capabilities.schema.json +33 -12
- package/dist/capabilities.cjs +2 -2
- package/dist/capabilities.d.cts +9 -2
- package/dist/capabilities.d.ts +9 -2
- package/dist/capabilities.js +1 -1
- package/dist/{chunk-GA6UCX3Z.js → chunk-B5SNZ3TZ.js} +3507 -3113
- package/dist/{chunk-L6V66V22.cjs → chunk-D5TVBI4U.cjs} +3039 -2645
- package/dist/{chunk-FLYTKJKV.cjs → chunk-JUO3URUI.cjs} +12361 -7885
- package/dist/{chunk-YH5CPRDN.cjs → chunk-N27NUB4R.cjs} +2466 -1745
- package/dist/{chunk-2T77J2ZM.js → chunk-OGNUI3UR.js} +12361 -7885
- package/dist/{chunk-XSYJB4IW.js → chunk-PVUUOM5G.js} +2092 -1371
- package/dist/{chunk-RMLGOCGA.cjs → chunk-TTQB537V.cjs} +135 -135
- package/dist/{chunk-JEJUFXHX.js → chunk-VYFO2N5F.js} +2 -2
- package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
- package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
- package/dist/{index-AN-NjLi9.d.cts → index-DBoX-Dnk.d.ts} +430 -952
- package/dist/{index-CvLLsOCP.d.ts → index-NC-x-bYl.d.cts} +430 -952
- package/dist/index.cjs +91 -62
- package/dist/index.d.cts +360 -5
- package/dist/index.d.ts +360 -5
- package/dist/index.js +74 -45
- package/dist/operations/index.cjs +462 -462
- package/dist/operations/index.d.cts +78 -84
- package/dist/operations/index.d.ts +78 -84
- package/dist/operations/index.js +382 -382
- package/dist/pool.cjs +8 -8
- package/dist/pool.d.cts +2 -2
- package/dist/pool.d.ts +2 -2
- package/dist/pool.js +4 -4
- package/dist/resources/index.cjs +4 -4
- package/dist/resources/index.d.cts +2 -2
- package/dist/resources/index.d.ts +2 -2
- package/dist/resources/index.js +3 -3
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/API.md +140 -45
- package/docs/N8N-ADAPTER.md +3 -3
- package/examples/mcp/errors.ts +135 -0
- package/examples/mcp/manifest.ts +168 -0
- package/examples/mcp/register.ts +66 -0
- package/examples/mcp/schemas.ts +203 -0
- package/examples/mcp/tools-meta.ts +120 -0
- package/examples/mcp/tools-mode.ts +177 -0
- package/examples/mcp/tools-read.ts +264 -0
- package/examples/mcp/types.ts +34 -0
- package/examples/mcp-register.ts +9 -1074
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
EMPTY_REASON_STAMPS,
|
|
8
8
|
FETCH_BY_IDS_CHUNK_SIZE,
|
|
9
9
|
FETCH_BY_IDS_MAX_ITEMS,
|
|
10
|
+
MODEL_FIELD_SETS,
|
|
11
|
+
MoveRefusedError,
|
|
10
12
|
NotFoundError,
|
|
11
13
|
RELATIONSHIP_MAP,
|
|
12
14
|
ResolutionError,
|
|
@@ -18,10 +20,12 @@ import {
|
|
|
18
20
|
blindStampFor,
|
|
19
21
|
buildDryRunResult,
|
|
20
22
|
buildFilter,
|
|
23
|
+
buildUiLink,
|
|
21
24
|
clampPageSize,
|
|
22
25
|
createNamePlan,
|
|
23
26
|
cursorToPath,
|
|
24
27
|
decodeFileData,
|
|
28
|
+
formatUnknownNameList,
|
|
25
29
|
formatVendorDetail,
|
|
26
30
|
isAutotaskError,
|
|
27
31
|
mergeDetails,
|
|
@@ -34,10 +38,10 @@ import {
|
|
|
34
38
|
unwrapItem,
|
|
35
39
|
unwrapItems,
|
|
36
40
|
unwrapOperation
|
|
37
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-B5SNZ3TZ.js";
|
|
38
42
|
import {
|
|
39
43
|
CAPABILITIES
|
|
40
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-OGNUI3UR.js";
|
|
41
45
|
|
|
42
46
|
// src/resources/appointments.ts
|
|
43
47
|
var AppointmentsResource = class extends BaseResource {
|
|
@@ -826,7 +830,76 @@ var ClassificationIconsResource = class extends BaseResource {
|
|
|
826
830
|
}
|
|
827
831
|
};
|
|
828
832
|
|
|
829
|
-
// src/resources/special/
|
|
833
|
+
// src/resources/special/_shared/dates.ts
|
|
834
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
835
|
+
function isDateYMD(v) {
|
|
836
|
+
return typeof v === "string" && DATE_RE.test(v) && !Number.isNaN(Date.parse(v));
|
|
837
|
+
}
|
|
838
|
+
function monthsInWindow(dateFrom, dateTo) {
|
|
839
|
+
const a = `${dateFrom.slice(0, 7)}`;
|
|
840
|
+
const b = `${dateTo.slice(0, 7)}`;
|
|
841
|
+
if (a > b) return 0;
|
|
842
|
+
const ay = Number(a.slice(0, 4));
|
|
843
|
+
const am = Number(a.slice(5, 7));
|
|
844
|
+
const by = Number(b.slice(0, 4));
|
|
845
|
+
const bm = Number(b.slice(5, 7));
|
|
846
|
+
return (by - ay) * 12 + (bm - am) + 1;
|
|
847
|
+
}
|
|
848
|
+
function monthsBetween(dateFrom, dateTo) {
|
|
849
|
+
const out = [];
|
|
850
|
+
let y = Number(dateFrom.slice(0, 4));
|
|
851
|
+
let m = Number(dateFrom.slice(5, 7));
|
|
852
|
+
const end = dateTo.slice(0, 7);
|
|
853
|
+
for (; ; ) {
|
|
854
|
+
const tag = `${y}-${String(m).padStart(2, "0")}`;
|
|
855
|
+
out.push(tag);
|
|
856
|
+
if (tag === end) break;
|
|
857
|
+
m += 1;
|
|
858
|
+
if (m === 13) {
|
|
859
|
+
m = 1;
|
|
860
|
+
y += 1;
|
|
861
|
+
}
|
|
862
|
+
if (out.length > 500) break;
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
function dayAfter(date) {
|
|
867
|
+
return new Date(Date.parse(date) + 864e5).toISOString().slice(0, 10);
|
|
868
|
+
}
|
|
869
|
+
function monthBounds(month, dateFrom, dateTo) {
|
|
870
|
+
const y = Number(month.slice(0, 4));
|
|
871
|
+
const m = Number(month.slice(5, 7));
|
|
872
|
+
const first = `${month}-01`;
|
|
873
|
+
const nextFirst = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10);
|
|
874
|
+
const toExcl = dayAfter(dateTo);
|
|
875
|
+
return {
|
|
876
|
+
start: first > dateFrom ? first : dateFrom,
|
|
877
|
+
endExclusive: nextFirst < toExcl ? nextFirst : toExcl
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/resources/special/_shared/numbers.ts
|
|
882
|
+
function num(v) {
|
|
883
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/resources/special/_shared/bounded-part.ts
|
|
887
|
+
async function boundedPart(http, path, conditions, limit, operation, includeFields) {
|
|
888
|
+
const body = { MaxRecords: limit, filter: conditions };
|
|
889
|
+
if (includeFields !== void 0) body.IncludeFields = includeFields;
|
|
890
|
+
const res = await http.request({
|
|
891
|
+
method: "POST",
|
|
892
|
+
idempotent: true,
|
|
893
|
+
path,
|
|
894
|
+
body,
|
|
895
|
+
operation
|
|
896
|
+
});
|
|
897
|
+
const { items, pageDetails } = unwrapItems(res, operation);
|
|
898
|
+
const capped = items.length >= limit;
|
|
899
|
+
return { items, capped, hasMore: capped && pageDetails.nextPageUrl != null };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/resources/special/companies/shared.ts
|
|
830
903
|
var DEFAULT_LIMIT2 = 25;
|
|
831
904
|
var MAX_LIMIT2 = 500;
|
|
832
905
|
function clampLimit(limit) {
|
|
@@ -834,6 +907,8 @@ function clampLimit(limit) {
|
|
|
834
907
|
if (!Number.isInteger(n) || n < 1) throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(limit)})`);
|
|
835
908
|
return Math.min(n, MAX_LIMIT2);
|
|
836
909
|
}
|
|
910
|
+
|
|
911
|
+
// src/resources/special/companies/search.ts
|
|
837
912
|
async function queryCompanies(http, filter, limit) {
|
|
838
913
|
const res = await http.request({
|
|
839
914
|
method: "POST",
|
|
@@ -854,9 +929,9 @@ async function queryContacts(http, filter, limit) {
|
|
|
854
929
|
});
|
|
855
930
|
return unwrapItems(res).items;
|
|
856
931
|
}
|
|
857
|
-
async function fetchCompanies(http,
|
|
932
|
+
async function fetchCompanies(http, ids2, limit) {
|
|
858
933
|
const out = [];
|
|
859
|
-
for (const id of
|
|
934
|
+
for (const id of ids2.slice(0, limit)) {
|
|
860
935
|
try {
|
|
861
936
|
const res = await http.request({
|
|
862
937
|
method: "GET",
|
|
@@ -881,8 +956,8 @@ async function searchByDomain(_resource, domain, opts = {}, http) {
|
|
|
881
956
|
const direct = await queryCompanies(http, filter, limit);
|
|
882
957
|
if (direct.length > 0) return direct;
|
|
883
958
|
const contacts = await queryContacts(http, [{ field: "emailAddress", op: "contains", value: text }], limit);
|
|
884
|
-
const
|
|
885
|
-
return fetchCompanies(http,
|
|
959
|
+
const ids2 = [...new Set(contacts.map((c) => c.companyID).filter((id) => typeof id === "number" && id >= 0))];
|
|
960
|
+
return fetchCompanies(http, ids2, limit);
|
|
886
961
|
}
|
|
887
962
|
async function searchByIdentity(_resource, identity, opts = {}, http) {
|
|
888
963
|
const name = identity.companyName?.trim() ?? "";
|
|
@@ -901,11 +976,13 @@ async function searchByIdentity(_resource, identity, opts = {}, http) {
|
|
|
901
976
|
if (name !== "") add(await queryCompanies(http, [{ field: "companyName", op: "contains", value: name }], limit));
|
|
902
977
|
if (email !== "") {
|
|
903
978
|
const contacts = await queryContacts(http, [{ field: "emailAddress", op: "contains", value: email }], limit);
|
|
904
|
-
const
|
|
905
|
-
add(await fetchCompanies(http,
|
|
979
|
+
const ids2 = [...new Set(contacts.map((c) => c.companyID).filter((id) => typeof id === "number" && id >= 0))];
|
|
980
|
+
add(await fetchCompanies(http, ids2, limit));
|
|
906
981
|
}
|
|
907
982
|
return [...found.values()].slice(0, limit);
|
|
908
983
|
}
|
|
984
|
+
|
|
985
|
+
// src/resources/special/companies/family.ts
|
|
909
986
|
var COMPANIES_FAMILY_CHILD_CAP = 50;
|
|
910
987
|
var COMPANIES_FAMILY_BLIND_NOTE = "the family is reported as SEEN BY THIS API USER, never as complete: childIDs (and the search hits themselves) are scoped to the caller-visible enumeration (companies.query is the plan-pinned unowned-blind surface \u2014 get is the authority)";
|
|
911
988
|
var COMPANY_SEARCH_OPT_KEYS = ["limit", "includeFamily"];
|
|
@@ -984,12 +1061,8 @@ async function searchWithFamily(_resource, query, opts = {}, http) {
|
|
|
984
1061
|
}
|
|
985
1062
|
return rows;
|
|
986
1063
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
var DEFAULT_COMPANY_TYPE = 1;
|
|
990
|
-
var DEDUP_NAME_LIMIT = 10;
|
|
991
|
-
var BLIND_SURFACE_CAVEAT = "dedup only covers companies this API user can enumerate (the unowned surface is blind); a same-name company outside that scope is not detected";
|
|
992
|
-
var SAFE_COMPANY_CREATE_DEFAULTS = {};
|
|
1064
|
+
|
|
1065
|
+
// src/resources/special/companies/errors.ts
|
|
993
1066
|
var OwnerResourceRequiredError = class extends AutotaskError {
|
|
994
1067
|
constructor(message, options = {}) {
|
|
995
1068
|
super(message, {
|
|
@@ -1016,7 +1089,15 @@ var CompanyCreateFailedError = class extends AutotaskError {
|
|
|
1016
1089
|
this.vendorFields = vendorFields;
|
|
1017
1090
|
}
|
|
1018
1091
|
};
|
|
1019
|
-
|
|
1092
|
+
|
|
1093
|
+
// src/resources/special/companies/upsertByName.ts
|
|
1094
|
+
var OP_UPSERT_NAME = "companies.upsertByName";
|
|
1095
|
+
var UPSERT_NAME_MAX_DISCOVERY_MERGES = 3;
|
|
1096
|
+
var DEFAULT_COMPANY_TYPE = 1;
|
|
1097
|
+
var DEDUP_NAME_LIMIT = 10;
|
|
1098
|
+
var BLIND_SURFACE_CAVEAT = "dedup only covers companies this API user can enumerate (the unowned surface is blind); a same-name company outside that scope is not detected";
|
|
1099
|
+
var SAFE_COMPANY_CREATE_DEFAULTS = {};
|
|
1100
|
+
function validateUpsertInput(companyName, params) {
|
|
1020
1101
|
if (typeof companyName !== "string" || companyName.trim() === "") {
|
|
1021
1102
|
throw new AutotaskConfigError("upsertByName: companyName is required (a non-empty string)", { operation: OP_UPSERT_NAME });
|
|
1022
1103
|
}
|
|
@@ -1031,73 +1112,53 @@ async function upsertByName(resource, companyName, params, opts, http) {
|
|
|
1031
1112
|
if (owner !== void 0 && (typeof owner !== "number" || !Number.isSafeInteger(owner) || owner < 1)) {
|
|
1032
1113
|
throw new AutotaskConfigError(`upsertByName: params.ownerResourceID must be a positive integer (got ${String(owner)})`, { operation: OP_UPSERT_NAME });
|
|
1033
1114
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
{
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
);
|
|
1059
|
-
}
|
|
1060
|
-
return { id: Number(match.id), created: false, existing: match, blindSurface: caveat };
|
|
1061
|
-
}
|
|
1062
|
-
const body = {
|
|
1063
|
-
companyName,
|
|
1064
|
-
companyType,
|
|
1065
|
-
phone: params.phone,
|
|
1066
|
-
isActive: true,
|
|
1067
|
-
...owner !== void 0 ? { ownerResourceID: owner } : {}
|
|
1068
|
-
};
|
|
1069
|
-
if (opts.dryRun === true) {
|
|
1070
|
-
const warnings = [
|
|
1071
|
-
`the vendor auto-creates a "Primary Address" location under the new company (V5 \u2014 UI-only cleanup; the delete 405 gap)`
|
|
1072
|
-
];
|
|
1073
|
-
if (owner === void 0) {
|
|
1074
|
-
warnings.push(
|
|
1075
|
-
"ownerResourceID is REQUIRED on the apply path (the vendor requires it; the node cannot enumerate candidates \u2014 the resources table is blind to this API user (count/query return 0)): the apply throws the typed OWNER_RESOURCE_REQUIRED if it is still absent"
|
|
1076
|
-
);
|
|
1077
|
-
}
|
|
1078
|
-
return buildDryRunResult(
|
|
1079
|
-
{
|
|
1080
|
-
operation: OP_UPSERT_NAME,
|
|
1081
|
-
method: "POST",
|
|
1082
|
-
path: "/V1.0/Companies",
|
|
1083
|
-
ids: [],
|
|
1084
|
-
checks: [
|
|
1085
|
-
{ name: "input-validation", ok: true },
|
|
1086
|
-
{ name: "duplicate-detection", ok: true }
|
|
1087
|
-
],
|
|
1088
|
-
impact: { affected: 2, scope: "company + the vendor auto-created Primary Address location", reversible: false },
|
|
1089
|
-
warnings,
|
|
1090
|
-
data: body
|
|
1091
|
-
},
|
|
1092
|
-
{ redactPersonalData: http.redactPersonalData }
|
|
1093
|
-
);
|
|
1094
|
-
}
|
|
1115
|
+
return { companyType, owner };
|
|
1116
|
+
}
|
|
1117
|
+
function existsDryRun(http, companyName, match) {
|
|
1118
|
+
return buildDryRunResult(
|
|
1119
|
+
{
|
|
1120
|
+
operation: OP_UPSERT_NAME,
|
|
1121
|
+
method: "POST",
|
|
1122
|
+
path: "/V1.0/Companies",
|
|
1123
|
+
ids: [Number(match.id)],
|
|
1124
|
+
checks: [
|
|
1125
|
+
{ name: "input-validation", ok: true },
|
|
1126
|
+
{ name: "duplicate-detection", ok: true }
|
|
1127
|
+
],
|
|
1128
|
+
impact: { affected: 0, scope: "no write \u2014 the company already exists (dedup match; the create is skipped)", reversible: true },
|
|
1129
|
+
warnings: [`EXISTS: company '${companyName}' already exists (id ${Number(match.id)}) \u2014 the apply would skip the create (zero mutating wire).`],
|
|
1130
|
+
data: { reason: "EXISTS", id: Number(match.id) }
|
|
1131
|
+
},
|
|
1132
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
function createDryRun(http, body, owner) {
|
|
1136
|
+
const warnings = [
|
|
1137
|
+
`the vendor auto-creates a "Primary Address" location under the new company (V5 \u2014 UI-only cleanup; the delete 405 gap)`
|
|
1138
|
+
];
|
|
1095
1139
|
if (owner === void 0) {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
{ operation: OP_UPSERT_NAME }
|
|
1140
|
+
warnings.push(
|
|
1141
|
+
"ownerResourceID is REQUIRED on the apply path (the vendor requires it; the node cannot enumerate candidates \u2014 the resources table is blind to this API user (count/query return 0)): the apply throws the typed OWNER_RESOURCE_REQUIRED if it is still absent"
|
|
1099
1142
|
);
|
|
1100
1143
|
}
|
|
1144
|
+
return buildDryRunResult(
|
|
1145
|
+
{
|
|
1146
|
+
operation: OP_UPSERT_NAME,
|
|
1147
|
+
method: "POST",
|
|
1148
|
+
path: "/V1.0/Companies",
|
|
1149
|
+
ids: [],
|
|
1150
|
+
checks: [
|
|
1151
|
+
{ name: "input-validation", ok: true },
|
|
1152
|
+
{ name: "duplicate-detection", ok: true }
|
|
1153
|
+
],
|
|
1154
|
+
impact: { affected: 2, scope: "company + the vendor auto-created Primary Address location", reversible: false },
|
|
1155
|
+
warnings,
|
|
1156
|
+
data: body
|
|
1157
|
+
},
|
|
1158
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
async function precheckOwner(http, owner) {
|
|
1101
1162
|
const ownerRes = await http.request({ method: "GET", path: `/V1.0/Resources/${owner}`, operation: OP_UPSERT_NAME });
|
|
1102
1163
|
const ownerBody = ownerRes;
|
|
1103
1164
|
if (ownerBody === null || !("item" in ownerBody) || ownerBody.item === null || ownerBody.item === void 0) {
|
|
@@ -1112,6 +1173,8 @@ async function upsertByName(resource, companyName, params, opts, http) {
|
|
|
1112
1173
|
{ resourceIds: [owner], details: { idRefs: [{ entity: "resource", id: owner }] }, operation: OP_UPSERT_NAME, suggestedAction: "Activate the resource or pass an active non-API-only one." }
|
|
1113
1174
|
);
|
|
1114
1175
|
}
|
|
1176
|
+
}
|
|
1177
|
+
async function createWithDiscovery(resource, body, owner, caveat, opts) {
|
|
1115
1178
|
let merges = 0;
|
|
1116
1179
|
for (; ; ) {
|
|
1117
1180
|
try {
|
|
@@ -1153,8 +1216,89 @@ async function upsertByName(resource, companyName, params, opts, http) {
|
|
|
1153
1216
|
}
|
|
1154
1217
|
}
|
|
1155
1218
|
}
|
|
1219
|
+
async function upsertByName(resource, companyName, params, opts, http) {
|
|
1220
|
+
const { companyType, owner } = validateUpsertInput(companyName, params);
|
|
1221
|
+
const q = await resource.queryBounded({ filter: [{ field: "companyName", op: "eq", value: companyName }], limit: DEDUP_NAME_LIMIT });
|
|
1222
|
+
const caveat = {
|
|
1223
|
+
companies: true,
|
|
1224
|
+
caveat: BLIND_SURFACE_CAVEAT,
|
|
1225
|
+
...q.blindSurface !== void 0 ? { sentinel: q.blindSurface } : {}
|
|
1226
|
+
};
|
|
1227
|
+
const match = q.items[0];
|
|
1228
|
+
if (match !== void 0) {
|
|
1229
|
+
if (opts.dryRun === true) return existsDryRun(http, companyName, match);
|
|
1230
|
+
return { id: Number(match.id), created: false, existing: match, blindSurface: caveat };
|
|
1231
|
+
}
|
|
1232
|
+
const body = {
|
|
1233
|
+
companyName,
|
|
1234
|
+
companyType,
|
|
1235
|
+
phone: params.phone,
|
|
1236
|
+
isActive: true,
|
|
1237
|
+
...owner !== void 0 ? { ownerResourceID: owner } : {}
|
|
1238
|
+
};
|
|
1239
|
+
if (opts.dryRun === true) return createDryRun(http, body, owner);
|
|
1240
|
+
if (owner === void 0) {
|
|
1241
|
+
throw new OwnerResourceRequiredError(
|
|
1242
|
+
`upsertByName: no company '${companyName}' is enumerable to this API user (the dedup query returned nothing \u2014 a miss is NOT proof of absence: the unowned surface is blind) and the create path requires ownerResourceID \u2014 companies.create requires ownerResourceID = an active non-API-only resource id; the resources table is blind to this API user (count/query return 0)`,
|
|
1243
|
+
{ operation: OP_UPSERT_NAME }
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
await precheckOwner(http, owner);
|
|
1247
|
+
return createWithDiscovery(resource, body, owner, caveat, opts);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/resources/special/companies/fullProfile.ts
|
|
1251
|
+
var FULL_PROFILE_PROJECTABLE_PARTS = ["locations", "contacts", "notes", "projects", "openTickets"];
|
|
1252
|
+
var PART_ENTITY = {
|
|
1253
|
+
locations: "company_locations",
|
|
1254
|
+
contacts: "contacts",
|
|
1255
|
+
notes: "company_notes",
|
|
1256
|
+
projects: "projects",
|
|
1257
|
+
openTickets: "tickets"
|
|
1258
|
+
};
|
|
1156
1259
|
var OPEN_TICKETS_NOTE = "open = status != 5 (Complete) \u2014 the ww29 ticket-status picklist has no Closed value; tickets in any other status count as open";
|
|
1157
1260
|
var PROFILE_PART_LIMIT = 50;
|
|
1261
|
+
var LOCATION_LIMIT_MAX = 500;
|
|
1262
|
+
function locationLimit(value) {
|
|
1263
|
+
if (value === void 0) return PROFILE_PART_LIMIT;
|
|
1264
|
+
if (!Number.isInteger(value) || value < 1 || value > LOCATION_LIMIT_MAX) {
|
|
1265
|
+
throw new AutotaskConfigError(`fullProfile: locationLimit must be an integer 1-${LOCATION_LIMIT_MAX} (got ${String(value)})`);
|
|
1266
|
+
}
|
|
1267
|
+
return value;
|
|
1268
|
+
}
|
|
1269
|
+
function validateProjection(fields) {
|
|
1270
|
+
if (fields === void 0) return {};
|
|
1271
|
+
if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
|
|
1272
|
+
throw new AutotaskConfigError("fullProfile: fields must be an object keyed by part name");
|
|
1273
|
+
}
|
|
1274
|
+
const out = {};
|
|
1275
|
+
for (const [part, list] of Object.entries(fields)) {
|
|
1276
|
+
if (!FULL_PROFILE_PROJECTABLE_PARTS.includes(part)) {
|
|
1277
|
+
throw new AutotaskConfigError(`fullProfile: fields has unknown part "${part.slice(0, 40)}" \u2014 projectable parts: ${FULL_PROFILE_PROJECTABLE_PARTS.join(", ")}`);
|
|
1278
|
+
}
|
|
1279
|
+
if (!Array.isArray(list) || list.length === 0 || list.some((n) => typeof n !== "string" || n.length === 0)) {
|
|
1280
|
+
throw new AutotaskConfigError(`fullProfile: fields.${part} must be a non-empty array of field-name strings`);
|
|
1281
|
+
}
|
|
1282
|
+
const names = [...new Set(list)];
|
|
1283
|
+
const entity = PART_ENTITY[part];
|
|
1284
|
+
const known = MODEL_FIELD_SETS[entity] ?? [];
|
|
1285
|
+
const unknown = names.filter((n) => !known.includes(n));
|
|
1286
|
+
if (unknown.length > 0) {
|
|
1287
|
+
throw new AutotaskConfigError(
|
|
1288
|
+
`fullProfile: fields.${part} has unknown field name(s) ${formatUnknownNameList(unknown)} against the vendored spec for ${entity} (${known.length} fields; operations.fieldCatalog resources: ["${entity}"] lists the live set) \u2014 no request was issued`
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
out[part] = names;
|
|
1292
|
+
}
|
|
1293
|
+
return out;
|
|
1294
|
+
}
|
|
1295
|
+
function project(items, names) {
|
|
1296
|
+
if (names === void 0) return items;
|
|
1297
|
+
return items.map((row) => {
|
|
1298
|
+
const r = row;
|
|
1299
|
+
return Object.fromEntries(names.filter((n) => n in r).map((n) => [n, r[n]]));
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1158
1302
|
function profilePartLimit(value, name) {
|
|
1159
1303
|
const n = value === void 0 ? PROFILE_PART_LIMIT : Math.trunc(value);
|
|
1160
1304
|
if (!Number.isInteger(n) || n < 1) {
|
|
@@ -1162,27 +1306,17 @@ function profilePartLimit(value, name) {
|
|
|
1162
1306
|
}
|
|
1163
1307
|
return n;
|
|
1164
1308
|
}
|
|
1165
|
-
async function scopedCompanyQuery(http, path, companyID, limit) {
|
|
1309
|
+
async function scopedCompanyQuery(http, path, companyID, limit, includeFields) {
|
|
1166
1310
|
const res = await http.request({
|
|
1167
1311
|
method: "POST",
|
|
1168
1312
|
idempotent: true,
|
|
1169
1313
|
path,
|
|
1170
|
-
body: { MaxRecords: limit, filter: [{ field: "companyID", op: "eq", value: companyID }] },
|
|
1314
|
+
body: { MaxRecords: limit, filter: [{ field: "companyID", op: "eq", value: companyID }], ...includeFields ? { IncludeFields: includeFields } : {} },
|
|
1171
1315
|
operation: "companies.fullProfile"
|
|
1172
1316
|
});
|
|
1173
|
-
return unwrapItems(res).items;
|
|
1317
|
+
return project(unwrapItems(res).items, includeFields);
|
|
1174
1318
|
}
|
|
1175
|
-
async function
|
|
1176
|
-
const contactLimit = profilePartLimit(opts.contactLimit, "contactLimit");
|
|
1177
|
-
const noteLimit = profilePartLimit(opts.noteLimit, "noteLimit");
|
|
1178
|
-
const ticketLimit = profilePartLimit(opts.ticketLimit, "ticketLimit");
|
|
1179
|
-
const projectLimit = profilePartLimit(opts.projectLimit, "projectLimit");
|
|
1180
|
-
const companyID = parseIdentifier(id, "id");
|
|
1181
|
-
const company = await resource.get(companyID);
|
|
1182
|
-
const locations = await scopedCompanyQuery(http, "/V1.0/CompanyLocations/query", companyID, PROFILE_PART_LIMIT);
|
|
1183
|
-
const contacts = await scopedCompanyQuery(http, "/V1.0/Contacts/query", companyID, contactLimit);
|
|
1184
|
-
const notes = await scopedCompanyQuery(http, "/V1.0/CompanyNotes/query", companyID, noteLimit);
|
|
1185
|
-
const projects = await scopedCompanyQuery(http, "/V1.0/Projects/query", companyID, projectLimit);
|
|
1319
|
+
async function readOpenTickets(http, companyID, ticketLimit, includeFields) {
|
|
1186
1320
|
const openRows = await (async () => {
|
|
1187
1321
|
const res = await http.request({
|
|
1188
1322
|
method: "POST",
|
|
@@ -1190,11 +1324,12 @@ async function fullProfile(resource, id, opts = {}, http) {
|
|
|
1190
1324
|
path: "/V1.0/Tickets/query",
|
|
1191
1325
|
body: {
|
|
1192
1326
|
MaxRecords: ticketLimit,
|
|
1193
|
-
filter: [{ field: "companyID", op: "eq", value: companyID }, { field: "status", op: "noteq", value: 5 }]
|
|
1327
|
+
filter: [{ field: "companyID", op: "eq", value: companyID }, { field: "status", op: "noteq", value: 5 }],
|
|
1328
|
+
...includeFields ? { IncludeFields: includeFields } : {}
|
|
1194
1329
|
},
|
|
1195
1330
|
operation: "companies.fullProfile"
|
|
1196
1331
|
});
|
|
1197
|
-
return unwrapItems(res).items;
|
|
1332
|
+
return project(unwrapItems(res).items, includeFields);
|
|
1198
1333
|
})();
|
|
1199
1334
|
let openTickets = {
|
|
1200
1335
|
items: openRows,
|
|
@@ -1217,9 +1352,72 @@ async function fullProfile(resource, id, opts = {}, http) {
|
|
|
1217
1352
|
};
|
|
1218
1353
|
}
|
|
1219
1354
|
}
|
|
1355
|
+
return openTickets;
|
|
1356
|
+
}
|
|
1357
|
+
async function addProfileNames(http, company, openTickets, projects, result, opts) {
|
|
1358
|
+
const builder = createNamePlan(TICKET_LABEL_FIELDS);
|
|
1359
|
+
const c = company;
|
|
1360
|
+
builder.addResource(c["ownerResourceID"]);
|
|
1361
|
+
builder.addResource(c["createdByResourceID"]);
|
|
1362
|
+
for (const row of openTickets.items) {
|
|
1363
|
+
const r = row;
|
|
1364
|
+
builder.addResource(r["creatorResourceID"]);
|
|
1365
|
+
builder.addFk("contract", r["contractID"]);
|
|
1366
|
+
builder.addFk("billingCode", r["billingCodeID"]);
|
|
1367
|
+
builder.addFk("role", r["assignedResourceRoleID"]);
|
|
1368
|
+
}
|
|
1369
|
+
for (const row of projects) {
|
|
1370
|
+
const r = row;
|
|
1371
|
+
builder.addResource(r["projectLeadResourceID"]);
|
|
1372
|
+
builder.addResource(r["creatorResourceID"]);
|
|
1373
|
+
builder.addFk("contract", r["contractID"]);
|
|
1374
|
+
}
|
|
1375
|
+
const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
|
|
1376
|
+
const companyRow = { ...company };
|
|
1377
|
+
addNameSibling(companyRow, "ownerResourceID", (v) => names.resourceName(v));
|
|
1378
|
+
addNameSibling(companyRow, "createdByResourceID", (v) => names.resourceName(v));
|
|
1379
|
+
result.company = companyRow;
|
|
1380
|
+
result.openTickets = {
|
|
1381
|
+
...openTickets,
|
|
1382
|
+
items: openTickets.items.map((row) => {
|
|
1383
|
+
const r = { ...row };
|
|
1384
|
+
addNameSibling(r, "creatorResourceID", (v) => names.resourceName(v));
|
|
1385
|
+
addNameSibling(r, "contractID", (v) => names.recordName("contract", v));
|
|
1386
|
+
addNameSibling(r, "billingCodeID", (v) => names.recordName("billingCode", v));
|
|
1387
|
+
addNameSibling(r, "assignedResourceRoleID", (v) => names.recordName("role", v));
|
|
1388
|
+
for (const f of TICKET_LABEL_FIELDS) addLabelSibling(r, f, names);
|
|
1389
|
+
return r;
|
|
1390
|
+
})
|
|
1391
|
+
};
|
|
1392
|
+
result.projects = {
|
|
1393
|
+
...result.projects,
|
|
1394
|
+
items: result.projects.items.map((row) => {
|
|
1395
|
+
const r = { ...row };
|
|
1396
|
+
addNameSibling(r, "projectLeadResourceID", (v) => names.resourceName(v));
|
|
1397
|
+
addNameSibling(r, "creatorResourceID", (v) => names.resourceName(v));
|
|
1398
|
+
addNameSibling(r, "contractID", (v) => names.recordName("contract", v));
|
|
1399
|
+
return r;
|
|
1400
|
+
})
|
|
1401
|
+
};
|
|
1402
|
+
result.names = names.section;
|
|
1403
|
+
}
|
|
1404
|
+
async function fullProfile(resource, id, opts = {}, http) {
|
|
1405
|
+
const contactLimit = profilePartLimit(opts.contactLimit, "contactLimit");
|
|
1406
|
+
const noteLimit = profilePartLimit(opts.noteLimit, "noteLimit");
|
|
1407
|
+
const ticketLimit = profilePartLimit(opts.ticketLimit, "ticketLimit");
|
|
1408
|
+
const projectLimit = profilePartLimit(opts.projectLimit, "projectLimit");
|
|
1409
|
+
const locLimit = locationLimit(opts.locationLimit);
|
|
1410
|
+
const proj = validateProjection(opts.fields);
|
|
1411
|
+
const companyID = parseIdentifier(id, "id");
|
|
1412
|
+
const company = await resource.get(companyID);
|
|
1413
|
+
const locations = await scopedCompanyQuery(http, "/V1.0/CompanyLocations/query", companyID, locLimit, proj.locations);
|
|
1414
|
+
const contacts = await scopedCompanyQuery(http, "/V1.0/Contacts/query", companyID, contactLimit, proj.contacts);
|
|
1415
|
+
const notes = await scopedCompanyQuery(http, "/V1.0/CompanyNotes/query", companyID, noteLimit, proj.notes);
|
|
1416
|
+
const projects = await scopedCompanyQuery(http, "/V1.0/Projects/query", companyID, projectLimit, proj.projects);
|
|
1417
|
+
const openTickets = await readOpenTickets(http, companyID, ticketLimit, proj.openTickets);
|
|
1220
1418
|
const result = {
|
|
1221
1419
|
company,
|
|
1222
|
-
locations: { items: locations, capped: locations.length >=
|
|
1420
|
+
locations: { items: locations, capped: locations.length >= locLimit },
|
|
1223
1421
|
contacts: { items: contacts, capped: contacts.length >= contactLimit },
|
|
1224
1422
|
notes: { items: notes, capped: notes.length >= noteLimit },
|
|
1225
1423
|
openTickets,
|
|
@@ -1233,57 +1431,15 @@ async function fullProfile(resource, id, opts = {}, http) {
|
|
|
1233
1431
|
projects: "ok"
|
|
1234
1432
|
}
|
|
1235
1433
|
};
|
|
1236
|
-
if (opts.names === true)
|
|
1237
|
-
const builder = createNamePlan(TICKET_LABEL_FIELDS);
|
|
1238
|
-
const c = company;
|
|
1239
|
-
builder.addResource(c["ownerResourceID"]);
|
|
1240
|
-
builder.addResource(c["createdByResourceID"]);
|
|
1241
|
-
for (const row of openTickets.items) {
|
|
1242
|
-
const r = row;
|
|
1243
|
-
builder.addResource(r["creatorResourceID"]);
|
|
1244
|
-
builder.addFk("contract", r["contractID"]);
|
|
1245
|
-
builder.addFk("billingCode", r["billingCodeID"]);
|
|
1246
|
-
builder.addFk("role", r["assignedResourceRoleID"]);
|
|
1247
|
-
}
|
|
1248
|
-
for (const row of projects) {
|
|
1249
|
-
const r = row;
|
|
1250
|
-
builder.addResource(r["projectLeadResourceID"]);
|
|
1251
|
-
builder.addResource(r["creatorResourceID"]);
|
|
1252
|
-
builder.addFk("contract", r["contractID"]);
|
|
1253
|
-
}
|
|
1254
|
-
const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
|
|
1255
|
-
const companyRow = { ...company };
|
|
1256
|
-
addNameSibling(companyRow, "ownerResourceID", (v) => names.resourceName(v));
|
|
1257
|
-
addNameSibling(companyRow, "createdByResourceID", (v) => names.resourceName(v));
|
|
1258
|
-
result.company = companyRow;
|
|
1259
|
-
result.openTickets = {
|
|
1260
|
-
...openTickets,
|
|
1261
|
-
items: openTickets.items.map((row) => {
|
|
1262
|
-
const r = { ...row };
|
|
1263
|
-
addNameSibling(r, "creatorResourceID", (v) => names.resourceName(v));
|
|
1264
|
-
addNameSibling(r, "contractID", (v) => names.recordName("contract", v));
|
|
1265
|
-
addNameSibling(r, "billingCodeID", (v) => names.recordName("billingCode", v));
|
|
1266
|
-
addNameSibling(r, "assignedResourceRoleID", (v) => names.recordName("role", v));
|
|
1267
|
-
for (const f of TICKET_LABEL_FIELDS) addLabelSibling(r, f, names);
|
|
1268
|
-
return r;
|
|
1269
|
-
})
|
|
1270
|
-
};
|
|
1271
|
-
result.projects = {
|
|
1272
|
-
...result.projects,
|
|
1273
|
-
items: result.projects.items.map((row) => {
|
|
1274
|
-
const r = { ...row };
|
|
1275
|
-
addNameSibling(r, "projectLeadResourceID", (v) => names.resourceName(v));
|
|
1276
|
-
addNameSibling(r, "creatorResourceID", (v) => names.resourceName(v));
|
|
1277
|
-
addNameSibling(r, "contractID", (v) => names.recordName("contract", v));
|
|
1278
|
-
return r;
|
|
1279
|
-
})
|
|
1280
|
-
};
|
|
1281
|
-
result.names = names.section;
|
|
1282
|
-
}
|
|
1434
|
+
if (opts.names === true) await addProfileNames(http, company, openTickets, projects, result, opts);
|
|
1283
1435
|
return result;
|
|
1284
1436
|
}
|
|
1437
|
+
|
|
1438
|
+
// src/resources/special/companies/unbilledWorkTypes.ts
|
|
1285
1439
|
var UNBILLED_WORK_NOTE_CHARS_DEFAULT = 400;
|
|
1286
1440
|
var UNBILLED_WORK_JOIN_SET_CAP = 300;
|
|
1441
|
+
|
|
1442
|
+
// src/resources/special/companies/unbilledWorkValidate.ts
|
|
1287
1443
|
var UNBILLED_WORK_OPT_KEYS = [
|
|
1288
1444
|
"source",
|
|
1289
1445
|
"includeParent",
|
|
@@ -1297,13 +1453,6 @@ var UNBILLED_WORK_OPT_KEYS = [
|
|
|
1297
1453
|
"limit"
|
|
1298
1454
|
];
|
|
1299
1455
|
var UNBILLED_WORK_SOURCE_SET = ["posted_uninvoiced", "invoice", "date_range", "tickets"];
|
|
1300
|
-
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1301
|
-
function num(v) {
|
|
1302
|
-
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
1303
|
-
}
|
|
1304
|
-
function isDateYMD(v) {
|
|
1305
|
-
return typeof v === "string" && DATE_RE.test(v) && !Number.isNaN(Date.parse(v));
|
|
1306
|
-
}
|
|
1307
1456
|
function validateUnbilledWorkOptions(companyId, opts) {
|
|
1308
1457
|
if (opts !== void 0 && (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
|
|
1309
1458
|
throw new AutotaskConfigError(
|
|
@@ -1394,22 +1543,11 @@ function validateUnbilledWorkOptions(companyId, opts) {
|
|
|
1394
1543
|
limit
|
|
1395
1544
|
};
|
|
1396
1545
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
idempotent: true,
|
|
1403
|
-
path,
|
|
1404
|
-
body,
|
|
1405
|
-
operation: "companies.unbilledWork"
|
|
1406
|
-
});
|
|
1407
|
-
const { items, pageDetails } = unwrapItems(res, "companies.unbilledWork");
|
|
1408
|
-
const capped = items.length >= limit;
|
|
1409
|
-
return { items, capped, hasMore: capped && pageDetails.nextPageUrl != null };
|
|
1410
|
-
}
|
|
1411
|
-
function capJoinSet(ids, cap) {
|
|
1412
|
-
const sorted = [...new Set(ids)].sort((a, b) => a - b);
|
|
1546
|
+
|
|
1547
|
+
// src/resources/special/companies/unbilledWork.ts
|
|
1548
|
+
var OP = "companies.unbilledWork";
|
|
1549
|
+
function capJoinSet(ids2, cap) {
|
|
1550
|
+
const sorted = [...new Set(ids2)].sort((a, b) => a - b);
|
|
1413
1551
|
if (sorted.length <= cap) return { ids: sorted, truncated: false };
|
|
1414
1552
|
return { ids: sorted.slice(0, cap), truncated: true };
|
|
1415
1553
|
}
|
|
@@ -1422,14 +1560,12 @@ function unbilledWorkPartStatus(key, conditions, items) {
|
|
|
1422
1560
|
note: `0 rows on ${key}.query for this filter (a filtered empty is a TRUE zero \u2014 the pinned blind surfaces stamp only their blind shape, and this call is always filtered)`
|
|
1423
1561
|
};
|
|
1424
1562
|
}
|
|
1425
|
-
async function
|
|
1426
|
-
const v = validateUnbilledWorkOptions(companyId, opts);
|
|
1427
|
-
const id = parseIdentifier(companyId, "companyId");
|
|
1563
|
+
async function readCompanyAndParent(resource, id, includeParent) {
|
|
1428
1564
|
const company = await resource.get(id);
|
|
1429
1565
|
const companyIds = [num(company.id)];
|
|
1430
1566
|
let parent = null;
|
|
1431
1567
|
let parentStatus = "skipped";
|
|
1432
|
-
if (
|
|
1568
|
+
if (includeParent) {
|
|
1433
1569
|
const parentID = company.parentCompanyID;
|
|
1434
1570
|
if (typeof parentID === "number" && parentID > 0) {
|
|
1435
1571
|
try {
|
|
@@ -1442,15 +1578,18 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1442
1578
|
}
|
|
1443
1579
|
}
|
|
1444
1580
|
}
|
|
1581
|
+
return { company, parent, parentStatus, companyIds };
|
|
1582
|
+
}
|
|
1583
|
+
async function readJoinSets(http, companyIds, v) {
|
|
1445
1584
|
const ticketsCond = [{ field: "companyID", op: "in", value: companyIds }];
|
|
1446
|
-
const ticketsRaw = await boundedPart(http, "/V1.0/Tickets/query", ticketsCond, v.limit);
|
|
1585
|
+
const ticketsRaw = await boundedPart(http, "/V1.0/Tickets/query", ticketsCond, v.limit, OP);
|
|
1447
1586
|
const projectsCond = [{ field: "companyID", op: "in", value: companyIds }];
|
|
1448
|
-
const projectsRaw = await boundedPart(http, "/V1.0/Projects/query", projectsCond, v.limit);
|
|
1587
|
+
const projectsRaw = await boundedPart(http, "/V1.0/Projects/query", projectsCond, v.limit, OP);
|
|
1449
1588
|
const projectIds = projectsRaw.items.map((p) => num(p.id)).filter((n) => n > 0);
|
|
1450
1589
|
let taskIds = [];
|
|
1451
1590
|
let tasksHasMore = false;
|
|
1452
1591
|
if (projectIds.length > 0) {
|
|
1453
|
-
const tasksRaw = await boundedPart(http, "/V1.0/Tasks/query", [{ field: "projectID", op: "in", value: projectIds }], v.limit);
|
|
1592
|
+
const tasksRaw = await boundedPart(http, "/V1.0/Tasks/query", [{ field: "projectID", op: "in", value: projectIds }], v.limit, OP);
|
|
1454
1593
|
taskIds = tasksRaw.items.map((t) => num(t.id)).filter((n) => n > 0);
|
|
1455
1594
|
tasksHasMore = tasksRaw.hasMore;
|
|
1456
1595
|
}
|
|
@@ -1458,6 +1597,9 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1458
1597
|
if (v.source === "tickets" && v.ticketIds !== void 0) {
|
|
1459
1598
|
for (const tid of v.ticketIds) ticketJoinIds.push(typeof tid === "number" ? tid : parseInt(tid, 10));
|
|
1460
1599
|
}
|
|
1600
|
+
return { ticketsRaw, projectsRaw, taskIds, tasksHasMore, ticketJoinIds };
|
|
1601
|
+
}
|
|
1602
|
+
async function readBillingPart(http, v, companyIds) {
|
|
1461
1603
|
const billingConditions = [{ field: "companyID", op: "in", value: companyIds }];
|
|
1462
1604
|
if (v.source === "posted_uninvoiced") billingConditions.push({ field: "invoiceID", op: "notExist" });
|
|
1463
1605
|
if (v.source === "invoice" && v.invoiceId !== void 0) billingConditions.push({ field: "invoiceID", op: "eq", value: v.invoiceId });
|
|
@@ -1471,12 +1613,31 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1471
1613
|
}
|
|
1472
1614
|
const billingFields = resolveFieldsPreset("billing_join", "billing_items");
|
|
1473
1615
|
const billingIncludeFields = [...billingFields, "id", ...v.includeNotes ? ["description"] : []];
|
|
1474
|
-
const billingRaw = await boundedPart(http, "/V1.0/BillingItems/query", billingConditions, v.limit, billingIncludeFields);
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1616
|
+
const billingRaw = await boundedPart(http, "/V1.0/BillingItems/query", billingConditions, v.limit, OP, billingIncludeFields);
|
|
1617
|
+
let billingItems = billingRaw.items;
|
|
1618
|
+
let billingNoteCharsTruncated = 0;
|
|
1619
|
+
if (v.includeNotes) {
|
|
1620
|
+
const noteFields = (UNTRUSTED_FIELDS_BY_RESOURCE["billing_items"] ?? []).filter((f) => billingIncludeFields.includes(f));
|
|
1621
|
+
[billingItems, billingNoteCharsTruncated] = truncateNoteChars(billingRaw.items, v.noteChars, noteFields);
|
|
1622
|
+
}
|
|
1623
|
+
const billingSt = unbilledWorkPartStatus("billing_items", billingConditions, billingRaw.items);
|
|
1624
|
+
const billingItemsPart = {
|
|
1625
|
+
items: billingItems,
|
|
1626
|
+
capped: billingRaw.capped,
|
|
1627
|
+
hasMore: billingRaw.hasMore,
|
|
1628
|
+
status: billingSt.status,
|
|
1629
|
+
blindSurface: billingSt.blindSurface,
|
|
1630
|
+
note: [...billingSt.note !== void 0 ? [billingSt.note] : [], ...billingNoteCharsTruncated > 0 ? [`noteChars: ${billingNoteCharsTruncated} note field value(s) truncated at ${v.noteChars} chars`] : []].filter(Boolean).join(" | ") || void 0
|
|
1631
|
+
};
|
|
1632
|
+
return { billingItems, billingSt, billingItemsPart };
|
|
1633
|
+
}
|
|
1634
|
+
async function readTimePart(http, v, join) {
|
|
1635
|
+
const { ticketsRaw, projectsRaw, taskIds, tasksHasMore, ticketJoinIds } = join;
|
|
1636
|
+
const taskCap = capJoinSet(taskIds, UNBILLED_WORK_JOIN_SET_CAP);
|
|
1637
|
+
const ticketCap = capJoinSet(ticketJoinIds, UNBILLED_WORK_JOIN_SET_CAP);
|
|
1638
|
+
const joinNotes = [];
|
|
1639
|
+
for (const [what, more] of [["ticket", ticketsRaw.hasMore], ["project", projectsRaw.hasMore], ["task", tasksHasMore]]) {
|
|
1640
|
+
if (more) joinNotes.push(`${what} join read hit the limit of ${v.limit} \u2014 time entries are joined against the first ${v.limit} ${what}s only (raise limit to widen it)`);
|
|
1480
1641
|
}
|
|
1481
1642
|
if (taskCap.truncated) joinNotes.push(`task join set truncated to ${UNBILLED_WORK_JOIN_SET_CAP} of ${taskIds.length} ids (the 8000-char filter cap \u2014 the results cover the returned join set only)`);
|
|
1482
1643
|
if (ticketCap.truncated) joinNotes.push(`ticket join set truncated to ${UNBILLED_WORK_JOIN_SET_CAP} of ${ticketJoinIds.length} ids (the 8000-char filter cap \u2014 the results cover the returned join set only)`);
|
|
@@ -1496,7 +1657,7 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1496
1657
|
if (v.dateFrom !== void 0) timeConditions.push({ field: "dateWorked", op: "gte", value: v.dateFrom });
|
|
1497
1658
|
if (v.dateTo !== void 0) timeConditions.push({ field: "dateWorked", op: "lte", value: v.dateTo });
|
|
1498
1659
|
const timeFields = ["taskID", "ticketID", "hoursWorked", "dateWorked", "id", ...v.includeNotes ? ["summaryNotes"] : []];
|
|
1499
|
-
const raw = await boundedPart(http, "/V1.0/TimeEntries/query", timeConditions, v.limit, timeFields);
|
|
1660
|
+
const raw = await boundedPart(http, "/V1.0/TimeEntries/query", timeConditions, v.limit, OP, timeFields);
|
|
1500
1661
|
entries = raw.items;
|
|
1501
1662
|
let cappedItems = entries;
|
|
1502
1663
|
let noteCharsTruncated = 0;
|
|
@@ -1513,21 +1674,9 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1513
1674
|
note: [...joinNotes, ...st.note !== void 0 ? [st.note] : [], ...noteCharsTruncated > 0 ? [`noteChars: ${noteCharsTruncated} note field value(s) truncated at ${v.noteChars} chars`] : []].filter(Boolean).join(" | ") || void 0
|
|
1514
1675
|
};
|
|
1515
1676
|
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
const noteFields = (UNTRUSTED_FIELDS_BY_RESOURCE["billing_items"] ?? []).filter((f) => billingIncludeFields.includes(f));
|
|
1520
|
-
[billingItems, billingNoteCharsTruncated] = truncateNoteChars(billingRaw.items, v.noteChars, noteFields);
|
|
1521
|
-
}
|
|
1522
|
-
const billingSt = unbilledWorkPartStatus("billing_items", billingConditions, billingRaw.items);
|
|
1523
|
-
const billingItemsPart = {
|
|
1524
|
-
items: billingItems,
|
|
1525
|
-
capped: billingRaw.capped,
|
|
1526
|
-
hasMore: billingRaw.hasMore,
|
|
1527
|
-
status: billingSt.status,
|
|
1528
|
-
blindSurface: billingSt.blindSurface,
|
|
1529
|
-
note: [...billingSt.note !== void 0 ? [billingSt.note] : [], ...billingNoteCharsTruncated > 0 ? [`noteChars: ${billingNoteCharsTruncated} note field value(s) truncated at ${v.noteChars} chars`] : []].filter(Boolean).join(" | ") || void 0
|
|
1530
|
-
};
|
|
1677
|
+
return { timePart, entries };
|
|
1678
|
+
}
|
|
1679
|
+
function foldRollup(v, billingItems, entries, timePart) {
|
|
1531
1680
|
const entryByTimeId = /* @__PURE__ */ new Map();
|
|
1532
1681
|
for (const e of entries) entryByTimeId.set(num(e.id), e);
|
|
1533
1682
|
const fold = /* @__PURE__ */ new Map();
|
|
@@ -1567,6 +1716,9 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1567
1716
|
return a.ticketId - b.ticketId;
|
|
1568
1717
|
});
|
|
1569
1718
|
const rollup = v.rollup === "entry" ? { mode: "entry", items: timePart.items, capped: timePart.capped, hasMore: timePart.hasMore } : { mode: "ticket", rows: rollupRows };
|
|
1719
|
+
return { entryByTimeId, totals, rollup };
|
|
1720
|
+
}
|
|
1721
|
+
function unpostedAndWarnings(v, billingItems, entries, entryByTimeId) {
|
|
1570
1722
|
const billedEntryIds = new Set(billingItems.map((i) => num(i.timeEntryID)).filter((n) => n > 0));
|
|
1571
1723
|
const unpostedAll = entries.filter((e) => num(e.id) > 0 && !billedEntryIds.has(num(e.id)));
|
|
1572
1724
|
const unpostedCapped = unpostedAll.length > v.limit;
|
|
@@ -1594,6 +1746,17 @@ async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
|
1594
1746
|
noTimeEntry: { items: noTimeEntryAll.slice(0, v.limit).map((i) => ({ billingItemId: num(i.id) })), count: noTimeEntryAll.length },
|
|
1595
1747
|
hourDelta: { items: hourDeltaAll.slice(0, v.limit), count: hourDeltaAll.length }
|
|
1596
1748
|
};
|
|
1749
|
+
return { unposted, warnings };
|
|
1750
|
+
}
|
|
1751
|
+
async function unbilledWork(resource, companyId, opts = {}, http) {
|
|
1752
|
+
const v = validateUnbilledWorkOptions(companyId, opts);
|
|
1753
|
+
const id = parseIdentifier(companyId, "companyId");
|
|
1754
|
+
const { company, parent, parentStatus, companyIds } = await readCompanyAndParent(resource, id, v.includeParent);
|
|
1755
|
+
const join = await readJoinSets(http, companyIds, v);
|
|
1756
|
+
const { billingItems, billingSt, billingItemsPart } = await readBillingPart(http, v, companyIds);
|
|
1757
|
+
const { timePart, entries } = await readTimePart(http, v, join);
|
|
1758
|
+
const { entryByTimeId, totals, rollup } = foldRollup(v, billingItems, entries, timePart);
|
|
1759
|
+
const { unposted, warnings } = unpostedAndWarnings(v, billingItems, entries, entryByTimeId);
|
|
1597
1760
|
return {
|
|
1598
1761
|
company,
|
|
1599
1762
|
parent,
|
|
@@ -4544,7 +4707,7 @@ var ContractServiceUnitsResource = class extends BaseResource {
|
|
|
4544
4707
|
}
|
|
4545
4708
|
};
|
|
4546
4709
|
|
|
4547
|
-
// src/resources/special/contracts.ts
|
|
4710
|
+
// src/resources/special/contracts/profitability.constants.ts
|
|
4548
4711
|
var PROFITABILITY_OPT_KEYS = [
|
|
4549
4712
|
"dateFrom",
|
|
4550
4713
|
"dateTo",
|
|
@@ -4578,58 +4741,11 @@ var PERIOD_TYPE_MONTHLY_FACTOR = { 2: 1, 3: 1 / 3, 4: 1 / 6, 5: 1 / 12 };
|
|
|
4578
4741
|
var PERIOD_TYPE_ONE_TIME = 1;
|
|
4579
4742
|
var SERVICE_LINE_FKS = ["contractServiceID", "contractServiceBundleID", "contractServicePeriodID", "contractServiceBundlePeriodID", "contractServiceAdjustmentID", "contractServiceBundleAdjustmentID"];
|
|
4580
4743
|
var CHARGE_LINE_FKS = ["contractChargeID", "ticketChargeID", "projectChargeID"];
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
4584
|
-
}
|
|
4585
|
-
function isDateYMD2(v) {
|
|
4586
|
-
return typeof v === "string" && DATE_RE2.test(v) && !Number.isNaN(Date.parse(v));
|
|
4587
|
-
}
|
|
4588
|
-
function monthsInWindow(dateFrom, dateTo) {
|
|
4589
|
-
const a = `${dateFrom.slice(0, 7)}`;
|
|
4590
|
-
const b = `${dateTo.slice(0, 7)}`;
|
|
4591
|
-
if (a > b) return 0;
|
|
4592
|
-
const ay = Number(a.slice(0, 4));
|
|
4593
|
-
const am = Number(a.slice(5, 7));
|
|
4594
|
-
const by = Number(b.slice(0, 4));
|
|
4595
|
-
const bm = Number(b.slice(5, 7));
|
|
4596
|
-
return (by - ay) * 12 + (bm - am) + 1;
|
|
4597
|
-
}
|
|
4598
|
-
function monthsBetween(dateFrom, dateTo) {
|
|
4599
|
-
const out = [];
|
|
4600
|
-
let y = Number(dateFrom.slice(0, 4));
|
|
4601
|
-
let m = Number(dateFrom.slice(5, 7));
|
|
4602
|
-
const end = dateTo.slice(0, 7);
|
|
4603
|
-
for (; ; ) {
|
|
4604
|
-
const tag = `${y}-${String(m).padStart(2, "0")}`;
|
|
4605
|
-
out.push(tag);
|
|
4606
|
-
if (tag === end) break;
|
|
4607
|
-
m += 1;
|
|
4608
|
-
if (m === 13) {
|
|
4609
|
-
m = 1;
|
|
4610
|
-
y += 1;
|
|
4611
|
-
}
|
|
4612
|
-
if (out.length > 500) break;
|
|
4613
|
-
}
|
|
4614
|
-
return out;
|
|
4615
|
-
}
|
|
4616
|
-
function dayAfter(date) {
|
|
4617
|
-
return new Date(Date.parse(date) + 864e5).toISOString().slice(0, 10);
|
|
4618
|
-
}
|
|
4619
|
-
function monthBounds(month, dateFrom, dateTo) {
|
|
4620
|
-
const y = Number(month.slice(0, 4));
|
|
4621
|
-
const m = Number(month.slice(5, 7));
|
|
4622
|
-
const first = `${month}-01`;
|
|
4623
|
-
const nextFirst = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10);
|
|
4624
|
-
const toExcl = dayAfter(dateTo);
|
|
4625
|
-
return {
|
|
4626
|
-
start: first > dateFrom ? first : dateFrom,
|
|
4627
|
-
endExclusive: nextFirst < toExcl ? nextFirst : toExcl
|
|
4628
|
-
};
|
|
4629
|
-
}
|
|
4744
|
+
|
|
4745
|
+
// src/resources/special/contracts/profitability.revenue.ts
|
|
4630
4746
|
function chargeCost(charge) {
|
|
4631
4747
|
const ext = charge["extendedCost"];
|
|
4632
|
-
const fromUnits =
|
|
4748
|
+
const fromUnits = num(charge["unitCost"]) * num(charge["unitQuantity"]);
|
|
4633
4749
|
if (typeof ext === "number" && Number.isFinite(ext) && !(ext === 0 && fromUnits !== 0)) return ext;
|
|
4634
4750
|
return fromUnits;
|
|
4635
4751
|
}
|
|
@@ -4657,42 +4773,165 @@ var BILLING_ITEM_TYPE_KIND = {
|
|
|
4657
4773
|
8: "other"
|
|
4658
4774
|
};
|
|
4659
4775
|
function billingLineType(item) {
|
|
4660
|
-
const byType = BILLING_ITEM_TYPE_KIND[
|
|
4776
|
+
const byType = BILLING_ITEM_TYPE_KIND[num(item["billingItemType"])];
|
|
4661
4777
|
if (byType !== void 0) return byType;
|
|
4662
|
-
if (
|
|
4663
|
-
if (
|
|
4664
|
-
if (CHARGE_LINE_FKS.some((k) =>
|
|
4665
|
-
if (SERVICE_LINE_FKS.some((k) =>
|
|
4778
|
+
if (num(item["timeEntryID"]) > 0) return "labour";
|
|
4779
|
+
if (num(item["expenseItemID"]) > 0) return "expenses";
|
|
4780
|
+
if (CHARGE_LINE_FKS.some((k) => num(item[k]) > 0)) return "charges";
|
|
4781
|
+
if (SERVICE_LINE_FKS.some((k) => num(item[k]) > 0)) return "recurring";
|
|
4666
4782
|
return "other";
|
|
4667
4783
|
}
|
|
4668
4784
|
function internalAmount(row, internalKey, localKey) {
|
|
4669
4785
|
const v = row[internalKey];
|
|
4670
|
-
return typeof v === "number" && Number.isFinite(v) ? v :
|
|
4786
|
+
return typeof v === "number" && Number.isFinite(v) ? v : num(row[localKey]);
|
|
4671
4787
|
}
|
|
4672
4788
|
function cents(v) {
|
|
4673
4789
|
return v === null ? null : Math.round(v * 100) / 100;
|
|
4674
4790
|
}
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
const body = typeof res === "object" && res !== null ? res : {};
|
|
4678
|
-
const fields = Array.isArray(body["fields"]) ? body["fields"] : [];
|
|
4679
|
-
const out = /* @__PURE__ */ new Map();
|
|
4680
|
-
for (const f of fields) {
|
|
4681
|
-
if (f["isPickList"] !== true || typeof f["name"] !== "string" || !Array.isArray(f["picklistValues"])) continue;
|
|
4682
|
-
const values = /* @__PURE__ */ new Map();
|
|
4683
|
-
for (const p of f["picklistValues"]) {
|
|
4684
|
-
const id = typeof p["value"] === "string" ? Number.parseInt(p["value"], 10) : Number.NaN;
|
|
4685
|
-
if (Number.isFinite(id)) values.set(id, String(p["label"] ?? ""));
|
|
4686
|
-
}
|
|
4687
|
-
out.set(f["name"], values);
|
|
4688
|
-
}
|
|
4689
|
-
return out;
|
|
4690
|
-
}
|
|
4791
|
+
|
|
4792
|
+
// src/resources/special/contracts/profitability.util.ts
|
|
4691
4793
|
function dateToDay(v) {
|
|
4692
4794
|
if (typeof v !== "string" || v.length < 10) return null;
|
|
4693
4795
|
const d = Date.parse(v.slice(0, 10));
|
|
4694
4796
|
return Number.isNaN(d) ? null : Math.floor(d / 864e5);
|
|
4695
4797
|
}
|
|
4798
|
+
function fmtHours(h) {
|
|
4799
|
+
const r = Math.round(h * 100) / 100;
|
|
4800
|
+
return String(r);
|
|
4801
|
+
}
|
|
4802
|
+
function fmtNum(v) {
|
|
4803
|
+
const r = Math.round(v * 100) / 100;
|
|
4804
|
+
return String(r);
|
|
4805
|
+
}
|
|
4806
|
+
function pctOrNa(r) {
|
|
4807
|
+
return r === null ? "n/a" : `${Math.round(r * 1e3) / 10}%`;
|
|
4808
|
+
}
|
|
4809
|
+
function dedupeSortCap(ids2, limit) {
|
|
4810
|
+
return [...new Set(ids2)].sort((a, b) => Number(a) - Number(b)).slice(0, limit);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
// src/resources/special/contracts/profitability.ladder.ts
|
|
4814
|
+
function ladderFrom(ctx) {
|
|
4815
|
+
const rungs = [];
|
|
4816
|
+
const push = (s) => rungs.push(s);
|
|
4817
|
+
switch (ctx.method) {
|
|
4818
|
+
case "billing_item_our_cost":
|
|
4819
|
+
push("billing_item.ourCost");
|
|
4820
|
+
push("contract_role_costs.rate");
|
|
4821
|
+
push("resource.internalCost");
|
|
4822
|
+
break;
|
|
4823
|
+
case "contract_role_cost":
|
|
4824
|
+
push("contract_role_costs.rate");
|
|
4825
|
+
push("resource.internalCost");
|
|
4826
|
+
break;
|
|
4827
|
+
case "resource_internal_cost":
|
|
4828
|
+
push("resource.internalCost");
|
|
4829
|
+
break;
|
|
4830
|
+
case "provided":
|
|
4831
|
+
break;
|
|
4832
|
+
}
|
|
4833
|
+
if (ctx.rate !== null) push("provided");
|
|
4834
|
+
return rungs;
|
|
4835
|
+
}
|
|
4836
|
+
function hoursOf(entry) {
|
|
4837
|
+
const h = entry["hoursWorked"];
|
|
4838
|
+
return typeof h === "number" && Number.isFinite(h) && h > 0 ? h : 0;
|
|
4839
|
+
}
|
|
4840
|
+
function resolveLabour(entry, ctx) {
|
|
4841
|
+
const id = num(entry["id"]);
|
|
4842
|
+
const hours = hoursOf(entry);
|
|
4843
|
+
for (const rung of ladderFrom(ctx)) {
|
|
4844
|
+
if (rung === "billing_item.ourCost") {
|
|
4845
|
+
const items = ctx.billedByEntryId.get(id) ?? [];
|
|
4846
|
+
const costs = items.map((i) => i["ourCost"]).filter((v) => typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
4847
|
+
if (costs.length > 0) {
|
|
4848
|
+
return {
|
|
4849
|
+
source: "billing_item.ourCost",
|
|
4850
|
+
cost: costs.reduce((a, b) => a + b, 0),
|
|
4851
|
+
rate: null,
|
|
4852
|
+
evidence: { billingItemIds: items.map((i) => num(i["id"])) }
|
|
4853
|
+
};
|
|
4854
|
+
}
|
|
4855
|
+
continue;
|
|
4856
|
+
}
|
|
4857
|
+
if (rung === "contract_role_costs.rate") {
|
|
4858
|
+
const resId = num(entry["resourceID"]);
|
|
4859
|
+
const roleId = num(entry["roleID"]);
|
|
4860
|
+
const row = resId > 0 && roleId > 0 ? ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) ?? ctx.roleCostByRole.get(roleId) : roleId > 0 ? ctx.roleCostByRole.get(roleId) : void 0;
|
|
4861
|
+
const rate = row ? row["rate"] : void 0;
|
|
4862
|
+
if (row !== void 0 && typeof rate === "number" && Number.isFinite(rate) && rate >= 0) {
|
|
4863
|
+
const matched = row !== void 0 && resId > 0 && roleId > 0 && ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) !== void 0 ? "resource+role" : "role";
|
|
4864
|
+
return { source: "contract_role_costs.rate", cost: rate * hours, rate, evidence: { contractRoleCostId: num(row["id"]), matched } };
|
|
4865
|
+
}
|
|
4866
|
+
continue;
|
|
4867
|
+
}
|
|
4868
|
+
if (rung === "resource.internalCost") {
|
|
4869
|
+
const resId = num(entry["resourceID"]);
|
|
4870
|
+
const r = resId > 0 ? ctx.resourcesById.get(resId) : void 0;
|
|
4871
|
+
const ic = r ? r["internalCost"] : void 0;
|
|
4872
|
+
if (r !== void 0 && typeof ic === "number" && Number.isFinite(ic) && ic >= 0) {
|
|
4873
|
+
return { source: "resource.internalCost", cost: ic * hours, rate: ic, evidence: { resourceId: resId } };
|
|
4874
|
+
}
|
|
4875
|
+
continue;
|
|
4876
|
+
}
|
|
4877
|
+
if (rung === "provided" && ctx.rate !== null) {
|
|
4878
|
+
return { source: "provided", cost: ctx.rate * hours, rate: ctx.rate, evidence: {} };
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
4881
|
+
return { source: "missing", cost: null, rate: null, evidence: {} };
|
|
4882
|
+
}
|
|
4883
|
+
function buildLadderCtx(method, rate, billingRows, roleCostRows, resources, resourceMissIds) {
|
|
4884
|
+
const billedByEntryId = /* @__PURE__ */ new Map();
|
|
4885
|
+
for (const b of billingRows) {
|
|
4886
|
+
const te = num(b["timeEntryID"]);
|
|
4887
|
+
if (te <= 0) continue;
|
|
4888
|
+
const arr = billedByEntryId.get(te) ?? [];
|
|
4889
|
+
arr.push(b);
|
|
4890
|
+
billedByEntryId.set(te, arr);
|
|
4891
|
+
}
|
|
4892
|
+
const roleCostByResourceRole = /* @__PURE__ */ new Map();
|
|
4893
|
+
const roleCostByRole = /* @__PURE__ */ new Map();
|
|
4894
|
+
for (const rc of roleCostRows) {
|
|
4895
|
+
const resId = num(rc["resourceID"]);
|
|
4896
|
+
const roleId = num(rc["roleID"]);
|
|
4897
|
+
if (resId > 0 && roleId > 0) roleCostByResourceRole.set(`${resId}:${roleId}`, rc);
|
|
4898
|
+
else if (roleId > 0) roleCostByRole.set(roleId, rc);
|
|
4899
|
+
}
|
|
4900
|
+
const resourcesById = new Map(resources.map((r) => [num(r["id"]), r]));
|
|
4901
|
+
return { method, rate, billedByEntryId, roleCostByResourceRole, roleCostByRole, resourcesById, resourceMissIds };
|
|
4902
|
+
}
|
|
4903
|
+
function resolveEntries(entries, ctx) {
|
|
4904
|
+
const entryResolution = /* @__PURE__ */ new Map();
|
|
4905
|
+
for (const e of entries) entryResolution.set(num(e["id"]), resolveLabour(e, ctx));
|
|
4906
|
+
return entryResolution;
|
|
4907
|
+
}
|
|
4908
|
+
function buildCostRateResolution(ctx, entries, entryResolution) {
|
|
4909
|
+
const sources = {};
|
|
4910
|
+
for (const rung of ladderFrom(ctx)) sources[rung] = { entries: 0, hours: 0, cost: 0 };
|
|
4911
|
+
sources.missing = { entries: 0, hours: 0, cost: 0 };
|
|
4912
|
+
const resolutionNotes = [];
|
|
4913
|
+
if (ctx.method === "billing_item_our_cost") {
|
|
4914
|
+
const noItems = entries.filter((e) => !ctx.billedByEntryId.has(num(e["id"]))).length;
|
|
4915
|
+
if (noItems > 0) resolutionNotes.push(`${noItems} ${noItems === 1 ? "entry" : "entries"} had no billing item (non-billable or unposted) \u2014 rung 1 not usable`);
|
|
4916
|
+
}
|
|
4917
|
+
for (const resId of [...ctx.resourceMissIds].sort((a, b) => a - b)) {
|
|
4918
|
+
const n = entries.filter((e) => num(e["resourceID"]) === resId).length;
|
|
4919
|
+
resolutionNotes.push(`resource ${resId} unreadable (fetchByIds NOT_FOUND) \u2014 rung 3 unavailable for ${n} ${n === 1 ? "entry" : "entries"}`);
|
|
4920
|
+
}
|
|
4921
|
+
for (const e of entries) {
|
|
4922
|
+
const r = entryResolution.get(num(e["id"]));
|
|
4923
|
+
const bucket = sources[r.source] ?? sources.missing;
|
|
4924
|
+
bucket.entries += 1;
|
|
4925
|
+
bucket.hours += hoursOf(e);
|
|
4926
|
+
bucket.cost += r.cost ?? 0;
|
|
4927
|
+
}
|
|
4928
|
+
for (const b of Object.values(sources)) b.cost = cents(b.cost);
|
|
4929
|
+
const unresolvedHours = sources.missing.hours;
|
|
4930
|
+
if (unresolvedHours > 0) resolutionNotes.push(`${fmtHours(unresolvedHours)} hours unresolved (missing rate) \u2014 total_cost is a lower bound`);
|
|
4931
|
+
return { method: ctx.method, sources, unresolvedHours, notes: resolutionNotes };
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4934
|
+
// src/resources/special/contracts/profitability.validate.ts
|
|
4696
4935
|
function validateProfitabilityOptions(contractId, opts) {
|
|
4697
4936
|
if (opts === void 0 || (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
|
|
4698
4937
|
throw new AutotaskConfigError(
|
|
@@ -4708,12 +4947,12 @@ function validateProfitabilityOptions(contractId, opts) {
|
|
|
4708
4947
|
);
|
|
4709
4948
|
}
|
|
4710
4949
|
}
|
|
4711
|
-
if (!
|
|
4712
|
-
if (!
|
|
4713
|
-
if (
|
|
4950
|
+
if (!isDateYMD(opts.dateFrom)) problems.push(`dateFrom is required (YYYY-MM-DD) (got ${String(opts.dateFrom)})`);
|
|
4951
|
+
if (!isDateYMD(opts.dateTo)) problems.push(`dateTo is required (YYYY-MM-DD) (got ${String(opts.dateTo)})`);
|
|
4952
|
+
if (isDateYMD(opts.dateFrom) && isDateYMD(opts.dateTo) && opts.dateTo < opts.dateFrom) {
|
|
4714
4953
|
problems.push(`dateTo must be >= dateFrom (got ${opts.dateTo})`);
|
|
4715
4954
|
}
|
|
4716
|
-
if (
|
|
4955
|
+
if (isDateYMD(opts.dateFrom) && isDateYMD(opts.dateTo) && opts.dateTo >= opts.dateFrom) {
|
|
4717
4956
|
const months = monthsInWindow(opts.dateFrom, opts.dateTo);
|
|
4718
4957
|
if (months > PROFITABILITY_MAX_WINDOW_MONTHS) {
|
|
4719
4958
|
problems.push(
|
|
@@ -4745,7 +4984,7 @@ function validateProfitabilityOptions(contractId, opts) {
|
|
|
4745
4984
|
if (!Array.isArray(emc)) {
|
|
4746
4985
|
problems.push("externalMonthlyCosts must be an array of finite numbers >= 0, indexed by month from dateFrom (got a non-array)");
|
|
4747
4986
|
} else {
|
|
4748
|
-
const max =
|
|
4987
|
+
const max = isDateYMD(opts.dateFrom) && isDateYMD(opts.dateTo) && opts.dateTo >= opts.dateFrom ? monthsInWindow(opts.dateFrom, opts.dateTo) : Infinity;
|
|
4749
4988
|
for (let i = 0; i < emc.length; i++) {
|
|
4750
4989
|
const v = emc[i];
|
|
4751
4990
|
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
@@ -4798,27 +5037,31 @@ function validateProfitabilityOptions(contractId, opts) {
|
|
|
4798
5037
|
includeRows: opts.includeRows ?? false
|
|
4799
5038
|
};
|
|
4800
5039
|
}
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
const res = await http.request({
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
5040
|
+
|
|
5041
|
+
// src/resources/special/contracts/profitability.fetch.ts
|
|
5042
|
+
async function readPicklists(http, entityPath, operation) {
|
|
5043
|
+
const res = await http.request({ method: "GET", path: `${entityPath}/entityInformation/fields`, operation });
|
|
5044
|
+
const body = typeof res === "object" && res !== null ? res : {};
|
|
5045
|
+
const fields = Array.isArray(body["fields"]) ? body["fields"] : [];
|
|
5046
|
+
const out = /* @__PURE__ */ new Map();
|
|
5047
|
+
for (const f of fields) {
|
|
5048
|
+
if (f["isPickList"] !== true || typeof f["name"] !== "string" || !Array.isArray(f["picklistValues"])) continue;
|
|
5049
|
+
const values = /* @__PURE__ */ new Map();
|
|
5050
|
+
for (const p of f["picklistValues"]) {
|
|
5051
|
+
const id = typeof p["value"] === "string" ? Number.parseInt(p["value"], 10) : Number.NaN;
|
|
5052
|
+
if (Number.isFinite(id)) values.set(id, String(p["label"] ?? ""));
|
|
5053
|
+
}
|
|
5054
|
+
out.set(f["name"], values);
|
|
5055
|
+
}
|
|
5056
|
+
return out;
|
|
4814
5057
|
}
|
|
4815
|
-
function capJoinSet2(
|
|
4816
|
-
const sorted = [...new Set(
|
|
5058
|
+
function capJoinSet2(ids2, cap) {
|
|
5059
|
+
const sorted = [...new Set(ids2)].sort((a, b) => a - b);
|
|
4817
5060
|
if (sorted.length <= cap) return { ids: sorted, truncated: false, total: sorted.length };
|
|
4818
5061
|
return { ids: sorted.slice(0, cap), truncated: true, total: sorted.length };
|
|
4819
5062
|
}
|
|
4820
|
-
async function fetchByIdsPart(http, entityPath,
|
|
4821
|
-
const unique = [...new Set(
|
|
5063
|
+
async function fetchByIdsPart(http, entityPath, ids2, fields, operation, entity) {
|
|
5064
|
+
const unique = [...new Set(ids2)].sort((a, b) => a - b);
|
|
4822
5065
|
const byKey = /* @__PURE__ */ new Map();
|
|
4823
5066
|
const misses = [];
|
|
4824
5067
|
const notes = [];
|
|
@@ -4834,7 +5077,7 @@ async function fetchByIdsPart(http, entityPath, ids, fields, operation, entity)
|
|
|
4834
5077
|
const res = await http.request({ method: "POST", idempotent: true, path: `${entityPath}/query`, body, operation });
|
|
4835
5078
|
const { items: rows } = unwrapItems(res, operation);
|
|
4836
5079
|
for (const row of rows) {
|
|
4837
|
-
const k =
|
|
5080
|
+
const k = num(row.id);
|
|
4838
5081
|
if (k > 0 && chunkKeys.has(k)) byKey.set(k, row);
|
|
4839
5082
|
}
|
|
4840
5083
|
} catch (err) {
|
|
@@ -4874,75 +5117,6 @@ async function fetchByIdsPart(http, entityPath, ids, fields, operation, entity)
|
|
|
4874
5117
|
}
|
|
4875
5118
|
return { items, foundIds, misses, notes };
|
|
4876
5119
|
}
|
|
4877
|
-
function ladderFrom(ctx) {
|
|
4878
|
-
const rungs = [];
|
|
4879
|
-
const push = (s) => rungs.push(s);
|
|
4880
|
-
switch (ctx.method) {
|
|
4881
|
-
case "billing_item_our_cost":
|
|
4882
|
-
push("billing_item.ourCost");
|
|
4883
|
-
push("contract_role_costs.rate");
|
|
4884
|
-
push("resource.internalCost");
|
|
4885
|
-
break;
|
|
4886
|
-
case "contract_role_cost":
|
|
4887
|
-
push("contract_role_costs.rate");
|
|
4888
|
-
push("resource.internalCost");
|
|
4889
|
-
break;
|
|
4890
|
-
case "resource_internal_cost":
|
|
4891
|
-
push("resource.internalCost");
|
|
4892
|
-
break;
|
|
4893
|
-
case "provided":
|
|
4894
|
-
break;
|
|
4895
|
-
}
|
|
4896
|
-
if (ctx.rate !== null) push("provided");
|
|
4897
|
-
return rungs;
|
|
4898
|
-
}
|
|
4899
|
-
function hoursOf(entry) {
|
|
4900
|
-
const h = entry["hoursWorked"];
|
|
4901
|
-
return typeof h === "number" && Number.isFinite(h) && h > 0 ? h : 0;
|
|
4902
|
-
}
|
|
4903
|
-
function resolveLabour(entry, ctx) {
|
|
4904
|
-
const id = num2(entry["id"]);
|
|
4905
|
-
const hours = hoursOf(entry);
|
|
4906
|
-
for (const rung of ladderFrom(ctx)) {
|
|
4907
|
-
if (rung === "billing_item.ourCost") {
|
|
4908
|
-
const items = ctx.billedByEntryId.get(id) ?? [];
|
|
4909
|
-
const costs = items.map((i) => i["ourCost"]).filter((v) => typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
4910
|
-
if (costs.length > 0) {
|
|
4911
|
-
return {
|
|
4912
|
-
source: "billing_item.ourCost",
|
|
4913
|
-
cost: costs.reduce((a, b) => a + b, 0),
|
|
4914
|
-
rate: null,
|
|
4915
|
-
evidence: { billingItemIds: items.map((i) => num2(i["id"])) }
|
|
4916
|
-
};
|
|
4917
|
-
}
|
|
4918
|
-
continue;
|
|
4919
|
-
}
|
|
4920
|
-
if (rung === "contract_role_costs.rate") {
|
|
4921
|
-
const resId = num2(entry["resourceID"]);
|
|
4922
|
-
const roleId = num2(entry["roleID"]);
|
|
4923
|
-
const row = resId > 0 && roleId > 0 ? ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) ?? ctx.roleCostByRole.get(roleId) : roleId > 0 ? ctx.roleCostByRole.get(roleId) : void 0;
|
|
4924
|
-
const rate = row ? row["rate"] : void 0;
|
|
4925
|
-
if (row !== void 0 && typeof rate === "number" && Number.isFinite(rate) && rate >= 0) {
|
|
4926
|
-
const matched = row !== void 0 && resId > 0 && roleId > 0 && ctx.roleCostByResourceRole.get(`${resId}:${roleId}`) !== void 0 ? "resource+role" : "role";
|
|
4927
|
-
return { source: "contract_role_costs.rate", cost: rate * hours, rate, evidence: { contractRoleCostId: num2(row["id"]), matched } };
|
|
4928
|
-
}
|
|
4929
|
-
continue;
|
|
4930
|
-
}
|
|
4931
|
-
if (rung === "resource.internalCost") {
|
|
4932
|
-
const resId = num2(entry["resourceID"]);
|
|
4933
|
-
const r = resId > 0 ? ctx.resourcesById.get(resId) : void 0;
|
|
4934
|
-
const ic = r ? r["internalCost"] : void 0;
|
|
4935
|
-
if (r !== void 0 && typeof ic === "number" && Number.isFinite(ic) && ic >= 0) {
|
|
4936
|
-
return { source: "resource.internalCost", cost: ic * hours, rate: ic, evidence: { resourceId: resId } };
|
|
4937
|
-
}
|
|
4938
|
-
continue;
|
|
4939
|
-
}
|
|
4940
|
-
if (rung === "provided" && ctx.rate !== null) {
|
|
4941
|
-
return { source: "provided", cost: ctx.rate * hours, rate: ctx.rate, evidence: {} };
|
|
4942
|
-
}
|
|
4943
|
-
}
|
|
4944
|
-
return { source: "missing", cost: null, rate: null, evidence: {} };
|
|
4945
|
-
}
|
|
4946
5120
|
async function expandByMonth(http, path, fullWindowCond, limit, operation, fields, dateField, dateFrom, dateTo, wires) {
|
|
4947
5121
|
const months = monthsBetween(dateFrom, dateTo);
|
|
4948
5122
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -4956,10 +5130,10 @@ async function expandByMonth(http, path, fullWindowCond, limit, operation, field
|
|
|
4956
5130
|
{ field: dateField, op: "gte", value: b.start },
|
|
4957
5131
|
{ field: dateField, op: "lt", value: b.endExclusive }
|
|
4958
5132
|
];
|
|
4959
|
-
const page = await
|
|
5133
|
+
const page = await boundedPart(http, path, cond, limit, operation, fields);
|
|
4960
5134
|
wires.issued += 1;
|
|
4961
5135
|
for (const row of page.items) {
|
|
4962
|
-
const k =
|
|
5136
|
+
const k = num(row["id"]);
|
|
4963
5137
|
if (seen.has(k)) continue;
|
|
4964
5138
|
seen.add(k);
|
|
4965
5139
|
all.push(row);
|
|
@@ -4979,28 +5153,33 @@ async function degradable(read) {
|
|
|
4979
5153
|
return { items: [], capped: false, hasMore: false, absent: err.code, absentMessage: err.message };
|
|
4980
5154
|
}
|
|
4981
5155
|
}
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
}
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
}
|
|
4990
|
-
|
|
4991
|
-
return
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
5156
|
+
|
|
5157
|
+
// src/resources/special/contracts/profitability.context.ts
|
|
5158
|
+
var PROFITABILITY_OP = "contracts.profitability";
|
|
5159
|
+
var skipPart = (note) => ({ items: [], capped: false, hasMore: false, status: "skipped", note });
|
|
5160
|
+
var stampPart = (key, raw, conditions, extraNotes) => {
|
|
5161
|
+
if (raw.absent !== void 0) {
|
|
5162
|
+
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` };
|
|
5163
|
+
}
|
|
5164
|
+
const st = unbilledWorkPartStatus(key, conditions, raw.items);
|
|
5165
|
+
return {
|
|
5166
|
+
items: raw.items,
|
|
5167
|
+
capped: raw.capped,
|
|
5168
|
+
hasMore: raw.hasMore,
|
|
5169
|
+
status: st.status,
|
|
5170
|
+
blindSurface: st.blindSurface,
|
|
5171
|
+
note: [st.note, ...extraNotes ?? []].filter(Boolean).join(" | ") || void 0,
|
|
5172
|
+
emptyReason: raw.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS[key]?.query : void 0
|
|
5173
|
+
};
|
|
5174
|
+
};
|
|
5175
|
+
function appendWiresNote(wires, note) {
|
|
5176
|
+
wires.note = (wires.note ? wires.note + " | " : "") + note;
|
|
4995
5177
|
}
|
|
4996
|
-
|
|
4997
|
-
const
|
|
4998
|
-
const OP2 = "contracts.profitability";
|
|
5178
|
+
function makeFetchCtx(rawHttp, v, contractId, budget) {
|
|
5179
|
+
const OP5 = PROFITABILITY_OP;
|
|
4999
5180
|
const id = parseIdentifier(contractId, "contractId");
|
|
5000
5181
|
const idNum = typeof id === "number" ? id : Number(id);
|
|
5001
|
-
const wires = { issued: 0, requests: 0, budget
|
|
5002
|
-
const asOf = (/* @__PURE__ */ new Date()).toISOString();
|
|
5003
|
-
const asOfMs = Date.parse(asOf);
|
|
5182
|
+
const wires = { issued: 0, requests: 0, budget };
|
|
5004
5183
|
const toExcl = dayAfter(v.dateTo);
|
|
5005
5184
|
const windowOn = (field) => [{ field, op: "gte", value: v.dateFrom }, { field, op: "lt", value: toExcl }];
|
|
5006
5185
|
const http = { request: (o) => {
|
|
@@ -5009,52 +5188,34 @@ async function profitability(resource, contractId, opts, rawHttp) {
|
|
|
5009
5188
|
} };
|
|
5010
5189
|
const readPart = (path, conditions, fields) => {
|
|
5011
5190
|
wires.issued += 1;
|
|
5012
|
-
return degradable(() =>
|
|
5013
|
-
};
|
|
5014
|
-
const skipPart = (note) => ({ items: [], capped: false, hasMore: false, status: "skipped", note });
|
|
5015
|
-
const stampPart = (key, raw, conditions, extraNotes) => {
|
|
5016
|
-
if (raw.absent !== void 0) {
|
|
5017
|
-
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` };
|
|
5018
|
-
}
|
|
5019
|
-
const st = unbilledWorkPartStatus(key, conditions, raw.items);
|
|
5020
|
-
return {
|
|
5021
|
-
items: raw.items,
|
|
5022
|
-
capped: raw.capped,
|
|
5023
|
-
hasMore: raw.hasMore,
|
|
5024
|
-
status: st.status,
|
|
5025
|
-
blindSurface: st.blindSurface,
|
|
5026
|
-
note: [st.note, ...extraNotes ?? []].filter(Boolean).join(" | ") || void 0,
|
|
5027
|
-
emptyReason: raw.items.length === 0 && st.status === "ok" ? EMPTY_REASON_STAMPS[key]?.query : void 0
|
|
5028
|
-
};
|
|
5191
|
+
return degradable(() => boundedPart(http, path, conditions, v.limit, OP5, fields));
|
|
5029
5192
|
};
|
|
5030
5193
|
const truncatedMonths = {};
|
|
5031
5194
|
const windowedPart = async (name, key, path, cond, fields, dateField) => {
|
|
5032
5195
|
const first = await readPart(path, cond, fields);
|
|
5033
5196
|
if (first.absent !== void 0 || !first.hasMore) return { part: stampPart(key, first, cond), rows: first.items };
|
|
5034
|
-
const wp = await degradable(() => expandByMonth(http, path, cond, v.limit,
|
|
5197
|
+
const wp = await degradable(() => expandByMonth(http, path, cond, v.limit, OP5, fields, dateField, v.dateFrom, v.dateTo, wires));
|
|
5035
5198
|
if (!("truncatedMonths" in wp)) return { part: stampPart(key, wp, cond), rows: [] };
|
|
5036
5199
|
const { truncatedMonths: tm, ...part } = wp;
|
|
5037
5200
|
truncatedMonths[name] = tm;
|
|
5038
5201
|
return { part, rows: wp.items };
|
|
5039
5202
|
};
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
const companyID = num2(contractRow["companyID"]);
|
|
5046
|
-
const contractType = num2(contractRow["contractType"]);
|
|
5203
|
+
return { v, OP: OP5, id, idNum, rawHttp, http, wires, windowOn, readPart, windowedPart, truncatedMonths };
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
// src/resources/special/contracts/profitability.stages.ts
|
|
5207
|
+
async function fetchCustomer(ctx, companyID) {
|
|
5047
5208
|
let customer = null;
|
|
5048
5209
|
let customerStatus = "skipped";
|
|
5049
5210
|
let customerAbsent;
|
|
5050
5211
|
if (companyID > 0) {
|
|
5051
5212
|
try {
|
|
5052
|
-
const res = await http.request({
|
|
5213
|
+
const res = await ctx.http.request({
|
|
5053
5214
|
method: "GET",
|
|
5054
5215
|
path: `/V1.0/Companies/${companyID}`,
|
|
5055
|
-
operation:
|
|
5216
|
+
operation: ctx.OP
|
|
5056
5217
|
});
|
|
5057
|
-
const item = unwrapItem(res, companyID,
|
|
5218
|
+
const item = unwrapItem(res, companyID, ctx.OP, "company");
|
|
5058
5219
|
customer = item ?? null;
|
|
5059
5220
|
customerStatus = "ok";
|
|
5060
5221
|
} catch (err) {
|
|
@@ -5064,13 +5225,16 @@ async function profitability(resource, contractId, opts, rawHttp) {
|
|
|
5064
5225
|
customerAbsent = err.code;
|
|
5065
5226
|
}
|
|
5066
5227
|
}
|
|
5067
|
-
wires.issued += 1;
|
|
5228
|
+
ctx.wires.issued += 1;
|
|
5068
5229
|
}
|
|
5230
|
+
return { customer, customerStatus, customerAbsent };
|
|
5231
|
+
}
|
|
5232
|
+
async function resolveContractType(ctx, contractType) {
|
|
5069
5233
|
let contractTypeLabel = null;
|
|
5070
5234
|
if (contractType > 0) {
|
|
5071
|
-
wires.issued += 1;
|
|
5235
|
+
ctx.wires.issued += 1;
|
|
5072
5236
|
try {
|
|
5073
|
-
contractTypeLabel = (await readPicklists(http, "/V1.0/Contracts",
|
|
5237
|
+
contractTypeLabel = (await readPicklists(ctx.http, "/V1.0/Contracts", ctx.OP)).get("contractType")?.get(contractType) ?? null;
|
|
5074
5238
|
} catch (err) {
|
|
5075
5239
|
if (!(err instanceof AutotaskError)) throw err;
|
|
5076
5240
|
}
|
|
@@ -5078,73 +5242,102 @@ async function profitability(resource, contractId, opts, rawHttp) {
|
|
|
5078
5242
|
const contractTypeKnown = contractTypeLabel !== null;
|
|
5079
5243
|
const isBlockHours = contractTypeKnown && BLOCK_HOURS_LABEL_RE.test(contractTypeLabel);
|
|
5080
5244
|
const isTimeAndMaterials = contractTypeKnown && TIME_AND_MATERIALS_LABEL_RE.test(contractTypeLabel);
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
const
|
|
5085
|
-
const
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
const
|
|
5100
|
-
const
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5245
|
+
return { contractTypeLabel, contractTypeKnown, isBlockHours, isTimeAndMaterials };
|
|
5246
|
+
}
|
|
5247
|
+
async function fetchRoot(ctx, resource) {
|
|
5248
|
+
const contractCached = ctx.rawHttp.readCache?.get(`/V1.0/Contracts/${ctx.id}`) !== void 0;
|
|
5249
|
+
const contract = await resource.get(ctx.id);
|
|
5250
|
+
ctx.wires.issued += 1;
|
|
5251
|
+
if (!contractCached) ctx.wires.requests += 1;
|
|
5252
|
+
const contractRow = contract;
|
|
5253
|
+
const companyID = num(contractRow["companyID"]);
|
|
5254
|
+
const contractType = num(contractRow["contractType"]);
|
|
5255
|
+
const customerStage = await fetchCustomer(ctx, companyID);
|
|
5256
|
+
const typeStage = await resolveContractType(ctx, contractType);
|
|
5257
|
+
return { contract, contractRow, contractType, ...customerStage, ...typeStage };
|
|
5258
|
+
}
|
|
5259
|
+
async function fetchCharges(ctx) {
|
|
5260
|
+
if (!ctx.v.includeRecurringCosts) {
|
|
5261
|
+
return { chargesPart: skipPart("skipped: includeRecurringCosts is false (zero wire)"), charges: [] };
|
|
5262
|
+
}
|
|
5263
|
+
const chargesCond = [{ field: "contractID", op: "eq", value: ctx.id }, ...ctx.windowOn("datePurchased")];
|
|
5264
|
+
const raw = await ctx.readPart("/V1.0/ContractCharges/query", chargesCond, ["id", "chargeType", "billableAmount", "extendedCost", "unitCost", "unitPrice", "unitQuantity", "datePurchased", "isBilled", "status", "contractServiceID"]);
|
|
5265
|
+
return { charges: raw.items, chargesPart: stampPart("contract_charges", raw, chargesCond) };
|
|
5266
|
+
}
|
|
5267
|
+
async function fetchBlocks(ctx, root) {
|
|
5268
|
+
if (!root.isBlockHours) {
|
|
5269
|
+
return skipPart(
|
|
5270
|
+
root.contractTypeKnown ? `skipped: not a Block Hours contract (contractType ${root.contractType} "${root.contractTypeLabel}") \u2014 included hours apply to hour blocks only (zero wire)` : `skipped: the contract type is unknown (contractType ${root.contractType || "absent"}; label unresolved) \u2014 included hours are not evaluable (zero wire)`
|
|
5105
5271
|
);
|
|
5106
|
-
} else {
|
|
5107
|
-
const blocksCond = [{ field: "contractID", op: "eq", value: id }];
|
|
5108
|
-
const raw = await readPart("/V1.0/ContractBlocks/query", blocksCond, ["id", "datePurchased", "startDate", "endDate", "hours", "hoursApproved", "status", "isPaid"]);
|
|
5109
|
-
blocksPart = stampPart("contract_blocks", raw, blocksCond);
|
|
5110
5272
|
}
|
|
5111
|
-
const
|
|
5273
|
+
const blocksCond = [{ field: "contractID", op: "eq", value: ctx.id }];
|
|
5274
|
+
const raw = await ctx.readPart("/V1.0/ContractBlocks/query", blocksCond, ["id", "datePurchased", "startDate", "endDate", "hours", "hoursApproved", "status", "isPaid"]);
|
|
5275
|
+
return stampPart("contract_blocks", raw, blocksCond);
|
|
5276
|
+
}
|
|
5277
|
+
async function fetchContractState(ctx, root) {
|
|
5278
|
+
const servicesCond = [{ field: "contractID", op: "eq", value: ctx.id }];
|
|
5279
|
+
const servicesRaw = await ctx.readPart("/V1.0/ContractServices/query", servicesCond, ["id", "serviceID", "unitPrice", "unitCost", "internalCurrencyUnitPrice", "internalDescription"]);
|
|
5280
|
+
const unitsCond = [{ field: "contractID", op: "eq", value: ctx.id }, ...ctx.windowOn("startDate")];
|
|
5281
|
+
const units = await ctx.windowedPart("contractServiceUnits", "contract_service_units", "/V1.0/ContractServiceUnits/query", unitsCond, ["id", "contractServiceID", "serviceID", "units", "price", "cost", "internalCurrencyPrice", "startDate", "endDate", "approveAndPostDate"], "startDate");
|
|
5282
|
+
const { chargesPart, charges } = await fetchCharges(ctx);
|
|
5283
|
+
const roleCostsCond = [{ field: "contractID", op: "eq", value: ctx.id }];
|
|
5284
|
+
const roleCostsRaw = await ctx.readPart("/V1.0/ContractRoleCosts/query", roleCostsCond, ["id", "rate", "resourceID", "roleID"]);
|
|
5285
|
+
const ratesCond = [{ field: "contractID", op: "eq", value: ctx.id }];
|
|
5286
|
+
const ratesRaw = await ctx.readPart("/V1.0/ContractRates/query", ratesCond, ["id", "roleID", "contractHourlyRate", "internalCurrencyContractHourlyRate"]);
|
|
5287
|
+
const blocksPart = await fetchBlocks(ctx, root);
|
|
5288
|
+
return { servicesRaw, servicesCond, unitRows: units.rows, unitsPartRaw: units.part, chargesPart, charges, roleCostsRaw, roleCostsCond, ratesRaw, ratesCond, blocksPart };
|
|
5289
|
+
}
|
|
5290
|
+
async function fetchBillingItems(ctx) {
|
|
5291
|
+
const billingCond = [{ field: "contractID", op: "eq", value: ctx.id }, ...ctx.windowOn("itemDate")];
|
|
5112
5292
|
const billingFields = ["id", "invoiceID", "timeEntryID", "ticketID", "ourCost", "quantity", "rate", "extendedPrice", "internalCurrencyExtendedPrice", "itemDate", "postedDate", "nonBillable", "billingCodeID", "billingItemType", "expenseItemID", ...SERVICE_LINE_FKS, ...CHARGE_LINE_FKS];
|
|
5113
|
-
const billing = await windowedPart("billingItems", "billing_items", "/V1.0/BillingItems/query", billingCond, billingFields, "itemDate");
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
let invoices = [];
|
|
5293
|
+
const billing = await ctx.windowedPart("billingItems", "billing_items", "/V1.0/BillingItems/query", billingCond, billingFields, "itemDate");
|
|
5294
|
+
return { billingItems: billing.part, billingRows: billing.rows };
|
|
5295
|
+
}
|
|
5296
|
+
async function fetchInvoices(ctx, billingRows) {
|
|
5297
|
+
const invoiceIds = [...new Set(billingRows.map((b) => num(b["invoiceID"])).filter((n) => n > 0))];
|
|
5119
5298
|
if (invoiceIds.length === 0) {
|
|
5120
|
-
invoicesPart
|
|
5121
|
-
} else {
|
|
5122
|
-
const capped = capJoinSet2(invoiceIds, FETCH_BY_IDS_MAX_ITEMS);
|
|
5123
|
-
if (capped.truncated) wires.note = (wires.note ? wires.note + " | " : "") + `invoiceJoinTruncated: the join set is capped at ${FETCH_BY_IDS_MAX_ITEMS} of ${capped.total} invoice ids (the fetch-by-ids cap) \u2014 the figures cover the returned set only`;
|
|
5124
|
-
const fb = await fetchByIdsPart(http, "/V1.0/Invoices", capped.ids, ["id", "invoiceNumber", "invoiceTotal", "paidDate", "isVoided", "dueDate"], OP2, "invoice");
|
|
5125
|
-
wires.issued += 1;
|
|
5126
|
-
invoices = fb.items;
|
|
5127
|
-
invoicesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
|
|
5299
|
+
return { invoicesPart: skipPart("skipped: the billing part has no invoiceIDs (zero wire)"), invoices: [] };
|
|
5128
5300
|
}
|
|
5129
|
-
const
|
|
5301
|
+
const capped = capJoinSet2(invoiceIds, FETCH_BY_IDS_MAX_ITEMS);
|
|
5302
|
+
if (capped.truncated) appendWiresNote(ctx.wires, `invoiceJoinTruncated: the join set is capped at ${FETCH_BY_IDS_MAX_ITEMS} of ${capped.total} invoice ids (the fetch-by-ids cap) \u2014 the figures cover the returned set only`);
|
|
5303
|
+
const fb = await fetchByIdsPart(ctx.http, "/V1.0/Invoices", capped.ids, ["id", "invoiceNumber", "invoiceTotal", "paidDate", "isVoided", "dueDate"], ctx.OP, "invoice");
|
|
5304
|
+
ctx.wires.issued += 1;
|
|
5305
|
+
return {
|
|
5306
|
+
invoices: fb.items,
|
|
5307
|
+
invoicesPart: { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 }
|
|
5308
|
+
};
|
|
5309
|
+
}
|
|
5310
|
+
async function fetchBilling(ctx) {
|
|
5311
|
+
const items = await fetchBillingItems(ctx);
|
|
5312
|
+
const inv = await fetchInvoices(ctx, items.billingRows);
|
|
5313
|
+
return { ...items, ...inv };
|
|
5314
|
+
}
|
|
5315
|
+
async function fetchTime(ctx) {
|
|
5316
|
+
const timeCond = [{ field: "contractID", op: "eq", value: ctx.id }, ...ctx.windowOn("dateWorked")];
|
|
5130
5317
|
const timeFields = ["id", "ticketID", "taskID", "resourceID", "roleID", "hoursWorked", "hoursToBill", "dateWorked", "startDateTime", "endDateTime", "isNonBillable", "timeEntryType", "billingCodeID", "createDateTime"];
|
|
5131
|
-
const time = await windowedPart("timeEntries", "time_entries", "/V1.0/TimeEntries/query", timeCond, timeFields, "dateWorked");
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5318
|
+
const time = await ctx.windowedPart("timeEntries", "time_entries", "/V1.0/TimeEntries/query", timeCond, timeFields, "dateWorked");
|
|
5319
|
+
return { timeEntries: time.part, entries: time.rows };
|
|
5320
|
+
}
|
|
5321
|
+
|
|
5322
|
+
// src/resources/special/contracts/profitability.joins.ts
|
|
5323
|
+
var TICKET_FIELDS = ["id", "ticketNumber", "title", "status", "ticketCategory", "contractID", "createDate", "resolvedDateTime", "completedDate", "lastActivityDate", "dueDateTime"];
|
|
5324
|
+
async function fetchTicketActivity(ctx, entries) {
|
|
5325
|
+
const createdInWindow = [{ field: "contractID", op: "eq", value: ctx.id }, ...ctx.windowOn("createDate")];
|
|
5326
|
+
const refTicketIds = capJoinSet2(entries.map((e) => num(e["ticketID"])).filter((n) => n > 0), ctx.v.maxTicketsForJoin);
|
|
5137
5327
|
const ticketsActCond = refTicketIds.ids.length === 0 ? createdInWindow : [{ op: "or", items: [{ op: "and", items: createdInWindow }, { field: "id", op: "in", value: refTicketIds.ids }] }];
|
|
5138
|
-
const ticketsActRaw = await readPart("/V1.0/Tickets/query", ticketsActCond,
|
|
5328
|
+
const ticketsActRaw = await ctx.readPart("/V1.0/Tickets/query", ticketsActCond, TICKET_FIELDS);
|
|
5329
|
+
return { ticketsActRaw, ticketsActCond, refTicketIds };
|
|
5330
|
+
}
|
|
5331
|
+
async function readOpenStatusSet(ctx) {
|
|
5139
5332
|
let openStatusSet = null;
|
|
5140
5333
|
let statusNote;
|
|
5141
|
-
wires.issued += 1;
|
|
5334
|
+
ctx.wires.issued += 1;
|
|
5142
5335
|
try {
|
|
5143
|
-
const values = (await readPicklists(http, "/V1.0/Tickets",
|
|
5336
|
+
const values = (await readPicklists(ctx.http, "/V1.0/Tickets", ctx.OP)).get("status");
|
|
5144
5337
|
if (values === void 0 || values.size === 0) {
|
|
5145
5338
|
statusNote = "the tickets.status picklist is not exposed to this API user \u2014 the open-status labels are unavailable";
|
|
5146
5339
|
} else {
|
|
5147
|
-
const open = [...values].filter(([, label]) => !CLOSED_STATUS_LABEL_RE.test(label)).map(([
|
|
5340
|
+
const open = [...values].filter(([, label]) => !CLOSED_STATUS_LABEL_RE.test(label)).map(([v]) => v);
|
|
5148
5341
|
if (open.length === 0) {
|
|
5149
5342
|
statusNote = "every tickets.status picklist label is closed-class \u2014 no open statuses exist in this tenant";
|
|
5150
5343
|
} else {
|
|
@@ -5154,346 +5347,396 @@ async function profitability(resource, contractId, opts, rawHttp) {
|
|
|
5154
5347
|
} catch (err) {
|
|
5155
5348
|
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`;
|
|
5156
5349
|
}
|
|
5157
|
-
|
|
5158
|
-
|
|
5350
|
+
return { openStatusSet, statusNote };
|
|
5351
|
+
}
|
|
5352
|
+
async function fetchOpenTickets(ctx) {
|
|
5353
|
+
const { openStatusSet, statusNote } = await readOpenStatusSet(ctx);
|
|
5159
5354
|
if (openStatusSet === null) {
|
|
5160
|
-
ticketsOpenPart
|
|
5161
|
-
}
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5355
|
+
return { openStatusSet, ticketsOpenPart: skipPart(`skipped: ${statusNote} (zero wire)`), openTickets: [] };
|
|
5356
|
+
}
|
|
5357
|
+
const openCond = [{ field: "contractID", op: "eq", value: ctx.id }, { field: "status", op: "in", value: [...openStatusSet] }];
|
|
5358
|
+
const raw = await ctx.readPart("/V1.0/Tickets/query", openCond, TICKET_FIELDS);
|
|
5359
|
+
return {
|
|
5360
|
+
openStatusSet,
|
|
5361
|
+
openTickets: raw.items,
|
|
5362
|
+
ticketsOpenPart: stampPart("tickets", raw, openCond, ["openStatusHeuristic: true (the open set is the tenant picklist minus the closed-class labels)"])
|
|
5363
|
+
};
|
|
5364
|
+
}
|
|
5365
|
+
async function fetchResources(ctx, entries) {
|
|
5366
|
+
const resourceIds = [...new Set(entries.map((t) => num(t["resourceID"])).filter((n) => n > 0))];
|
|
5170
5367
|
const resourceMissIds = /* @__PURE__ */ new Set();
|
|
5171
5368
|
if (resourceIds.length === 0) {
|
|
5172
|
-
resourcesPart
|
|
5173
|
-
} else {
|
|
5174
|
-
const capped = capJoinSet2(resourceIds, FETCH_BY_IDS_MAX_ITEMS);
|
|
5175
|
-
if (capped.truncated) wires.note = (wires.note ? wires.note + " | " : "") + `resourceJoinTruncated: the join set is capped at ${FETCH_BY_IDS_MAX_ITEMS} of ${capped.total} resource ids (the fetch-by-ids cap) \u2014 the figures cover the returned set only`;
|
|
5176
|
-
const fb = await fetchByIdsPart(http, "/V1.0/Resources", capped.ids, ["id", "internalCost", "firstName", "lastName", "isActive", "userType"], OP2, "resource");
|
|
5177
|
-
wires.issued += 1;
|
|
5178
|
-
resources = fb.items;
|
|
5179
|
-
for (const m of fb.misses) if (m.code === "NOT_FOUND") resourceMissIds.add(m.id);
|
|
5180
|
-
resourcesPart = { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 };
|
|
5369
|
+
return { resourcesPart: skipPart("skipped: the time part has no resourceIDs (zero wire)"), resources: [], resourceMissIds };
|
|
5181
5370
|
}
|
|
5371
|
+
const capped = capJoinSet2(resourceIds, FETCH_BY_IDS_MAX_ITEMS);
|
|
5372
|
+
if (capped.truncated) appendWiresNote(ctx.wires, `resourceJoinTruncated: the join set is capped at ${FETCH_BY_IDS_MAX_ITEMS} of ${capped.total} resource ids (the fetch-by-ids cap) \u2014 the figures cover the returned set only`);
|
|
5373
|
+
const fb = await fetchByIdsPart(ctx.http, "/V1.0/Resources", capped.ids, ["id", "internalCost", "firstName", "lastName", "isActive", "userType"], ctx.OP, "resource");
|
|
5374
|
+
ctx.wires.issued += 1;
|
|
5375
|
+
for (const m of fb.misses) if (m.code === "NOT_FOUND") resourceMissIds.add(m.id);
|
|
5376
|
+
return {
|
|
5377
|
+
resources: fb.items,
|
|
5378
|
+
resourceMissIds,
|
|
5379
|
+
resourcesPart: { items: fb.items, capped: capped.truncated, hasMore: capped.truncated, status: "ok", note: fb.notes.join(" | ") || void 0 }
|
|
5380
|
+
};
|
|
5381
|
+
}
|
|
5382
|
+
function buildTicketJoin(ctx, ticketRows) {
|
|
5182
5383
|
const fetchedTickets = /* @__PURE__ */ new Map();
|
|
5183
|
-
for (const t of
|
|
5184
|
-
const k =
|
|
5384
|
+
for (const t of ticketRows) {
|
|
5385
|
+
const k = num(t["id"]);
|
|
5185
5386
|
if (k > 0 && !fetchedTickets.has(k)) fetchedTickets.set(k, t);
|
|
5186
5387
|
}
|
|
5187
|
-
const contractTickets = [...fetchedTickets.values()].filter((t) =>
|
|
5188
|
-
const ticketJoin = capJoinSet2(contractTickets.map((t) =>
|
|
5189
|
-
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;
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
const expCond = [{ field: "ticketID", op: "in", value: ticketJoin.ids }, ...windowOn("expenseDate")];
|
|
5196
|
-
const raw = await readPart("/V1.0/ExpenseItems/query", expCond, ["id", "expenseDate", "expenseCategory", "paymentType", "expenseCurrencyExpenseAmount", "internalCurrencyExpenseAmount", "ticketID"]);
|
|
5197
|
-
expenses = raw.items;
|
|
5198
|
-
expensesPart = stampPart("expense_items", raw, expCond, joinNote === void 0 ? void 0 : [joinNote]);
|
|
5199
|
-
}
|
|
5200
|
-
let probePart;
|
|
5201
|
-
let probeRows = [];
|
|
5202
|
-
if (ticketJoin.ids.length === 0) {
|
|
5203
|
-
probePart = skipPart("skipped: the contract has no fetched tickets to probe against (zero wire) \u2014 check 1 (miscoded_time_out) is not evaluable");
|
|
5204
|
-
} else {
|
|
5205
|
-
const probeCond = [
|
|
5206
|
-
{ field: "ticketID", op: "in", value: ticketJoin.ids },
|
|
5207
|
-
{ op: "or", items: [{ field: "contractID", op: "noteq", value: id }, { field: "contractID", op: "notExist" }] },
|
|
5208
|
-
...windowOn("dateWorked")
|
|
5209
|
-
];
|
|
5210
|
-
const raw = await readPart("/V1.0/TimeEntries/query", probeCond, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
|
|
5211
|
-
probeRows = raw.items;
|
|
5212
|
-
probePart = stampPart("time_entries", raw, probeCond, joinNote === void 0 ? void 0 : [joinNote]);
|
|
5213
|
-
}
|
|
5214
|
-
let configurationItemsPart;
|
|
5215
|
-
let cis = [];
|
|
5216
|
-
if (!v.includeRecurringCosts) {
|
|
5217
|
-
configurationItemsPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
|
|
5218
|
-
} else {
|
|
5219
|
-
const ciCond = [{ field: "contractID", op: "eq", value: id }];
|
|
5220
|
-
const raw = await readPart("/V1.0/ConfigurationItems/query", ciCond, ["id", "configurationItemName", "status", "contractID"]);
|
|
5221
|
-
cis = raw.items;
|
|
5222
|
-
configurationItemsPart = stampPart("configuration_items", raw, ciCond);
|
|
5388
|
+
const contractTickets = [...fetchedTickets.values()].filter((t) => num(t["contractID"]) === ctx.idNum);
|
|
5389
|
+
const ticketJoin = capJoinSet2(contractTickets.map((t) => num(t["id"])), ctx.v.maxTicketsForJoin);
|
|
5390
|
+
const joinNote = ticketJoin.truncated ? `the ticket join set is capped at ${ctx.v.maxTicketsForJoin} of ${ticketJoin.total} ids \u2014 the expenses + the miscoding probe cover the returned join set only` : void 0;
|
|
5391
|
+
return { fetchedTickets, contractTickets, ticketJoin, joinNote };
|
|
5392
|
+
}
|
|
5393
|
+
async function fetchExpenses(ctx, join) {
|
|
5394
|
+
if (join.ticketJoin.ids.length === 0) {
|
|
5395
|
+
return { expenses: [], 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)") };
|
|
5223
5396
|
}
|
|
5224
|
-
|
|
5225
|
-
|
|
5397
|
+
const expCond = [{ field: "ticketID", op: "in", value: join.ticketJoin.ids }, ...ctx.windowOn("expenseDate")];
|
|
5398
|
+
const raw = await ctx.readPart("/V1.0/ExpenseItems/query", expCond, ["id", "expenseDate", "expenseCategory", "paymentType", "expenseCurrencyExpenseAmount", "internalCurrencyExpenseAmount", "ticketID"]);
|
|
5399
|
+
return {
|
|
5400
|
+
expenses: raw.items,
|
|
5401
|
+
expensesPart: stampPart("expense_items", raw, expCond, join.joinNote === void 0 ? void 0 : [join.joinNote])
|
|
5402
|
+
};
|
|
5403
|
+
}
|
|
5404
|
+
async function fetchProbe(ctx, join) {
|
|
5405
|
+
if (join.ticketJoin.ids.length === 0) {
|
|
5406
|
+
return { probeRows: [], probePart: skipPart("skipped: the contract has no fetched tickets to probe against (zero wire) \u2014 check 1 (miscoded_time_out) is not evaluable") };
|
|
5407
|
+
}
|
|
5408
|
+
const probeCond = [
|
|
5409
|
+
{ field: "ticketID", op: "in", value: join.ticketJoin.ids },
|
|
5410
|
+
{ op: "or", items: [{ field: "contractID", op: "noteq", value: ctx.id }, { field: "contractID", op: "notExist" }] },
|
|
5411
|
+
...ctx.windowOn("dateWorked")
|
|
5412
|
+
];
|
|
5413
|
+
const raw = await ctx.readPart("/V1.0/TimeEntries/query", probeCond, ["id", "ticketID", "contractID", "resourceID", "hoursWorked", "dateWorked"]);
|
|
5414
|
+
return {
|
|
5415
|
+
probeRows: raw.items,
|
|
5416
|
+
probePart: stampPart("time_entries", raw, probeCond, join.joinNote === void 0 ? void 0 : [join.joinNote])
|
|
5417
|
+
};
|
|
5418
|
+
}
|
|
5419
|
+
async function fetchTicketJoinParts(ctx, ticketRows) {
|
|
5420
|
+
const join = buildTicketJoin(ctx, ticketRows);
|
|
5421
|
+
const exp = await fetchExpenses(ctx, join);
|
|
5422
|
+
const probe = await fetchProbe(ctx, join);
|
|
5423
|
+
return { ...join, ...exp, ...probe };
|
|
5424
|
+
}
|
|
5425
|
+
async function fetchConfigurationItems(ctx) {
|
|
5426
|
+
if (!ctx.v.includeRecurringCosts) {
|
|
5427
|
+
return { part: skipPart("skipped: includeRecurringCosts is false (zero wire)"), cis: [] };
|
|
5428
|
+
}
|
|
5429
|
+
const ciCond = [{ field: "contractID", op: "eq", value: ctx.id }];
|
|
5430
|
+
const raw = await ctx.readPart("/V1.0/ConfigurationItems/query", ciCond, ["id", "configurationItemName", "status", "contractID"]);
|
|
5431
|
+
return { cis: raw.items, part: stampPart("configuration_items", raw, ciCond) };
|
|
5432
|
+
}
|
|
5433
|
+
async function readSubscriptionLabels(ctx, subscriptions) {
|
|
5434
|
+
if (subscriptions.length === 0) return null;
|
|
5435
|
+
ctx.wires.issued += 1;
|
|
5436
|
+
try {
|
|
5437
|
+
return await readPicklists(ctx.http, "/V1.0/Subscriptions", ctx.OP);
|
|
5438
|
+
} catch (err) {
|
|
5439
|
+
if (!(err instanceof AutotaskError)) throw err;
|
|
5440
|
+
return null;
|
|
5441
|
+
}
|
|
5442
|
+
}
|
|
5443
|
+
async function fetchSubscriptions(ctx, cis) {
|
|
5226
5444
|
const subFactor = /* @__PURE__ */ new Map();
|
|
5227
5445
|
const oneTimeSubs = /* @__PURE__ */ new Set();
|
|
5228
5446
|
const cancelledSubs = /* @__PURE__ */ new Set();
|
|
5229
5447
|
if (cis.length === 0) {
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
if (subscriptions.length > 0) {
|
|
5238
|
-
wires.issued += 1;
|
|
5239
|
-
try {
|
|
5240
|
-
labels = await readPicklists(http, "/V1.0/Subscriptions", OP2);
|
|
5241
|
-
} catch (err) {
|
|
5242
|
-
if (!(err instanceof AutotaskError)) throw err;
|
|
5243
|
-
}
|
|
5244
|
-
}
|
|
5245
|
-
for (const x of subscriptions) {
|
|
5246
|
-
const sid = num2(x["id"]);
|
|
5247
|
-
const periodLabel = labels?.get("periodType")?.get(num2(x["periodType"]));
|
|
5248
|
-
subFactor.set(sid, periodMonthlyFactor(x["periodType"], periodLabel));
|
|
5249
|
-
if (isOneTimePeriod(x["periodType"], periodLabel)) oneTimeSubs.add(sid);
|
|
5250
|
-
const statusLabel = labels?.get("status")?.get(num2(x["status"]));
|
|
5251
|
-
const notActive = statusLabel !== void 0 ? NOT_ACTIVE_LABEL_RE.test(statusLabel) : labels === null && SUBSCRIPTION_NOT_ACTIVE_STATUS_IDS.has(num2(x["status"]));
|
|
5252
|
-
if (notActive) cancelledSubs.add(sid);
|
|
5253
|
-
}
|
|
5254
|
-
const unknownPeriods = [...new Set(subscriptions.filter((x) => subFactor.get(num2(x["id"])) === null && !oneTimeSubs.has(num2(x["id"]))).map((x) => String(x["periodType"] ?? "absent")))];
|
|
5255
|
-
subscriptionsPart = stampPart("subscriptions", raw, subCond, [
|
|
5256
|
-
"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)",
|
|
5257
|
-
...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)"] : [],
|
|
5258
|
-
...cancelledSubs.size > 0 ? [`${cancelledSubs.size} not-active (cancelled/inactive) subscription(s) excluded from tooling_cost.vendor`] : [],
|
|
5259
|
-
...oneTimeSubs.size > 0 ? [`${oneTimeSubs.size} one-time subscription(s): periodCost booked whole in the effectiveDate month, never prorated`] : [],
|
|
5260
|
-
...unknownPeriods.length > 0 ? [`unrecognised periodType ${unknownPeriods.join(", ")} \u2014 those subscriptions are excluded from tooling_cost.vendor (not prorated)`] : []
|
|
5261
|
-
]);
|
|
5448
|
+
return {
|
|
5449
|
+
subscriptionsPart: skipPart("skipped: the CI join found no configuration items \u2014 the subscriptions part is not evaluable (zero wire)"),
|
|
5450
|
+
subscriptions: [],
|
|
5451
|
+
subFactor,
|
|
5452
|
+
oneTimeSubs,
|
|
5453
|
+
cancelledSubs
|
|
5454
|
+
};
|
|
5262
5455
|
}
|
|
5263
|
-
const
|
|
5264
|
-
const
|
|
5265
|
-
const
|
|
5266
|
-
const
|
|
5267
|
-
const
|
|
5268
|
-
|
|
5269
|
-
|
|
5456
|
+
const ciIds = capJoinSet2(cis.map((c) => num(c["id"])).filter((n) => n > 0), FETCH_BY_IDS_MAX_ITEMS);
|
|
5457
|
+
const subCond = [{ field: "configurationItemID", op: "in", value: ciIds.ids }];
|
|
5458
|
+
const raw = await ctx.readPart("/V1.0/Subscriptions/query", subCond, ["id", "subscriptionName", "configurationItemID", "periodCost", "periodPrice", "periodType", "effectiveDate", "expirationDate", "status", "totalCost"]);
|
|
5459
|
+
const subscriptions = raw.items;
|
|
5460
|
+
const labels = await readSubscriptionLabels(ctx, subscriptions);
|
|
5461
|
+
for (const x of subscriptions) {
|
|
5462
|
+
const sid = num(x["id"]);
|
|
5463
|
+
const periodLabel = labels?.get("periodType")?.get(num(x["periodType"]));
|
|
5464
|
+
subFactor.set(sid, periodMonthlyFactor(x["periodType"], periodLabel));
|
|
5465
|
+
if (isOneTimePeriod(x["periodType"], periodLabel)) oneTimeSubs.add(sid);
|
|
5466
|
+
const statusLabel = labels?.get("status")?.get(num(x["status"]));
|
|
5467
|
+
const notActive = statusLabel !== void 0 ? NOT_ACTIVE_LABEL_RE.test(statusLabel) : labels === null && SUBSCRIPTION_NOT_ACTIVE_STATUS_IDS.has(num(x["status"]));
|
|
5468
|
+
if (notActive) cancelledSubs.add(sid);
|
|
5469
|
+
}
|
|
5470
|
+
const unknownPeriods = [...new Set(subscriptions.filter((x) => subFactor.get(num(x["id"])) === null && !oneTimeSubs.has(num(x["id"]))).map((x) => String(x["periodType"] ?? "absent")))];
|
|
5471
|
+
const subscriptionsPart = stampPart("subscriptions", raw, subCond, [
|
|
5472
|
+
"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)",
|
|
5473
|
+
...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)"] : [],
|
|
5474
|
+
...cancelledSubs.size > 0 ? [`${cancelledSubs.size} not-active (cancelled/inactive) subscription(s) excluded from tooling_cost.vendor`] : [],
|
|
5475
|
+
...oneTimeSubs.size > 0 ? [`${oneTimeSubs.size} one-time subscription(s): periodCost booked whole in the effectiveDate month, never prorated`] : [],
|
|
5476
|
+
...unknownPeriods.length > 0 ? [`unrecognised periodType ${unknownPeriods.join(", ")} \u2014 those subscriptions are excluded from tooling_cost.vendor (not prorated)`] : []
|
|
5270
5477
|
]);
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
const
|
|
5288
|
-
const
|
|
5289
|
-
const
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
}
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5478
|
+
return { subscriptionsPart, subscriptions, subFactor, oneTimeSubs, cancelledSubs };
|
|
5479
|
+
}
|
|
5480
|
+
async function fetchSubscriptionsJoin(ctx) {
|
|
5481
|
+
const ci = await fetchConfigurationItems(ctx);
|
|
5482
|
+
const subs = await fetchSubscriptions(ctx, ci.cis);
|
|
5483
|
+
return { configurationItemsPart: ci.part, ...subs };
|
|
5484
|
+
}
|
|
5485
|
+
|
|
5486
|
+
// src/resources/special/contracts/profitability.data.ts
|
|
5487
|
+
async function gather(ctx, resource) {
|
|
5488
|
+
const root = await fetchRoot(ctx, resource);
|
|
5489
|
+
const state = await fetchContractState(ctx, root);
|
|
5490
|
+
const billing = await fetchBilling(ctx);
|
|
5491
|
+
const time = await fetchTime(ctx);
|
|
5492
|
+
const activity = await fetchTicketActivity(ctx, time.entries);
|
|
5493
|
+
const open = await fetchOpenTickets(ctx);
|
|
5494
|
+
const res = await fetchResources(ctx, time.entries);
|
|
5495
|
+
const join = await fetchTicketJoinParts(ctx, [...activity.ticketsActRaw.items, ...open.openTickets]);
|
|
5496
|
+
const subs = await fetchSubscriptionsJoin(ctx);
|
|
5497
|
+
return { root, state, billing, time, activity, open, res, join, subs };
|
|
5498
|
+
}
|
|
5499
|
+
function stampListParts(v, f) {
|
|
5500
|
+
const { state, activity, join } = f;
|
|
5501
|
+
return {
|
|
5502
|
+
servicesPart: stampPart("contract_services", state.servicesRaw, state.servicesCond),
|
|
5503
|
+
unitsPart: state.unitsPartRaw,
|
|
5504
|
+
roleCostsPart: stampPart("contract_role_costs", state.roleCostsRaw, state.roleCostsCond),
|
|
5505
|
+
ratesPart: stampPart("contract_rates", state.ratesRaw, state.ratesCond),
|
|
5506
|
+
ticketsActivityPart: stampPart("tickets", activity.ticketsActRaw, activity.ticketsActCond, [
|
|
5507
|
+
...activity.ticketsActRaw.hasMore ? [`${join.fetchedTickets.size} ticket(s) returned of possibly more \u2014 the checks cover the returned set only`] : [],
|
|
5508
|
+
...activity.refTicketIds.truncated ? [`the time-referenced ticket ids are capped at ${v.maxTicketsForJoin} of ${activity.refTicketIds.total} \u2014 the rest read as ticket_not_in_fetched_set`] : []
|
|
5509
|
+
])
|
|
5510
|
+
};
|
|
5511
|
+
}
|
|
5512
|
+
function makeFoldCtx(ctx, f, st, asOfMs) {
|
|
5513
|
+
const { v } = ctx;
|
|
5514
|
+
const ladder = buildLadderCtx(v.method, v.labourRatePerHour, f.billing.billingRows, f.state.roleCostsRaw.items, f.res.resources, f.res.resourceMissIds);
|
|
5515
|
+
const entryResolution = resolveEntries(f.time.entries, ladder);
|
|
5516
|
+
return {
|
|
5517
|
+
v,
|
|
5518
|
+
f,
|
|
5519
|
+
st,
|
|
5520
|
+
idNum: ctx.idNum,
|
|
5521
|
+
asOfMs,
|
|
5522
|
+
months: monthsBetween(v.dateFrom, v.dateTo),
|
|
5523
|
+
truncatedMonths: ctx.truncatedMonths,
|
|
5524
|
+
ladder,
|
|
5525
|
+
activeRungs: ladderFrom(ladder),
|
|
5526
|
+
entryResolution,
|
|
5527
|
+
costRateResolution: buildCostRateResolution(ladder, f.time.entries, entryResolution)
|
|
5528
|
+
};
|
|
5529
|
+
}
|
|
5530
|
+
|
|
5531
|
+
// src/resources/special/contracts/profitability.monthly.ts
|
|
5532
|
+
function makeMonthCalc(fc) {
|
|
5533
|
+
const { v, f } = fc;
|
|
5534
|
+
const unitRows = f.state.unitRows;
|
|
5535
|
+
const billingRows = f.billing.billingRows;
|
|
5536
|
+
const monthOf = (date) => typeof date === "string" && DATE_RE.test(`${date}`.slice(0, 10)) ? date.slice(0, 7) : null;
|
|
5317
5537
|
const inMonth = (date, m) => monthOf(date) === m;
|
|
5318
5538
|
const unitIn = (unit, m) => inMonth(unit["startDate"], m);
|
|
5319
5539
|
const lineRevenue = (b) => internalAmount(b, "internalCurrencyExtendedPrice", "extendedPrice");
|
|
5320
|
-
const expectedBasis = unitsPart.status !== "absent" && (servicesRaw.items.length > 0 || unitRows.length > 0);
|
|
5540
|
+
const expectedBasis = fc.st.unitsPart.status !== "absent" && (f.state.servicesRaw.items.length > 0 || unitRows.length > 0);
|
|
5321
5541
|
const expectedIn = (m) => unitRows.filter((u) => unitIn(u, m)).reduce((a, u) => a + internalAmount(u, "internalCurrencyPrice", "price"), 0);
|
|
5322
5542
|
const recurringIn = (m) => {
|
|
5323
5543
|
if (!v.includeRecurringCosts) return 0;
|
|
5324
5544
|
let total = 0;
|
|
5325
|
-
for (const c of charges) if (inMonth(c["datePurchased"], m)) total += chargeCost(c);
|
|
5326
|
-
for (const u of unitRows) if (unitIn(u, m)) total +=
|
|
5545
|
+
for (const c of f.state.charges) if (inMonth(c["datePurchased"], m)) total += chargeCost(c);
|
|
5546
|
+
for (const u of unitRows) if (unitIn(u, m)) total += num(u["cost"]);
|
|
5327
5547
|
return total;
|
|
5328
5548
|
};
|
|
5329
|
-
const invoicesById = new Map(invoices.map((i) => [
|
|
5549
|
+
const invoicesById = new Map(f.billing.invoices.map((i) => [num(i["id"]), i]));
|
|
5330
5550
|
const billedServiceIn = (m) => {
|
|
5331
5551
|
let billed = 0;
|
|
5332
|
-
const
|
|
5552
|
+
const ids2 = [];
|
|
5333
5553
|
for (const b of billingRows) {
|
|
5334
5554
|
if (!inMonth(b["itemDate"], m) || billingLineType(b) !== "recurring") continue;
|
|
5335
|
-
const invId =
|
|
5555
|
+
const invId = num(b["invoiceID"]);
|
|
5336
5556
|
const inv = invId > 0 ? invoicesById.get(invId) : void 0;
|
|
5337
5557
|
if (inv === void 0 || inv["isVoided"] === true) continue;
|
|
5338
5558
|
billed += lineRevenue(b);
|
|
5339
|
-
|
|
5559
|
+
ids2.push(num(b["id"]));
|
|
5340
5560
|
}
|
|
5341
|
-
return { billed, ids };
|
|
5561
|
+
return { billed, ids: ids2 };
|
|
5342
5562
|
};
|
|
5343
5563
|
const contractRateByRole = /* @__PURE__ */ new Map();
|
|
5344
|
-
for (const r of ratesRaw.items) {
|
|
5564
|
+
for (const r of f.state.ratesRaw.items) {
|
|
5345
5565
|
const rate = typeof r["internalCurrencyContractHourlyRate"] === "number" ? r["internalCurrencyContractHourlyRate"] : r["contractHourlyRate"];
|
|
5346
|
-
if (
|
|
5566
|
+
if (num(r["roleID"]) > 0 && typeof rate === "number" && Number.isFinite(rate)) contractRateByRole.set(num(r["roleID"]), rate);
|
|
5347
5567
|
}
|
|
5568
|
+
return { monthOf, inMonth, unitIn, lineRevenue, expectedBasis, expectedIn, recurringIn, invoicesById, billedServiceIn, contractRateByRole };
|
|
5569
|
+
}
|
|
5570
|
+
function coverageGaps(fc, m) {
|
|
5571
|
+
const { f, st, truncatedMonths, v } = fc;
|
|
5348
5572
|
const monthlyParts = [
|
|
5349
|
-
["contractServiceUnits", unitsPart],
|
|
5350
|
-
["contractCharges", chargesPart],
|
|
5351
|
-
["contractRoleCosts", roleCostsPart],
|
|
5352
|
-
["billingItems", billingItems],
|
|
5353
|
-
["timeEntries", timeEntries],
|
|
5354
|
-
["expenses", expensesPart],
|
|
5355
|
-
["subscriptions", subscriptionsPart]
|
|
5573
|
+
["contractServiceUnits", st.unitsPart],
|
|
5574
|
+
["contractCharges", f.state.chargesPart],
|
|
5575
|
+
["contractRoleCosts", st.roleCostsPart],
|
|
5576
|
+
["billingItems", f.billing.billingItems],
|
|
5577
|
+
["timeEntries", f.time.timeEntries],
|
|
5578
|
+
["expenses", f.join.expensesPart],
|
|
5579
|
+
["subscriptions", f.subs.subscriptionsPart]
|
|
5356
5580
|
];
|
|
5357
|
-
const
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
const
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
const invId = num2(b["invoiceID"]);
|
|
5379
|
-
if (invId <= 0) {
|
|
5380
|
-
postedUninvoiced += lineRevenue(b);
|
|
5381
|
-
continue;
|
|
5382
|
-
}
|
|
5383
|
-
const inv = invoicesById.get(invId);
|
|
5384
|
-
if (inv === void 0) {
|
|
5385
|
-
unresolvedInvoice += lineRevenue(b);
|
|
5386
|
-
unresolvedLines += 1;
|
|
5387
|
-
continue;
|
|
5388
|
-
}
|
|
5389
|
-
if (inv["isVoided"] === true) revenueVoided += lineRevenue(b);
|
|
5390
|
-
else {
|
|
5391
|
-
revenueInvoiced += lineRevenue(b);
|
|
5392
|
-
byType[billingLineType(b)] += lineRevenue(b);
|
|
5393
|
-
}
|
|
5581
|
+
const gaps = [];
|
|
5582
|
+
for (const [name, p] of monthlyParts) {
|
|
5583
|
+
if (p.status === "absent") gaps.push(`${name}: absent (${p.absent})`);
|
|
5584
|
+
else if (truncatedMonths[name] !== void 0) {
|
|
5585
|
+
if (truncatedMonths[name].includes(m)) gaps.push(`${name}: truncated at limit ${v.limit}`);
|
|
5586
|
+
} else if (p.hasMore) gaps.push(`${name}: capped at limit ${v.limit}`);
|
|
5587
|
+
}
|
|
5588
|
+
return gaps;
|
|
5589
|
+
}
|
|
5590
|
+
function monthRevenue(calc, mBilling) {
|
|
5591
|
+
let revenueInvoiced = 0;
|
|
5592
|
+
let revenueVoided = 0;
|
|
5593
|
+
let postedUninvoiced = 0;
|
|
5594
|
+
const byType = { recurring: 0, labour: 0, charges: 0, expenses: 0, other: 0 };
|
|
5595
|
+
let unresolvedInvoice = 0;
|
|
5596
|
+
let unresolvedLines = 0;
|
|
5597
|
+
for (const b of mBilling) {
|
|
5598
|
+
const invId = num(b["invoiceID"]);
|
|
5599
|
+
if (invId <= 0) {
|
|
5600
|
+
postedUninvoiced += calc.lineRevenue(b);
|
|
5601
|
+
continue;
|
|
5394
5602
|
}
|
|
5395
|
-
const
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
let unresolved = 0;
|
|
5401
|
-
const rateSources = {};
|
|
5402
|
-
for (const rung of activeRungs) rateSources[rung] = { entries: 0, hours: 0, cost: 0 };
|
|
5403
|
-
rateSources.missing = { entries: 0, hours: 0, cost: 0 };
|
|
5404
|
-
for (const e of mEntries) {
|
|
5405
|
-
const r = entryResolution.get(num2(e["id"]));
|
|
5406
|
-
labourHours += hoursOf(e);
|
|
5407
|
-
const bucket = rateSources[r.source];
|
|
5408
|
-
bucket.entries += 1;
|
|
5409
|
-
bucket.hours += hoursOf(e);
|
|
5410
|
-
bucket.cost += r.cost ?? 0;
|
|
5411
|
-
if (r.source === "missing") unresolved += hoursOf(e);
|
|
5412
|
-
else labourTotal += r.cost ?? 0;
|
|
5603
|
+
const inv = calc.invoicesById.get(invId);
|
|
5604
|
+
if (inv === void 0) {
|
|
5605
|
+
unresolvedInvoice += calc.lineRevenue(b);
|
|
5606
|
+
unresolvedLines += 1;
|
|
5607
|
+
continue;
|
|
5413
5608
|
}
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
if (!contractTypeKnown) {
|
|
5419
|
-
unpostedTime = { ...hoursPart, value: null, absent: "CONTRACT_TYPE_UNKNOWN" };
|
|
5420
|
-
} else if (!isTimeAndMaterials) {
|
|
5421
|
-
unpostedTime = { ...hoursPart, value: null, absent: "NOT_TIME_AND_MATERIALS_CONTRACT" };
|
|
5422
|
-
} else if (unposted.some((e) => !contractRateByRole.has(num2(e["roleID"])))) {
|
|
5423
|
-
unpostedTime = { ...hoursPart, value: null, absent: "NO_CONTRACT_RATE" };
|
|
5424
|
-
} else if (hoursMissing) {
|
|
5425
|
-
unpostedTime = { ...hoursPart, value: null, absent: "HOURS_TO_BILL_MISSING" };
|
|
5426
|
-
} else {
|
|
5427
|
-
const value = unposted.reduce((a, e) => a + Math.max(0, num2(e["hoursToBill"])) * contractRateByRole.get(num2(e["roleID"])), 0);
|
|
5428
|
-
unpostedTime = { ...hoursPart, value };
|
|
5609
|
+
if (inv["isVoided"] === true) revenueVoided += calc.lineRevenue(b);
|
|
5610
|
+
else {
|
|
5611
|
+
revenueInvoiced += calc.lineRevenue(b);
|
|
5612
|
+
byType[billingLineType(b)] += calc.lineRevenue(b);
|
|
5429
5613
|
}
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5614
|
+
}
|
|
5615
|
+
return { revenueInvoiced, revenueVoided, postedUninvoiced, byType, unresolvedInvoice, unresolvedLines };
|
|
5616
|
+
}
|
|
5617
|
+
function monthLabour(fc, mEntries) {
|
|
5618
|
+
let labourHours = 0;
|
|
5619
|
+
let labourTotal = 0;
|
|
5620
|
+
let unresolved = 0;
|
|
5621
|
+
const rateSources = {};
|
|
5622
|
+
for (const rung of fc.activeRungs) rateSources[rung] = { entries: 0, hours: 0, cost: 0 };
|
|
5623
|
+
rateSources.missing = { entries: 0, hours: 0, cost: 0 };
|
|
5624
|
+
for (const e of mEntries) {
|
|
5625
|
+
const r = fc.entryResolution.get(num(e["id"]));
|
|
5626
|
+
labourHours += hoursOf(e);
|
|
5627
|
+
const bucket = rateSources[r.source];
|
|
5628
|
+
bucket.entries += 1;
|
|
5629
|
+
bucket.hours += hoursOf(e);
|
|
5630
|
+
bucket.cost += r.cost ?? 0;
|
|
5631
|
+
if (r.source === "missing") unresolved += hoursOf(e);
|
|
5632
|
+
else labourTotal += r.cost ?? 0;
|
|
5633
|
+
}
|
|
5634
|
+
return { labourHours, labourTotal, unresolved, rateSources };
|
|
5635
|
+
}
|
|
5636
|
+
function monthUnposted(fc, calc, mEntries) {
|
|
5637
|
+
const { contractTypeKnown, isTimeAndMaterials } = fc.f.root;
|
|
5638
|
+
const unposted = mEntries.filter((e) => e["isNonBillable"] !== true && !fc.ladder.billedByEntryId.has(num(e["id"])));
|
|
5639
|
+
const hoursMissing = unposted.some((e) => typeof e["hoursToBill"] !== "number" || !Number.isFinite(e["hoursToBill"]));
|
|
5640
|
+
const hoursPart = hoursMissing ? { entries: unposted.length, hours: null, hoursAbsent: "HOURS_TO_BILL_MISSING" } : { entries: unposted.length, hours: unposted.reduce((a, e) => a + Math.max(0, num(e["hoursToBill"])), 0) };
|
|
5641
|
+
if (!contractTypeKnown) return { ...hoursPart, value: null, absent: "CONTRACT_TYPE_UNKNOWN" };
|
|
5642
|
+
if (!isTimeAndMaterials) return { ...hoursPart, value: null, absent: "NOT_TIME_AND_MATERIALS_CONTRACT" };
|
|
5643
|
+
if (unposted.some((e) => !calc.contractRateByRole.has(num(e["roleID"])))) return { ...hoursPart, value: null, absent: "NO_CONTRACT_RATE" };
|
|
5644
|
+
if (hoursMissing) return { ...hoursPart, value: null, absent: "HOURS_TO_BILL_MISSING" };
|
|
5645
|
+
const value = unposted.reduce((a, e) => a + Math.max(0, num(e["hoursToBill"])) * calc.contractRateByRole.get(num(e["roleID"])), 0);
|
|
5646
|
+
return { ...hoursPart, value };
|
|
5647
|
+
}
|
|
5648
|
+
function monthToolingVendor(fc, calc, m) {
|
|
5649
|
+
const { subs } = fc.f;
|
|
5650
|
+
if (subs.subscriptionsPart.status === "skipped" || subs.subscriptionsPart.status === "absent") return null;
|
|
5651
|
+
let toolingVendor = 0;
|
|
5652
|
+
for (const s of subs.subscriptions) {
|
|
5653
|
+
const eff = calc.monthOf(s["effectiveDate"]) ?? "0000-00";
|
|
5654
|
+
const exp = calc.monthOf(s["expirationDate"]) ?? "9999-99";
|
|
5655
|
+
const sid = num(s["id"]);
|
|
5656
|
+
if (subs.cancelledSubs.has(sid)) continue;
|
|
5657
|
+
if (subs.oneTimeSubs.has(sid)) {
|
|
5658
|
+
if (eff === m) toolingVendor += num(s["periodCost"]);
|
|
5659
|
+
continue;
|
|
5444
5660
|
}
|
|
5445
|
-
const
|
|
5446
|
-
|
|
5447
|
-
let expenseCost = 0;
|
|
5448
|
-
for (const x of expenses) if (inMonth(x["expenseDate"], m)) expenseCost += num2(x["internalCurrencyExpenseAmount"]);
|
|
5449
|
-
const totalCost = labourTotal + callerSupplied + recurring + expenseCost;
|
|
5450
|
-
const profit = revenueInvoiced - totalCost;
|
|
5451
|
-
const margin = revenueInvoiced > 0 ? profit / revenueInvoiced : null;
|
|
5452
|
-
const nonLabour = totalCost - labourTotal;
|
|
5453
|
-
const resolvedHours = mEntries.filter((e) => entryResolution.get(num2(e["id"])).source !== "missing").reduce((a, e) => a + hoursOf(e), 0);
|
|
5454
|
-
const avgRate = resolvedHours > 0 ? labourTotal / resolvedHours : 0;
|
|
5455
|
-
const breakEven = avgRate > 0 ? Math.max(0, (revenueInvoiced - nonLabour) / avgRate) : null;
|
|
5456
|
-
for (const b of Object.values(rateSources)) b.cost = cents(b.cost);
|
|
5457
|
-
const gaps = coverageGaps(m);
|
|
5458
|
-
if (unresolvedLines > 0) gaps.push(`invoices: ${unresolvedLines} line(s) on unresolved invoices`);
|
|
5459
|
-
monthly.push({
|
|
5460
|
-
month: m,
|
|
5461
|
-
revenue_invoiced: cents(revenueInvoiced),
|
|
5462
|
-
revenue_by_type: { recurring: cents(byType.recurring), labour: cents(byType.labour), charges: cents(byType.charges), expenses: cents(byType.expenses), other: cents(byType.other) },
|
|
5463
|
-
revenue_voided_excluded: cents(revenueVoided),
|
|
5464
|
-
revenue_posted_uninvoiced: cents(postedUninvoiced),
|
|
5465
|
-
revenue_unresolved_invoice: cents(unresolvedInvoice),
|
|
5466
|
-
revenue_expected: cents(expected),
|
|
5467
|
-
revenue_expected_unbilled: cents(expectedUnbilled),
|
|
5468
|
-
unposted_time: { ...unpostedTime, value: cents(unpostedTime.value) },
|
|
5469
|
-
labour_hours: labourHours,
|
|
5470
|
-
labour_cost: { total: cents(labourTotal), unresolvedHours: unresolved, rateSources },
|
|
5471
|
-
tooling_cost: { vendor: cents(toolingVendor), caller_supplied: cents(callerSupplied) },
|
|
5472
|
-
recurring_cost: cents(recurring),
|
|
5473
|
-
expense_cost: cents(expenseCost),
|
|
5474
|
-
total_cost: cents(totalCost),
|
|
5475
|
-
profit: cents(profit),
|
|
5476
|
-
margin,
|
|
5477
|
-
break_even_hours: breakEven,
|
|
5478
|
-
coverage_gaps: gaps
|
|
5479
|
-
});
|
|
5661
|
+
const factor = subs.subFactor.get(sid) ?? null;
|
|
5662
|
+
if (factor !== null && eff <= m && m <= exp) toolingVendor += num(s["periodCost"]) * factor;
|
|
5480
5663
|
}
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
const
|
|
5488
|
-
|
|
5664
|
+
return toolingVendor;
|
|
5665
|
+
}
|
|
5666
|
+
function buildMonthlyRow(fc, calc, mi, m) {
|
|
5667
|
+
const { v, f } = fc;
|
|
5668
|
+
const rev = monthRevenue(calc, f.billing.billingRows.filter((b) => calc.inMonth(b["itemDate"], m)));
|
|
5669
|
+
const expected = v.includeUnbilledRevenue && calc.expectedBasis ? calc.expectedIn(m) : null;
|
|
5670
|
+
const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - calc.billedServiceIn(m).billed);
|
|
5671
|
+
const mEntries = f.time.entries.filter((e) => calc.inMonth(e["dateWorked"], m));
|
|
5672
|
+
const lab = monthLabour(fc, mEntries);
|
|
5673
|
+
const unpostedTime = monthUnposted(fc, calc, mEntries);
|
|
5674
|
+
const toolingVendor = monthToolingVendor(fc, calc, m);
|
|
5675
|
+
const callerSupplied = v.externalMonthlyCosts[mi] ?? 0;
|
|
5676
|
+
const recurring = calc.recurringIn(m);
|
|
5677
|
+
let expenseCost = 0;
|
|
5678
|
+
for (const x of f.join.expenses) if (calc.inMonth(x["expenseDate"], m)) expenseCost += num(x["internalCurrencyExpenseAmount"]);
|
|
5679
|
+
const totalCost = lab.labourTotal + callerSupplied + recurring + expenseCost;
|
|
5680
|
+
const profit = rev.revenueInvoiced - totalCost;
|
|
5681
|
+
const margin = rev.revenueInvoiced > 0 ? profit / rev.revenueInvoiced : null;
|
|
5682
|
+
const nonLabour = totalCost - lab.labourTotal;
|
|
5683
|
+
const resolvedHours = mEntries.filter((e) => fc.entryResolution.get(num(e["id"])).source !== "missing").reduce((a, e) => a + hoursOf(e), 0);
|
|
5684
|
+
const avgRate = resolvedHours > 0 ? lab.labourTotal / resolvedHours : 0;
|
|
5685
|
+
const breakEven = avgRate > 0 ? Math.max(0, (rev.revenueInvoiced - nonLabour) / avgRate) : null;
|
|
5686
|
+
for (const b of Object.values(lab.rateSources)) b.cost = cents(b.cost);
|
|
5687
|
+
const gaps = coverageGaps(fc, m);
|
|
5688
|
+
if (rev.unresolvedLines > 0) gaps.push(`invoices: ${rev.unresolvedLines} line(s) on unresolved invoices`);
|
|
5689
|
+
return {
|
|
5690
|
+
month: m,
|
|
5691
|
+
revenue_invoiced: cents(rev.revenueInvoiced),
|
|
5692
|
+
revenue_by_type: { recurring: cents(rev.byType.recurring), labour: cents(rev.byType.labour), charges: cents(rev.byType.charges), expenses: cents(rev.byType.expenses), other: cents(rev.byType.other) },
|
|
5693
|
+
revenue_voided_excluded: cents(rev.revenueVoided),
|
|
5694
|
+
revenue_posted_uninvoiced: cents(rev.postedUninvoiced),
|
|
5695
|
+
revenue_unresolved_invoice: cents(rev.unresolvedInvoice),
|
|
5696
|
+
revenue_expected: cents(expected),
|
|
5697
|
+
revenue_expected_unbilled: cents(expectedUnbilled),
|
|
5698
|
+
unposted_time: { ...unpostedTime, value: cents(unpostedTime.value) },
|
|
5699
|
+
labour_hours: lab.labourHours,
|
|
5700
|
+
labour_cost: { total: cents(lab.labourTotal), unresolvedHours: lab.unresolved, rateSources: lab.rateSources },
|
|
5701
|
+
tooling_cost: { vendor: cents(toolingVendor), caller_supplied: cents(callerSupplied) },
|
|
5702
|
+
recurring_cost: cents(recurring),
|
|
5703
|
+
expense_cost: cents(expenseCost),
|
|
5704
|
+
total_cost: cents(totalCost),
|
|
5705
|
+
profit: cents(profit),
|
|
5706
|
+
margin,
|
|
5707
|
+
break_even_hours: breakEven,
|
|
5708
|
+
coverage_gaps: gaps
|
|
5709
|
+
};
|
|
5710
|
+
}
|
|
5711
|
+
function buildMonthly(fc, calc) {
|
|
5712
|
+
return fc.months.map((m, mi) => buildMonthlyRow(fc, calc, mi, m));
|
|
5713
|
+
}
|
|
5714
|
+
|
|
5715
|
+
// src/resources/special/contracts/profitability.quality.ts
|
|
5716
|
+
function findCrossTimeEntries(fc) {
|
|
5717
|
+
return fc.f.time.entries.filter((e) => {
|
|
5718
|
+
const t = num(e["ticketID"]);
|
|
5489
5719
|
if (t <= 0) return false;
|
|
5490
|
-
const ticket = fetchedTickets.get(t);
|
|
5491
|
-
return ticket !== void 0 &&
|
|
5720
|
+
const ticket = fc.f.join.fetchedTickets.get(t);
|
|
5721
|
+
return ticket !== void 0 && num(ticket["contractID"]) !== fc.idNum;
|
|
5492
5722
|
});
|
|
5493
|
-
|
|
5723
|
+
}
|
|
5724
|
+
function findUnresolvedRefs(fc) {
|
|
5725
|
+
const { fetchedTickets } = fc.f.join;
|
|
5726
|
+
return fc.f.time.entries.filter((e) => num(e["ticketID"]) > 0 && !fetchedTickets.has(num(e["ticketID"])));
|
|
5727
|
+
}
|
|
5728
|
+
var ids = (rows) => rows.map((e) => num(e["id"]));
|
|
5729
|
+
function check1(fc) {
|
|
5730
|
+
const { probeRows } = fc.f.join;
|
|
5731
|
+
return { code: "miscoded_time_out", count: probeRows.length, sample_ids: dedupeSortCap(ids(probeRows), fc.v.limit) };
|
|
5732
|
+
}
|
|
5733
|
+
function check2(fc, crossTimeEntries) {
|
|
5734
|
+
return { code: "cross_contract_ticket_time", count: crossTimeEntries.length, sample_ids: dedupeSortCap(ids(crossTimeEntries), fc.v.limit) };
|
|
5735
|
+
}
|
|
5736
|
+
function check3(fc) {
|
|
5494
5737
|
const tupleGroups = /* @__PURE__ */ new Map();
|
|
5495
|
-
for (const e of entries) {
|
|
5496
|
-
const key = [String(
|
|
5738
|
+
for (const e of fc.f.time.entries) {
|
|
5739
|
+
const key = [String(num(e["resourceID"])), String(e["dateWorked"] ?? ""), String(num(e["ticketID"])), String(e["hoursWorked"] ?? "")].join("|");
|
|
5497
5740
|
const arr = tupleGroups.get(key) ?? [];
|
|
5498
5741
|
arr.push(e);
|
|
5499
5742
|
tupleGroups.set(key, arr);
|
|
@@ -5502,20 +5745,24 @@ async function profitability(resource, contractId, opts, rawHttp) {
|
|
|
5502
5745
|
const dupSample = [];
|
|
5503
5746
|
for (const group of tupleGroups.values()) {
|
|
5504
5747
|
if (group.length < 2) continue;
|
|
5505
|
-
const sorted = [...group].sort((a, b) =>
|
|
5506
|
-
for (const e of sorted) dupAll.push(
|
|
5507
|
-
for (const e of sorted.slice(1)) dupSample.push(
|
|
5748
|
+
const sorted = [...group].sort((a, b) => num(a["id"]) - num(b["id"]));
|
|
5749
|
+
for (const e of sorted) dupAll.push(num(e["id"]));
|
|
5750
|
+
for (const e of sorted.slice(1)) dupSample.push(num(e["id"]));
|
|
5508
5751
|
}
|
|
5509
|
-
|
|
5510
|
-
|
|
5752
|
+
return { code: "duplicate_entries", count: dupAll.length, sample_ids: dedupeSortCap(dupSample, fc.v.limit) };
|
|
5753
|
+
}
|
|
5754
|
+
function check4(fc) {
|
|
5755
|
+
const zeroHours = fc.f.time.entries.filter((e) => {
|
|
5511
5756
|
const h = e["hoursWorked"];
|
|
5512
5757
|
return !(typeof h === "number" && Number.isFinite(h) && h > 0);
|
|
5513
5758
|
});
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5759
|
+
return { code: "zero_negative_hours", count: zeroHours.length, sample_ids: dedupeSortCap(ids(zeroHours), fc.v.limit) };
|
|
5760
|
+
}
|
|
5761
|
+
function check5(fc) {
|
|
5762
|
+
const outPeriod = fc.f.time.entries.filter((e) => {
|
|
5763
|
+
const t = num(e["ticketID"]);
|
|
5517
5764
|
if (t <= 0) return false;
|
|
5518
|
-
const ticket = fetchedTickets.get(t);
|
|
5765
|
+
const ticket = fc.f.join.fetchedTickets.get(t);
|
|
5519
5766
|
if (ticket === void 0) return false;
|
|
5520
5767
|
const dW = dateToDay(e["dateWorked"]);
|
|
5521
5768
|
if (dW === null) return false;
|
|
@@ -5525,280 +5772,370 @@ async function profitability(resource, contractId, opts, rawHttp) {
|
|
|
5525
5772
|
if (ends.length > 0 && dW > Math.min(...ends)) return true;
|
|
5526
5773
|
return false;
|
|
5527
5774
|
});
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5775
|
+
return { code: "time_outside_ticket_period", count: outPeriod.length, sample_ids: dedupeSortCap(ids(outPeriod), fc.v.limit) };
|
|
5776
|
+
}
|
|
5777
|
+
function check6(fc) {
|
|
5778
|
+
const missingRate = fc.f.time.entries.filter((e) => fc.entryResolution.get(num(e["id"])).source === "missing");
|
|
5779
|
+
return { code: "missing_cost_rate", count: missingRate.length, sample_ids: dedupeSortCap(ids(missingRate), fc.v.limit) };
|
|
5780
|
+
}
|
|
5781
|
+
function check7(fc, calc) {
|
|
5782
|
+
const { months, f, v } = fc;
|
|
5531
5783
|
let driftCount = 0;
|
|
5532
5784
|
const driftIds = /* @__PURE__ */ new Set();
|
|
5533
5785
|
if (v.includeRecurringCosts) {
|
|
5534
5786
|
for (let i = 1; i < months.length; i++) {
|
|
5535
|
-
const r1 = recurringIn(months[i]);
|
|
5536
|
-
const r0 = recurringIn(months[i - 1]);
|
|
5787
|
+
const r1 = calc.recurringIn(months[i]);
|
|
5788
|
+
const r0 = calc.recurringIn(months[i - 1]);
|
|
5537
5789
|
if (r1 <= 0 || r0 <= 0 || Math.abs(r1 - r0) <= RECURRING_DRIFT_EPSILON) continue;
|
|
5538
5790
|
driftCount += 1;
|
|
5539
5791
|
const mA = months[i - 1];
|
|
5540
5792
|
const mB = months[i];
|
|
5541
|
-
for (const c of charges) {
|
|
5542
|
-
const cA = inMonth(c["datePurchased"], mA) ? chargeCost(c) : 0;
|
|
5543
|
-
const cB = inMonth(c["datePurchased"], mB) ? chargeCost(c) : 0;
|
|
5544
|
-
if (Math.abs(cB - cA) > 0) driftIds.add(
|
|
5793
|
+
for (const c of f.state.charges) {
|
|
5794
|
+
const cA = calc.inMonth(c["datePurchased"], mA) ? chargeCost(c) : 0;
|
|
5795
|
+
const cB = calc.inMonth(c["datePurchased"], mB) ? chargeCost(c) : 0;
|
|
5796
|
+
if (Math.abs(cB - cA) > 0) driftIds.add(num(c["id"]));
|
|
5545
5797
|
}
|
|
5546
|
-
for (const u of unitRows) {
|
|
5547
|
-
const cA = unitIn(u, mA) ?
|
|
5548
|
-
const cB = unitIn(u, mB) ?
|
|
5549
|
-
if (Math.abs(cB - cA) > 0) driftIds.add(
|
|
5798
|
+
for (const u of f.state.unitRows) {
|
|
5799
|
+
const cA = calc.unitIn(u, mA) ? num(u["cost"]) : 0;
|
|
5800
|
+
const cB = calc.unitIn(u, mB) ? num(u["cost"]) : 0;
|
|
5801
|
+
if (Math.abs(cB - cA) > 0) driftIds.add(num(u["id"]));
|
|
5550
5802
|
}
|
|
5551
5803
|
}
|
|
5552
5804
|
}
|
|
5553
|
-
|
|
5805
|
+
return { code: "recurring_cost_drift", count: driftCount, sample_ids: dedupeSortCap([...driftIds], v.limit) };
|
|
5806
|
+
}
|
|
5807
|
+
function check8(fc, calc) {
|
|
5554
5808
|
let expectedUnbilledCount = 0;
|
|
5555
5809
|
const expectedUnbilledSamples = [];
|
|
5556
|
-
if (v.includeUnbilledRevenue && expectedBasis) {
|
|
5557
|
-
for (const m of months) {
|
|
5558
|
-
const { billed, ids } = billedServiceIn(m);
|
|
5559
|
-
if (expectedIn(m) > billed + 5e-3) {
|
|
5810
|
+
if (fc.v.includeUnbilledRevenue && calc.expectedBasis) {
|
|
5811
|
+
for (const m of fc.months) {
|
|
5812
|
+
const { billed, ids: lineIds } = calc.billedServiceIn(m);
|
|
5813
|
+
if (calc.expectedIn(m) > billed + 5e-3) {
|
|
5560
5814
|
expectedUnbilledCount += 1;
|
|
5561
|
-
for (const i of
|
|
5815
|
+
for (const i of lineIds) expectedUnbilledSamples.push(i);
|
|
5562
5816
|
}
|
|
5563
5817
|
}
|
|
5564
5818
|
}
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5819
|
+
return { code: "expected_unbilled_revenue", count: expectedUnbilledCount, sample_ids: dedupeSortCap(expectedUnbilledSamples, fc.v.limit) };
|
|
5820
|
+
}
|
|
5821
|
+
function check9(fc, unresolvedRefs) {
|
|
5822
|
+
const driftTickets = [...fc.f.join.fetchedTickets.values()].filter((t) => num(t["contractID"]) !== fc.idNum);
|
|
5823
|
+
return {
|
|
5569
5824
|
code: "cross_contract_tickets",
|
|
5570
5825
|
count: driftTickets.length + unresolvedRefs.length,
|
|
5571
|
-
sample_ids: dedupeSortCap([...driftTickets.map((t) =>
|
|
5572
|
-
}
|
|
5826
|
+
sample_ids: dedupeSortCap([...driftTickets.map((t) => num(t["id"])), ...unresolvedRefs.map((e) => num(e["id"]))], fc.v.limit)
|
|
5827
|
+
};
|
|
5828
|
+
}
|
|
5829
|
+
function check10(fc) {
|
|
5830
|
+
const { openStatusSet } = fc.f.open;
|
|
5573
5831
|
const stale = [];
|
|
5574
5832
|
if (openStatusSet !== null) {
|
|
5575
|
-
for (const t of contractTickets) {
|
|
5833
|
+
for (const t of fc.f.join.contractTickets) {
|
|
5576
5834
|
const status = t["status"];
|
|
5577
5835
|
if (typeof status !== "number" || !openStatusSet.has(status)) continue;
|
|
5578
5836
|
const lastDay = dateToDay(t["lastActivityDate"]);
|
|
5579
5837
|
if (lastDay === null) continue;
|
|
5580
|
-
const days = asOfMs / 864e5 - lastDay;
|
|
5581
|
-
if (days > v.staleAfterDays) stale.push(
|
|
5838
|
+
const days = fc.asOfMs / 864e5 - lastDay;
|
|
5839
|
+
if (days > fc.v.staleAfterDays) stale.push(num(t["id"]));
|
|
5582
5840
|
}
|
|
5583
5841
|
}
|
|
5584
|
-
|
|
5842
|
+
return { code: "open_stale_tickets", count: stale.length, sample_ids: dedupeSortCap(stale, fc.v.limit) };
|
|
5843
|
+
}
|
|
5844
|
+
function check11(fc, monthly) {
|
|
5845
|
+
const { contractRow } = fc.f.root;
|
|
5585
5846
|
let estimateCount = 0;
|
|
5586
5847
|
const estimateSample = [];
|
|
5587
5848
|
let estimateNote;
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
estimateSample.push(idNum);
|
|
5609
|
-
}
|
|
5610
|
-
estimateNote = `estimate vs actual (window-prorated ${overlapDays}/${termDays} days): revenue ${pctOrNa(devRev)} deviation (actual ${fmtNum(actualRevenue)} vs prorated ${fmtNum(estRev * frac)}), cost ${pctOrNa(devCost)} deviation (actual ${fmtNum(actualCost)} vs prorated ${fmtNum(estCost * frac)})`;
|
|
5849
|
+
const actualRevenue = monthly.reduce((a, r) => a + r.revenue_invoiced, 0);
|
|
5850
|
+
const actualCost = monthly.reduce((a, r) => a + r.total_cost, 0);
|
|
5851
|
+
const termStart = dateToDay(contractRow["startDate"]);
|
|
5852
|
+
const termEnd = dateToDay(contractRow["endDate"]);
|
|
5853
|
+
const fromDay = dateToDay(fc.v.dateFrom);
|
|
5854
|
+
const toDay = dateToDay(fc.v.dateTo);
|
|
5855
|
+
if (termStart !== null && termEnd !== null && fromDay !== null && toDay !== null) {
|
|
5856
|
+
const overlapStart = Math.max(termStart, fromDay);
|
|
5857
|
+
const overlapEnd = Math.min(termEnd, toDay);
|
|
5858
|
+
const termDays = termEnd - termStart + 1;
|
|
5859
|
+
const overlapDays = overlapEnd >= overlapStart ? overlapEnd - overlapStart + 1 : 0;
|
|
5860
|
+
if (termDays > 0 && overlapDays > 0) {
|
|
5861
|
+
const frac = overlapDays / termDays;
|
|
5862
|
+
const estRev = num(contractRow["estimatedRevenue"]);
|
|
5863
|
+
const estCost = num(contractRow["estimatedCost"]);
|
|
5864
|
+
const devRev = estRev > 0 ? Math.abs(actualRevenue - estRev * frac) / (estRev * frac) : null;
|
|
5865
|
+
const devCost = estCost > 0 ? Math.abs(actualCost - estCost * frac) / (estCost * frac) : null;
|
|
5866
|
+
if (devRev !== null && devRev > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100 || devCost !== null && devCost > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100) {
|
|
5867
|
+
estimateCount = 1;
|
|
5868
|
+
estimateSample.push(fc.idNum);
|
|
5611
5869
|
}
|
|
5870
|
+
estimateNote = `estimate vs actual (window-prorated ${overlapDays}/${termDays} days): revenue ${pctOrNa(devRev)} deviation (actual ${fmtNum(actualRevenue)} vs prorated ${fmtNum(estRev * frac)}), cost ${pctOrNa(devCost)} deviation (actual ${fmtNum(actualCost)} vs prorated ${fmtNum(estCost * frac)})`;
|
|
5612
5871
|
}
|
|
5613
5872
|
}
|
|
5614
|
-
|
|
5873
|
+
return { code: "estimate_vs_actual", count: estimateCount, sample_ids: estimateSample, ...estimateCount === 1 && estimateNote !== void 0 ? { note: estimateNote } : {} };
|
|
5874
|
+
}
|
|
5875
|
+
function buildWarnings(fc, calc, monthly, crossTimeEntries, unresolvedRefs) {
|
|
5876
|
+
return [
|
|
5877
|
+
check1(fc),
|
|
5878
|
+
check2(fc, crossTimeEntries),
|
|
5879
|
+
check3(fc),
|
|
5880
|
+
check4(fc),
|
|
5881
|
+
check5(fc),
|
|
5882
|
+
check6(fc),
|
|
5883
|
+
check7(fc, calc),
|
|
5884
|
+
check8(fc, calc),
|
|
5885
|
+
check9(fc, unresolvedRefs),
|
|
5886
|
+
check10(fc),
|
|
5887
|
+
check11(fc, monthly)
|
|
5888
|
+
];
|
|
5889
|
+
}
|
|
5890
|
+
|
|
5891
|
+
// src/resources/special/contracts/profitability.sections.ts
|
|
5892
|
+
function makeTicketCalc(fc) {
|
|
5615
5893
|
const entriesByTicket = /* @__PURE__ */ new Map();
|
|
5616
|
-
for (const e of entries) {
|
|
5617
|
-
const t =
|
|
5894
|
+
for (const e of fc.f.time.entries) {
|
|
5895
|
+
const t = num(e["ticketID"]);
|
|
5618
5896
|
if (t <= 0) continue;
|
|
5619
5897
|
const arr = entriesByTicket.get(t) ?? [];
|
|
5620
5898
|
arr.push(e);
|
|
5621
5899
|
entriesByTicket.set(t, arr);
|
|
5622
5900
|
}
|
|
5623
5901
|
const expenseByTicket = /* @__PURE__ */ new Map();
|
|
5624
|
-
for (const x of expenses) {
|
|
5625
|
-
const t =
|
|
5902
|
+
for (const x of fc.f.join.expenses) {
|
|
5903
|
+
const t = num(x["ticketID"]);
|
|
5626
5904
|
if (t <= 0) continue;
|
|
5627
|
-
expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) +
|
|
5905
|
+
expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num(x["internalCurrencyExpenseAmount"]));
|
|
5628
5906
|
}
|
|
5629
5907
|
const ticketCost = (t) => {
|
|
5630
|
-
const tid =
|
|
5908
|
+
const tid = num(t["id"]);
|
|
5631
5909
|
const es = entriesByTicket.get(tid) ?? [];
|
|
5632
5910
|
let cost = expenseByTicket.get(tid) ?? 0;
|
|
5633
|
-
for (const e of es) cost += entryResolution.get(
|
|
5911
|
+
for (const e of es) cost += fc.entryResolution.get(num(e["id"])).cost ?? 0;
|
|
5634
5912
|
return cost;
|
|
5635
5913
|
};
|
|
5636
|
-
const ticketHours = (t) => (entriesByTicket.get(
|
|
5637
|
-
|
|
5914
|
+
const ticketHours = (t) => (entriesByTicket.get(num(t["id"])) ?? []).reduce((a, e) => a + hoursOf(e), 0);
|
|
5915
|
+
return { entriesByTicket, ticketCost, ticketHours };
|
|
5916
|
+
}
|
|
5917
|
+
function buildActivityCategories(fc, tc) {
|
|
5638
5918
|
const activityCategories = [];
|
|
5639
|
-
if (v.groupByActivity)
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5919
|
+
if (!fc.v.groupByActivity) return activityCategories;
|
|
5920
|
+
const byCat = /* @__PURE__ */ new Map();
|
|
5921
|
+
for (const t of fc.f.join.fetchedTickets.values()) {
|
|
5922
|
+
const cat = num(t["ticketCategory"]) > 0 ? num(t["ticketCategory"]) : null;
|
|
5923
|
+
const arr = byCat.get(cat) ?? [];
|
|
5924
|
+
arr.push(t);
|
|
5925
|
+
byCat.set(cat, arr);
|
|
5926
|
+
}
|
|
5927
|
+
for (const [cat, tickets] of byCat) {
|
|
5928
|
+
let entryCount = 0;
|
|
5929
|
+
let hours = 0;
|
|
5930
|
+
let cost = 0;
|
|
5931
|
+
for (const t of tickets) {
|
|
5932
|
+
const es = tc.entriesByTicket.get(num(t["id"])) ?? [];
|
|
5933
|
+
entryCount += es.length;
|
|
5934
|
+
hours += es.reduce((a, e) => a + hoursOf(e), 0);
|
|
5935
|
+
cost += tc.ticketCost(t);
|
|
5646
5936
|
}
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
cost += ticketCost(t);
|
|
5656
|
-
}
|
|
5657
|
-
activityCategories.push({
|
|
5658
|
-
ticketCategory: cat,
|
|
5659
|
-
ticketCount: tickets.length,
|
|
5660
|
-
entryCount,
|
|
5661
|
-
hours,
|
|
5662
|
-
cost: cents(cost),
|
|
5663
|
-
sample_ticketNumbers: dedupeSortCap(tickets.map((t) => num2(t["ticketNumber"])).filter((n) => n > 0), v.limit)
|
|
5664
|
-
});
|
|
5665
|
-
}
|
|
5666
|
-
activityCategories.sort((a, b) => b.cost - a.cost || (a.ticketCategory === null ? 1 : b.ticketCategory === null ? -1 : (a.ticketCategory ?? 0) - (b.ticketCategory ?? 0)));
|
|
5937
|
+
activityCategories.push({
|
|
5938
|
+
ticketCategory: cat,
|
|
5939
|
+
ticketCount: tickets.length,
|
|
5940
|
+
entryCount,
|
|
5941
|
+
hours,
|
|
5942
|
+
cost: cents(cost),
|
|
5943
|
+
sample_ticketNumbers: dedupeSortCap(tickets.map((t) => num(t["ticketNumber"])).filter((n) => n > 0), fc.v.limit)
|
|
5944
|
+
});
|
|
5667
5945
|
}
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5946
|
+
activityCategories.sort((a, b) => b.cost - a.cost || (a.ticketCategory === null ? 1 : b.ticketCategory === null ? -1 : (a.ticketCategory ?? 0) - (b.ticketCategory ?? 0)));
|
|
5947
|
+
return activityCategories;
|
|
5948
|
+
}
|
|
5949
|
+
function buildHighestCostTickets(fc, tc) {
|
|
5950
|
+
return [...fc.f.join.fetchedTickets.values()].map((t) => ({
|
|
5951
|
+
ticketId: num(t["id"]),
|
|
5952
|
+
ticketNumber: num(t["ticketNumber"]) > 0 ? num(t["ticketNumber"]) : null,
|
|
5671
5953
|
title: typeof t["title"] === "string" ? t["title"] : "",
|
|
5672
5954
|
status: typeof t["status"] === "number" ? t["status"] : null,
|
|
5673
|
-
hours: ticketHours(t),
|
|
5674
|
-
cost: cents(ticketCost(t)),
|
|
5955
|
+
hours: tc.ticketHours(t),
|
|
5956
|
+
cost: cents(tc.ticketCost(t)),
|
|
5675
5957
|
lastActivityDate: typeof t["lastActivityDate"] === "string" ? t["lastActivityDate"] : null
|
|
5676
|
-
})).sort((a, b) => b.cost - a.cost || a.ticketId - b.ticketId).slice(0, v.limit);
|
|
5958
|
+
})).sort((a, b) => b.cost - a.cost || a.ticketId - b.ticketId).slice(0, fc.v.limit);
|
|
5959
|
+
}
|
|
5960
|
+
function buildActivity(fc) {
|
|
5961
|
+
const tc = makeTicketCalc(fc);
|
|
5962
|
+
return { activityCategories: buildActivityCategories(fc, tc), highestCostTickets: buildHighestCostTickets(fc, tc) };
|
|
5963
|
+
}
|
|
5964
|
+
function buildMiscoding(fc, crossTimeEntries, unresolvedRefs) {
|
|
5677
5965
|
const miscoding = /* @__PURE__ */ new Map();
|
|
5678
5966
|
const putMiscoding = (e, reason) => {
|
|
5679
|
-
const eid =
|
|
5967
|
+
const eid = num(e["id"]);
|
|
5680
5968
|
if (eid <= 0 || miscoding.has(eid)) return;
|
|
5681
|
-
const c =
|
|
5682
|
-
miscoding.set(eid, { entryId: eid, reason, ticketId:
|
|
5969
|
+
const c = num(e["contractID"]);
|
|
5970
|
+
miscoding.set(eid, { entryId: eid, reason, ticketId: num(e["ticketID"]) > 0 ? num(e["ticketID"]) : null, contractId: c > 0 ? c : null, hours: num(e["hoursWorked"]) });
|
|
5683
5971
|
};
|
|
5684
|
-
for (const p of probeRows) putMiscoding(p,
|
|
5972
|
+
for (const p of fc.f.join.probeRows) putMiscoding(p, num(p["contractID"]) > 0 ? "ticket_of_other_contract" : "no_contract_on_entry");
|
|
5685
5973
|
for (const e of crossTimeEntries) putMiscoding(e, "ticket_of_other_contract");
|
|
5686
5974
|
for (const e of unresolvedRefs) putMiscoding(e, "ticket_not_in_fetched_set");
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
}
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
for (const s of subscriptions) {
|
|
5706
|
-
openRenewal.push({
|
|
5707
|
-
kind: "subscription",
|
|
5708
|
-
id: num2(s["id"]),
|
|
5709
|
-
label: typeof s["subscriptionName"] === "string" && s["subscriptionName"] !== "" ? s["subscriptionName"] : `subscription ${num2(s["id"])}`,
|
|
5710
|
-
date: typeof s["expirationDate"] === "string" ? s["expirationDate"].slice(0, 10) : null,
|
|
5711
|
-
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`})`
|
|
5712
|
-
});
|
|
5713
|
-
}
|
|
5714
|
-
const contractEndDay = dateToDay(contractRow["endDate"]);
|
|
5715
|
-
if (contractEndDay !== null) {
|
|
5716
|
-
openRenewal.push({
|
|
5717
|
-
kind: "contract_end",
|
|
5718
|
-
id: idNum,
|
|
5719
|
-
label: typeof contractRow["contractName"] === "string" && contractRow["contractName"] !== "" ? contractRow["contractName"] : `contract ${idNum}`,
|
|
5720
|
-
date: String(contractRow["endDate"]).slice(0, 10),
|
|
5721
|
-
detail: `the contract ends ${String(contractRow["endDate"]).slice(0, 10)}`
|
|
5975
|
+
return [...miscoding.values()].sort((a, b) => a.entryId - b.entryId).slice(0, fc.v.limit);
|
|
5976
|
+
}
|
|
5977
|
+
function openTicketItems(fc) {
|
|
5978
|
+
const { openStatusSet } = fc.f.open;
|
|
5979
|
+
const items = [];
|
|
5980
|
+
if (openStatusSet === null) return items;
|
|
5981
|
+
for (const t of fc.f.join.contractTickets) {
|
|
5982
|
+
const status = t["status"];
|
|
5983
|
+
if (typeof status !== "number" || !openStatusSet.has(status)) continue;
|
|
5984
|
+
const last = t["lastActivityDate"];
|
|
5985
|
+
const lastDay = typeof last === "string" ? (fc.asOfMs - Date.parse(last.slice(0, 10))) / 864e5 : null;
|
|
5986
|
+
const staleDays = lastDay !== null && lastDay > fc.v.staleAfterDays ? Math.floor(lastDay) : null;
|
|
5987
|
+
items.push({
|
|
5988
|
+
kind: "ticket",
|
|
5989
|
+
id: num(t["id"]),
|
|
5990
|
+
label: typeof t["title"] === "string" && t["title"] !== "" ? t["title"] : `ticket ${num(t["ticketNumber"]) > 0 ? num(t["ticketNumber"]) : num(t["id"])}`,
|
|
5991
|
+
date: typeof last === "string" ? last.slice(0, 10) : null,
|
|
5992
|
+
detail: staleDays !== null ? `open \u2014 stale ${staleDays} days (last activity)` : "open"
|
|
5722
5993
|
});
|
|
5723
5994
|
}
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5995
|
+
return items;
|
|
5996
|
+
}
|
|
5997
|
+
function subscriptionItems(fc) {
|
|
5998
|
+
const { subscriptions, cancelledSubs, oneTimeSubs, subFactor } = fc.f.subs;
|
|
5999
|
+
return subscriptions.map((s) => ({
|
|
6000
|
+
kind: "subscription",
|
|
6001
|
+
id: num(s["id"]),
|
|
6002
|
+
label: typeof s["subscriptionName"] === "string" && s["subscriptionName"] !== "" ? s["subscriptionName"] : `subscription ${num(s["id"])}`,
|
|
6003
|
+
date: typeof s["expirationDate"] === "string" ? s["expirationDate"].slice(0, 10) : null,
|
|
6004
|
+
detail: `periodType: ${typeof s["periodType"] === "number" ? s["periodType"] : "unknown"} (period cost ${fmtNum(num(s["periodCost"]))}${cancelledSubs.has(num(s["id"])) ? " \u2014 not active, not in tooling_cost.vendor" : oneTimeSubs.has(num(s["id"])) ? " \u2014 one-time, booked in its effective month" : subFactor.get(num(s["id"])) == null ? " \u2014 unrecognised period, not in tooling_cost.vendor" : `, ${fmtNum(num(s["periodCost"]) * subFactor.get(num(s["id"])))}/month`})`
|
|
6005
|
+
}));
|
|
6006
|
+
}
|
|
6007
|
+
function contractEndItems(fc) {
|
|
6008
|
+
const { contractRow } = fc.f.root;
|
|
6009
|
+
if (dateToDay(contractRow["endDate"]) === null) return [];
|
|
6010
|
+
return [{
|
|
6011
|
+
kind: "contract_end",
|
|
6012
|
+
id: fc.idNum,
|
|
6013
|
+
label: typeof contractRow["contractName"] === "string" && contractRow["contractName"] !== "" ? contractRow["contractName"] : `contract ${fc.idNum}`,
|
|
6014
|
+
date: String(contractRow["endDate"]).slice(0, 10),
|
|
6015
|
+
detail: `the contract ends ${String(contractRow["endDate"]).slice(0, 10)}`
|
|
6016
|
+
}];
|
|
6017
|
+
}
|
|
6018
|
+
function hourBlockItems(fc) {
|
|
6019
|
+
const blocks = fc.f.state.blocksPart.items;
|
|
6020
|
+
return blocks.map((b) => {
|
|
6021
|
+
const purchased = num(b["hours"]);
|
|
6022
|
+
const approved = num(b["hoursApproved"]);
|
|
5728
6023
|
const left = purchased - approved;
|
|
5729
|
-
|
|
6024
|
+
return {
|
|
5730
6025
|
kind: "included_hours_burn",
|
|
5731
|
-
id:
|
|
5732
|
-
label: `hour block ${
|
|
6026
|
+
id: num(b["id"]),
|
|
6027
|
+
label: `hour block ${num(b["id"])}`,
|
|
5733
6028
|
date: typeof b["endDate"] === "string" ? b["endDate"].slice(0, 10) : null,
|
|
5734
6029
|
detail: `${fmtHours(approved)} h approved of ${fmtHours(purchased)} h purchased (${left >= 0 ? `${fmtHours(left)} h left` : `${fmtHours(Math.abs(left))} h over`})`
|
|
5735
|
-
}
|
|
5736
|
-
}
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
6030
|
+
};
|
|
6031
|
+
});
|
|
6032
|
+
}
|
|
6033
|
+
function buildOpenRenewal(fc) {
|
|
6034
|
+
return [...openTicketItems(fc), ...subscriptionItems(fc), ...contractEndItems(fc), ...hourBlockItems(fc)];
|
|
6035
|
+
}
|
|
6036
|
+
function buildIncludedHours(fc) {
|
|
6037
|
+
const { contractTypeKnown, isBlockHours } = fc.f.root;
|
|
6038
|
+
const { blocksPart } = fc.f.state;
|
|
6039
|
+
const blocks = blocksPart.items;
|
|
6040
|
+
const purchasedHours = blocks.reduce((a, b) => a + num(b["hours"]), 0);
|
|
6041
|
+
const approvedHours = blocks.reduce((a, b) => a + num(b["hoursApproved"]), 0);
|
|
6042
|
+
return !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 } };
|
|
6043
|
+
}
|
|
6044
|
+
|
|
6045
|
+
// src/resources/special/contracts/profitability.ts
|
|
6046
|
+
function summarizeRows(includeRows) {
|
|
6047
|
+
return (part) => {
|
|
6048
|
+
if (includeRows) return part;
|
|
5742
6049
|
const { items, ...stamps } = part;
|
|
5743
6050
|
return { ...stamps, rows_fetched: items.length };
|
|
5744
6051
|
};
|
|
6052
|
+
}
|
|
6053
|
+
function partStatuses(f, st) {
|
|
6054
|
+
return {
|
|
6055
|
+
contract: "ok",
|
|
6056
|
+
customer: f.root.customerStatus,
|
|
6057
|
+
contractServices: st.servicesPart.status,
|
|
6058
|
+
contractServiceUnits: st.unitsPart.status,
|
|
6059
|
+
contractCharges: f.state.chargesPart.status,
|
|
6060
|
+
contractRoleCosts: st.roleCostsPart.status,
|
|
6061
|
+
contractRates: st.ratesPart.status,
|
|
6062
|
+
contractBlocks: f.state.blocksPart.status,
|
|
6063
|
+
billingItems: f.billing.billingItems.status,
|
|
6064
|
+
invoices: f.billing.invoicesPart.status,
|
|
6065
|
+
timeEntries: f.time.timeEntries.status,
|
|
6066
|
+
ticketsActivity: st.ticketsActivityPart.status,
|
|
6067
|
+
ticketsOpen: f.open.ticketsOpenPart.status,
|
|
6068
|
+
resources: f.res.resourcesPart.status,
|
|
6069
|
+
expenses: f.join.expensesPart.status,
|
|
6070
|
+
miscodingProbe: f.join.probePart.status,
|
|
6071
|
+
configurationItems: f.subs.configurationItemsPart.status,
|
|
6072
|
+
subscriptions: f.subs.subscriptionsPart.status
|
|
6073
|
+
};
|
|
6074
|
+
}
|
|
6075
|
+
function partContainers(fc) {
|
|
6076
|
+
const { f, st } = fc;
|
|
6077
|
+
const rows = summarizeRows(fc.v.includeRows);
|
|
6078
|
+
return {
|
|
6079
|
+
contractServices: rows(st.servicesPart),
|
|
6080
|
+
contractServiceUnits: rows(st.unitsPart),
|
|
6081
|
+
contractCharges: rows(f.state.chargesPart),
|
|
6082
|
+
contractRoleCosts: rows(st.roleCostsPart),
|
|
6083
|
+
contractRates: rows(st.ratesPart),
|
|
6084
|
+
contractBlocks: rows(f.state.blocksPart),
|
|
6085
|
+
billingItems: rows(f.billing.billingItems),
|
|
6086
|
+
invoices: rows(f.billing.invoicesPart),
|
|
6087
|
+
timeEntries: rows(f.time.timeEntries),
|
|
6088
|
+
ticketsActivity: rows(st.ticketsActivityPart),
|
|
6089
|
+
ticketsOpen: rows(f.open.ticketsOpenPart),
|
|
6090
|
+
resources: rows(f.res.resourcesPart),
|
|
6091
|
+
expenses: rows(f.join.expensesPart),
|
|
6092
|
+
miscodingProbe: rows(f.join.probePart),
|
|
6093
|
+
configurationItems: rows(f.subs.configurationItemsPart),
|
|
6094
|
+
subscriptions: rows(f.subs.subscriptionsPart)
|
|
6095
|
+
};
|
|
6096
|
+
}
|
|
6097
|
+
var CURRENCY_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";
|
|
6098
|
+
function assemble(ctx, fc, asOf) {
|
|
6099
|
+
const { v, f } = fc;
|
|
6100
|
+
const { customer } = f.root;
|
|
6101
|
+
const calc = makeMonthCalc(fc);
|
|
6102
|
+
const monthly = buildMonthly(fc, calc);
|
|
6103
|
+
const crossTimeEntries = findCrossTimeEntries(fc);
|
|
6104
|
+
const unresolvedRefs = findUnresolvedRefs(fc);
|
|
6105
|
+
const warnings = buildWarnings(fc, calc, monthly, crossTimeEntries, unresolvedRefs);
|
|
6106
|
+
const { activityCategories, highestCostTickets } = buildActivity(fc);
|
|
6107
|
+
const { contractType, contractTypeKnown, contractTypeLabel, customerAbsent } = f.root;
|
|
5745
6108
|
return {
|
|
5746
|
-
contract,
|
|
6109
|
+
contract: f.root.contract,
|
|
5747
6110
|
customer,
|
|
5748
6111
|
...customerAbsent !== void 0 ? { customerAbsent } : {},
|
|
5749
6112
|
contract_type: contractTypeKnown ? { id: contractType, label: contractTypeLabel } : { id: contractType > 0 ? contractType : null, label: null, absent: "CONTRACT_TYPE_UNKNOWN" },
|
|
5750
|
-
included_hours:
|
|
5751
|
-
period: { dateFrom: v.dateFrom, dateTo: v.dateTo, months, asOf },
|
|
6113
|
+
included_hours: buildIncludedHours(fc),
|
|
6114
|
+
period: { dateFrom: v.dateFrom, dateTo: v.dateTo, months: fc.months, asOf },
|
|
5752
6115
|
currency: {
|
|
5753
6116
|
companyCurrencyID: customer && typeof customer["currencyID"] === "number" ? customer["currencyID"] : null,
|
|
5754
|
-
note:
|
|
6117
|
+
note: CURRENCY_NOTE
|
|
5755
6118
|
},
|
|
5756
6119
|
monthly,
|
|
5757
6120
|
activity_categories: activityCategories,
|
|
5758
6121
|
highest_cost_tickets: highestCostTickets,
|
|
5759
|
-
possible_miscoding:
|
|
5760
|
-
open_renewal_items:
|
|
6122
|
+
possible_miscoding: buildMiscoding(fc, crossTimeEntries, unresolvedRefs),
|
|
6123
|
+
open_renewal_items: buildOpenRenewal(fc),
|
|
5761
6124
|
data_quality_warnings: warnings,
|
|
5762
|
-
costRateResolution,
|
|
5763
|
-
parts:
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
contractServices: servicesPart.status,
|
|
5767
|
-
contractServiceUnits: unitsPart.status,
|
|
5768
|
-
contractCharges: chargesPart.status,
|
|
5769
|
-
contractRoleCosts: roleCostsPart.status,
|
|
5770
|
-
contractRates: ratesPart.status,
|
|
5771
|
-
contractBlocks: blocksPart.status,
|
|
5772
|
-
billingItems: billingItems.status,
|
|
5773
|
-
invoices: invoicesPart.status,
|
|
5774
|
-
timeEntries: timeEntries.status,
|
|
5775
|
-
ticketsActivity: ticketsActivityPart.status,
|
|
5776
|
-
ticketsOpen: ticketsOpenPart.status,
|
|
5777
|
-
resources: resourcesPart.status,
|
|
5778
|
-
expenses: expensesPart.status,
|
|
5779
|
-
miscodingProbe: probePart.status,
|
|
5780
|
-
configurationItems: configurationItemsPart.status,
|
|
5781
|
-
subscriptions: subscriptionsPart.status
|
|
5782
|
-
},
|
|
5783
|
-
contractServices: rows(servicesPart),
|
|
5784
|
-
contractServiceUnits: rows(unitsPart),
|
|
5785
|
-
contractCharges: rows(chargesPart),
|
|
5786
|
-
contractRoleCosts: rows(roleCostsPart),
|
|
5787
|
-
contractRates: rows(ratesPart),
|
|
5788
|
-
contractBlocks: rows(blocksPart),
|
|
5789
|
-
billingItems: rows(billingItems),
|
|
5790
|
-
invoices: rows(invoicesPart),
|
|
5791
|
-
timeEntries: rows(timeEntries),
|
|
5792
|
-
ticketsActivity: rows(ticketsActivityPart),
|
|
5793
|
-
ticketsOpen: rows(ticketsOpenPart),
|
|
5794
|
-
resources: rows(resourcesPart),
|
|
5795
|
-
expenses: rows(expensesPart),
|
|
5796
|
-
miscodingProbe: rows(probePart),
|
|
5797
|
-
configurationItems: rows(configurationItemsPart),
|
|
5798
|
-
subscriptions: rows(subscriptionsPart),
|
|
5799
|
-
wires: { issued: wires.issued, requests: wires.requests, budget: wires.budget, note: wires.note }
|
|
6125
|
+
costRateResolution: fc.costRateResolution,
|
|
6126
|
+
parts: partStatuses(f, fc.st),
|
|
6127
|
+
...partContainers(fc),
|
|
6128
|
+
wires: { issued: ctx.wires.issued, requests: ctx.wires.requests, budget: ctx.wires.budget, note: ctx.wires.note }
|
|
5800
6129
|
};
|
|
5801
6130
|
}
|
|
6131
|
+
async function profitability(resource, contractId, opts, rawHttp) {
|
|
6132
|
+
const v = validateProfitabilityOptions(contractId, opts);
|
|
6133
|
+
const ctx = makeFetchCtx(rawHttp, v, contractId, PROFITABILITY_WIRE_BUDGET);
|
|
6134
|
+
const asOf = (/* @__PURE__ */ new Date()).toISOString();
|
|
6135
|
+
const f = await gather(ctx, resource);
|
|
6136
|
+
const fc = makeFoldCtx(ctx, f, stampListParts(v, f), Date.parse(asOf));
|
|
6137
|
+
return assemble(ctx, fc, asOf);
|
|
6138
|
+
}
|
|
5802
6139
|
|
|
5803
6140
|
// src/resources/contracts.ts
|
|
5804
6141
|
var ContractsResource = class extends BaseResource {
|
|
@@ -7366,18 +7703,18 @@ var ProjectsResource = class extends BaseResource {
|
|
|
7366
7703
|
* closed scope; project picklists stay label-less) + the top-level `names` section;
|
|
7367
7704
|
* `maxNameReads` may lower the name-read budget (default 20), never raise it. */
|
|
7368
7705
|
async getContext(id, opts = {}) {
|
|
7369
|
-
const
|
|
7370
|
-
const root =
|
|
7706
|
+
const project2 = await this.fetchRecordRaw("/V1.0/Projects", id);
|
|
7707
|
+
const root = project2;
|
|
7371
7708
|
const company = await this.contextFetch("/V1.0/Companies", root["companyID"]);
|
|
7372
7709
|
const opportunity = await this.contextFetch("/V1.0/Opportunities", root["opportunityID"]);
|
|
7373
7710
|
const leadResource = await this.contextFetch("/V1.0/Resources", root["projectLeadResourceID"]);
|
|
7374
|
-
const result = { project, company, opportunity, leadResource };
|
|
7711
|
+
const result = { project: project2, company, opportunity, leadResource };
|
|
7375
7712
|
if (opts.names === true) {
|
|
7376
7713
|
const builder = createNamePlan();
|
|
7377
7714
|
builder.addResource(root["creatorResourceID"]);
|
|
7378
7715
|
builder.addFk("contract", root["contractID"]);
|
|
7379
7716
|
const names = await resolveCompositeNames(this.http, builder.plan(), { maxNameReads: opts.maxNameReads });
|
|
7380
|
-
const row = { ...
|
|
7717
|
+
const row = { ...project2 };
|
|
7381
7718
|
addNameSibling(row, "creatorResourceID", (v) => names.resourceName(v));
|
|
7382
7719
|
addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
|
|
7383
7720
|
result.project = row;
|
|
@@ -8013,15 +8350,15 @@ var ResourceSkillsResource = class extends BaseResource {
|
|
|
8013
8350
|
|
|
8014
8351
|
// src/resources/special/resources.ts
|
|
8015
8352
|
var MAX_TRANSFER_IDS = 500;
|
|
8016
|
-
function requirePositiveIds(
|
|
8017
|
-
if (!Array.isArray(
|
|
8353
|
+
function requirePositiveIds(ids2, label) {
|
|
8354
|
+
if (!Array.isArray(ids2) || ids2.length === 0) {
|
|
8018
8355
|
throw new AutotaskConfigError(`${label}: at least one positive integer id is required`);
|
|
8019
8356
|
}
|
|
8020
|
-
if (
|
|
8357
|
+
if (ids2.length > MAX_TRANSFER_IDS) {
|
|
8021
8358
|
throw new AutotaskConfigError(`${label}: at most ${MAX_TRANSFER_IDS} ids are accepted (bounded fan-out)`);
|
|
8022
8359
|
}
|
|
8023
8360
|
const out = [];
|
|
8024
|
-
for (const raw of
|
|
8361
|
+
for (const raw of ids2) {
|
|
8025
8362
|
const id = parseIdentifier(raw, `${label} id`);
|
|
8026
8363
|
if (typeof id === "number" && id < 1) {
|
|
8027
8364
|
throw new AutotaskConfigError(`${label}: every id must be a positive integer (got ${String(raw)})`);
|
|
@@ -8044,16 +8381,16 @@ async function whoAmI(_resource, userName, http) {
|
|
|
8044
8381
|
const matches = unwrapItems(res).items;
|
|
8045
8382
|
if (matches.length === 0) return null;
|
|
8046
8383
|
if (matches.length > 1) {
|
|
8047
|
-
const
|
|
8384
|
+
const ids2 = matches.map((m) => m.id).filter((id) => typeof id === "number");
|
|
8048
8385
|
throw new ResolutionError(
|
|
8049
8386
|
`whoAmI: "${value}" is ambiguous (matched ${matches.length} resources)`,
|
|
8050
|
-
{ code: "RESOLUTION_AMBIGUOUS", resourceIds:
|
|
8387
|
+
{ code: "RESOLUTION_AMBIGUOUS", resourceIds: ids2, details: { idRefs: ids2.map((cid) => ({ entity: "resource", id: cid })) } }
|
|
8051
8388
|
);
|
|
8052
8389
|
}
|
|
8053
8390
|
return matches[0];
|
|
8054
8391
|
}
|
|
8055
8392
|
async function transferOwnership(_resource, companyIds, destinationResourceId, http, opts) {
|
|
8056
|
-
const
|
|
8393
|
+
const ids2 = requirePositiveIds(companyIds, "transferOwnership");
|
|
8057
8394
|
const destination = parseIdentifier(destinationResourceId, "destinationResourceId");
|
|
8058
8395
|
if (typeof destination === "number" && destination < 1) {
|
|
8059
8396
|
throw new AutotaskConfigError("transferOwnership: destinationResourceId must be a positive integer");
|
|
@@ -8061,7 +8398,7 @@ async function transferOwnership(_resource, companyIds, destinationResourceId, h
|
|
|
8061
8398
|
const dryRun = opts?.dryRun ?? true;
|
|
8062
8399
|
const changes = [];
|
|
8063
8400
|
const applied = [];
|
|
8064
|
-
for (const id of
|
|
8401
|
+
for (const id of ids2) {
|
|
8065
8402
|
try {
|
|
8066
8403
|
const res = await http.request({
|
|
8067
8404
|
method: "GET",
|
|
@@ -8087,7 +8424,7 @@ async function transferOwnership(_resource, companyIds, destinationResourceId, h
|
|
|
8087
8424
|
if (!dryRun && err instanceof AutotaskError) {
|
|
8088
8425
|
err.resourceIds = [id];
|
|
8089
8426
|
err.details = mergeDetails(err.details, { idRefs: [{ entity: "company", id }] });
|
|
8090
|
-
err.suggestedAction = `Partial transfer: ${applied.length} of ${
|
|
8427
|
+
err.suggestedAction = `Partial transfer: ${applied.length} of ${ids2.length} companies were re-owned (ids: ${applied.join(", ") || "none"}); id ${id} failed. Re-run transferOwnership for the remaining ids (from ${id} on).`;
|
|
8091
8428
|
}
|
|
8092
8429
|
throw err;
|
|
8093
8430
|
}
|
|
@@ -8100,18 +8437,18 @@ async function transferOwnership(_resource, companyIds, destinationResourceId, h
|
|
|
8100
8437
|
method: "PUT",
|
|
8101
8438
|
path: "/V1.0/Companies",
|
|
8102
8439
|
// the collection (D1: the item path is GET-only)
|
|
8103
|
-
ids,
|
|
8440
|
+
ids: ids2,
|
|
8104
8441
|
checks: [
|
|
8105
8442
|
{ name: "ids-positive-integer", ok: true },
|
|
8106
8443
|
{ name: "destination-valid", ok: true },
|
|
8107
8444
|
{ name: "owner-change-pending", ok: anyChange }
|
|
8108
8445
|
],
|
|
8109
|
-
impact: { affected:
|
|
8446
|
+
impact: { affected: ids2.length, scope: "companies ownerResourceID", reversible: true },
|
|
8110
8447
|
warnings: [
|
|
8111
|
-
`the per-record request fans out to ${
|
|
8448
|
+
`the per-record request fans out to ${ids2.length} id${ids2.length === 1 ? "" : "s"} (bounded ${MAX_TRANSFER_IDS})`
|
|
8112
8449
|
]
|
|
8113
8450
|
}, { redactPersonalData: http.redactPersonalData }),
|
|
8114
|
-
target: { resource: "companies", ids },
|
|
8451
|
+
target: { resource: "companies", ids: ids2 },
|
|
8115
8452
|
changes
|
|
8116
8453
|
};
|
|
8117
8454
|
}
|
|
@@ -9731,6 +10068,230 @@ var TicketChangeRequestApprovalsResource = class extends BaseResource {
|
|
|
9731
10068
|
}
|
|
9732
10069
|
};
|
|
9733
10070
|
|
|
10071
|
+
// src/resources/special/_shared/move-engine.ts
|
|
10072
|
+
import { randomUUID } from "crypto";
|
|
10073
|
+
var MOVE_SOURCE_AGE_DEFAULT_DAYS = 14;
|
|
10074
|
+
var TICKET_COMPLETE_STATUS = 5;
|
|
10075
|
+
var MOVE_EFFECTS_NOTE = "Create then delete: the new record gets a fresh create date and creator; creating/deleting may trigger notifications, SLA and workflow rules the API cannot suppress; vendor acceptance of the create is not validated by a dry run";
|
|
10076
|
+
var refuse = (code, message, ids2, operation, extra = {}) => new MoveRefusedError(message, { code, operation, details: { status: "failed", ...ids2, ...extra } });
|
|
10077
|
+
async function queryRows(http, resource, filter, operation, retries) {
|
|
10078
|
+
const res = await http.request({ method: "POST", idempotent: true, path: `/V1.0/${resource}/query`, body: { MaxRecords: 5, filter }, operation, maxRetries: retries });
|
|
10079
|
+
return unwrapItems(res).items;
|
|
10080
|
+
}
|
|
10081
|
+
function sameValue(a, b) {
|
|
10082
|
+
if (a === null || a === void 0 || a === "") return b === null || b === void 0 || b === "";
|
|
10083
|
+
if (b === null || b === void 0 || b === "") return false;
|
|
10084
|
+
if (typeof a === "number" || typeof b === "number") return Math.abs(Number(a) - Number(b)) < 1e-9;
|
|
10085
|
+
if (typeof a === "boolean" || typeof b === "boolean") return a === b;
|
|
10086
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
10087
|
+
const iso3 = /^\d{4}-\d{2}-\d{2}T/;
|
|
10088
|
+
if (iso3.test(a) && iso3.test(b)) return Date.parse(a) === Date.parse(b);
|
|
10089
|
+
return a.trim() === b.trim();
|
|
10090
|
+
}
|
|
10091
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
10092
|
+
}
|
|
10093
|
+
async function readTicket(http, id, retries, operation) {
|
|
10094
|
+
try {
|
|
10095
|
+
const res = await http.request({ method: "GET", path: `/V1.0/Tickets/${id}`, operation, maxRetries: retries });
|
|
10096
|
+
const item = res?.item;
|
|
10097
|
+
return item ?? null;
|
|
10098
|
+
} catch (e) {
|
|
10099
|
+
if (isAutotaskError(e) && (e.httpStatus === 404 || e.code === "NOT_FOUND")) return null;
|
|
10100
|
+
throw e;
|
|
10101
|
+
}
|
|
10102
|
+
}
|
|
10103
|
+
async function preview(http, spec, sourceId, toTicketId, opts) {
|
|
10104
|
+
const op = spec.operation;
|
|
10105
|
+
const ids2 = { sourceId, toTicketId };
|
|
10106
|
+
const source = await spec.readSource(http, sourceId, opts.maxRetries);
|
|
10107
|
+
if (source === null) throw refuse("MOVE_SOURCE_NOT_FOUND", `${op}: source ${spec.entity} ${sourceId} does not exist`, ids2, op);
|
|
10108
|
+
const sourceTicketId = spec.sourceTicketId(source);
|
|
10109
|
+
if (typeof sourceTicketId !== "number") {
|
|
10110
|
+
throw refuse("MOVE_SOURCE_UNSUPPORTED", `${op}: source ${spec.entity} ${sourceId} is not attached to a ticket (task and internal records are not moved)`, ids2, op);
|
|
10111
|
+
}
|
|
10112
|
+
if (sourceTicketId === toTicketId) throw refuse("MOVE_TARGET_SAME", `${op}: target ticket ${toTicketId} is the source's current ticket`, ids2, op);
|
|
10113
|
+
if (opts.lastModifiedDate !== void 0) {
|
|
10114
|
+
const current = source.lastModifiedDateTime ?? source.lastModifiedDate;
|
|
10115
|
+
if (current !== opts.lastModifiedDate) {
|
|
10116
|
+
throw refuse("STALE_OBJECT", `${op}: source ${spec.entity} ${sourceId} was modified since the snapshot`, ids2, op);
|
|
10117
|
+
}
|
|
10118
|
+
}
|
|
10119
|
+
const cap = opts.maxSourceAgeDays === void 0 ? MOVE_SOURCE_AGE_DEFAULT_DAYS : Math.min(MOVE_SOURCE_AGE_DEFAULT_DAYS, opts.maxSourceAgeDays);
|
|
10120
|
+
if (!Number.isFinite(cap) || cap < 0) throw new AutotaskConfigError(`${op}: maxSourceAgeDays must be a number >= 0`);
|
|
10121
|
+
const when = Date.parse(String(source[spec.dateField] ?? ""));
|
|
10122
|
+
if (!Number.isFinite(when) || Date.now() - when > cap * 864e5) {
|
|
10123
|
+
throw refuse("MOVE_SOURCE_TOO_OLD", `${op}: source ${spec.entity} ${sourceId} is older than ${cap} day(s) (or undated) \u2014 move it in the Autotask UI`, ids2, op, { manualAction: manualFor(http, spec, source, "older than the move age limit") });
|
|
10124
|
+
}
|
|
10125
|
+
const target = await readTicket(http, toTicketId, opts.maxRetries, op);
|
|
10126
|
+
if (target === null) throw refuse("MOVE_TARGET_MISSING", `${op}: target ticket ${toTicketId} does not exist`, ids2, op);
|
|
10127
|
+
if (target.status === TICKET_COMPLETE_STATUS || target.completedDate !== void 0 && target.completedDate !== null) {
|
|
10128
|
+
throw refuse("MOVE_TARGET_CLOSED", `${op}: target ticket ${toTicketId} is closed (status 5 or completed)`, ids2, op);
|
|
10129
|
+
}
|
|
10130
|
+
const origin = await readTicket(http, sourceTicketId, opts.maxRetries, op);
|
|
10131
|
+
const facts = {
|
|
10132
|
+
sourceTicketId,
|
|
10133
|
+
sourceCompanyID: origin?.companyID ?? null,
|
|
10134
|
+
targetCompanyID: target.companyID ?? null,
|
|
10135
|
+
companyChanged: origin === null ? null : origin.companyID !== target.companyID,
|
|
10136
|
+
contractChanged: origin === null ? null : (origin.contractID ?? null) !== (target.contractID ?? null)
|
|
10137
|
+
};
|
|
10138
|
+
const ctx = { http, spec, sourceId, source, sourceTicketId, toTicketId, facts, retries: opts.maxRetries };
|
|
10139
|
+
await spec.checkSource(ctx);
|
|
10140
|
+
const sameScope = facts.companyChanged === false && facts.contractChanged === false;
|
|
10141
|
+
const payload = spec.buildPayload(source, toTicketId, { sameScope });
|
|
10142
|
+
await assertNoTwin(ctx, payload);
|
|
10143
|
+
return { ctx, payload };
|
|
10144
|
+
}
|
|
10145
|
+
function manualFor(http, spec, source, reason) {
|
|
10146
|
+
return { action: "delete_source", uiLink: spec.manualLink(http, source), reason };
|
|
10147
|
+
}
|
|
10148
|
+
async function assertNoTwin(ctx, payload) {
|
|
10149
|
+
const q = ctx.spec.twinQuery(payload);
|
|
10150
|
+
const rows = await queryRows(ctx.http, q.resource, q.filter, ctx.spec.operation, ctx.retries);
|
|
10151
|
+
if (rows.length > 0) {
|
|
10152
|
+
throw refuse("MOVE_TARGET_HAS_TWIN", `${ctx.spec.operation}: target ticket ${ctx.toTicketId} already has an equivalent ${ctx.spec.entity} (id ${String(rows[0].id)}) \u2014 a duplicate or resumed move`, { sourceId: ctx.sourceId, toTicketId: ctx.toTicketId }, ctx.spec.operation, { twinId: rows[0].id });
|
|
10153
|
+
}
|
|
10154
|
+
}
|
|
10155
|
+
var statusOf = (e) => isAutotaskError(e) ? e.httpStatus : void 0;
|
|
10156
|
+
async function runMove(http, spec, sourceIdIn, toTicketIn, opts = {}) {
|
|
10157
|
+
const op = spec.operation;
|
|
10158
|
+
const sourceId = Number(parseIdentifier(sourceIdIn, "sourceId"));
|
|
10159
|
+
const toTicketId = Number(parseIdentifier(toTicketIn, "toTicketId"));
|
|
10160
|
+
if (!Number.isSafeInteger(sourceId) || !Number.isSafeInteger(toTicketId)) {
|
|
10161
|
+
throw new AutotaskConfigError(`${op}: sourceId and toTicketId must be safe integers`);
|
|
10162
|
+
}
|
|
10163
|
+
const { ctx, payload } = await preview(http, spec, sourceId, toTicketId, opts);
|
|
10164
|
+
if (opts.dryRun === true) {
|
|
10165
|
+
return buildDryRunResult(
|
|
10166
|
+
{
|
|
10167
|
+
operation: op,
|
|
10168
|
+
method: "POST",
|
|
10169
|
+
path: spec.createPath(toTicketId),
|
|
10170
|
+
ids: [sourceId],
|
|
10171
|
+
checks: [
|
|
10172
|
+
{ name: "source-exists", ok: true },
|
|
10173
|
+
{ name: "target-open", ok: true },
|
|
10174
|
+
{ name: "invariants", ok: true },
|
|
10175
|
+
{ name: "no-twin-on-target", ok: true }
|
|
10176
|
+
],
|
|
10177
|
+
impact: { affected: 2, scope: "create-then-delete", reversible: false },
|
|
10178
|
+
warnings: [MOVE_EFFECTS_NOTE, ...ctx.facts.companyChanged === true ? ["the target ticket belongs to a different company"] : []],
|
|
10179
|
+
data: payload
|
|
10180
|
+
},
|
|
10181
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
10182
|
+
);
|
|
10183
|
+
}
|
|
10184
|
+
const correlationId = randomUUID();
|
|
10185
|
+
const ids2 = { sourceId, toTicketId, correlationId };
|
|
10186
|
+
await assertNoTwin(ctx, payload);
|
|
10187
|
+
let newId;
|
|
10188
|
+
try {
|
|
10189
|
+
const created = await http.request({ method: "POST", path: spec.createPath(toTicketId), body: payload, operation: op, maxRetries: 0 });
|
|
10190
|
+
newId = Number(unwrapOperation(created, op).id);
|
|
10191
|
+
} catch (e) {
|
|
10192
|
+
const status = statusOf(e);
|
|
10193
|
+
if (status !== void 0 && status >= 400 && status < 500) {
|
|
10194
|
+
throw new MoveRefusedError(`${op}: the vendor refused the create \u2014 nothing changed`, { code: "MOVE_CREATE_REFUSED", operation: op, httpStatus: status, details: { status: "failed", ...ids2 } });
|
|
10195
|
+
}
|
|
10196
|
+
throw new MoveRefusedError(`${op}: the create outcome is unknown (never resent) \u2014 check the target ticket for a new ${spec.entity} before retrying; the twin check blocks a second create`, { code: "MOVE_UNKNOWN", operation: op, details: { status: "unknown", ...ids2 } });
|
|
10197
|
+
}
|
|
10198
|
+
const rollback = async (code, message) => {
|
|
10199
|
+
try {
|
|
10200
|
+
await http.request({ method: "DELETE", path: spec.deletePath(newId, toTicketId), operation: op, maxRetries: 0 });
|
|
10201
|
+
} catch (re) {
|
|
10202
|
+
const unreadable = statusOf(re) === void 0 || (statusOf(re) ?? 0) >= 500;
|
|
10203
|
+
throw new MoveRefusedError(`${op}: ${message}; rolling back the new ${spec.entity} ${newId} FAILED \u2014 both records exist`, { code: unreadable ? "MOVE_UNKNOWN" : "MOVE_PARTIAL", operation: op, details: { status: unreadable ? "unknown" : "partial", ...ids2, newId, reason: code } });
|
|
10204
|
+
}
|
|
10205
|
+
throw new MoveRefusedError(`${op}: ${message}; the new ${spec.entity} ${newId} was rolled back, the source is unchanged`, { code, operation: op, details: { status: "failed", ...ids2, newId } });
|
|
10206
|
+
};
|
|
10207
|
+
const fresh = await spec.readById(http, newId, toTicketId).catch(() => null);
|
|
10208
|
+
if (fresh === null) return rollback("MOVE_VERIFY_FAILED", "the new record could not be read back");
|
|
10209
|
+
const mismatched = [...spec.verifyFields, "ticketID"].filter((f) => !sameValue(f === "ticketID" ? toTicketId : payload[f], fresh[f]));
|
|
10210
|
+
if (mismatched.length > 0) return rollback("MOVE_VERIFY_FAILED", `the read-back differs on ${mismatched.join(", ")}`);
|
|
10211
|
+
const again = await spec.readSource(http, sourceId, void 0).catch(() => null);
|
|
10212
|
+
if (again === null) {
|
|
10213
|
+
return { status: "moved_source_absent", sourceId, newId, correlationId, snapshot: ctx.source, facts: ctx.facts };
|
|
10214
|
+
}
|
|
10215
|
+
const changed = [...spec.guardFields].filter((f) => !sameValue(ctx.source[f], again[f]));
|
|
10216
|
+
if (changed.length > 0) return rollback("MOVE_SOURCE_CHANGED", `the source changed after the preview (${changed.join(", ")})`);
|
|
10217
|
+
try {
|
|
10218
|
+
await http.request({ method: "DELETE", path: spec.deletePath(sourceId, ctx.sourceTicketId), operation: op, maxRetries: 0 });
|
|
10219
|
+
} catch (e) {
|
|
10220
|
+
const status = statusOf(e);
|
|
10221
|
+
if (status === 404) return { status: "moved_source_absent", sourceId, newId, correlationId, snapshot: ctx.source, facts: ctx.facts };
|
|
10222
|
+
if (status !== void 0 && status >= 400 && status < 500) {
|
|
10223
|
+
return {
|
|
10224
|
+
status: "moved_manual_delete",
|
|
10225
|
+
sourceId,
|
|
10226
|
+
newId,
|
|
10227
|
+
correlationId,
|
|
10228
|
+
snapshot: ctx.source,
|
|
10229
|
+
facts: ctx.facts,
|
|
10230
|
+
manualAction: manualFor(http, spec, ctx.source, `the vendor refused to delete the source (HTTP ${status}); the verified copy exists on the target \u2014 delete the source in the Autotask UI`)
|
|
10231
|
+
};
|
|
10232
|
+
}
|
|
10233
|
+
if (status === void 0) {
|
|
10234
|
+
throw new MoveRefusedError(`${op}: the source delete outcome is unknown (never resent) \u2014 re-read the source: absent means the move completed`, { code: "MOVE_UNKNOWN", operation: op, details: { status: "unknown", ...ids2, newId } });
|
|
10235
|
+
}
|
|
10236
|
+
return rollback("MOVE_SOURCE_DELETE_FAILED", `the source delete failed (HTTP ${status})`);
|
|
10237
|
+
}
|
|
10238
|
+
return { status: "moved", sourceId, newId, correlationId, snapshot: ctx.source, facts: ctx.facts };
|
|
10239
|
+
}
|
|
10240
|
+
function uiLinkFor(http, entity, id) {
|
|
10241
|
+
return buildUiLink({ entity, id, baseUrl: http.baseUrl }) ?? null;
|
|
10242
|
+
}
|
|
10243
|
+
|
|
10244
|
+
// src/resources/special/ticket_charges.ts
|
|
10245
|
+
var OP2 = "ticket_charges.moveToTicket";
|
|
10246
|
+
var COPY = ["name", "description", "chargeType", "billingCodeID", "productID", "unitQuantity", "unitCost", "unitPrice", "datePurchased", "isBillableToCompany", "notes", "purchaseOrderNumber", "internalPurchaseOrderNumber"];
|
|
10247
|
+
var CONTRACT_SCOPED = ["contractServiceID", "contractServiceBundleID"];
|
|
10248
|
+
async function readCharge(http, id, retries) {
|
|
10249
|
+
const rows = await queryRows(http, "TicketCharges", [{ field: "id", op: "eq", value: id }], OP2, retries);
|
|
10250
|
+
return rows[0] ?? null;
|
|
10251
|
+
}
|
|
10252
|
+
async function checkSource({ http, source, sourceId, sourceTicketId, toTicketId }) {
|
|
10253
|
+
if (source.isBilled === true) {
|
|
10254
|
+
throw refuse("MOVE_BLOCKED_BILLED", `${OP2}: ticket charge ${sourceId} is billed \u2014 move it in the Autotask UI`, { sourceId, toTicketId }, OP2, {
|
|
10255
|
+
manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "tickets", sourceTicketId), reason: "billed charges are not moved by the API (the ticket link opens its charges)" }
|
|
10256
|
+
});
|
|
10257
|
+
}
|
|
10258
|
+
}
|
|
10259
|
+
var SPEC = {
|
|
10260
|
+
operation: OP2,
|
|
10261
|
+
entity: "ticket_charge",
|
|
10262
|
+
readSource: readCharge,
|
|
10263
|
+
sourceTicketId: (s) => typeof s.ticketID === "number" ? s.ticketID : null,
|
|
10264
|
+
dateField: "datePurchased",
|
|
10265
|
+
checkSource,
|
|
10266
|
+
buildPayload(source, toTicketId, { sameScope }) {
|
|
10267
|
+
const body = { ticketID: toTicketId };
|
|
10268
|
+
for (const k of COPY) if (source[k] !== void 0 && source[k] !== null) body[k] = source[k];
|
|
10269
|
+
if (sameScope) {
|
|
10270
|
+
for (const k of CONTRACT_SCOPED) if (source[k] !== void 0 && source[k] !== null) body[k] = source[k];
|
|
10271
|
+
}
|
|
10272
|
+
return body;
|
|
10273
|
+
},
|
|
10274
|
+
createPath: (toTicketId) => `/V1.0/Tickets/${toTicketId}/Charges`,
|
|
10275
|
+
deletePath: (id, ticketId) => `/V1.0/Tickets/${ticketId}/Charges/${id}`,
|
|
10276
|
+
readById: (http, id) => readCharge(http, id),
|
|
10277
|
+
verifyFields: ["name", "chargeType", "billingCodeID", "unitQuantity", "unitCost", "unitPrice", "datePurchased", "isBillableToCompany"],
|
|
10278
|
+
guardFields: ["ticketID", "name", "chargeType", "billingCodeID", "unitQuantity", "unitCost", "unitPrice", "datePurchased", "isBilled"],
|
|
10279
|
+
twinQuery: (p) => ({
|
|
10280
|
+
resource: "TicketCharges",
|
|
10281
|
+
filter: [
|
|
10282
|
+
{ field: "ticketID", op: "eq", value: p.ticketID },
|
|
10283
|
+
{ field: "name", op: "eq", value: p.name },
|
|
10284
|
+
{ field: "unitQuantity", op: "eq", value: p.unitQuantity },
|
|
10285
|
+
{ field: "datePurchased", op: "eq", value: p.datePurchased }
|
|
10286
|
+
]
|
|
10287
|
+
}),
|
|
10288
|
+
// charges have no ExecuteCommand code: the parent ticket is the closest clickable target
|
|
10289
|
+
manualLink: (http, source) => typeof source.ticketID === "number" ? uiLinkFor(http, "tickets", source.ticketID) : null
|
|
10290
|
+
};
|
|
10291
|
+
function moveToTicket(_resource, sourceId, toTicketId, opts, http) {
|
|
10292
|
+
return runMove(http, SPEC, sourceId, toTicketId, opts);
|
|
10293
|
+
}
|
|
10294
|
+
|
|
9734
10295
|
// src/resources/ticket_charges.ts
|
|
9735
10296
|
var TicketChargesResource = class extends BaseResource {
|
|
9736
10297
|
constructor(http, pageSize) {
|
|
@@ -9800,6 +10361,10 @@ var TicketChargesResource = class extends BaseResource {
|
|
|
9800
10361
|
async search(query, opts) {
|
|
9801
10362
|
return this.searchBase(query, opts);
|
|
9802
10363
|
}
|
|
10364
|
+
/** Node-parity special op (hand-written in src/resources/special/ticket_charges.ts). */
|
|
10365
|
+
moveToTicket(sourceId, toTicketId, opts = {}) {
|
|
10366
|
+
return moveToTicket(this, sourceId, toTicketId, opts, this.http);
|
|
10367
|
+
}
|
|
9803
10368
|
};
|
|
9804
10369
|
|
|
9805
10370
|
// src/resources/ticket_checklist_items.ts
|
|
@@ -10221,9 +10786,7 @@ var TicketSecondaryResourcesResource = class extends BaseResource {
|
|
|
10221
10786
|
}
|
|
10222
10787
|
};
|
|
10223
10788
|
|
|
10224
|
-
// src/resources/special/tickets.ts
|
|
10225
|
-
var MS = 864e5;
|
|
10226
|
-
var SLA_PAGE = 100;
|
|
10789
|
+
// src/resources/special/tickets/common.ts
|
|
10227
10790
|
async function fetchTicket(http, id, operation) {
|
|
10228
10791
|
const res = await http.request({
|
|
10229
10792
|
method: "GET",
|
|
@@ -10247,6 +10810,13 @@ async function ticketQueryItems(http, path, filter, maxRecords, operation) {
|
|
|
10247
10810
|
});
|
|
10248
10811
|
return unwrapItems(res).items;
|
|
10249
10812
|
}
|
|
10813
|
+
function truncate(text, limit) {
|
|
10814
|
+
const full = text ?? "";
|
|
10815
|
+
if (full.length <= limit) return { value: full, truncated: false, length: full.length };
|
|
10816
|
+
return { value: `${full.slice(0, limit)}\u2026`, truncated: true, length: full.length };
|
|
10817
|
+
}
|
|
10818
|
+
|
|
10819
|
+
// src/resources/special/tickets/childRead.ts
|
|
10250
10820
|
function isRouteUnavailable(err) {
|
|
10251
10821
|
return isAutotaskError(err) && (err.httpStatus === 404 || err.httpStatus === 500);
|
|
10252
10822
|
}
|
|
@@ -10340,11 +10910,10 @@ function applyRouteStatus(part, read) {
|
|
|
10340
10910
|
if (read.status === "ok-scoped") part.route = "scoped";
|
|
10341
10911
|
if (read.status === "miss") part.routeFacts = read.routeFacts;
|
|
10342
10912
|
}
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
}
|
|
10913
|
+
|
|
10914
|
+
// src/resources/special/tickets/sla.ts
|
|
10915
|
+
var MS = 864e5;
|
|
10916
|
+
var SLA_PAGE = 100;
|
|
10348
10917
|
async function slaHealthCheck(_resource, id, opts = {}, http) {
|
|
10349
10918
|
void opts;
|
|
10350
10919
|
const ticketId = parseIdentifier(id, "id");
|
|
@@ -10358,11 +10927,11 @@ async function slaHealthCheck(_resource, id, opts = {}, http) {
|
|
|
10358
10927
|
);
|
|
10359
10928
|
const slas = rows.map((row) => {
|
|
10360
10929
|
const bool = (v) => typeof v === "boolean" ? v : null;
|
|
10361
|
-
const
|
|
10930
|
+
const num2 = (v) => typeof v === "number" ? v : null;
|
|
10362
10931
|
const milestones = [
|
|
10363
|
-
{ name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours:
|
|
10364
|
-
{ name: "resolution", met: bool(row.isResolutionMet), elapsedHours:
|
|
10365
|
-
{ name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours:
|
|
10932
|
+
{ name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num2(row.firstResponseElapsedHours) },
|
|
10933
|
+
{ name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num2(row.resolutionElapsedHours) },
|
|
10934
|
+
{ name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num2(row.resolutionPlanElapsedHours) }
|
|
10366
10935
|
];
|
|
10367
10936
|
return { id: Number(row.id), name: row.serviceLevelAgreementName ?? null, milestones };
|
|
10368
10937
|
});
|
|
@@ -10399,7 +10968,9 @@ async function summary(_resource, id, opts = {}, http) {
|
|
|
10399
10968
|
ageDays
|
|
10400
10969
|
};
|
|
10401
10970
|
}
|
|
10402
|
-
|
|
10971
|
+
|
|
10972
|
+
// src/resources/special/tickets/timeline.ts
|
|
10973
|
+
function validateTimelineLimits(opts) {
|
|
10403
10974
|
const limit = opts.limit === void 0 ? 100 : Math.trunc(opts.limit);
|
|
10404
10975
|
if (!Number.isInteger(limit) || limit < 1) throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(opts.limit)})`);
|
|
10405
10976
|
const bounded = Math.min(limit, 500);
|
|
@@ -10407,6 +10978,56 @@ async function timeline(_resource, id, opts = {}, http) {
|
|
|
10407
10978
|
if (!Number.isInteger(textLimit) || textLimit < 1) {
|
|
10408
10979
|
throw new AutotaskConfigError(`textLimit must be an integer >= 1 (got ${String(opts.textLimit)})`);
|
|
10409
10980
|
}
|
|
10981
|
+
return { bounded, textLimit };
|
|
10982
|
+
}
|
|
10983
|
+
function noteEvent(n, textLimit) {
|
|
10984
|
+
const text = truncate(typeof n.description === "string" ? n.description : null, textLimit);
|
|
10985
|
+
return {
|
|
10986
|
+
kind: "note",
|
|
10987
|
+
id: Number(n.id),
|
|
10988
|
+
date: typeof n.createDateTime === "string" ? n.createDateTime : null,
|
|
10989
|
+
text: text.value || null,
|
|
10990
|
+
title: typeof n.title === "string" ? n.title : null,
|
|
10991
|
+
hours: null,
|
|
10992
|
+
action: null,
|
|
10993
|
+
textTruncated: text.truncated
|
|
10994
|
+
};
|
|
10995
|
+
}
|
|
10996
|
+
function timeEntryEvent(t) {
|
|
10997
|
+
return {
|
|
10998
|
+
kind: "timeEntry",
|
|
10999
|
+
id: Number(t.id),
|
|
11000
|
+
date: typeof t.dateWorked === "string" ? t.dateWorked : null,
|
|
11001
|
+
text: null,
|
|
11002
|
+
title: null,
|
|
11003
|
+
hours: typeof t.hoursWorked === "number" ? t.hoursWorked : null,
|
|
11004
|
+
action: null,
|
|
11005
|
+
textTruncated: false
|
|
11006
|
+
};
|
|
11007
|
+
}
|
|
11008
|
+
function historyEvent(h, textLimit) {
|
|
11009
|
+
const text = truncate(typeof h.detail === "string" ? h.detail : null, textLimit);
|
|
11010
|
+
return {
|
|
11011
|
+
kind: "history",
|
|
11012
|
+
id: Number(h.id),
|
|
11013
|
+
date: typeof h.date === "string" ? h.date : null,
|
|
11014
|
+
text: text.value || null,
|
|
11015
|
+
title: null,
|
|
11016
|
+
hours: null,
|
|
11017
|
+
action: typeof h.action === "string" ? h.action : null,
|
|
11018
|
+
textTruncated: text.truncated
|
|
11019
|
+
};
|
|
11020
|
+
}
|
|
11021
|
+
function compareEventDates(a, b) {
|
|
11022
|
+
const da = a.date ?? "";
|
|
11023
|
+
const db = b.date ?? "";
|
|
11024
|
+
if (da === "" && db === "") return 0;
|
|
11025
|
+
if (da === "") return 1;
|
|
11026
|
+
if (db === "") return -1;
|
|
11027
|
+
return da < db ? -1 : da > db ? 1 : 0;
|
|
11028
|
+
}
|
|
11029
|
+
async function timeline(_resource, id, opts = {}, http) {
|
|
11030
|
+
const { bounded, textLimit } = validateTimelineLimits(opts);
|
|
10410
11031
|
const ticketId = parseIdentifier(id, "id");
|
|
10411
11032
|
await fetchTicket(http, ticketId, "tickets.timeline");
|
|
10412
11033
|
const dateFilters = (field) => {
|
|
@@ -10419,59 +11040,17 @@ async function timeline(_resource, id, opts = {}, http) {
|
|
|
10419
11040
|
const cappedSources = [];
|
|
10420
11041
|
const notes = await ticketChildRead(http, { globalPath: "/V1.0/TicketNotes/query", resourceKey: "ticket_notes", filter: dateFilters("createDateTime"), limit: bounded, operation: "tickets.timeline" }, ticketId);
|
|
10421
11042
|
if (notes.capped) cappedSources.push("notes");
|
|
10422
|
-
for (const n of notes.items)
|
|
10423
|
-
const text = truncate(typeof n.description === "string" ? n.description : null, textLimit);
|
|
10424
|
-
events.push({
|
|
10425
|
-
kind: "note",
|
|
10426
|
-
id: Number(n.id),
|
|
10427
|
-
date: typeof n.createDateTime === "string" ? n.createDateTime : null,
|
|
10428
|
-
text: text.value || null,
|
|
10429
|
-
title: typeof n.title === "string" ? n.title : null,
|
|
10430
|
-
hours: null,
|
|
10431
|
-
action: null,
|
|
10432
|
-
textTruncated: text.truncated
|
|
10433
|
-
});
|
|
10434
|
-
}
|
|
11043
|
+
for (const n of notes.items) events.push(noteEvent(n, textLimit));
|
|
10435
11044
|
const entries = await ticketChildRead(http, { globalPath: "/V1.0/TimeEntries/query", resourceKey: "time_entries", filter: dateFilters("dateWorked"), limit: bounded, operation: "tickets.timeline" }, ticketId);
|
|
10436
11045
|
if (entries.capped) cappedSources.push("timeEntries");
|
|
10437
|
-
for (const t of entries.items)
|
|
10438
|
-
events.push({
|
|
10439
|
-
kind: "timeEntry",
|
|
10440
|
-
id: Number(t.id),
|
|
10441
|
-
date: typeof t.dateWorked === "string" ? t.dateWorked : null,
|
|
10442
|
-
text: null,
|
|
10443
|
-
title: null,
|
|
10444
|
-
hours: typeof t.hoursWorked === "number" ? t.hoursWorked : null,
|
|
10445
|
-
action: null,
|
|
10446
|
-
textTruncated: false
|
|
10447
|
-
});
|
|
10448
|
-
}
|
|
11046
|
+
for (const t of entries.items) events.push(timeEntryEvent(t));
|
|
10449
11047
|
let historiesRead;
|
|
10450
11048
|
if (opts.includeHistories) {
|
|
10451
11049
|
historiesRead = await ticketChildRead(http, { globalPath: "/V1.0/TicketHistory/query", resourceKey: "ticket_history", filter: dateFilters("date"), limit: bounded, operation: "tickets.timeline" }, ticketId);
|
|
10452
11050
|
if (historiesRead.capped) cappedSources.push("histories");
|
|
10453
|
-
for (const h of historiesRead.items)
|
|
10454
|
-
const text = truncate(typeof h.detail === "string" ? h.detail : null, textLimit);
|
|
10455
|
-
events.push({
|
|
10456
|
-
kind: "history",
|
|
10457
|
-
id: Number(h.id),
|
|
10458
|
-
date: typeof h.date === "string" ? h.date : null,
|
|
10459
|
-
text: text.value || null,
|
|
10460
|
-
title: null,
|
|
10461
|
-
hours: null,
|
|
10462
|
-
action: typeof h.action === "string" ? h.action : null,
|
|
10463
|
-
textTruncated: text.truncated
|
|
10464
|
-
});
|
|
10465
|
-
}
|
|
11051
|
+
for (const h of historiesRead.items) events.push(historyEvent(h, textLimit));
|
|
10466
11052
|
}
|
|
10467
|
-
events.sort(
|
|
10468
|
-
const da = a.date ?? "";
|
|
10469
|
-
const db = b.date ?? "";
|
|
10470
|
-
if (da === "" && db === "") return 0;
|
|
10471
|
-
if (da === "") return 1;
|
|
10472
|
-
if (db === "") return -1;
|
|
10473
|
-
return da < db ? -1 : da > db ? 1 : 0;
|
|
10474
|
-
});
|
|
11053
|
+
events.sort(compareEventDates);
|
|
10475
11054
|
const capped = events.slice(0, bounded);
|
|
10476
11055
|
const result = {
|
|
10477
11056
|
ticketId,
|
|
@@ -10487,6 +11066,64 @@ async function timeline(_resource, id, opts = {}, http) {
|
|
|
10487
11066
|
}
|
|
10488
11067
|
return result;
|
|
10489
11068
|
}
|
|
11069
|
+
|
|
11070
|
+
// src/resources/special/tickets/errors.ts
|
|
11071
|
+
var QueueRequiredError = class extends AutotaskError {
|
|
11072
|
+
constructor(message, options = {}) {
|
|
11073
|
+
super(message, {
|
|
11074
|
+
code: "QUEUE_REQUIRED",
|
|
11075
|
+
category: "validation",
|
|
11076
|
+
suggestedAction: "pass params.queueID (discover via autotask_describe(tickets.create) or a known queue id)",
|
|
11077
|
+
...options
|
|
11078
|
+
});
|
|
11079
|
+
this.name = "QueueRequiredError";
|
|
11080
|
+
}
|
|
11081
|
+
};
|
|
11082
|
+
var TicketResourceInvalidError = class extends AutotaskError {
|
|
11083
|
+
constructor(message, options = {}) {
|
|
11084
|
+
super(message, { code: "RESOURCE_INVALID", category: "validation", ...options });
|
|
11085
|
+
this.name = "TicketResourceInvalidError";
|
|
11086
|
+
}
|
|
11087
|
+
};
|
|
11088
|
+
var TicketCreateValidationFailedError = class extends AutotaskError {
|
|
11089
|
+
/** The vendor-named fields (empty when the 500 body named nothing parseable). */
|
|
11090
|
+
vendorFields;
|
|
11091
|
+
constructor(message, vendorFields, options = {}) {
|
|
11092
|
+
super(message, { code: "VALIDATION_FAILED", category: "validation", ...options });
|
|
11093
|
+
this.name = "TicketCreateValidationFailedError";
|
|
11094
|
+
this.vendorFields = vendorFields;
|
|
11095
|
+
}
|
|
11096
|
+
};
|
|
11097
|
+
var StatusUnknownError = class extends AutotaskError {
|
|
11098
|
+
/** The live picklist size (the message names it; the values + labels are via operations.fieldCatalog with labels: true). */
|
|
11099
|
+
picklistCount;
|
|
11100
|
+
constructor(message, options = {}) {
|
|
11101
|
+
super(message, {
|
|
11102
|
+
code: "STATUS_UNKNOWN",
|
|
11103
|
+
category: "validation",
|
|
11104
|
+
suggestedAction: `Pass one of the tenant's live status values (discover the current values + labels with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })).`,
|
|
11105
|
+
...options
|
|
11106
|
+
});
|
|
11107
|
+
this.name = "StatusUnknownError";
|
|
11108
|
+
this.picklistCount = options.picklistCount ?? 0;
|
|
11109
|
+
}
|
|
11110
|
+
};
|
|
11111
|
+
var StatusFieldsRequiredError = class extends AutotaskError {
|
|
11112
|
+
vendorFields;
|
|
11113
|
+
currentStatus;
|
|
11114
|
+
targetStatus;
|
|
11115
|
+
currentValues;
|
|
11116
|
+
constructor(message, details, options = {}) {
|
|
11117
|
+
super(message, { code: "STATUS_FIELDS_REQUIRED", category: "validation", suggestedAction: "Pass the required field value(s) in the `fields` argument and retry (the node never fills a field without a proven-safe default).", ...options });
|
|
11118
|
+
this.name = "StatusFieldsRequiredError";
|
|
11119
|
+
this.vendorFields = details.vendorFields;
|
|
11120
|
+
this.currentStatus = details.currentStatus;
|
|
11121
|
+
this.targetStatus = details.targetStatus;
|
|
11122
|
+
this.currentValues = details.currentValues;
|
|
11123
|
+
}
|
|
11124
|
+
};
|
|
11125
|
+
|
|
11126
|
+
// src/resources/special/tickets/writeSupport.ts
|
|
10490
11127
|
var OP_OPEN = "tickets.openWithNoteAndAssign";
|
|
10491
11128
|
var OP_STATUS = "tickets.changeStatus";
|
|
10492
11129
|
var MAX_DISCOVERY_MERGES = 3;
|
|
@@ -10538,39 +11175,15 @@ function stepErrorText(code, err) {
|
|
|
10538
11175
|
const base = isAutotaskError(err) ? err.message : String(err);
|
|
10539
11176
|
return detail ? `${code}: ${detail}` : `${code}: ${base}`;
|
|
10540
11177
|
}
|
|
11178
|
+
|
|
11179
|
+
// src/resources/special/tickets/openWithNoteAndAssign.ts
|
|
10541
11180
|
var DEFAULT_TICKET_PRIORITY = 3;
|
|
10542
11181
|
var DEFAULT_TICKET_STATUS = 1;
|
|
10543
11182
|
var DEFAULT_NOTE_TYPE = 1;
|
|
10544
11183
|
var DEFAULT_PUBLISH = 1;
|
|
10545
11184
|
var PUBLISH_VALUES = /* @__PURE__ */ new Set([1, 2, 4]);
|
|
10546
11185
|
var SAFE_TICKET_CREATE_DEFAULTS = {};
|
|
10547
|
-
|
|
10548
|
-
constructor(message, options = {}) {
|
|
10549
|
-
super(message, {
|
|
10550
|
-
code: "QUEUE_REQUIRED",
|
|
10551
|
-
category: "validation",
|
|
10552
|
-
suggestedAction: "pass params.queueID (discover via autotask_describe(tickets.create) or a known queue id)",
|
|
10553
|
-
...options
|
|
10554
|
-
});
|
|
10555
|
-
this.name = "QueueRequiredError";
|
|
10556
|
-
}
|
|
10557
|
-
};
|
|
10558
|
-
var TicketResourceInvalidError = class extends AutotaskError {
|
|
10559
|
-
constructor(message, options = {}) {
|
|
10560
|
-
super(message, { code: "RESOURCE_INVALID", category: "validation", ...options });
|
|
10561
|
-
this.name = "TicketResourceInvalidError";
|
|
10562
|
-
}
|
|
10563
|
-
};
|
|
10564
|
-
var TicketCreateValidationFailedError = class extends AutotaskError {
|
|
10565
|
-
/** The vendor-named fields (empty when the 500 body named nothing parseable). */
|
|
10566
|
-
vendorFields;
|
|
10567
|
-
constructor(message, vendorFields, options = {}) {
|
|
10568
|
-
super(message, { code: "VALIDATION_FAILED", category: "validation", ...options });
|
|
10569
|
-
this.name = "TicketCreateValidationFailedError";
|
|
10570
|
-
this.vendorFields = vendorFields;
|
|
10571
|
-
}
|
|
10572
|
-
};
|
|
10573
|
-
async function openWithNoteAndAssign(resource, params, note, assign, opts, http) {
|
|
11186
|
+
function validateOpenInput(params, note, assign) {
|
|
10574
11187
|
const companyID = parseIdentifier(params?.companyID, "params.companyID");
|
|
10575
11188
|
const title = params?.title;
|
|
10576
11189
|
if (typeof title !== "string" || title.trim() === "") {
|
|
@@ -10609,73 +11222,65 @@ async function openWithNoteAndAssign(resource, params, note, assign, opts, http)
|
|
|
10609
11222
|
if (assign !== void 0 && !isPositiveInt(roleID)) {
|
|
10610
11223
|
throw new AutotaskConfigError("openWithNoteAndAssign: assign.roleID is required (a positive integer) \u2014 the ww29 vendor requires BOTH assignedResourceID and assignedResourceRoleID when assigning a resource (S12-A live-verified: Data violation: When assigning a Resource, you must assign both a assignedResourceID and assignedResourceRoleID)", { operation: OP_OPEN });
|
|
10611
11224
|
}
|
|
10612
|
-
|
|
10613
|
-
|
|
10614
|
-
|
|
10615
|
-
|
|
10616
|
-
|
|
10617
|
-
|
|
10618
|
-
|
|
10619
|
-
)
|
|
10620
|
-
|
|
10621
|
-
|
|
10622
|
-
throw new TicketResourceInvalidError(
|
|
10623
|
-
`openWithNoteAndAssign: assignee resource ${assignee} is not active (isActive !== true) \u2014 the vendor would reject the assignment`,
|
|
10624
|
-
{ resourceIds: [rid], details: { idRefs: [{ entity: "resource", id: rid }] }, operation: OP_OPEN, suggestedAction: "Activate the resource or assign an active one." }
|
|
10625
|
-
);
|
|
10626
|
-
}
|
|
11225
|
+
return { companyID, title, priority, status, assignee, roleID };
|
|
11226
|
+
}
|
|
11227
|
+
async function precheckAssignee(http, assignee, opts) {
|
|
11228
|
+
const rid = assignee;
|
|
11229
|
+
const rec = await fetchItem(http, `/V1.0/Resources/${rid}`, OP_OPEN, { maxRetries: opts.maxRetries });
|
|
11230
|
+
if (rec === null) {
|
|
11231
|
+
throw new TicketResourceInvalidError(
|
|
11232
|
+
`openWithNoteAndAssign: assignee resource ${assignee} was not found (resources.get returned item: null \u2014 the resources table is blind to the API user; get is the authority)`,
|
|
11233
|
+
{ resourceIds: [rid], details: { idRefs: [{ entity: "resource", id: rid }] }, operation: OP_OPEN, category: "not_found", suggestedAction: "Resolve the resource id (a ticket assignedResourceID via tickets.fullContext) and retry." }
|
|
11234
|
+
);
|
|
10627
11235
|
}
|
|
10628
|
-
|
|
10629
|
-
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
// REQUIRED by the vendor (S12-A live-verified: Missing Required Field: title)
|
|
10633
|
-
description: note.description,
|
|
10634
|
-
noteType: note.noteType ?? DEFAULT_NOTE_TYPE,
|
|
10635
|
-
publish: note.publish ?? DEFAULT_PUBLISH
|
|
10636
|
-
};
|
|
10637
|
-
if (opts.dryRun === true) {
|
|
10638
|
-
const warnings = [];
|
|
10639
|
-
if (params.queueID === void 0) {
|
|
10640
|
-
warnings.push("queueID is omitted \u2014 the node NEVER guesses a queue: if the vendor 500 names it, the apply throws the typed QUEUE_REQUIRED (pass params.queueID \u2014 discover via autotask_describe(tickets.create)).");
|
|
10641
|
-
}
|
|
10642
|
-
if (assign !== void 0) {
|
|
10643
|
-
warnings.push(`the assignment is the v0.6.0 MERGE update (unredacted GET + collection PUT, 2 wire) setting assignedResourceID ${String(assignee)} + assignedResourceRoleID ${String(roleID)} (the vendor requires both \u2014 S12-A live-verified); a failure lands in steps[] as ASSIGN_FAILED (the applied ticketId is still returned).`);
|
|
10644
|
-
}
|
|
10645
|
-
return buildDryRunResult(
|
|
10646
|
-
{
|
|
10647
|
-
operation: OP_OPEN,
|
|
10648
|
-
method: "POST",
|
|
10649
|
-
path: "/V1.0/Tickets",
|
|
10650
|
-
ids: [companyID],
|
|
10651
|
-
checks: [
|
|
10652
|
-
{ name: "input-validation", ok: true },
|
|
10653
|
-
{ name: "assignee-active", ok: true }
|
|
10654
|
-
],
|
|
10655
|
-
impact: {
|
|
10656
|
-
affected: 1 + (noteBody !== null ? 1 : 0) + (assign !== void 0 ? 1 : 0),
|
|
10657
|
-
scope: "ticket (+ scoped note, + assignment merge)",
|
|
10658
|
-
reversible: false
|
|
10659
|
-
},
|
|
10660
|
-
warnings,
|
|
10661
|
-
data: {
|
|
10662
|
-
ticket: body,
|
|
10663
|
-
note: noteBody,
|
|
10664
|
-
assign: assign !== void 0 ? { method: "GET+PUT", path: `/V1.0/Tickets/<newId>`, body: { assignedResourceID: assignee, assignedResourceRoleID: roleID } } : null
|
|
10665
|
-
}
|
|
10666
|
-
},
|
|
10667
|
-
{ redactPersonalData: http.redactPersonalData }
|
|
11236
|
+
if (rec.isActive !== true) {
|
|
11237
|
+
throw new TicketResourceInvalidError(
|
|
11238
|
+
`openWithNoteAndAssign: assignee resource ${assignee} is not active (isActive !== true) \u2014 the vendor would reject the assignment`,
|
|
11239
|
+
{ resourceIds: [rid], details: { idRefs: [{ entity: "resource", id: rid }] }, operation: OP_OPEN, suggestedAction: "Activate the resource or assign an active one." }
|
|
10668
11240
|
);
|
|
10669
11241
|
}
|
|
10670
|
-
|
|
10671
|
-
|
|
11242
|
+
}
|
|
11243
|
+
function openDryRun(http, v, params, assign, body, noteBody) {
|
|
11244
|
+
const { companyID, assignee, roleID } = v;
|
|
11245
|
+
const warnings = [];
|
|
11246
|
+
if (params.queueID === void 0) {
|
|
11247
|
+
warnings.push("queueID is omitted \u2014 the node NEVER guesses a queue: if the vendor 500 names it, the apply throws the typed QUEUE_REQUIRED (pass params.queueID \u2014 discover via autotask_describe(tickets.create)).");
|
|
11248
|
+
}
|
|
11249
|
+
if (assign !== void 0) {
|
|
11250
|
+
warnings.push(`the assignment is the v0.6.0 MERGE update (unredacted GET + collection PUT, 2 wire) setting assignedResourceID ${String(assignee)} + assignedResourceRoleID ${String(roleID)} (the vendor requires both \u2014 S12-A live-verified); a failure lands in steps[] as ASSIGN_FAILED (the applied ticketId is still returned).`);
|
|
11251
|
+
}
|
|
11252
|
+
return buildDryRunResult(
|
|
11253
|
+
{
|
|
11254
|
+
operation: OP_OPEN,
|
|
11255
|
+
method: "POST",
|
|
11256
|
+
path: "/V1.0/Tickets",
|
|
11257
|
+
ids: [companyID],
|
|
11258
|
+
checks: [
|
|
11259
|
+
{ name: "input-validation", ok: true },
|
|
11260
|
+
{ name: "assignee-active", ok: true }
|
|
11261
|
+
],
|
|
11262
|
+
impact: {
|
|
11263
|
+
affected: 1 + (noteBody !== null ? 1 : 0) + (assign !== void 0 ? 1 : 0),
|
|
11264
|
+
scope: "ticket (+ scoped note, + assignment merge)",
|
|
11265
|
+
reversible: false
|
|
11266
|
+
},
|
|
11267
|
+
warnings,
|
|
11268
|
+
data: {
|
|
11269
|
+
ticket: body,
|
|
11270
|
+
note: noteBody,
|
|
11271
|
+
assign: assign !== void 0 ? { method: "GET+PUT", path: `/V1.0/Tickets/<newId>`, body: { assignedResourceID: assignee, assignedResourceRoleID: roleID } } : null
|
|
11272
|
+
}
|
|
11273
|
+
},
|
|
11274
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
11275
|
+
);
|
|
11276
|
+
}
|
|
11277
|
+
async function createTicketWithDiscovery(resource, params, companyID, body, steps) {
|
|
10672
11278
|
let merges = 0;
|
|
10673
11279
|
for (; ; ) {
|
|
10674
11280
|
try {
|
|
10675
11281
|
const created = await resource.create(body, {});
|
|
10676
|
-
ticketId = created.id;
|
|
10677
11282
|
steps.push({ name: "create", ok: true });
|
|
10678
|
-
|
|
11283
|
+
return created.id;
|
|
10679
11284
|
} catch (err) {
|
|
10680
11285
|
const named = namedVendorFields(err);
|
|
10681
11286
|
if (params.queueID === void 0 && named.includes("queueID")) {
|
|
@@ -10708,34 +11313,57 @@ async function openWithNoteAndAssign(resource, params, note, assign, opts, http)
|
|
|
10708
11313
|
);
|
|
10709
11314
|
}
|
|
10710
11315
|
}
|
|
10711
|
-
|
|
10712
|
-
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
}
|
|
11316
|
+
}
|
|
11317
|
+
async function createNoteStep(http, ticketId, noteBody, opts, steps) {
|
|
11318
|
+
try {
|
|
11319
|
+
const res = await http.request({ method: "POST", path: `/V1.0/Tickets/${ticketId}/Notes`, body: noteBody, operation: OP_OPEN, maxRetries: opts.maxRetries });
|
|
11320
|
+
const noteId = operationId(res) ?? null;
|
|
11321
|
+
steps.push({ name: "note", ok: true });
|
|
11322
|
+
return noteId;
|
|
11323
|
+
} catch (err) {
|
|
11324
|
+
steps.push({ name: "note", ok: false, error: stepErrorText("NOTE_CREATE_FAILED", err) });
|
|
11325
|
+
return null;
|
|
10720
11326
|
}
|
|
11327
|
+
}
|
|
11328
|
+
async function assignStep(http, ticketId, assignee, roleID, opts, steps) {
|
|
10721
11329
|
let assigned = false;
|
|
10722
11330
|
let ticketNumber = null;
|
|
10723
|
-
|
|
10724
|
-
|
|
10725
|
-
|
|
10726
|
-
|
|
10727
|
-
throw new NotFoundError(`openWithNoteAndAssign: ticket ${ticketId} vanished between the create and the assign merge read`, { operation: OP_OPEN, resourceIds: [ticketId], details: { idRefs: [{ entity: "ticket", id: ticketId }] } });
|
|
10728
|
-
}
|
|
10729
|
-
ticketNumber = typeof current.ticketNumber === "string" ? current.ticketNumber : null;
|
|
10730
|
-
const merged = mergePartialBody(current, { id: ticketId, assignedResourceID: assignee, assignedResourceRoleID: roleID });
|
|
10731
|
-
merged.id = ticketId;
|
|
10732
|
-
await http.request({ method: "PUT", path: "/V1.0/Tickets", body: merged, operation: OP_OPEN, maxRetries: opts.maxRetries });
|
|
10733
|
-
assigned = true;
|
|
10734
|
-
steps.push({ name: "assign", ok: true });
|
|
10735
|
-
} catch (err) {
|
|
10736
|
-
steps.push({ name: "assign", ok: false, error: stepErrorText("ASSIGN_FAILED", err) });
|
|
11331
|
+
try {
|
|
11332
|
+
const current = await fetchItemUnredacted(http, `/V1.0/Tickets/${ticketId}`, OP_OPEN, { maxRetries: opts.maxRetries });
|
|
11333
|
+
if (current === null) {
|
|
11334
|
+
throw new NotFoundError(`openWithNoteAndAssign: ticket ${ticketId} vanished between the create and the assign merge read`, { operation: OP_OPEN, resourceIds: [ticketId], details: { idRefs: [{ entity: "ticket", id: ticketId }] } });
|
|
10737
11335
|
}
|
|
11336
|
+
ticketNumber = typeof current.ticketNumber === "string" ? current.ticketNumber : null;
|
|
11337
|
+
const merged = mergePartialBody(current, { id: ticketId, assignedResourceID: assignee, assignedResourceRoleID: roleID });
|
|
11338
|
+
merged.id = ticketId;
|
|
11339
|
+
await http.request({ method: "PUT", path: "/V1.0/Tickets", body: merged, operation: OP_OPEN, maxRetries: opts.maxRetries });
|
|
11340
|
+
assigned = true;
|
|
11341
|
+
steps.push({ name: "assign", ok: true });
|
|
11342
|
+
} catch (err) {
|
|
11343
|
+
steps.push({ name: "assign", ok: false, error: stepErrorText("ASSIGN_FAILED", err) });
|
|
10738
11344
|
}
|
|
11345
|
+
return { assigned, ticketNumber };
|
|
11346
|
+
}
|
|
11347
|
+
async function openWithNoteAndAssign(resource, params, note, assign, opts, http) {
|
|
11348
|
+
const validated = validateOpenInput(params, note, assign);
|
|
11349
|
+
const { companyID, title, priority, status, assignee } = validated;
|
|
11350
|
+
if (assign !== void 0 && assignee !== void 0) await precheckAssignee(http, assignee, opts);
|
|
11351
|
+
const body = { title, status, priority, companyID };
|
|
11352
|
+
if (params.queueID !== void 0) body.queueID = params.queueID;
|
|
11353
|
+
const noteBody = note === void 0 ? null : {
|
|
11354
|
+
title: note.title,
|
|
11355
|
+
// REQUIRED by the vendor (S12-A live-verified: Missing Required Field: title)
|
|
11356
|
+
description: note.description,
|
|
11357
|
+
noteType: note.noteType ?? DEFAULT_NOTE_TYPE,
|
|
11358
|
+
publish: note.publish ?? DEFAULT_PUBLISH
|
|
11359
|
+
};
|
|
11360
|
+
if (opts.dryRun === true) return openDryRun(http, validated, params, assign, body, noteBody);
|
|
11361
|
+
const steps = [];
|
|
11362
|
+
const ticketId = await createTicketWithDiscovery(resource, params, companyID, body, steps);
|
|
11363
|
+
const noteId = noteBody !== null ? await createNoteStep(http, ticketId, noteBody, opts, steps) : null;
|
|
11364
|
+
let assigned = false;
|
|
11365
|
+
let ticketNumber = null;
|
|
11366
|
+
if (assign !== void 0) ({ assigned, ticketNumber } = await assignStep(http, ticketId, assignee, validated.roleID, opts, steps));
|
|
10739
11367
|
return {
|
|
10740
11368
|
ticketId,
|
|
10741
11369
|
ticketNumber,
|
|
@@ -10745,60 +11373,30 @@ async function openWithNoteAndAssign(resource, params, note, assign, opts, http)
|
|
|
10745
11373
|
...assign !== void 0 ? { blind: { resources: true, note: "the resources table is blind to this API user (count/query return 0) \u2014 the assignee was verified via resources.get (the authority)" } } : {}
|
|
10746
11374
|
};
|
|
10747
11375
|
}
|
|
11376
|
+
|
|
11377
|
+
// src/resources/special/tickets/changeStatus.ts
|
|
10748
11378
|
var STATUS_REQUIRED_FIELDS = {
|
|
10749
11379
|
// <discovery output lands here — e.g. 8: { requiredFields: ['dueDateTime'], safeDefaults: { dueDateTime: '<now>' } }>
|
|
10750
11380
|
};
|
|
10751
|
-
var StatusUnknownError = class extends AutotaskError {
|
|
10752
|
-
/** The live picklist size (the message names it; the values + labels are via operations.fieldCatalog with labels: true). */
|
|
10753
|
-
picklistCount;
|
|
10754
|
-
constructor(message, options = {}) {
|
|
10755
|
-
super(message, {
|
|
10756
|
-
code: "STATUS_UNKNOWN",
|
|
10757
|
-
category: "validation",
|
|
10758
|
-
suggestedAction: `Pass one of the tenant's live status values (discover the current values + labels with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })).`,
|
|
10759
|
-
...options
|
|
10760
|
-
});
|
|
10761
|
-
this.name = "StatusUnknownError";
|
|
10762
|
-
this.picklistCount = options.picklistCount ?? 0;
|
|
10763
|
-
}
|
|
10764
|
-
};
|
|
10765
|
-
var StatusFieldsRequiredError = class extends AutotaskError {
|
|
10766
|
-
vendorFields;
|
|
10767
|
-
currentStatus;
|
|
10768
|
-
targetStatus;
|
|
10769
|
-
currentValues;
|
|
10770
|
-
constructor(message, details, options = {}) {
|
|
10771
|
-
super(message, { code: "STATUS_FIELDS_REQUIRED", category: "validation", suggestedAction: "Pass the required field value(s) in the `fields` argument and retry (the node never fills a field without a proven-safe default).", ...options });
|
|
10772
|
-
this.name = "StatusFieldsRequiredError";
|
|
10773
|
-
this.vendorFields = details.vendorFields;
|
|
10774
|
-
this.currentStatus = details.currentStatus;
|
|
10775
|
-
this.targetStatus = details.targetStatus;
|
|
10776
|
-
this.currentValues = details.currentValues;
|
|
10777
|
-
}
|
|
10778
|
-
};
|
|
10779
11381
|
async function readLiveStatusSet(resource) {
|
|
10780
11382
|
const raw = await resource.getFieldInfo();
|
|
10781
11383
|
const row = raw.find((f) => f.name === "status");
|
|
10782
11384
|
const values = row !== void 0 && row.isPickList === true ? row.picklistValues : void 0;
|
|
10783
|
-
const
|
|
11385
|
+
const ids2 = /* @__PURE__ */ new Set();
|
|
10784
11386
|
if (Array.isArray(values)) {
|
|
10785
11387
|
for (const pv of values) {
|
|
10786
11388
|
const n = Number(pv.value);
|
|
10787
|
-
if (Number.isFinite(n))
|
|
11389
|
+
if (Number.isFinite(n)) ids2.add(n);
|
|
10788
11390
|
}
|
|
10789
11391
|
}
|
|
10790
|
-
return
|
|
11392
|
+
return ids2;
|
|
10791
11393
|
}
|
|
10792
11394
|
var GUARD_DEGRADED_WARNING = "the tenant's live status picklist metadata was unavailable (the local guard is degraded for this call \u2014 the W-A4 wall or a metadata failure); the vendor's own validation is the final authority on the status value";
|
|
10793
|
-
async function
|
|
10794
|
-
const ticketId = parseIdentifier(id, "id");
|
|
10795
|
-
if (!isPositiveInt(targetStatus)) {
|
|
10796
|
-
throw new AutotaskConfigError(`changeStatus: targetStatus must be a positive integer (got ${String(targetStatus)})`, { operation: OP_STATUS });
|
|
10797
|
-
}
|
|
11395
|
+
async function guardTargetStatus(resource, http, targetStatus) {
|
|
10798
11396
|
let liveStatus = null;
|
|
10799
11397
|
try {
|
|
10800
|
-
const
|
|
10801
|
-
if (
|
|
11398
|
+
const ids2 = await readLiveStatusSet(resource);
|
|
11399
|
+
if (ids2.size > 0) liveStatus = ids2;
|
|
10802
11400
|
} catch (err) {
|
|
10803
11401
|
if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
|
|
10804
11402
|
}
|
|
@@ -10825,41 +11423,31 @@ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
|
|
|
10825
11423
|
);
|
|
10826
11424
|
}
|
|
10827
11425
|
}
|
|
10828
|
-
|
|
10829
|
-
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
|
|
10833
|
-
|
|
10834
|
-
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
{
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
`NO_CHANGE: the ticket is already in the target status ${targetStatus} \u2014 the apply would be a zero-wire no-op.`
|
|
10854
|
-
],
|
|
10855
|
-
data: { reason: "NO_CHANGE", status: targetStatus }
|
|
10856
|
-
},
|
|
10857
|
-
{ redactPersonalData: http.redactPersonalData }
|
|
10858
|
-
);
|
|
10859
|
-
}
|
|
10860
|
-
return { ticketId, changed: false, status: targetStatus, reason: "NO_CHANGE" };
|
|
10861
|
-
}
|
|
10862
|
-
const mapEntry = STATUS_REQUIRED_FIELDS[targetStatus];
|
|
11426
|
+
return { guardDegraded: liveStatus === null };
|
|
11427
|
+
}
|
|
11428
|
+
function noChangeDryRun(http, ticketId, targetStatus, guardDegraded) {
|
|
11429
|
+
return buildDryRunResult(
|
|
11430
|
+
{
|
|
11431
|
+
operation: OP_STATUS,
|
|
11432
|
+
method: "PUT",
|
|
11433
|
+
path: "/V1.0/Tickets",
|
|
11434
|
+
ids: [ticketId],
|
|
11435
|
+
checks: [
|
|
11436
|
+
{ name: "status-known", ok: true },
|
|
11437
|
+
{ name: "merge-read", ok: true },
|
|
11438
|
+
{ name: "no-op", ok: true }
|
|
11439
|
+
],
|
|
11440
|
+
impact: { affected: 0, scope: "single-record", reversible: true },
|
|
11441
|
+
warnings: [
|
|
11442
|
+
...guardDegraded ? [GUARD_DEGRADED_WARNING] : [],
|
|
11443
|
+
`NO_CHANGE: the ticket is already in the target status ${targetStatus} \u2014 the apply would be a zero-wire no-op.`
|
|
11444
|
+
],
|
|
11445
|
+
data: { reason: "NO_CHANGE", status: targetStatus }
|
|
11446
|
+
},
|
|
11447
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
11448
|
+
);
|
|
11449
|
+
}
|
|
11450
|
+
function resolveMergeData(ticketId, targetStatus, previousStatus, current, fields, mapEntry) {
|
|
10863
11451
|
const mergeData = { status: targetStatus };
|
|
10864
11452
|
for (const [k, v] of Object.entries(fields ?? {})) {
|
|
10865
11453
|
if (v !== void 0) mergeData[k] = v;
|
|
@@ -10881,33 +11469,36 @@ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
|
|
|
10881
11469
|
filled.push(field);
|
|
10882
11470
|
}
|
|
10883
11471
|
}
|
|
10884
|
-
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
|
|
10894
|
-
{
|
|
10895
|
-
operation: OP_STATUS,
|
|
10896
|
-
method: "PUT",
|
|
10897
|
-
path: "/V1.0/Tickets",
|
|
10898
|
-
ids: [ticketId],
|
|
10899
|
-
checks: [
|
|
10900
|
-
{ name: "status-known", ok: true },
|
|
10901
|
-
{ name: "merge-read", ok: true },
|
|
10902
|
-
{ name: "fields-resolved", ok: true }
|
|
10903
|
-
],
|
|
10904
|
-
impact: { affected: 1, scope: "single-record", reversible: true },
|
|
10905
|
-
warnings,
|
|
10906
|
-
data: mergeData
|
|
10907
|
-
},
|
|
10908
|
-
{ redactPersonalData: http.redactPersonalData }
|
|
10909
|
-
);
|
|
11472
|
+
return { mergeData, filled };
|
|
11473
|
+
}
|
|
11474
|
+
function mergeDryRun(http, ticketId, targetStatus, guardDegraded, filled, mapEntry, mergeData) {
|
|
11475
|
+
const warnings = [];
|
|
11476
|
+
if (guardDegraded) warnings.push(GUARD_DEGRADED_WARNING);
|
|
11477
|
+
if (filled.length > 0) {
|
|
11478
|
+
warnings.push(`the node will fill the map-required field(s) ${filled.join(", ")} with the proven-safe defaults (the live-discovered map); the caller value always wins.`);
|
|
11479
|
+
}
|
|
11480
|
+
if (mapEntry === void 0) {
|
|
11481
|
+
warnings.push(`status ${targetStatus} has no entry in the live-discovered required-field map yet \u2014 if the vendor 500 names a required field without a safe default, the apply throws the typed STATUS_FIELDS_REQUIRED (the caller supplies the value; never a blind retry).`);
|
|
10910
11482
|
}
|
|
11483
|
+
return buildDryRunResult(
|
|
11484
|
+
{
|
|
11485
|
+
operation: OP_STATUS,
|
|
11486
|
+
method: "PUT",
|
|
11487
|
+
path: "/V1.0/Tickets",
|
|
11488
|
+
ids: [ticketId],
|
|
11489
|
+
checks: [
|
|
11490
|
+
{ name: "status-known", ok: true },
|
|
11491
|
+
{ name: "merge-read", ok: true },
|
|
11492
|
+
{ name: "fields-resolved", ok: true }
|
|
11493
|
+
],
|
|
11494
|
+
impact: { affected: 1, scope: "single-record", reversible: true },
|
|
11495
|
+
warnings,
|
|
11496
|
+
data: mergeData
|
|
11497
|
+
},
|
|
11498
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
11499
|
+
);
|
|
11500
|
+
}
|
|
11501
|
+
async function putStatusWithDiscovery(http, ticketId, targetStatus, previousStatus, current, mergeData, mapEntry, filled, opts) {
|
|
10911
11502
|
const safeDefaults = mapEntry?.safeDefaults ?? {};
|
|
10912
11503
|
const body = mergePartialBody(current, mergeData);
|
|
10913
11504
|
body.id = ticketId;
|
|
@@ -10915,7 +11506,7 @@ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
|
|
|
10915
11506
|
for (; ; ) {
|
|
10916
11507
|
try {
|
|
10917
11508
|
await http.request({ method: "PUT", path: "/V1.0/Tickets", body, operation: OP_STATUS, maxRetries: opts.maxRetries });
|
|
10918
|
-
|
|
11509
|
+
return merges;
|
|
10919
11510
|
} catch (err) {
|
|
10920
11511
|
const named = namedVendorFields(err);
|
|
10921
11512
|
const merge = discoveryMerge(err, body, safeDefaults);
|
|
@@ -10943,6 +11534,8 @@ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
|
|
|
10943
11534
|
throw err;
|
|
10944
11535
|
}
|
|
10945
11536
|
}
|
|
11537
|
+
}
|
|
11538
|
+
async function verifyStatusApplied(http, ticketId, targetStatus, opts) {
|
|
10946
11539
|
const verify = await fetchItem(http, `/V1.0/Tickets/${ticketId}`, OP_STATUS, { maxRetries: opts.maxRetries });
|
|
10947
11540
|
if (verify === null) {
|
|
10948
11541
|
throw new AutotaskError(`changeStatus: the post-PUT read-back found no ticket ${ticketId} (the PUT 200ed but the record is gone)`, {
|
|
@@ -10968,8 +11561,34 @@ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
|
|
|
10968
11561
|
}
|
|
10969
11562
|
);
|
|
10970
11563
|
}
|
|
11564
|
+
}
|
|
11565
|
+
async function changeStatus(resource, id, targetStatus, fields, opts, http) {
|
|
11566
|
+
const ticketId = parseIdentifier(id, "id");
|
|
11567
|
+
if (!isPositiveInt(targetStatus)) {
|
|
11568
|
+
throw new AutotaskConfigError(`changeStatus: targetStatus must be a positive integer (got ${String(targetStatus)})`, { operation: OP_STATUS });
|
|
11569
|
+
}
|
|
11570
|
+
const { guardDegraded } = await guardTargetStatus(resource, http, targetStatus);
|
|
11571
|
+
if (fields !== void 0 && (typeof fields !== "object" || fields === null || Array.isArray(fields))) {
|
|
11572
|
+
throw new AutotaskConfigError("changeStatus: fields must be a plain object of ticket fields (or omitted)", { operation: OP_STATUS });
|
|
11573
|
+
}
|
|
11574
|
+
const current = await fetchItemUnredacted(http, `/V1.0/Tickets/${ticketId}`, OP_STATUS, { maxRetries: opts.maxRetries });
|
|
11575
|
+
if (current === null) {
|
|
11576
|
+
throw new NotFoundError(`changeStatus: ticket ${ticketId} was not found (tickets.get returned item: null)`, { operation: OP_STATUS, resourceIds: [ticketId], details: { idRefs: [{ entity: "ticket", id: ticketId }] } });
|
|
11577
|
+
}
|
|
11578
|
+
const previousStatus = typeof current.status === "number" ? current.status : null;
|
|
11579
|
+
if (previousStatus === targetStatus) {
|
|
11580
|
+
if (opts.dryRun === true) return noChangeDryRun(http, ticketId, targetStatus, guardDegraded);
|
|
11581
|
+
return { ticketId, changed: false, status: targetStatus, reason: "NO_CHANGE" };
|
|
11582
|
+
}
|
|
11583
|
+
const mapEntry = STATUS_REQUIRED_FIELDS[targetStatus];
|
|
11584
|
+
const { mergeData, filled } = resolveMergeData(ticketId, targetStatus, previousStatus, current, fields, mapEntry);
|
|
11585
|
+
if (opts.dryRun === true) return mergeDryRun(http, ticketId, targetStatus, guardDegraded, filled, mapEntry, mergeData);
|
|
11586
|
+
const merges = await putStatusWithDiscovery(http, ticketId, targetStatus, previousStatus, current, mergeData, mapEntry, filled, opts);
|
|
11587
|
+
await verifyStatusApplied(http, ticketId, targetStatus, opts);
|
|
10971
11588
|
return { ticketId, previousStatus, status: targetStatus, changed: true, filledFields: filled, discoveryMerges: merges };
|
|
10972
11589
|
}
|
|
11590
|
+
|
|
11591
|
+
// src/resources/special/tickets/fullContext.ts
|
|
10973
11592
|
var DEFAULT_PART_LIMIT = 50;
|
|
10974
11593
|
function partLimit(value, name) {
|
|
10975
11594
|
const n = value === void 0 ? DEFAULT_PART_LIMIT : Math.trunc(value);
|
|
@@ -10986,6 +11605,38 @@ function servedTextRecord(row, textField, limit) {
|
|
|
10986
11605
|
out[textField] = `${text.slice(0, limit)}\u2026`;
|
|
10987
11606
|
return out;
|
|
10988
11607
|
}
|
|
11608
|
+
async function addCompositeNames(http, ctx, entriesPart, result, opts) {
|
|
11609
|
+
const builder = createNamePlan(TICKET_LABEL_FIELDS);
|
|
11610
|
+
const t = ctx.ticket;
|
|
11611
|
+
builder.addResource(t["creatorResourceID"]);
|
|
11612
|
+
builder.addFk("contract", t["contractID"]);
|
|
11613
|
+
builder.addFk("billingCode", t["billingCodeID"]);
|
|
11614
|
+
builder.addFk("role", t["assignedResourceRoleID"]);
|
|
11615
|
+
for (const e of entriesPart.items) {
|
|
11616
|
+
builder.addResource(e["resourceID"]);
|
|
11617
|
+
builder.addFk("role", e["roleID"]);
|
|
11618
|
+
builder.addFk("billingCode", e["billingCodeID"]);
|
|
11619
|
+
builder.addFk("contract", e["contractID"]);
|
|
11620
|
+
}
|
|
11621
|
+
const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
|
|
11622
|
+
const ticketRow = { ...ctx.ticket };
|
|
11623
|
+
addNameSibling(ticketRow, "creatorResourceID", (v) => names.resourceName(v));
|
|
11624
|
+
addNameSibling(ticketRow, "contractID", (v) => names.recordName("contract", v));
|
|
11625
|
+
addNameSibling(ticketRow, "billingCodeID", (v) => names.recordName("billingCode", v));
|
|
11626
|
+
addNameSibling(ticketRow, "assignedResourceRoleID", (v) => names.recordName("role", v));
|
|
11627
|
+
for (const f of TICKET_LABEL_FIELDS) addLabelSibling(ticketRow, f, names);
|
|
11628
|
+
result.ticket = ticketRow;
|
|
11629
|
+
result.timeEntries.items = entriesPart.items.map((e) => {
|
|
11630
|
+
const row = { ...e };
|
|
11631
|
+
addNameSibling(row, "resourceID", (v) => names.resourceName(v));
|
|
11632
|
+
addNameSibling(row, "roleID", (v) => names.recordName("role", v));
|
|
11633
|
+
addNameSibling(row, "billingCodeID", (v) => names.recordName("billingCode", v));
|
|
11634
|
+
addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
|
|
11635
|
+
return row;
|
|
11636
|
+
});
|
|
11637
|
+
result.names = names.section;
|
|
11638
|
+
result.truncated = result.truncated || names.section.truncated;
|
|
11639
|
+
}
|
|
10989
11640
|
async function fullContext(resource, id, opts = {}, http) {
|
|
10990
11641
|
const noteLimit = partLimit(opts.noteLimit, "noteLimit");
|
|
10991
11642
|
const timeEntryLimit = partLimit(opts.timeEntryLimit, "timeEntryLimit");
|
|
@@ -11043,38 +11694,7 @@ async function fullContext(resource, id, opts = {}, http) {
|
|
|
11043
11694
|
result.parts.histories = historiesRead.status;
|
|
11044
11695
|
result.truncated = result.truncated || part.capped || historyRows.some((h) => h.detailTruncated === true);
|
|
11045
11696
|
}
|
|
11046
|
-
if (opts.names === true)
|
|
11047
|
-
const builder = createNamePlan(TICKET_LABEL_FIELDS);
|
|
11048
|
-
const t = ctx.ticket;
|
|
11049
|
-
builder.addResource(t["creatorResourceID"]);
|
|
11050
|
-
builder.addFk("contract", t["contractID"]);
|
|
11051
|
-
builder.addFk("billingCode", t["billingCodeID"]);
|
|
11052
|
-
builder.addFk("role", t["assignedResourceRoleID"]);
|
|
11053
|
-
for (const e of entriesPart.items) {
|
|
11054
|
-
builder.addResource(e["resourceID"]);
|
|
11055
|
-
builder.addFk("role", e["roleID"]);
|
|
11056
|
-
builder.addFk("billingCode", e["billingCodeID"]);
|
|
11057
|
-
builder.addFk("contract", e["contractID"]);
|
|
11058
|
-
}
|
|
11059
|
-
const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
|
|
11060
|
-
const ticketRow = { ...ctx.ticket };
|
|
11061
|
-
addNameSibling(ticketRow, "creatorResourceID", (v) => names.resourceName(v));
|
|
11062
|
-
addNameSibling(ticketRow, "contractID", (v) => names.recordName("contract", v));
|
|
11063
|
-
addNameSibling(ticketRow, "billingCodeID", (v) => names.recordName("billingCode", v));
|
|
11064
|
-
addNameSibling(ticketRow, "assignedResourceRoleID", (v) => names.recordName("role", v));
|
|
11065
|
-
for (const f of TICKET_LABEL_FIELDS) addLabelSibling(ticketRow, f, names);
|
|
11066
|
-
result.ticket = ticketRow;
|
|
11067
|
-
result.timeEntries.items = entriesPart.items.map((e) => {
|
|
11068
|
-
const row = { ...e };
|
|
11069
|
-
addNameSibling(row, "resourceID", (v) => names.resourceName(v));
|
|
11070
|
-
addNameSibling(row, "roleID", (v) => names.recordName("role", v));
|
|
11071
|
-
addNameSibling(row, "billingCodeID", (v) => names.recordName("billingCode", v));
|
|
11072
|
-
addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
|
|
11073
|
-
return row;
|
|
11074
|
-
});
|
|
11075
|
-
result.names = names.section;
|
|
11076
|
-
result.truncated = result.truncated || names.section.truncated;
|
|
11077
|
-
}
|
|
11697
|
+
if (opts.names === true) await addCompositeNames(http, ctx, entriesPart, result, opts);
|
|
11078
11698
|
return result;
|
|
11079
11699
|
}
|
|
11080
11700
|
|
|
@@ -11155,8 +11775,8 @@ var TicketsResource = class extends BaseResource {
|
|
|
11155
11775
|
const company = await this.contextFetch("/V1.0/Companies", root["companyID"]);
|
|
11156
11776
|
const contact = await this.contextFetch("/V1.0/Contacts", root["contactID"]);
|
|
11157
11777
|
const resource = await this.contextFetch("/V1.0/Resources", root["assignedResourceID"]);
|
|
11158
|
-
const
|
|
11159
|
-
return { ticket, company, contact, resource, project };
|
|
11778
|
+
const project2 = await this.contextFetch("/V1.0/Projects", root["projectID"]);
|
|
11779
|
+
return { ticket, company, contact, resource, project: project2 };
|
|
11160
11780
|
}
|
|
11161
11781
|
/** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
|
|
11162
11782
|
changeStatus(id, targetStatus, fields, opts = {}) {
|
|
@@ -11260,7 +11880,7 @@ var TicketWebhooksResource = class extends BaseResource {
|
|
|
11260
11880
|
}
|
|
11261
11881
|
};
|
|
11262
11882
|
|
|
11263
|
-
// src/resources/special/time_entries.ts
|
|
11883
|
+
// src/resources/special/time_entries/windows.ts
|
|
11264
11884
|
var DAY_MS2 = 864e5;
|
|
11265
11885
|
var iso2 = (ms) => new Date(ms).toISOString().slice(0, 19) + "Z";
|
|
11266
11886
|
async function queryTimeEntries(_resource, filter, limit, operation, http) {
|
|
@@ -11310,12 +11930,12 @@ async function postedDateStatusQuery(_resource, mode, http, opts) {
|
|
|
11310
11930
|
return items;
|
|
11311
11931
|
}
|
|
11312
11932
|
async function postedWithin(_resource, from, to, http, opts, opLabel = "time_entries.postedWithin") {
|
|
11313
|
-
const
|
|
11314
|
-
const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz,
|
|
11315
|
-
const fromB = customRangeDay(from, "from", zone,
|
|
11316
|
-
const toB = customRangeDay(to, "to", zone,
|
|
11933
|
+
const OP5 = opLabel;
|
|
11934
|
+
const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, OP5);
|
|
11935
|
+
const fromB = customRangeDay(from, "from", zone, OP5);
|
|
11936
|
+
const toB = customRangeDay(to, "to", zone, OP5);
|
|
11317
11937
|
if (fromB.ms > toB.ms) {
|
|
11318
|
-
throw new AutotaskConfigError(`${
|
|
11938
|
+
throw new AutotaskConfigError(`${OP5}: from (${iso2(fromB.ms)}) must not be after to (${iso2(toB.ms)})`, { operation: OP5 });
|
|
11319
11939
|
}
|
|
11320
11940
|
const windowToMs = zone === void 0 ? toB.ms + DAY_MS2 - 1 : localDayEndMs(toB.y, toB.mo, toB.d, zone);
|
|
11321
11941
|
const filter = [
|
|
@@ -11326,7 +11946,7 @@ async function postedWithin(_resource, from, to, http, opts, opLabel = "time_ent
|
|
|
11326
11946
|
else if (opts?.posted === false) filter.push({ field: "postedDate", op: "notExist" });
|
|
11327
11947
|
if (opts?.ticketID !== void 0) filter.push({ field: "ticketID", op: "eq", value: opts.ticketID });
|
|
11328
11948
|
if (opts?.resourceID !== void 0) filter.push({ field: "resourceID", op: "eq", value: opts.resourceID });
|
|
11329
|
-
const { items, hasMore } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit),
|
|
11949
|
+
const { items, hasMore } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), OP5, http);
|
|
11330
11950
|
let postedCount = 0;
|
|
11331
11951
|
for (const item of items) {
|
|
11332
11952
|
const postedDate = item.postedDate;
|
|
@@ -11347,6 +11967,44 @@ async function postedWithin(_resource, from, to, http, opts, opLabel = "time_ent
|
|
|
11347
11967
|
async function dateWorkedWindow(resource, from, to, http, opts) {
|
|
11348
11968
|
return postedWithin(resource, from, to, http, opts, "time_entries.dateWorkedWindow");
|
|
11349
11969
|
}
|
|
11970
|
+
|
|
11971
|
+
// src/resources/special/time_entries/errors.ts
|
|
11972
|
+
var ResourceInvalidError = class extends AutotaskError {
|
|
11973
|
+
constructor(message, options = {}) {
|
|
11974
|
+
super(message, { code: "RESOURCE_INVALID", category: "validation", ...options });
|
|
11975
|
+
this.name = "ResourceInvalidError";
|
|
11976
|
+
}
|
|
11977
|
+
};
|
|
11978
|
+
var AllocationCodeError = class extends AutotaskError {
|
|
11979
|
+
/** The failure kind (the machine code is `ALLOCATION_CODE_<KIND>`). */
|
|
11980
|
+
kind;
|
|
11981
|
+
constructor(kind, message, options = {}) {
|
|
11982
|
+
super(message, { code: `ALLOCATION_CODE_${kind}`, category: "validation", ...options });
|
|
11983
|
+
this.name = "AllocationCodeError";
|
|
11984
|
+
this.kind = kind;
|
|
11985
|
+
}
|
|
11986
|
+
};
|
|
11987
|
+
var NoApplicableAllocationCodeError = class extends AutotaskError {
|
|
11988
|
+
/** The structured blocker details (filters tried + department sets). */
|
|
11989
|
+
details;
|
|
11990
|
+
constructor(details, options = {}) {
|
|
11991
|
+
const families = details.families.map((f) => `${f.label} (useType eq ${f.family} + isActive eq true)`).join(", ");
|
|
11992
|
+
super(
|
|
11993
|
+
`No applicable allocation code for resource ${details.resourceID}: no active code passed the department applicability check for any family tried (${families}). The API user cannot create or activate allocation codes (userAccessForCreate "None") \u2014 this is a tenant-admin blocker.`,
|
|
11994
|
+
{
|
|
11995
|
+
code: "NO_APPLICABLE_ALLOCATION_CODE",
|
|
11996
|
+
category: "policy",
|
|
11997
|
+
resourceIds: [details.resourceID],
|
|
11998
|
+
suggestedAction: `In the tenant administration, activate (or create, then activate) an allocation code that is tenant-wide (department null) or scoped to one of the resource departments [${details.resourceDepartments.join(", ")}] for a needed family, then retry.`,
|
|
11999
|
+
...options
|
|
12000
|
+
}
|
|
12001
|
+
);
|
|
12002
|
+
this.name = "NoApplicableAllocationCodeError";
|
|
12003
|
+
this.details = { ...details, idRefs: [{ entity: "resource", id: details.resourceID }] };
|
|
12004
|
+
}
|
|
12005
|
+
};
|
|
12006
|
+
|
|
12007
|
+
// src/resources/special/time_entries/createTypes.ts
|
|
11350
12008
|
var USE_TYPE_GENERAL = 1;
|
|
11351
12009
|
var USE_TYPE_INTERNAL = 3;
|
|
11352
12010
|
var BODY_INTERNAL_CODE_KEY = "internalBillingCodeID";
|
|
@@ -11382,40 +12040,8 @@ var LINKAGE_OWNED_FIELDS = /* @__PURE__ */ new Set([
|
|
|
11382
12040
|
"roleID"
|
|
11383
12041
|
]);
|
|
11384
12042
|
var TIME_WINDOW_FIELDS = /* @__PURE__ */ new Set(["dateWorked", "startDateTime", "endDateTime", "hoursWorked"]);
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
super(message, { code: "RESOURCE_INVALID", category: "validation", ...options });
|
|
11388
|
-
this.name = "ResourceInvalidError";
|
|
11389
|
-
}
|
|
11390
|
-
};
|
|
11391
|
-
var AllocationCodeError = class extends AutotaskError {
|
|
11392
|
-
/** The failure kind (the machine code is `ALLOCATION_CODE_<KIND>`). */
|
|
11393
|
-
kind;
|
|
11394
|
-
constructor(kind, message, options = {}) {
|
|
11395
|
-
super(message, { code: `ALLOCATION_CODE_${kind}`, category: "validation", ...options });
|
|
11396
|
-
this.name = "AllocationCodeError";
|
|
11397
|
-
this.kind = kind;
|
|
11398
|
-
}
|
|
11399
|
-
};
|
|
11400
|
-
var NoApplicableAllocationCodeError = class extends AutotaskError {
|
|
11401
|
-
/** The structured blocker details (filters tried + department sets). */
|
|
11402
|
-
details;
|
|
11403
|
-
constructor(details, options = {}) {
|
|
11404
|
-
const families = details.families.map((f) => `${f.label} (useType eq ${f.family} + isActive eq true)`).join(", ");
|
|
11405
|
-
super(
|
|
11406
|
-
`No applicable allocation code for resource ${details.resourceID}: no active code passed the department applicability check for any family tried (${families}). The API user cannot create or activate allocation codes (userAccessForCreate "None") \u2014 this is a tenant-admin blocker.`,
|
|
11407
|
-
{
|
|
11408
|
-
code: "NO_APPLICABLE_ALLOCATION_CODE",
|
|
11409
|
-
category: "policy",
|
|
11410
|
-
resourceIds: [details.resourceID],
|
|
11411
|
-
suggestedAction: `In the tenant administration, activate (or create, then activate) an allocation code that is tenant-wide (department null) or scoped to one of the resource departments [${details.resourceDepartments.join(", ")}] for a needed family, then retry.`,
|
|
11412
|
-
...options
|
|
11413
|
-
}
|
|
11414
|
-
);
|
|
11415
|
-
this.name = "NoApplicableAllocationCodeError";
|
|
11416
|
-
this.details = { ...details, idRefs: [{ entity: "resource", id: details.resourceID }] };
|
|
11417
|
-
}
|
|
11418
|
-
};
|
|
12043
|
+
|
|
12044
|
+
// src/resources/special/time_entries/resolve.ts
|
|
11419
12045
|
async function boundedQuery(http, path, filter, totalCap, operation) {
|
|
11420
12046
|
const items = [];
|
|
11421
12047
|
const pageSize = Math.min(PAGE_SIZE, totalCap);
|
|
@@ -11486,7 +12112,7 @@ function rankCodes(codes) {
|
|
|
11486
12112
|
return Number(a.id) - Number(b.id);
|
|
11487
12113
|
});
|
|
11488
12114
|
}
|
|
11489
|
-
async function resolveCode(http, family,
|
|
12115
|
+
async function resolveCode(http, family, familyLabel2, pinnedId, departments, operation) {
|
|
11490
12116
|
const applicabilityFilters = [
|
|
11491
12117
|
{ field: "useType", op: "eq", value: family },
|
|
11492
12118
|
{ field: "isActive", op: "eq", value: true }
|
|
@@ -11497,7 +12123,7 @@ async function resolveCode(http, family, familyLabel, pinnedId, departments, ope
|
|
|
11497
12123
|
throw new AllocationCodeError(
|
|
11498
12124
|
"NOT_FOUND",
|
|
11499
12125
|
`Pinned allocation code ${pinnedId} was not found (billing_codes.get returned item: null)`,
|
|
11500
|
-
{ resourceIds: [pinnedId], details: { idRefs: [{ entity: "billing_code", id: pinnedId }] }, operation, suggestedAction: `Resolve an existing ${
|
|
12126
|
+
{ resourceIds: [pinnedId], details: { idRefs: [{ entity: "billing_code", id: pinnedId }] }, operation, suggestedAction: `Resolve an existing ${familyLabel2} allocation code id (billing_codes.query with a filter) and re-pin.` }
|
|
11501
12127
|
);
|
|
11502
12128
|
}
|
|
11503
12129
|
if (rec.isActive !== true) {
|
|
@@ -11510,7 +12136,7 @@ async function resolveCode(http, family, familyLabel, pinnedId, departments, ope
|
|
|
11510
12136
|
if (rec.useType !== family) {
|
|
11511
12137
|
throw new AllocationCodeError(
|
|
11512
12138
|
"WRONG_TYPE",
|
|
11513
|
-
`Pinned allocation code ${pinnedId} is useType ${String(rec.useType ?? "unknown")}, not the ${
|
|
12139
|
+
`Pinned allocation code ${pinnedId} is useType ${String(rec.useType ?? "unknown")}, not the ${familyLabel2} family (useType ${family})`,
|
|
11514
12140
|
{ resourceIds: [pinnedId], details: { idRefs: [{ entity: "billing_code", id: pinnedId }] }, operation, suggestedAction: `Pin a code from the right family (billing_codes.query with useType eq ${family} + isActive eq true).` }
|
|
11515
12141
|
);
|
|
11516
12142
|
}
|
|
@@ -11528,7 +12154,9 @@ async function resolveCode(http, family, familyLabel, pinnedId, departments, ope
|
|
|
11528
12154
|
const applicable = candidates.filter((c) => typeof c.department === "number" ? departments.has(c.department) : true);
|
|
11529
12155
|
return rankCodes(applicable)[0] ?? null;
|
|
11530
12156
|
}
|
|
11531
|
-
|
|
12157
|
+
|
|
12158
|
+
// src/resources/special/time_entries/createForResource.ts
|
|
12159
|
+
var OP3 = "time_entries.createForResource";
|
|
11532
12160
|
var PARAM_KEYS = /* @__PURE__ */ new Set([
|
|
11533
12161
|
"internallAllocationCodeID",
|
|
11534
12162
|
"ticketID",
|
|
@@ -11550,8 +12178,9 @@ function windowDefaults(params) {
|
|
|
11550
12178
|
hoursWorked: 8
|
|
11551
12179
|
};
|
|
11552
12180
|
}
|
|
11553
|
-
|
|
11554
|
-
|
|
12181
|
+
var familyOf = (l) => l === "internal" ? USE_TYPE_INTERNAL : USE_TYPE_GENERAL;
|
|
12182
|
+
var familyLabel = (f) => f === USE_TYPE_GENERAL ? "General Allocation Code" : "Internal Allocation Code";
|
|
12183
|
+
function validateCreateParams(params) {
|
|
11555
12184
|
const pin = (value, label) => typeof value === "number" ? parseIdentifier(value, label) : void 0;
|
|
11556
12185
|
const pins = {
|
|
11557
12186
|
code: pin(params.internallAllocationCodeID, "internallAllocationCodeID"),
|
|
@@ -11563,27 +12192,30 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
|
|
|
11563
12192
|
if (pinCount > 1) {
|
|
11564
12193
|
throw new AutotaskConfigError(
|
|
11565
12194
|
"createForResource: exactly one linkage pin is honoured (internallAllocationCodeID | ticketID | taskID) \u2014 the vendor gate requires exactly one; pass one pin (or none for the default internal linkage)",
|
|
11566
|
-
{ operation:
|
|
12195
|
+
{ operation: OP3 }
|
|
11567
12196
|
);
|
|
11568
12197
|
}
|
|
11569
12198
|
if (pins.role !== void 0 && pins.code !== void 0) {
|
|
11570
12199
|
throw new AutotaskConfigError(
|
|
11571
12200
|
"createForResource: a pinned roleID is only meaningful with the task/ticket linkage (internallAllocationCodeID pins the internal linkage and owns the code) \u2014 drop one",
|
|
11572
|
-
{ operation:
|
|
12201
|
+
{ operation: OP3 }
|
|
11573
12202
|
);
|
|
11574
12203
|
}
|
|
11575
12204
|
if (params.hoursWorked !== void 0 && (typeof params.hoursWorked !== "number" || !Number.isFinite(params.hoursWorked) || params.hoursWorked <= 0)) {
|
|
11576
12205
|
throw new AutotaskConfigError(
|
|
11577
12206
|
`createForResource: hoursWorked must be a positive finite number (got ${String(params.hoursWorked)})`,
|
|
11578
|
-
{ operation:
|
|
12207
|
+
{ operation: OP3 }
|
|
11579
12208
|
);
|
|
11580
12209
|
}
|
|
11581
|
-
|
|
12210
|
+
return pins;
|
|
12211
|
+
}
|
|
12212
|
+
async function assertResourceActive(http, rid) {
|
|
12213
|
+
const rec = await fetchItem2(http, `/V1.0/Resources/${rid}`, OP3);
|
|
11582
12214
|
if (rec === null) {
|
|
11583
12215
|
throw new ResourceInvalidError(`Resource ${rid} was not found \u2014 a time entry cannot attach to a missing resource`, {
|
|
11584
12216
|
resourceIds: [rid],
|
|
11585
12217
|
details: { idRefs: [{ entity: "resource", id: rid }] },
|
|
11586
|
-
operation:
|
|
12218
|
+
operation: OP3,
|
|
11587
12219
|
category: "not_found",
|
|
11588
12220
|
suggestedAction: "Resolve the resource id (resources.get / resources.search) and retry."
|
|
11589
12221
|
});
|
|
@@ -11592,24 +12224,21 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
|
|
|
11592
12224
|
throw new ResourceInvalidError(`Resource ${rid} is not active (isActive !== true) \u2014 the vendor would reject a time entry for an inactive resource`, {
|
|
11593
12225
|
resourceIds: [rid],
|
|
11594
12226
|
details: { idRefs: [{ entity: "resource", id: rid }] },
|
|
11595
|
-
operation:
|
|
12227
|
+
operation: OP3,
|
|
11596
12228
|
suggestedAction: "Activate the resource or pick an active one."
|
|
11597
12229
|
});
|
|
11598
12230
|
}
|
|
11599
|
-
|
|
11600
|
-
|
|
11601
|
-
const departments = roleRows.departments;
|
|
11602
|
-
const familyOf = (l) => l === "internal" ? USE_TYPE_INTERNAL : USE_TYPE_GENERAL;
|
|
11603
|
-
const familyLabel = (f) => f === USE_TYPE_GENERAL ? "General Allocation Code" : "Internal Allocation Code";
|
|
12231
|
+
}
|
|
12232
|
+
async function resolveAllocationCode(http, rid, linkage, pins, departments) {
|
|
11604
12233
|
let usedLinkage = linkage;
|
|
11605
12234
|
const pinnedForCode = pins.code !== void 0 ? pins.code : pins.role;
|
|
11606
12235
|
let code = null;
|
|
11607
12236
|
const familiesTried = [];
|
|
11608
|
-
code = await resolveCode(http, familyOf(linkage), familyLabel(familyOf(linkage)), pinnedForCode, departments,
|
|
12237
|
+
code = await resolveCode(http, familyOf(linkage), familyLabel(familyOf(linkage)), pinnedForCode, departments, OP3);
|
|
11609
12238
|
familiesTried.push({ family: familyOf(linkage), label: familyLabel(familyOf(linkage)), filters: [{ field: "useType", op: "eq", value: familyOf(linkage) }, { field: "isActive", op: "eq", value: true }] });
|
|
11610
12239
|
if (code === null && linkage === "task" && pins.role === void 0) {
|
|
11611
12240
|
usedLinkage = "internal";
|
|
11612
|
-
code = await resolveCode(http, USE_TYPE_INTERNAL, familyLabel(USE_TYPE_INTERNAL), void 0, departments,
|
|
12241
|
+
code = await resolveCode(http, USE_TYPE_INTERNAL, familyLabel(USE_TYPE_INTERNAL), void 0, departments, OP3);
|
|
11613
12242
|
familiesTried.push({ family: USE_TYPE_INTERNAL, label: familyLabel(USE_TYPE_INTERNAL), filters: [{ field: "useType", op: "eq", value: USE_TYPE_INTERNAL }, { field: "isActive", op: "eq", value: true }] });
|
|
11614
12243
|
}
|
|
11615
12244
|
if (code === null) {
|
|
@@ -11619,11 +12248,13 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
|
|
|
11619
12248
|
families: familiesTried,
|
|
11620
12249
|
resourceDepartments: [...departments].sort((a, b) => a - b)
|
|
11621
12250
|
},
|
|
11622
|
-
{ operation:
|
|
12251
|
+
{ operation: OP3 }
|
|
11623
12252
|
);
|
|
11624
12253
|
}
|
|
11625
12254
|
const resolvedCode = codeInfo(code);
|
|
11626
|
-
|
|
12255
|
+
return { usedLinkage, resolvedCode };
|
|
12256
|
+
}
|
|
12257
|
+
function buildCreateBody(rid, params, pins, usedLinkage, resolvedCode, defaults) {
|
|
11627
12258
|
const window = {};
|
|
11628
12259
|
for (const field of ["dateWorked", "startDateTime", "endDateTime", "hoursWorked"]) {
|
|
11629
12260
|
const callerValue = params[field];
|
|
@@ -11642,34 +12273,37 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
|
|
|
11642
12273
|
body[usedLinkage === "task" ? "taskID" : "ticketID"] = usedLinkage === "task" ? pins.task : pins.ticket;
|
|
11643
12274
|
body.roleID = resolvedCode.id;
|
|
11644
12275
|
}
|
|
11645
|
-
|
|
11646
|
-
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
if (usedLinkage === "internal" && linkage === "task") {
|
|
11653
|
-
warnings.push("the task had zero applicable general allocation codes \u2014 the linkage fell back to internal (the sanctioned fallback; no taskID/roleID in the body).");
|
|
11654
|
-
}
|
|
11655
|
-
return buildDryRunResult(
|
|
11656
|
-
{
|
|
11657
|
-
operation: OP,
|
|
11658
|
-
method: "POST",
|
|
11659
|
-
path: "/V1.0/TimeEntries",
|
|
11660
|
-
ids: [rid],
|
|
11661
|
-
checks: [
|
|
11662
|
-
{ name: "input-validation", ok: true },
|
|
11663
|
-
{ name: "resource-active", ok: true },
|
|
11664
|
-
{ name: "allocation-code-resolved", ok: true }
|
|
11665
|
-
],
|
|
11666
|
-
impact: { affected: 1, scope: "single-record", reversible: true },
|
|
11667
|
-
warnings,
|
|
11668
|
-
data: body
|
|
11669
|
-
},
|
|
11670
|
-
{ redactPersonalData: http.redactPersonalData }
|
|
12276
|
+
return body;
|
|
12277
|
+
}
|
|
12278
|
+
function createDryRun2(http, rid, body, linkage, usedLinkage, resolvedCode) {
|
|
12279
|
+
const warnings = [];
|
|
12280
|
+
if (usedLinkage !== "internal") {
|
|
12281
|
+
warnings.push(
|
|
12282
|
+
`roleID strategy: the resolved ${familyLabel(familyOf(usedLinkage))} code id ${resolvedCode.id} (H2 primary); on a vendor gate-3 rejection ("not an active general allocation code") the H1 demotion ladder applies (task.assignedResourceRoleID \u2192 the resource's active resource_roles rows) \u2014 at most ${MAX_CREATE_ATTEMPTS} create attempts.`
|
|
11671
12283
|
);
|
|
11672
12284
|
}
|
|
12285
|
+
if (usedLinkage === "internal" && linkage === "task") {
|
|
12286
|
+
warnings.push("the task had zero applicable general allocation codes \u2014 the linkage fell back to internal (the sanctioned fallback; no taskID/roleID in the body).");
|
|
12287
|
+
}
|
|
12288
|
+
return buildDryRunResult(
|
|
12289
|
+
{
|
|
12290
|
+
operation: OP3,
|
|
12291
|
+
method: "POST",
|
|
12292
|
+
path: "/V1.0/TimeEntries",
|
|
12293
|
+
ids: [rid],
|
|
12294
|
+
checks: [
|
|
12295
|
+
{ name: "input-validation", ok: true },
|
|
12296
|
+
{ name: "resource-active", ok: true },
|
|
12297
|
+
{ name: "allocation-code-resolved", ok: true }
|
|
12298
|
+
],
|
|
12299
|
+
impact: { affected: 1, scope: "single-record", reversible: true },
|
|
12300
|
+
warnings,
|
|
12301
|
+
data: body
|
|
12302
|
+
},
|
|
12303
|
+
{ redactPersonalData: http.redactPersonalData }
|
|
12304
|
+
);
|
|
12305
|
+
}
|
|
12306
|
+
async function createWithAttempts(resource, http, opts, body, defaults, pins, usedLinkage, resolvedCode, roleRows) {
|
|
11673
12307
|
const roleCandidates = [resolvedCode.id];
|
|
11674
12308
|
let taskRoleFetched = false;
|
|
11675
12309
|
const pushLadder = (value) => {
|
|
@@ -11695,7 +12329,7 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
|
|
|
11695
12329
|
const gate3 = ROLE_REJECTION_PATTERN.test(text);
|
|
11696
12330
|
if (usedLinkage !== "internal" && gate3) {
|
|
11697
12331
|
if (!taskRoleFetched && usedLinkage === "task" && pins.task !== void 0) {
|
|
11698
|
-
const task = await fetchItem2(http, `/V1.0/Tasks/${pins.task}`,
|
|
12332
|
+
const task = await fetchItem2(http, `/V1.0/Tasks/${pins.task}`, OP3);
|
|
11699
12333
|
taskRoleFetched = true;
|
|
11700
12334
|
if (task !== null) pushLadder(task.assignedResourceRoleID);
|
|
11701
12335
|
}
|
|
@@ -11716,6 +12350,19 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
|
|
|
11716
12350
|
}
|
|
11717
12351
|
throw lastError;
|
|
11718
12352
|
}
|
|
12353
|
+
async function createForResource(resource, resourceID, params, opts = {}, http) {
|
|
12354
|
+
const rid = parseIdentifier(resourceID, "resourceID");
|
|
12355
|
+
const pins = validateCreateParams(params);
|
|
12356
|
+
await assertResourceActive(http, rid);
|
|
12357
|
+
const linkage = pins.code !== void 0 ? "internal" : pins.ticket !== void 0 ? "ticket" : pins.task !== void 0 ? "task" : "internal";
|
|
12358
|
+
const roleRows = await resourceRoleRows(http, rid, OP3);
|
|
12359
|
+
const departments = roleRows.departments;
|
|
12360
|
+
const { usedLinkage, resolvedCode } = await resolveAllocationCode(http, rid, linkage, pins, departments);
|
|
12361
|
+
const defaults = windowDefaults(params);
|
|
12362
|
+
const body = buildCreateBody(rid, params, pins, usedLinkage, resolvedCode, defaults);
|
|
12363
|
+
if (opts.dryRun === true) return createDryRun2(http, rid, body, linkage, usedLinkage, resolvedCode);
|
|
12364
|
+
return createWithAttempts(resource, http, opts, body, defaults, pins, usedLinkage, resolvedCode, roleRows);
|
|
12365
|
+
}
|
|
11719
12366
|
function discoveryField(err, body, defaults) {
|
|
11720
12367
|
if (!isAutotaskError(err) || err.vendorError === void 0) return null;
|
|
11721
12368
|
const entries = parseVendorErrors(err.vendorError);
|
|
@@ -11730,6 +12377,76 @@ function discoveryField(err, body, defaults) {
|
|
|
11730
12377
|
return null;
|
|
11731
12378
|
}
|
|
11732
12379
|
|
|
12380
|
+
// src/resources/special/time_entries/move.ts
|
|
12381
|
+
var OP4 = "time_entries.moveToTicket";
|
|
12382
|
+
var COPY2 = ["resourceID", "roleID", "billingCodeID", "dateWorked", "startDateTime", "endDateTime", "hoursWorked", "offsetHours", "summaryNotes", "internalNotes", "isNonBillable", "showOnInvoice"];
|
|
12383
|
+
var CONTRACT_SCOPED2 = ["contractID", "contractServiceID", "contractServiceBundleID"];
|
|
12384
|
+
async function readTimeEntry(http, id, retries) {
|
|
12385
|
+
try {
|
|
12386
|
+
const res = await http.request({ method: "GET", path: `/V1.0/TimeEntries/${id}`, operation: OP4, maxRetries: retries });
|
|
12387
|
+
return res?.item ?? null;
|
|
12388
|
+
} catch (e) {
|
|
12389
|
+
if (isAutotaskError(e) && (e.httpStatus === 404 || e.code === "NOT_FOUND")) return null;
|
|
12390
|
+
throw e;
|
|
12391
|
+
}
|
|
12392
|
+
}
|
|
12393
|
+
async function checkSource2({ http, source, sourceId, toTicketId, retries }) {
|
|
12394
|
+
const ids2 = { sourceId, toTicketId };
|
|
12395
|
+
if (source.taskID !== null && source.taskID !== void 0) {
|
|
12396
|
+
throw refuse("MOVE_SOURCE_UNSUPPORTED", `${OP4}: time entry ${sourceId} is on a task \u2014 only ticket time entries are moved`, ids2, OP4);
|
|
12397
|
+
}
|
|
12398
|
+
if (source.billingApprovalDateTime || source.billingApprovalResourceID || source.billingApprovalLevelMostRecent) {
|
|
12399
|
+
throw refuse("MOVE_BLOCKED_APPROVED", `${OP4}: time entry ${sourceId} has billing approval set \u2014 move it in the Autotask UI`, ids2, OP4, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "approved entries are not moved by the API" } });
|
|
12400
|
+
}
|
|
12401
|
+
let billing;
|
|
12402
|
+
try {
|
|
12403
|
+
billing = await queryRows(http, "BillingItems", [{ field: "timeEntryID", op: "eq", value: sourceId }], OP4, retries);
|
|
12404
|
+
} catch {
|
|
12405
|
+
throw refuse("MOVE_BLOCKED_BILLED", `${OP4}: the billing items of time entry ${sourceId} could not be read \u2014 refused (fails closed)`, ids2, OP4, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "billing state unreadable" } });
|
|
12406
|
+
}
|
|
12407
|
+
if (billing.some((b) => b.invoiceID || b.postedDate)) {
|
|
12408
|
+
throw refuse("MOVE_BLOCKED_BILLED", `${OP4}: time entry ${sourceId} is billed or posted \u2014 move it in the Autotask UI`, ids2, OP4, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "billed entries are not moved by the API" } });
|
|
12409
|
+
}
|
|
12410
|
+
const attachments = await queryRows(http, "TimeEntryAttachments", [{ field: "timeEntryID", op: "eq", value: sourceId }], OP4, retries);
|
|
12411
|
+
if (attachments.length > 0) {
|
|
12412
|
+
throw refuse("MOVE_BLOCKED_ATTACHMENTS", `${OP4}: time entry ${sourceId} has attachments, which a delete would destroy \u2014 move it in the Autotask UI`, ids2, OP4, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "entries with attachments are not moved by the API" } });
|
|
12413
|
+
}
|
|
12414
|
+
}
|
|
12415
|
+
var SPEC2 = {
|
|
12416
|
+
operation: OP4,
|
|
12417
|
+
entity: "time_entry",
|
|
12418
|
+
readSource: readTimeEntry,
|
|
12419
|
+
sourceTicketId: (s) => typeof s.ticketID === "number" ? s.ticketID : null,
|
|
12420
|
+
dateField: "dateWorked",
|
|
12421
|
+
checkSource: checkSource2,
|
|
12422
|
+
buildPayload(source, toTicketId, { sameScope }) {
|
|
12423
|
+
const body = { ticketID: toTicketId };
|
|
12424
|
+
for (const k of COPY2) if (source[k] !== void 0 && source[k] !== null) body[k] = source[k];
|
|
12425
|
+
if (sameScope) {
|
|
12426
|
+
for (const k of CONTRACT_SCOPED2) if (source[k] !== void 0 && source[k] !== null) body[k] = source[k];
|
|
12427
|
+
}
|
|
12428
|
+
return body;
|
|
12429
|
+
},
|
|
12430
|
+
createPath: () => "/V1.0/TimeEntries",
|
|
12431
|
+
deletePath: (id) => `/V1.0/TimeEntries/${id}`,
|
|
12432
|
+
readById: (http, id) => readTimeEntry(http, id),
|
|
12433
|
+
verifyFields: ["resourceID", "roleID", "billingCodeID", "dateWorked", "startDateTime", "endDateTime", "hoursWorked", "summaryNotes", "internalNotes", "isNonBillable"],
|
|
12434
|
+
guardFields: ["ticketID", "resourceID", "roleID", "dateWorked", "startDateTime", "endDateTime", "hoursWorked", "summaryNotes", "billingApprovalDateTime", "billingApprovalResourceID", "lastModifiedDateTime"],
|
|
12435
|
+
twinQuery: (p) => ({
|
|
12436
|
+
resource: "TimeEntries",
|
|
12437
|
+
filter: [
|
|
12438
|
+
{ field: "ticketID", op: "eq", value: p.ticketID },
|
|
12439
|
+
{ field: "resourceID", op: "eq", value: p.resourceID },
|
|
12440
|
+
{ field: "dateWorked", op: "eq", value: p.dateWorked },
|
|
12441
|
+
{ field: "hoursWorked", op: "eq", value: p.hoursWorked }
|
|
12442
|
+
]
|
|
12443
|
+
}),
|
|
12444
|
+
manualLink: (http, source) => uiLinkFor(http, "time_entries", Number(source.id))
|
|
12445
|
+
};
|
|
12446
|
+
function moveToTicket2(_resource, sourceId, toTicketId, opts, http) {
|
|
12447
|
+
return runMove(http, SPEC2, sourceId, toTicketId, opts);
|
|
12448
|
+
}
|
|
12449
|
+
|
|
11733
12450
|
// src/resources/time_entries.ts
|
|
11734
12451
|
var TimeEntriesResource = class extends BaseResource {
|
|
11735
12452
|
constructor(http, pageSize) {
|
|
@@ -11854,6 +12571,10 @@ var TimeEntriesResource = class extends BaseResource {
|
|
|
11854
12571
|
return windowQuery3(this, "lastFullWeek", this.http, opts);
|
|
11855
12572
|
}
|
|
11856
12573
|
/** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
|
|
12574
|
+
moveToTicket(sourceId, toTicketId, opts = {}) {
|
|
12575
|
+
return moveToTicket2(this, sourceId, toTicketId, opts, this.http);
|
|
12576
|
+
}
|
|
12577
|
+
/** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
|
|
11857
12578
|
postedWithin(from, to, opts) {
|
|
11858
12579
|
return postedWithin(this, from, to, this.http, opts);
|
|
11859
12580
|
}
|