node-autotask 0.21.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.
Files changed (56) hide show
  1. package/ARCHITECTURE.md +44 -21
  2. package/CHANGELOG.md +31 -0
  3. package/MCP_TOOL_CATALOG.json +69 -10
  4. package/MCP_TOOL_MANIFEST.md +20 -17
  5. package/MCP_TOOL_OVERRIDES.json +6 -6
  6. package/N8N_MCP_MANIFEST.json +20 -4
  7. package/N8N_MCP_MANIFEST.md +6 -5
  8. package/README.md +9 -9
  9. package/capabilities.json +2898 -7412
  10. package/capabilities.schema.json +0 -12
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.d.cts +0 -2
  13. package/dist/capabilities.d.ts +0 -2
  14. package/dist/capabilities.js +1 -1
  15. package/dist/{chunk-KD6LOSAK.js → chunk-B5SNZ3TZ.js} +3473 -3084
  16. package/dist/{chunk-QWAWNQEZ.cjs → chunk-D5TVBI4U.cjs} +3021 -2632
  17. package/dist/{chunk-CBUY3MKB.cjs → chunk-JUO3URUI.cjs} +2897 -7411
  18. package/dist/{chunk-HEW4WJLC.cjs → chunk-N27NUB4R.cjs} +2466 -1745
  19. package/dist/{chunk-DM67SXCO.js → chunk-OGNUI3UR.js} +2897 -7411
  20. package/dist/{chunk-GBVUXAUA.js → chunk-PVUUOM5G.js} +2092 -1371
  21. package/dist/{chunk-PMPFQDVM.cjs → chunk-TTQB537V.cjs} +135 -135
  22. package/dist/{chunk-JRSU6PKP.js → chunk-VYFO2N5F.js} +2 -2
  23. package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
  24. package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
  25. package/dist/{index-DSnuyiTr.d.ts → index-DBoX-Dnk.d.ts} +430 -957
  26. package/dist/{index-ByWYfNog.d.cts → index-NC-x-bYl.d.cts} +430 -957
  27. package/dist/index.cjs +91 -62
  28. package/dist/index.d.cts +360 -5
  29. package/dist/index.d.ts +360 -5
  30. package/dist/index.js +74 -45
  31. package/dist/operations/index.cjs +462 -462
  32. package/dist/operations/index.d.cts +78 -84
  33. package/dist/operations/index.d.ts +78 -84
  34. package/dist/operations/index.js +382 -382
  35. package/dist/pool.cjs +8 -8
  36. package/dist/pool.d.cts +2 -2
  37. package/dist/pool.d.ts +2 -2
  38. package/dist/pool.js +4 -4
  39. package/dist/resources/index.cjs +4 -4
  40. package/dist/resources/index.d.cts +2 -2
  41. package/dist/resources/index.d.ts +2 -2
  42. package/dist/resources/index.js +3 -3
  43. package/dist/types/index.d.cts +1 -1
  44. package/dist/types/index.d.ts +1 -1
  45. package/docs/API.md +133 -41
  46. package/docs/N8N-ADAPTER.md +3 -3
  47. package/examples/mcp/errors.ts +135 -0
  48. package/examples/mcp/manifest.ts +168 -0
  49. package/examples/mcp/register.ts +66 -0
  50. package/examples/mcp/schemas.ts +203 -0
  51. package/examples/mcp/tools-meta.ts +120 -0
  52. package/examples/mcp/tools-mode.ts +177 -0
  53. package/examples/mcp/tools-read.ts +264 -0
  54. package/examples/mcp/types.ts +34 -0
  55. package/examples/mcp-register.ts +9 -1074
  56. 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-KD6LOSAK.js";
41
+ } from "./chunk-B5SNZ3TZ.js";
38
42
  import {
39
43
  CAPABILITIES
40
- } from "./chunk-DM67SXCO.js";
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/companies.ts
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, ids, limit) {
932
+ async function fetchCompanies(http, ids2, limit) {
858
933
  const out = [];
859
- for (const id of ids.slice(0, limit)) {
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 ids = [...new Set(contacts.map((c) => c.companyID).filter((id) => typeof id === "number" && id >= 0))];
885
- return fetchCompanies(http, ids, limit);
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 ids = [...new Set(contacts.map((c) => c.companyID).filter((id) => typeof id === "number" && id >= 0))];
905
- add(await fetchCompanies(http, ids, limit));
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
- var OP_UPSERT_NAME = "companies.upsertByName";
988
- var UPSERT_NAME_MAX_DISCOVERY_MERGES = 3;
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
- async function upsertByName(resource, companyName, params, opts, http) {
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
- const q = await resource.queryBounded({ filter: [{ field: "companyName", op: "eq", value: companyName }], limit: DEDUP_NAME_LIMIT });
1035
- const caveat = {
1036
- companies: true,
1037
- caveat: BLIND_SURFACE_CAVEAT,
1038
- ...q.blindSurface !== void 0 ? { sentinel: q.blindSurface } : {}
1039
- };
1040
- const match = q.items[0];
1041
- if (match !== void 0) {
1042
- if (opts.dryRun === true) {
1043
- return buildDryRunResult(
1044
- {
1045
- operation: OP_UPSERT_NAME,
1046
- method: "POST",
1047
- path: "/V1.0/Companies",
1048
- ids: [Number(match.id)],
1049
- checks: [
1050
- { name: "input-validation", ok: true },
1051
- { name: "duplicate-detection", ok: true }
1052
- ],
1053
- impact: { affected: 0, scope: "no write \u2014 the company already exists (dedup match; the create is skipped)", reversible: true },
1054
- warnings: [`EXISTS: company '${companyName}' already exists (id ${Number(match.id)}) \u2014 the apply would skip the create (zero mutating wire).`],
1055
- data: { reason: "EXISTS", id: Number(match.id) }
1056
- },
1057
- { redactPersonalData: http.redactPersonalData }
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
- throw new OwnerResourceRequiredError(
1097
- `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)`,
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 fullProfile(resource, id, opts = {}, http) {
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 >= PROFILE_PART_LIMIT },
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
- async function boundedPart(http, path, conditions, limit, includeFields) {
1398
- const body = { MaxRecords: limit, filter: conditions };
1399
- if (includeFields !== void 0) body.IncludeFields = includeFields;
1400
- const res = await http.request({
1401
- method: "POST",
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 unbilledWork(resource, companyId, opts = {}, http) {
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 (v.includeParent) {
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
- const taskCap = capJoinSet(taskIds, UNBILLED_WORK_JOIN_SET_CAP);
1476
- const ticketCap = capJoinSet(ticketJoinIds, UNBILLED_WORK_JOIN_SET_CAP);
1477
- const joinNotes = [];
1478
- for (const [what, more] of [["ticket", ticketsRaw.hasMore], ["project", projectsRaw.hasMore], ["task", tasksHasMore]]) {
1479
- 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)`);
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
- let billingItems = billingRaw.items;
1517
- let billingNoteCharsTruncated = 0;
1518
- if (v.includeNotes) {
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
- var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
4582
- function num2(v) {
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 = num2(charge["unitCost"]) * num2(charge["unitQuantity"]);
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[num2(item["billingItemType"])];
4776
+ const byType = BILLING_ITEM_TYPE_KIND[num(item["billingItemType"])];
4661
4777
  if (byType !== void 0) return byType;
4662
- if (num2(item["timeEntryID"]) > 0) return "labour";
4663
- if (num2(item["expenseItemID"]) > 0) return "expenses";
4664
- if (CHARGE_LINE_FKS.some((k) => num2(item[k]) > 0)) return "charges";
4665
- if (SERVICE_LINE_FKS.some((k) => num2(item[k]) > 0)) return "recurring";
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 : num2(row[localKey]);
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
- async function readPicklists(http, entityPath, operation) {
4676
- const res = await http.request({ method: "GET", path: `${entityPath}/entityInformation/fields`, operation });
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 (!isDateYMD2(opts.dateFrom)) problems.push(`dateFrom is required (YYYY-MM-DD) (got ${String(opts.dateFrom)})`);
4712
- if (!isDateYMD2(opts.dateTo)) problems.push(`dateTo is required (YYYY-MM-DD) (got ${String(opts.dateTo)})`);
4713
- if (isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo < opts.dateFrom) {
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 (isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo >= opts.dateFrom) {
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 = isDateYMD2(opts.dateFrom) && isDateYMD2(opts.dateTo) && opts.dateTo >= opts.dateFrom ? monthsInWindow(opts.dateFrom, opts.dateTo) : Infinity;
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
- async function boundedPart2(http, path, conditions, limit, operation, includeFields) {
4802
- const body = { MaxRecords: limit, filter: conditions };
4803
- if (includeFields !== void 0) body.IncludeFields = includeFields;
4804
- const res = await http.request({
4805
- method: "POST",
4806
- idempotent: true,
4807
- path,
4808
- body,
4809
- operation
4810
- });
4811
- const { items, pageDetails } = unwrapItems(res, operation);
4812
- const capped = items.length >= limit;
4813
- return { items, capped, hasMore: capped && pageDetails.nextPageUrl != null };
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(ids, cap) {
4816
- const sorted = [...new Set(ids)].sort((a, b) => a - b);
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, ids, fields, operation, entity) {
4821
- const unique = [...new Set(ids)].sort((a, b) => a - b);
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 = num2(row.id);
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 boundedPart2(http, path, cond, limit, operation, fields);
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 = num2(row["id"]);
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
- function fmtHours(h) {
4983
- const r = Math.round(h * 100) / 100;
4984
- return String(r);
4985
- }
4986
- function fmtNum(v) {
4987
- const r = Math.round(v * 100) / 100;
4988
- return String(r);
4989
- }
4990
- function pctOrNa(r) {
4991
- return r === null ? "n/a" : `${Math.round(r * 1e3) / 10}%`;
4992
- }
4993
- function dedupeSortCap(ids, limit) {
4994
- return [...new Set(ids)].sort((a, b) => Number(a) - Number(b)).slice(0, limit);
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
- async function profitability(resource, contractId, opts, rawHttp) {
4997
- const v = validateProfitabilityOptions(contractId, opts);
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: PROFITABILITY_WIRE_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(() => boundedPart2(http, path, conditions, v.limit, OP2, fields));
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, OP2, fields, dateField, v.dateFrom, v.dateTo, wires));
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
- const contractCached = rawHttp.readCache?.get(`/V1.0/Contracts/${id}`) !== void 0;
5041
- const contract = await resource.get(id);
5042
- wires.issued += 1;
5043
- if (!contractCached) wires.requests += 1;
5044
- const contractRow = contract;
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: OP2
5216
+ operation: ctx.OP
5056
5217
  });
5057
- const item = unwrapItem(res, companyID, OP2, "company");
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", OP2)).get("contractType")?.get(contractType) ?? null;
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
- const servicesCond = [{ field: "contractID", op: "eq", value: id }];
5082
- const servicesRaw = await readPart("/V1.0/ContractServices/query", servicesCond, ["id", "serviceID", "unitPrice", "unitCost", "internalCurrencyUnitPrice", "internalDescription"]);
5083
- const unitsCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("startDate")];
5084
- const units = await windowedPart("contractServiceUnits", "contract_service_units", "/V1.0/ContractServiceUnits/query", unitsCond, ["id", "contractServiceID", "serviceID", "units", "price", "cost", "internalCurrencyPrice", "startDate", "endDate", "approveAndPostDate"], "startDate");
5085
- const unitRows = units.rows;
5086
- let chargesPart;
5087
- let charges = [];
5088
- let chargesCond = [];
5089
- if (v.includeRecurringCosts) {
5090
- chargesCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("datePurchased")];
5091
- const raw = await readPart("/V1.0/ContractCharges/query", chargesCond, ["id", "chargeType", "billableAmount", "extendedCost", "unitCost", "unitPrice", "unitQuantity", "datePurchased", "isBilled", "status", "contractServiceID"]);
5092
- charges = raw.items;
5093
- chargesPart = stampPart("contract_charges", raw, chargesCond);
5094
- } else {
5095
- chargesPart = skipPart("skipped: includeRecurringCosts is false (zero wire)");
5096
- }
5097
- const roleCostsCond = [{ field: "contractID", op: "eq", value: id }];
5098
- const roleCostsRaw = await readPart("/V1.0/ContractRoleCosts/query", roleCostsCond, ["id", "rate", "resourceID", "roleID"]);
5099
- const ratesCond = [{ field: "contractID", op: "eq", value: id }];
5100
- const ratesRaw = await readPart("/V1.0/ContractRates/query", ratesCond, ["id", "roleID", "contractHourlyRate", "internalCurrencyContractHourlyRate"]);
5101
- let blocksPart;
5102
- if (!isBlockHours) {
5103
- blocksPart = skipPart(
5104
- contractTypeKnown ? `skipped: not a Block Hours contract (contractType ${contractType} "${contractTypeLabel}") \u2014 included hours apply to hour blocks only (zero wire)` : `skipped: the contract type is unknown (contractType ${contractType || "absent"}; label unresolved) \u2014 included hours are not evaluable (zero wire)`
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 billingCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("itemDate")];
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
- const billingItems = billing.part;
5115
- const billingRows = billing.rows;
5116
- const invoiceIds = [...new Set(billingRows.map((b) => num2(b["invoiceID"])).filter((n) => n > 0))];
5117
- let invoicesPart;
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 = skipPart("skipped: the billing part has no invoiceIDs (zero wire)");
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 timeCond = [{ field: "contractID", op: "eq", value: id }, ...windowOn("dateWorked")];
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
- const timeEntries = time.part;
5133
- const entries = time.rows;
5134
- const ticketFields = ["id", "ticketNumber", "title", "status", "ticketCategory", "contractID", "createDate", "resolvedDateTime", "completedDate", "lastActivityDate", "dueDateTime"];
5135
- const createdInWindow = [{ field: "contractID", op: "eq", value: id }, ...windowOn("createDate")];
5136
- const refTicketIds = capJoinSet2(entries.map((e) => num2(e["ticketID"])).filter((n) => n > 0), v.maxTicketsForJoin);
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, ticketFields);
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", OP2)).get("status");
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(([v2]) => v2);
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
- let ticketsOpenPart;
5158
- let openTickets = [];
5350
+ return { openStatusSet, statusNote };
5351
+ }
5352
+ async function fetchOpenTickets(ctx) {
5353
+ const { openStatusSet, statusNote } = await readOpenStatusSet(ctx);
5159
5354
  if (openStatusSet === null) {
5160
- ticketsOpenPart = skipPart(`skipped: ${statusNote} (zero wire)`);
5161
- } else {
5162
- const openCond = [{ field: "contractID", op: "eq", value: id }, { field: "status", op: "in", value: [...openStatusSet] }];
5163
- const raw = await readPart("/V1.0/Tickets/query", openCond, ticketFields);
5164
- openTickets = raw.items;
5165
- ticketsOpenPart = stampPart("tickets", raw, openCond, ["openStatusHeuristic: true (the open set is the tenant picklist minus the closed-class labels)"]);
5166
- }
5167
- const resourceIds = [...new Set(entries.map((t) => num2(t["resourceID"])).filter((n) => n > 0))];
5168
- let resourcesPart;
5169
- let resources = [];
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 = skipPart("skipped: the time part has no resourceIDs (zero wire)");
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 [...ticketsActRaw.items, ...openTickets]) {
5184
- const k = num2(t["id"]);
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) => num2(t["contractID"]) === idNum);
5188
- const ticketJoin = capJoinSet2(contractTickets.map((t) => num2(t["id"])), v.maxTicketsForJoin);
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
- let expensesPart;
5191
- let expenses = [];
5192
- if (ticketJoin.ids.length === 0) {
5193
- 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)");
5194
- } else {
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
- let subscriptionsPart;
5225
- let subscriptions = [];
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
- subscriptionsPart = skipPart("skipped: the CI join found no configuration items \u2014 the subscriptions part is not evaluable (zero wire)");
5231
- } else {
5232
- const ciIds = capJoinSet2(cis.map((c) => num2(c["id"])).filter((n) => n > 0), FETCH_BY_IDS_MAX_ITEMS);
5233
- const subCond = [{ field: "configurationItemID", op: "in", value: ciIds.ids }];
5234
- const raw = await readPart("/V1.0/Subscriptions/query", subCond, ["id", "subscriptionName", "configurationItemID", "periodCost", "periodPrice", "periodType", "effectiveDate", "expirationDate", "status", "totalCost"]);
5235
- subscriptions = raw.items;
5236
- let labels = null;
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 servicesPart = stampPart("contract_services", servicesRaw, servicesCond);
5264
- const unitsPart = units.part;
5265
- const roleCostsPart = stampPart("contract_role_costs", roleCostsRaw, roleCostsCond);
5266
- const ratesPart = stampPart("contract_rates", ratesRaw, ratesCond);
5267
- const ticketsActivityPart = stampPart("tickets", ticketsActRaw, ticketsActCond, [
5268
- ...ticketsActRaw.hasMore ? [`${fetchedTickets.size} ticket(s) returned of possibly more \u2014 the checks cover the returned set only`] : [],
5269
- ...refTicketIds.truncated ? [`the time-referenced ticket ids are capped at ${v.maxTicketsForJoin} of ${refTicketIds.total} \u2014 the rest read as ticket_not_in_fetched_set`] : []
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
- const billedByEntryId = /* @__PURE__ */ new Map();
5272
- for (const b of billingRows) {
5273
- const te = num2(b["timeEntryID"]);
5274
- if (te <= 0) continue;
5275
- const arr = billedByEntryId.get(te) ?? [];
5276
- arr.push(b);
5277
- billedByEntryId.set(te, arr);
5278
- }
5279
- const roleCostByResourceRole = /* @__PURE__ */ new Map();
5280
- const roleCostByRole = /* @__PURE__ */ new Map();
5281
- for (const rc of roleCostsRaw.items) {
5282
- const resId = num2(rc["resourceID"]);
5283
- const roleId = num2(rc["roleID"]);
5284
- if (resId > 0 && roleId > 0) roleCostByResourceRole.set(`${resId}:${roleId}`, rc);
5285
- else if (roleId > 0) roleCostByRole.set(roleId, rc);
5286
- }
5287
- const resourcesById = new Map(resources.map((r) => [num2(r["id"]), r]));
5288
- const ladderCtx = { method: v.method, rate: v.labourRatePerHour, billedByEntryId, roleCostByResourceRole, roleCostByRole, resourcesById, resourceMissIds };
5289
- const entryResolution = /* @__PURE__ */ new Map();
5290
- for (const e of entries) entryResolution.set(num2(e["id"]), resolveLabour(e, ladderCtx));
5291
- const activeRungs = ladderFrom(ladderCtx);
5292
- const sources = {};
5293
- for (const rung of activeRungs) sources[rung] = { entries: 0, hours: 0, cost: 0 };
5294
- sources.missing = { entries: 0, hours: 0, cost: 0 };
5295
- const resolutionNotes = [];
5296
- if (v.method === "billing_item_our_cost") {
5297
- const noItems = entries.filter((e) => !billedByEntryId.has(num2(e["id"]))).length;
5298
- if (noItems > 0) resolutionNotes.push(`${noItems} ${noItems === 1 ? "entry" : "entries"} had no billing item (non-billable or unposted) \u2014 rung 1 not usable`);
5299
- }
5300
- for (const resId of [...resourceMissIds].sort((a, b) => a - b)) {
5301
- const n = entries.filter((e) => num2(e["resourceID"]) === resId).length;
5302
- resolutionNotes.push(`resource ${resId} unreadable (fetchByIds NOT_FOUND) \u2014 rung 3 unavailable for ${n} ${n === 1 ? "entry" : "entries"}`);
5303
- }
5304
- for (const e of entries) {
5305
- const r = entryResolution.get(num2(e["id"]));
5306
- const bucket = sources[r.source] ?? sources.missing;
5307
- bucket.entries += 1;
5308
- bucket.hours += hoursOf(e);
5309
- bucket.cost += r.cost ?? 0;
5310
- }
5311
- for (const b of Object.values(sources)) b.cost = cents(b.cost);
5312
- const unresolvedHours = sources.missing.hours;
5313
- if (unresolvedHours > 0) resolutionNotes.push(`${fmtHours(unresolvedHours)} hours unresolved (missing rate) \u2014 total_cost is a lower bound`);
5314
- const costRateResolution = { method: v.method, sources, unresolvedHours, notes: resolutionNotes };
5315
- const months = monthsBetween(v.dateFrom, v.dateTo);
5316
- const monthOf = (date) => typeof date === "string" && DATE_RE2.test(`${date}`.slice(0, 10)) ? date.slice(0, 7) : null;
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 += num2(u["cost"]);
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) => [num2(i["id"]), 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 ids = [];
5552
+ const ids2 = [];
5333
5553
  for (const b of billingRows) {
5334
5554
  if (!inMonth(b["itemDate"], m) || billingLineType(b) !== "recurring") continue;
5335
- const invId = num2(b["invoiceID"]);
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
- ids.push(num2(b["id"]));
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 (num2(r["roleID"]) > 0 && typeof rate === "number" && Number.isFinite(rate)) contractRateByRole.set(num2(r["roleID"]), rate);
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 coverageGaps = (m) => {
5358
- const gaps = [];
5359
- for (const [name, p] of monthlyParts) {
5360
- if (p.status === "absent") gaps.push(`${name}: absent (${p.absent})`);
5361
- else if (truncatedMonths[name] !== void 0) {
5362
- if (truncatedMonths[name].includes(m)) gaps.push(`${name}: truncated at limit ${v.limit}`);
5363
- } else if (p.hasMore) gaps.push(`${name}: capped at limit ${v.limit}`);
5364
- }
5365
- return gaps;
5366
- };
5367
- const monthly = [];
5368
- for (let mi = 0; mi < months.length; mi++) {
5369
- const m = months[mi];
5370
- const mBilling = billingRows.filter((b) => inMonth(b["itemDate"], m));
5371
- let revenueInvoiced = 0;
5372
- let revenueVoided = 0;
5373
- let postedUninvoiced = 0;
5374
- const byType = { recurring: 0, labour: 0, charges: 0, expenses: 0, other: 0 };
5375
- let unresolvedInvoice = 0;
5376
- let unresolvedLines = 0;
5377
- for (const b of mBilling) {
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 expected = v.includeUnbilledRevenue && expectedBasis ? expectedIn(m) : null;
5396
- const expectedUnbilled = expected === null ? 0 : Math.max(0, expected - billedServiceIn(m).billed);
5397
- const mEntries = entries.filter((e) => inMonth(e["dateWorked"], m));
5398
- let labourHours = 0;
5399
- let labourTotal = 0;
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
- const unposted = mEntries.filter((e) => e["isNonBillable"] !== true && !billedByEntryId.has(num2(e["id"])));
5415
- const hoursMissing = unposted.some((e) => typeof e["hoursToBill"] !== "number" || !Number.isFinite(e["hoursToBill"]));
5416
- const hoursPart = hoursMissing ? { entries: unposted.length, hours: null, hoursAbsent: "HOURS_TO_BILL_MISSING" } : { entries: unposted.length, hours: unposted.reduce((a, e) => a + Math.max(0, num2(e["hoursToBill"])), 0) };
5417
- let unpostedTime;
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
- let toolingVendor = subscriptionsPart.status === "skipped" || subscriptionsPart.status === "absent" ? null : 0;
5431
- if (toolingVendor !== null) {
5432
- for (const s of subscriptions) {
5433
- const eff = monthOf(s["effectiveDate"]) ?? "0000-00";
5434
- const exp = monthOf(s["expirationDate"]) ?? "9999-99";
5435
- const sid = num2(s["id"]);
5436
- if (cancelledSubs.has(sid)) continue;
5437
- if (oneTimeSubs.has(sid)) {
5438
- if (eff === m) toolingVendor += num2(s["periodCost"]);
5439
- continue;
5440
- }
5441
- const factor = subFactor.get(sid) ?? null;
5442
- if (factor !== null && eff <= m && m <= exp) toolingVendor += num2(s["periodCost"]) * factor;
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 callerSupplied = v.externalMonthlyCosts[mi] ?? 0;
5446
- const recurring = recurringIn(m);
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
- const warnings = [];
5482
- warnings.push({
5483
- code: "miscoded_time_out",
5484
- count: probeRows.length,
5485
- sample_ids: dedupeSortCap(probeRows.map((p) => num2(p["id"])), v.limit)
5486
- });
5487
- const crossTimeEntries = entries.filter((e) => {
5488
- const t = num2(e["ticketID"]);
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 && num2(ticket["contractID"]) !== idNum;
5720
+ const ticket = fc.f.join.fetchedTickets.get(t);
5721
+ return ticket !== void 0 && num(ticket["contractID"]) !== fc.idNum;
5492
5722
  });
5493
- warnings.push({ code: "cross_contract_ticket_time", count: crossTimeEntries.length, sample_ids: dedupeSortCap(crossTimeEntries.map((e) => num2(e["id"])), v.limit) });
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(num2(e["resourceID"])), String(e["dateWorked"] ?? ""), String(num2(e["ticketID"])), String(e["hoursWorked"] ?? "")].join("|");
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) => num2(a["id"]) - num2(b["id"]));
5506
- for (const e of sorted) dupAll.push(num2(e["id"]));
5507
- for (const e of sorted.slice(1)) dupSample.push(num2(e["id"]));
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
- warnings.push({ code: "duplicate_entries", count: dupAll.length, sample_ids: dedupeSortCap(dupSample, v.limit) });
5510
- const zeroHours = entries.filter((e) => {
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
- warnings.push({ code: "zero_negative_hours", count: zeroHours.length, sample_ids: dedupeSortCap(zeroHours.map((e) => num2(e["id"])), v.limit) });
5515
- const outPeriod = entries.filter((e) => {
5516
- const t = num2(e["ticketID"]);
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
- warnings.push({ code: "time_outside_ticket_period", count: outPeriod.length, sample_ids: dedupeSortCap(outPeriod.map((e) => num2(e["id"])), v.limit) });
5529
- const missingRate = entries.filter((e) => entryResolution.get(num2(e["id"])).source === "missing");
5530
- warnings.push({ code: "missing_cost_rate", count: missingRate.length, sample_ids: dedupeSortCap(missingRate.map((e) => num2(e["id"])), v.limit) });
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(num2(c["id"]));
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) ? num2(u["cost"]) : 0;
5548
- const cB = unitIn(u, mB) ? num2(u["cost"]) : 0;
5549
- if (Math.abs(cB - cA) > 0) driftIds.add(num2(u["id"]));
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
- warnings.push({ code: "recurring_cost_drift", count: driftCount, sample_ids: dedupeSortCap([...driftIds], v.limit) });
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 ids) expectedUnbilledSamples.push(i);
5815
+ for (const i of lineIds) expectedUnbilledSamples.push(i);
5562
5816
  }
5563
5817
  }
5564
5818
  }
5565
- warnings.push({ code: "expected_unbilled_revenue", count: expectedUnbilledCount, sample_ids: dedupeSortCap(expectedUnbilledSamples, v.limit) });
5566
- const driftTickets = [...fetchedTickets.values()].filter((t) => num2(t["contractID"]) !== idNum);
5567
- const unresolvedRefs = entries.filter((e) => num2(e["ticketID"]) > 0 && !fetchedTickets.has(num2(e["ticketID"])));
5568
- warnings.push({
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) => num2(t["id"])), ...unresolvedRefs.map((e) => num2(e["id"]))], v.limit)
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(num2(t["id"]));
5838
+ const days = fc.asOfMs / 864e5 - lastDay;
5839
+ if (days > fc.v.staleAfterDays) stale.push(num(t["id"]));
5582
5840
  }
5583
5841
  }
5584
- warnings.push({ code: "open_stale_tickets", count: stale.length, sample_ids: dedupeSortCap(stale, v.limit) });
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
- const actualRevenue = monthly.reduce((a, r) => a + r.revenue_invoiced, 0);
5590
- const actualCost = monthly.reduce((a, r) => a + r.total_cost, 0);
5591
- const termStart = dateToDay(contractRow["startDate"]);
5592
- const termEnd = dateToDay(contractRow["endDate"]);
5593
- const fromDay = dateToDay(v.dateFrom);
5594
- const toDay = dateToDay(v.dateTo);
5595
- if (termStart !== null && termEnd !== null && fromDay !== null && toDay !== null) {
5596
- const overlapStart = Math.max(termStart, fromDay);
5597
- const overlapEnd = Math.min(termEnd, toDay);
5598
- const termDays = termEnd - termStart + 1;
5599
- const overlapDays = overlapEnd >= overlapStart ? overlapEnd - overlapStart + 1 : 0;
5600
- if (termDays > 0 && overlapDays > 0) {
5601
- const frac = overlapDays / termDays;
5602
- const estRev = num2(contractRow["estimatedRevenue"]);
5603
- const estCost = num2(contractRow["estimatedCost"]);
5604
- const devRev = estRev > 0 ? Math.abs(actualRevenue - estRev * frac) / (estRev * frac) : null;
5605
- const devCost = estCost > 0 ? Math.abs(actualCost - estCost * frac) / (estCost * frac) : null;
5606
- if (devRev !== null && devRev > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100 || devCost !== null && devCost > ESTIMATE_DEVIATION_THRESHOLD_PCT / 100) {
5607
- estimateCount = 1;
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
- warnings.push({ code: "estimate_vs_actual", count: estimateCount, sample_ids: estimateSample, ...estimateCount === 1 && estimateNote !== void 0 ? { note: estimateNote } : {} });
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 = num2(e["ticketID"]);
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 = num2(x["ticketID"]);
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) + num2(x["internalCurrencyExpenseAmount"]));
5905
+ expenseByTicket.set(t, (expenseByTicket.get(t) ?? 0) + num(x["internalCurrencyExpenseAmount"]));
5628
5906
  }
5629
5907
  const ticketCost = (t) => {
5630
- const tid = num2(t["id"]);
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(num2(e["id"])).cost ?? 0;
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(num2(t["id"])) ?? []).reduce((a, e) => a + hoursOf(e), 0);
5637
- const allTickets = [...fetchedTickets.values()];
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
- const byCat = /* @__PURE__ */ new Map();
5641
- for (const t of allTickets) {
5642
- const cat = num2(t["ticketCategory"]) > 0 ? num2(t["ticketCategory"]) : null;
5643
- const arr = byCat.get(cat) ?? [];
5644
- arr.push(t);
5645
- byCat.set(cat, arr);
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
- for (const [cat, tickets] of byCat) {
5648
- let entryCount = 0;
5649
- let hours = 0;
5650
- let cost = 0;
5651
- for (const t of tickets) {
5652
- const es = entriesByTicket.get(num2(t["id"])) ?? [];
5653
- entryCount += es.length;
5654
- hours += es.reduce((a, e) => a + hoursOf(e), 0);
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
- const highestCostTickets = allTickets.map((t) => ({
5669
- ticketId: num2(t["id"]),
5670
- ticketNumber: num2(t["ticketNumber"]) > 0 ? num2(t["ticketNumber"]) : null,
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 = num2(e["id"]);
5967
+ const eid = num(e["id"]);
5680
5968
  if (eid <= 0 || miscoding.has(eid)) return;
5681
- const c = num2(e["contractID"]);
5682
- miscoding.set(eid, { entryId: eid, reason, ticketId: num2(e["ticketID"]) > 0 ? num2(e["ticketID"]) : null, contractId: c > 0 ? c : null, hours: num2(e["hoursWorked"]) });
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, num2(p["contractID"]) > 0 ? "ticket_of_other_contract" : "no_contract_on_entry");
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
- const possibleMiscoding = [...miscoding.values()].sort((a, b) => a.entryId - b.entryId).slice(0, v.limit);
5688
- const openRenewal = [];
5689
- if (openStatusSet !== null) {
5690
- for (const t of contractTickets) {
5691
- const status = t["status"];
5692
- if (typeof status !== "number" || !openStatusSet.has(status)) continue;
5693
- const last = t["lastActivityDate"];
5694
- const lastDay = typeof last === "string" ? (asOfMs - Date.parse(last.slice(0, 10))) / 864e5 : null;
5695
- const staleDays = lastDay !== null && lastDay > v.staleAfterDays ? Math.floor(lastDay) : null;
5696
- openRenewal.push({
5697
- kind: "ticket",
5698
- id: num2(t["id"]),
5699
- label: typeof t["title"] === "string" && t["title"] !== "" ? t["title"] : `ticket ${num2(t["ticketNumber"]) > 0 ? num2(t["ticketNumber"]) : num2(t["id"])}`,
5700
- date: typeof last === "string" ? last.slice(0, 10) : null,
5701
- detail: staleDays !== null ? `open \u2014 stale ${staleDays} days (last activity)` : "open"
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
- const blocks = blocksPart.items;
5725
- for (const b of blocks) {
5726
- const purchased = num2(b["hours"]);
5727
- const approved = num2(b["hoursApproved"]);
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
- openRenewal.push({
6024
+ return {
5730
6025
  kind: "included_hours_burn",
5731
- id: num2(b["id"]),
5732
- label: `hour block ${num2(b["id"])}`,
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
- const purchasedHours = blocks.reduce((a, b) => a + num2(b["hours"]), 0);
5738
- const approvedHours = blocks.reduce((a, b) => a + num2(b["hoursApproved"]), 0);
5739
- const includedHours = !contractTypeKnown ? { value: null, absent: "CONTRACT_TYPE_UNKNOWN" } : !isBlockHours ? { value: null, absent: "NOT_BLOCK_HOURS_CONTRACT" } : blocksPart.status === "absent" ? { value: null, absent: blocksPart.absent } : { value: { purchased: purchasedHours, approved: approvedHours, remaining: purchasedHours - approvedHours } };
5740
- const rows = (part) => {
5741
- if (v.includeRows) return part;
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: includedHours,
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: "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"
6117
+ note: CURRENCY_NOTE
5755
6118
  },
5756
6119
  monthly,
5757
6120
  activity_categories: activityCategories,
5758
6121
  highest_cost_tickets: highestCostTickets,
5759
- possible_miscoding: possibleMiscoding,
5760
- open_renewal_items: openRenewal,
6122
+ possible_miscoding: buildMiscoding(fc, crossTimeEntries, unresolvedRefs),
6123
+ open_renewal_items: buildOpenRenewal(fc),
5761
6124
  data_quality_warnings: warnings,
5762
- costRateResolution,
5763
- parts: {
5764
- contract: "ok",
5765
- customer: customerStatus,
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 project = await this.fetchRecordRaw("/V1.0/Projects", id);
7370
- const root = project;
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 = { ...project };
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(ids, label) {
8017
- if (!Array.isArray(ids) || ids.length === 0) {
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 (ids.length > MAX_TRANSFER_IDS) {
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 ids) {
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 ids = matches.map((m) => m.id).filter((id) => typeof id === "number");
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: ids, details: { idRefs: ids.map((cid) => ({ entity: "resource", id: cid })) } }
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 ids = requirePositiveIds(companyIds, "transferOwnership");
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 ids) {
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 ${ids.length} companies were re-owned (ids: ${applied.join(", ") || "none"}); id ${id} failed. Re-run transferOwnership for the remaining ids (from ${id} on).`;
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: ids.length, scope: "companies ownerResourceID", reversible: true },
8446
+ impact: { affected: ids2.length, scope: "companies ownerResourceID", reversible: true },
8110
8447
  warnings: [
8111
- `the per-record request fans out to ${ids.length} id${ids.length === 1 ? "" : "s"} (bounded ${MAX_TRANSFER_IDS})`
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
- function truncate(text, limit) {
10344
- const full = text ?? "";
10345
- if (full.length <= limit) return { value: full, truncated: false, length: full.length };
10346
- return { value: `${full.slice(0, limit)}\u2026`, truncated: true, length: full.length };
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 num3 = (v) => typeof v === "number" ? v : null;
10930
+ const num2 = (v) => typeof v === "number" ? v : null;
10362
10931
  const milestones = [
10363
- { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num3(row.firstResponseElapsedHours) },
10364
- { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num3(row.resolutionElapsedHours) },
10365
- { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num3(row.resolutionPlanElapsedHours) }
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
- async function timeline(_resource, id, opts = {}, http) {
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((a, b) => {
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
- var QueueRequiredError = class extends AutotaskError {
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
- if (assign !== void 0 && assignee !== void 0) {
10613
- const rid = assignee;
10614
- const rec = await fetchItem(http, `/V1.0/Resources/${rid}`, OP_OPEN, { maxRetries: opts.maxRetries });
10615
- if (rec === null) {
10616
- throw new TicketResourceInvalidError(
10617
- `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)`,
10618
- { 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." }
10619
- );
10620
- }
10621
- if (rec.isActive !== true) {
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
- const body = { title, status, priority, companyID };
10629
- if (params.queueID !== void 0) body.queueID = params.queueID;
10630
- const noteBody = note === void 0 ? null : {
10631
- title: note.title,
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
- const steps = [];
10671
- let ticketId;
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
- break;
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
- let noteId = null;
10712
- if (noteBody !== null) {
10713
- try {
10714
- const res = await http.request({ method: "POST", path: `/V1.0/Tickets/${ticketId}/Notes`, body: noteBody, operation: OP_OPEN, maxRetries: opts.maxRetries });
10715
- noteId = operationId(res) ?? null;
10716
- steps.push({ name: "note", ok: true });
10717
- } catch (err) {
10718
- steps.push({ name: "note", ok: false, error: stepErrorText("NOTE_CREATE_FAILED", err) });
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
- if (assign !== void 0) {
10724
- try {
10725
- const current = await fetchItemUnredacted(http, `/V1.0/Tickets/${ticketId}`, OP_OPEN, { maxRetries: opts.maxRetries });
10726
- if (current === null) {
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 ids = /* @__PURE__ */ new Set();
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)) ids.add(n);
11389
+ if (Number.isFinite(n)) ids2.add(n);
10788
11390
  }
10789
11391
  }
10790
- return ids;
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 changeStatus(resource, id, targetStatus, fields, opts, http) {
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 ids = await readLiveStatusSet(resource);
10801
- if (ids.size > 0) liveStatus = ids;
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
- const guardDegraded = liveStatus === null;
10829
- if (fields !== void 0 && (typeof fields !== "object" || fields === null || Array.isArray(fields))) {
10830
- throw new AutotaskConfigError("changeStatus: fields must be a plain object of ticket fields (or omitted)", { operation: OP_STATUS });
10831
- }
10832
- const current = await fetchItemUnredacted(http, `/V1.0/Tickets/${ticketId}`, OP_STATUS, { maxRetries: opts.maxRetries });
10833
- if (current === null) {
10834
- 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 }] } });
10835
- }
10836
- const previousStatus = typeof current.status === "number" ? current.status : null;
10837
- if (previousStatus === targetStatus) {
10838
- if (opts.dryRun === true) {
10839
- return buildDryRunResult(
10840
- {
10841
- operation: OP_STATUS,
10842
- method: "PUT",
10843
- path: "/V1.0/Tickets",
10844
- ids: [ticketId],
10845
- checks: [
10846
- { name: "status-known", ok: true },
10847
- { name: "merge-read", ok: true },
10848
- { name: "no-op", ok: true }
10849
- ],
10850
- impact: { affected: 0, scope: "single-record", reversible: true },
10851
- warnings: [
10852
- ...guardDegraded ? [GUARD_DEGRADED_WARNING] : [],
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
- if (opts.dryRun === true) {
10885
- const warnings = [];
10886
- if (guardDegraded) warnings.push(GUARD_DEGRADED_WARNING);
10887
- if (filled.length > 0) {
10888
- 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.`);
10889
- }
10890
- if (mapEntry === void 0) {
10891
- 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).`);
10892
- }
10893
- return buildDryRunResult(
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
- break;
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 project = await this.contextFetch("/V1.0/Projects", root["projectID"]);
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 OP2 = opLabel;
11314
- const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, OP2);
11315
- const fromB = customRangeDay(from, "from", zone, OP2);
11316
- const toB = customRangeDay(to, "to", zone, OP2);
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(`${OP2}: from (${iso2(fromB.ms)}) must not be after to (${iso2(toB.ms)})`, { operation: OP2 });
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), OP2, http);
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
- var ResourceInvalidError = class extends AutotaskError {
11386
- constructor(message, options = {}) {
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, familyLabel, pinnedId, departments, operation) {
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 ${familyLabel} allocation code id (billing_codes.query with a filter) and re-pin.` }
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 ${familyLabel} family (useType ${family})`,
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
- var OP = "time_entries.createForResource";
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
- async function createForResource(resource, resourceID, params, opts = {}, http) {
11554
- const rid = parseIdentifier(resourceID, "resourceID");
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: OP }
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: OP }
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: OP }
12207
+ { operation: OP3 }
11579
12208
  );
11580
12209
  }
11581
- const rec = await fetchItem2(http, `/V1.0/Resources/${rid}`, OP);
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: OP,
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: OP,
12227
+ operation: OP3,
11596
12228
  suggestedAction: "Activate the resource or pick an active one."
11597
12229
  });
11598
12230
  }
11599
- const linkage = pins.code !== void 0 ? "internal" : pins.ticket !== void 0 ? "ticket" : pins.task !== void 0 ? "task" : "internal";
11600
- const roleRows = await resourceRoleRows(http, rid, OP);
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, OP);
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, OP);
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: OP }
12251
+ { operation: OP3 }
11623
12252
  );
11624
12253
  }
11625
12254
  const resolvedCode = codeInfo(code);
11626
- const defaults = windowDefaults(params);
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
- if (opts.dryRun === true) {
11646
- const warnings = [];
11647
- if (usedLinkage !== "internal") {
11648
- warnings.push(
11649
- `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.`
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}`, OP);
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
  }