import { sdkLogger as logger } from "../reporting-service/logger.js"; import { ERPObjType } from "../../types/erp-types.js"; import { MMReceiveDispatchPriority, MMReceiveLaborTicket, MMReceiveTimesheet, MMReceiveWorkOrder, MMReceiveWorkOrderOperation, } from "./types/receive-types.js"; import { assertValidDispatchPriorityRows, buildDispatchPrioritiesPageQuery, buildDispatchPrioritiesWhere, buildDispatchPriorityScopesPageQuery, buildRemoveDispatchPriorityScopeMutation, buildReplaceDispatchPriorityScopeMutation, dedupeDispatchPriorityRows, FetchDispatchPrioritiesFilter, normalizeDispatchSequenceNumber, parseDispatchPriorityRow, ranksCollapsedByStorage, readAffectedRows, RemoveDispatchPriorityScopeResult, ReplaceDispatchPriorityScopeResult, sortByRank, } from "./dispatch-priorities.js"; import { chunkList, DEFAULT_GRAPHQL_PAGE_SIZE, normalizePositiveInteger, toNullableFiniteNumber, } from "./graphql-helpers.js"; import { buildWorkOrderOperationsByWorkOrderIdsQuery, DEFAULT_WORK_ORDER_IDS_CHUNK_SIZE, ERP_WORK_ORDER_OPERATION_GRAPHQL_FIELDS, ERP_WORK_ORDER_OPERATION_GRAPHQL_ORDER_BY, normalizeWorkOrderIdList, } from "./work-order-operations-graphql.js"; import { MMTokenManager } from "./token-mgr.js"; import * as MM_SEND_TYPES from "./types/send-types.js"; import { HTTPClient, HTTPMethod, HTTPRequestConfig, HTTPClientFactory, } from "../../utils/http-client.js"; import { Checkpoint } from "./types/checkpoint.js"; import { CoreConfiguration } from "../data-sync-service/configuration-manager.js"; import { ErrorHandler } from "../erp-api-services/errors.js"; import { assertMmGraphqlProxyResponseHasNoErrors, buildMmGraphqlProxyRequestBody, } from "./mm-graphql-proxy.js"; import { buildAggregatedPartCountsQuery, MM_DEFAULT_PART_COUNT_METRIC_KEY, parseAggregatedPartCountBucketRow, } from "./mm-aggregated-part-counts.js"; import { escapeGraphqlStringLiteral } from "./graphql-string-literal.js"; import { fetchMesMachineSettingsRows, fetchMesSchedulableErpResourceIds, mapMesMachineSettingsToMappedErpResourceRows, } from "./mes-machine-settings.js"; import { MMAggregatedPartCountBucket, MMApiBaseResponse, MMGraphQLDispatchPrioritiesPageResponse, MMGraphQLDispatchPriorityScopesPageResponse, MMMappedErpResourceRow, MMGraphQLReasonResponse, MMGraphQLWorkOrderResponse, MMGraphQLWorkOrderOperationRow, MMGraphQLWorkOrderOperationsPageResponse, MMGraphQLWorkOrderRow, MMGraphQLWorkOrdersPageResponse, MMMachineGroupsResponse, } from "./types/mm-response-interfaces.js"; /** Field list shared by the erpWorkOrders GraphQL queries. */ const ERP_WORK_ORDER_GRAPHQL_FIELDS = "companyId workOrderId lot split sub status dueDate description scheduledStartDate scheduledEndDate closedDate quantityRequired partNumber partRevision method createdAt updatedAt customFieldsSchemaRef customFields"; /** * One `erpWorkOrderOperations` GraphQL row → {@link MMReceiveWorkOrderOperation}: * `sequenceNumber` rendered as the string the REST import uses, nullable numeric columns * kept as `number | null`. */ function toReceiveWorkOrderOperation( row: MMGraphQLWorkOrderOperationRow ): MMReceiveWorkOrderOperation { return new MMReceiveWorkOrderOperation({ ...row, sequenceNumber: row.sequenceNumber === null || row.sequenceNumber === undefined ? "" : String(row.sequenceNumber), startQuantity: toNullableFiniteNumber(row.startQuantity), finishQuantity: toNullableFiniteNumber(row.finishQuantity), expectedRejectRate: toNullableFiniteNumber(row.expectedRejectRate), cycleTimeMs: toNullableFiniteNumber(row.cycleTimeMs), setupTimeMs: toNullableFiniteNumber(row.setupTimeMs), productionburdenRateHourly: toNullableFiniteNumber( row.productionburdenRateHourly ), setupburdenRatehourly: toNullableFiniteNumber(row.setupburdenRatehourly), quantityPerPart: toNullableFiniteNumber(row.quantityPerPart), }); } /** * Dropped-duplicate examples in the warn log; the full list is on the result. The SDK logger * pretty-prints meta and cuts the line at 2,048 characters, which is about five examples — * more would be computed and then truncated mid-JSON. */ const DROPPED_DUPLICATE_LOG_SAMPLE = 5; /** * Collapsed-rank examples in the warn log, capped for the same reason: a scope ranked by * epoch milliseconds collapses on nearly every row, and one line per collapse would be * hundreds of warnings per replacement. */ const COLLAPSED_RANK_LOG_SAMPLE = 5; const MAPPING = "MAPPING"; const CHECKPOINTS = "CHECKPOINTS"; /** Not in ERPObjType; same pattern as MAPPING / CHECKPOINTS */ const TIMESHEETS = "TIMESHEETS"; const MACHINE_GROUPS = "MACHINE_GROUPS"; const GRAPHQL = "GRAPHQL"; enum UrlBase { ErpApiSvcBase = "ErpApiSvcBase", ApiBase = "ApiBase", } /** * Class to manage interactions with the MM Mapping Service * Integrates an MM Token Manager to handle the API token. */ export class MMApiClient { tokenMgr: MMTokenManager; api: HTTPClient; private baseUrls: Record; resourceURLs = { [ERPObjType.RESOURCES]: "/erp/v1/resources", [ERPObjType.PARTS]: "/erp/v1/parts", [ERPObjType.PART_OPERATION]: "/erp/v1/part-operations", [ERPObjType.WORK_ORDERS]: "/erp/v1/production-orders", [ERPObjType.WORK_ORDER_OPERATIONS]: "/erp/v1/production-order-operations", [ERPObjType.PERSONS]: "/erp/v1/persons", [ERPObjType.REASONS]: "/erp/v1/reasons", [ERPObjType.LABOR_TICKETS]: "/erp/v1/labor-tickets", [MAPPING]: "/erp/v1/mapping", [CHECKPOINTS]: "/erp/v1/checkpoints", [TIMESHEETS]: "/erp/v1/timesheets", [MACHINE_GROUPS]: "/machine-groups", [GRAPHQL]: "/proxy/graphql", }; constructor() { this.tokenMgr = new MMTokenManager(); this.baseUrls = { [UrlBase.ErpApiSvcBase]: CoreConfiguration.inst().mmERPSvcApiBaseUrl, [UrlBase.ApiBase]: CoreConfiguration.inst().mmApiBaseUrl, }; this.api = HTTPClientFactory.getInstance({ baseUrl: "", retryAttempts: CoreConfiguration.inst().mmApiRetryAttempts, timeoutMs: CoreConfiguration.inst().mmApiTimeoutSeconds * 1000, }); } //#region Private Methods private toMMApiPayload( obj: MM_SEND_TYPES.IToRESTApiObject ): Record { const maybe = obj as unknown as { toFullApiObject?: () => Record; }; if (typeof maybe.toFullApiObject === "function") { return maybe.toFullApiObject(); } return obj.toRESTApiObject(); } private async executeWithAuthRetry( operation: (token: string) => Promise, options: { token?: string } = {} ): Promise { let token = options.token || (await this.tokenMgr.getToken()); try { return await operation(token); } catch (error: unknown) { // Type guard to check if error has status property const hasStatus = (err: unknown): err is { status: number } => { return typeof err === "object" && err !== null && "status" in err; }; // Check if this looks like an auth error - retry on auth status codes or JWT expiration errors const isAuthError = (hasStatus(error) && (error.status === 401 || error.status === 403)) || (hasStatus(error) && error.status === 500 && typeof (error as any).data?.error === "string" && (error as any).data.error.includes("JWT")); if (isAuthError && !options.token) { // Only retry if we used cached token logger.info("Retrying request with fresh token due to auth error"); this.tokenMgr.invalidateToken(); token = await this.tokenMgr.getToken(); return await operation(token); // Single retry } throw error; // Re-throw if not auth error or already retried } } /** * Fetch data from the MM API, with an auth retry mechanism * Auth Retry Level (executeWithAuthRetry) * - 401 (Unauthorized) and 403 (Forbidden) get special treatment * - These trigger a token refresh and one retry attempt * - If the retry still fails, the error is re-thrown * * HTTP Status Codes behavior: * - 2xx - Success, returns normally * - 4xx - Client errors, throws immediately * - 5xx - Server errors, retries then throws * * Flags: */ private async getData< T extends Record = Record, >( path: string, queryParams: { [key: string]: string } = {}, options: { baseUrl?: UrlBase; token?: string; headers?: Record; } = {} ): Promise { try { return await this.executeWithAuthRetry(async (token) => { const baseUrl = options.baseUrl || UrlBase.ErpApiSvcBase; const params = new URLSearchParams({ ...queryParams, ...(baseUrl === UrlBase.ErpApiSvcBase ? { raw: "true" } : {}), }); const url = params.size > 0 ? `${path}?${params.toString()}` : path; const fullUrl = this.baseUrls[baseUrl] + url; const result = await this.api.request({ url: fullUrl, method: "GET" as HTTPMethod, headers: { Authorization: `Bearer ${token}`, ...options.headers, }, }); // Always include HTTP status in the response return { ...(result.data as T), httpStatus: result.status, } as T & MMApiBaseResponse; }, options); } catch (error) { ErrorHandler.handle(error); } } /** * Send data to the MM API, with an auth retry mechanism * Auth Retry Level (executeWithAuthRetry) * - 401 (Unauthorized) and 403 (Forbidden) get special treatment * - These trigger a token refresh and one retry attempt * - If the retry still fails, the error is re-thrown * * HTTP Status Codes behavior: * - 2xx - Success, returns normally * - 4xx - Client errors, throws immediately * - 5xx - Server errors, retries then throws * * Flags: * - 'raw' = true and 'dedupe' = true will be set for MM's ERP API url */ private async postData< T extends Record = Record, >( path: string, data: object = {}, queryParams: { [key: string]: string } = {}, options: { baseUrl?: UrlBase; token?: string; headers?: Record; } = {} ): Promise { try { return await this.executeWithAuthRetry(async (token) => { const baseUrl = options.baseUrl || UrlBase.ErpApiSvcBase; const params = new URLSearchParams({ ...queryParams, ...(baseUrl === UrlBase.ErpApiSvcBase ? { dedupe: "true", raw: "true" } : {}), }); const url = params.size > 0 ? `${path}?${params.toString()}` : path; const fullUrl = this.baseUrls[baseUrl] + url; const request: HTTPRequestConfig = { url: fullUrl, method: "POST", data: JSON.stringify(data), headers: { Authorization: `Bearer ${token}`, ...options.headers, }, }; const result = await this.api.request(request); // Always include HTTP status in the response return { ...(result.data as T), httpStatus: result.status, } as T & MMApiBaseResponse; }, options); } catch (error) { ErrorHandler.handle(error); } } /** * Delete data from the MM API, with an auth retry mechanism * Auth Retry Level (executeWithAuthRetry) * - 401 (Unauthorized) and 403 (Forbidden) get special treatment * - These trigger a token refresh and one retry attempt * - If the retry still fails, the error is re-thrown * * HTTP Status Codes behavior: * - 2xx - Success, returns normally * - 4xx - Client errors, throws immediately * - 5xx - Server errors, retries then throws * */ private async deleteData< T extends Record = Record, >( path: string, data: object = {}, options: { baseUrl?: UrlBase; token?: string; headers?: Record; } = {} ): Promise { try { return await this.executeWithAuthRetry(async (token) => { const baseUrl = options.baseUrl || UrlBase.ErpApiSvcBase; const params = new URLSearchParams({ ...(baseUrl === UrlBase.ErpApiSvcBase ? { raw: "true" } : {}), }); const url = params.size > 0 ? `${path}?${params.toString()}` : path; const fullUrl = this.baseUrls[baseUrl] + url; const result = await this.api.request({ url: fullUrl, method: "DELETE", data, headers: { Authorization: `Bearer ${token}`, ...options.headers, }, }); // Always include HTTP status in the response return { ...(result.data as T), httpStatus: result.status, } as T & MMApiBaseResponse; }, options); } catch (error) { ErrorHandler.handle(error); } } /** * Patch data to the MM API, with an auth retry mechanism * Auth Retry Level (executeWithAuthRetry) * - 401 (Unauthorized) and 403 (Forbidden) get special treatment * - These trigger a token refresh and one retry attempt * - If the retry still fails, the error is re-thrown * * HTTP Status Codes behavior: * - 2xx - Success, returns normally * - 4xx - Client errors, throws immediately * - 5xx - Server errors, retries then throws * */ private async patchData< T extends Record = Record, >( path: string, data: object = {}, options: { baseUrl?: UrlBase; token?: string; headers?: Record; } = {} ): Promise { try { return await this.executeWithAuthRetry(async (token) => { const baseUrl = options.baseUrl || UrlBase.ErpApiSvcBase; const params = new URLSearchParams({ ...(baseUrl === UrlBase.ErpApiSvcBase ? { raw: "true" } : {}), }); const url = params.size > 0 ? `${path}?${params.toString()}` : path; const fullUrl = this.baseUrls[baseUrl] + url; const request: HTTPRequestConfig = { url: fullUrl, method: "PATCH", data, headers: { Authorization: `Bearer ${token}`, ...options.headers, }, }; const result = await this.api.request(request); // Always include HTTP status in the response return { ...(result.data as T), httpStatus: result.status, } as T & MMApiBaseResponse; }, options); } catch (error) { ErrorHandler.handle(error); } } private async graphqlRequest( operationName: string, query: string, variables?: Record ): Promise { try { const body = buildMmGraphqlProxyRequestBody( operationName, query, variables ); const result = (await this.postData( this.resourceURLs[GRAPHQL], body, {}, { baseUrl: UrlBase.ApiBase, token: CoreConfiguration.inst().mmApiAuthToken, headers: { "Content-Type": "application/json" }, } )) as Record & MMApiBaseResponse; assertMmGraphqlProxyResponseHasNoErrors(result); return result; } catch (error) { ErrorHandler.handle(error); } } /** * Fetch a checkpoint for a specific system, table, and checkpoint type * @param checkpoint The checkpoint to fetch * @returns Promise with the checkpoint data */ private async fetchCheckpoint( checkpoint: Checkpoint ): Promise { const { system, table, checkpointType } = checkpoint; return this.getData(this.resourceURLs[CHECKPOINTS], { system, table, checkpointType, }); } //#endregion private methods //#region Public Methods /** * Send resources to the MM API * @param resources Array of resources to send * @returns Promise with the API response */ async sendResourcesToMM( resources: MM_SEND_TYPES.MMSendResource[] ): Promise { return await this.postData(this.resourceURLs[ERPObjType.RESOURCES], { data: resources.map((resource) => this.toMMApiPayload(resource)), }); } /** * Send parts to the MM API * @param parts Array of parts to send * @returns Promise with the API response */ async sendPartsToMM( parts: MM_SEND_TYPES.MMSendPart[] ): Promise { return await this.postData(this.resourceURLs[ERPObjType.PARTS], { data: parts.map((part) => this.toMMApiPayload(part)), }); } /** * Send part operations to the MM API * @param operations Array of part operations to send * @returns Promise with the API response */ async sendPartOperationsToMM( operations: MM_SEND_TYPES.MMSendPartOperation[] ): Promise { return await this.postData(this.resourceURLs[ERPObjType.PART_OPERATION], { data: operations.map((operation) => this.toMMApiPayload(operation)), }); } /** * Send work orders to the MM API * @param workOrders Array of work orders to send * @param queryParams Optional query params forwarded to the endpoint (e.g. * `{ clearColumns: 'closedDate' }` to opt specific columns into null-clearing) * @returns Promise with the API response */ async sendWorkOrdersToMM( workOrders: MM_SEND_TYPES.MMSendWorkOrderPayload[], queryParams: { [key: string]: string } = {} ): Promise { return await this.postData( this.resourceURLs[ERPObjType.WORK_ORDERS], { data: workOrders.map((workOrder) => this.toMMApiPayload(workOrder)), }, queryParams ); } /** * Send work order operations to the MM API * @param workOrderOperations Array of work order operations to send * @param queryParams Optional query params forwarded to the endpoint (e.g. * `{ clearColumns: 'closedDate' }` to opt specific columns into null-clearing) * @returns Promise with the API response */ async sendWorkOrderOperationsToMM( workOrderOperations: MM_SEND_TYPES.MMSendWorkOrderOperationPayload[], queryParams: { [key: string]: string } = {} ): Promise { return await this.postData( this.resourceURLs[ERPObjType.WORK_ORDER_OPERATIONS], { data: workOrderOperations.map((operation) => this.toMMApiPayload(operation) ), }, queryParams ); } /** * Send persons to the MM API * @param persons Array of persons to send * @returns Promise with the API response */ async sendPersonsToMM( persons: MM_SEND_TYPES.MMSendPerson[] ): Promise { return await this.postData(this.resourceURLs[ERPObjType.PERSONS], { data: persons.map((person) => this.toMMApiPayload(person)), }); } /** * Send reasons to the MM API * @param reasons Array of reasons to send * @returns Promise with the API response */ async sendReasonsToMM( reasons: MM_SEND_TYPES.MMSendReason[] ): Promise { return await this.postData(this.resourceURLs[ERPObjType.REASONS], { data: reasons.map((reason) => this.toMMApiPayload(reason)), }); } /** * Send labor tickets to the MM API * Note: Deduplication marking is handled by callers (NATS listener and to-erp job) * @param laborTickets Array of labor tickets to send * @returns Promise with the API response */ async sendLaborTicketsToMM( laborTickets: MM_SEND_TYPES.MMSendLaborTicket[] ): Promise { return await this.postData(this.resourceURLs[ERPObjType.LABOR_TICKETS], { data: laborTickets.map((ticket) => this.toMMApiPayload(ticket)), }); } /** * Delete labor tickets from the MM API * @param laborTickets Array of labor ticket records to delete * @returns Promise with the API response */ async deleteLaborTickets( laborTickets: Record[] ): Promise { logger.debug("deleteLaborTickets:", { laborTickets }); return await this.deleteData(this.resourceURLs[ERPObjType.LABOR_TICKETS], { laborTickets, }); } /** * Fetch transformed labor tickets from the MM API * @returns Promise with the transformed labor tickets */ async fetchTransformedLaborTickets(): Promise { return await this.getData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/export` ); } /** * Fetch labor ticket updates from the MM API * @param query Query parameters for filtering updates * @returns Promise with an array of labor ticket updates */ async fetchLaborTicketUpdates( query: Record ): Promise { const updates = (await this.getData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/export/updates`, query )) as { data: MMReceiveLaborTicket[] }; return updates.data.map((ticket) => new MMReceiveLaborTicket(ticket)); } /** * Fetch timesheet updates from the MM API (clock in/out rows pending export to ERP). * @param query Typically `{ system, checkpointType: 'export' }` aligned with labor export/updates. */ async fetchTimesheetUpdates( query: Record ): Promise { const updates = (await this.getData( `${this.resourceURLs[TIMESHEETS]}/updates`, query )) as { data: MMReceiveTimesheet[] }; if (!Array.isArray(updates.data)) { throw new Error( "fetchTimesheetUpdates: unexpected response shape — expected { data: [...] }" ); } return updates.data.map((row) => new MMReceiveTimesheet(row)); } /** * Fetch a single labor ticket by reference from the MM API * @param laborTicketRef The labor ticket reference to fetch * @returns Promise with the labor ticket data */ async fetchLaborTicketByRef( laborTicketRef: string ): Promise { const response = (await this.getData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/${laborTicketRef}` )) as { data: MMReceiveLaborTicket }; return new MMReceiveLaborTicket(response.data); } /** * Fetch a checkpoint for a specific system, table, and checkpoint type * @param checkpoint The checkpoint to fetch * @returns Promise with the checkpoint data */ async getCheckpoint(checkpoint: Checkpoint): Promise { return await this.fetchCheckpoint(checkpoint); } /** * Save a checkpoint to the MM API * @param checkpoint The checkpoint to save * @returns Promise with the API response */ async saveCheckpoint(checkpoint: Checkpoint): Promise { return await this.postData(this.resourceURLs[CHECKPOINTS], checkpoint); } /** * Initialize a checkpoint for a specific system, table, and checkpoint type * @param checkpoint The checkpoint to initialize * @returns Promise with the checkpoint data */ async initializeCheckpoint(checkpoint: Checkpoint): Promise { const currentCheckpoint = await this.fetchCheckpoint(checkpoint); logger.info("currentCheckpoint:", { currentCheckpoint }); // Check if we have a meaningful checkpoint response // The API returns either: // - Empty: just { httpStatus: 200 } // - With data: { timestamp: "2025-08-08T18:53:14.295Z", httpStatus: 200 } const checkpointResponse = currentCheckpoint as any; const hasCheckpointData = checkpointResponse && checkpointResponse.timestamp; if (!hasCheckpointData) { logger.info("No checkpoint data found, initializing checkpoint"); const saveCheckpoint = await this.saveCheckpoint(checkpoint); logger.info("saveCheckpoint:", { saveCheckpoint }); } else { logger.info("Existing checkpoint found:", { checkpointTimestamp: checkpointResponse.timestamp, }); } } /** * Send data to MM API based on the object type * @param type The type of ERP object to send * @param objects Array of objects to send * @returns Promise with the API response including affected rows count */ public async sendDataToMMByType( type: ERPObjType, objects: MM_SEND_TYPES.IToRESTApiObject[] ): Promise { switch (type) { case ERPObjType.PERSONS: return await this.sendPersonsToMM( objects as MM_SEND_TYPES.MMSendPerson[] ); case ERPObjType.RESOURCES: return await this.sendResourcesToMM( objects as MM_SEND_TYPES.MMSendResource[] ); case ERPObjType.PARTS: return await this.sendPartsToMM(objects as MM_SEND_TYPES.MMSendPart[]); case ERPObjType.PART_OPERATION: return await this.sendPartOperationsToMM( objects as MM_SEND_TYPES.MMSendPartOperation[] ); case ERPObjType.WORK_ORDERS: return await this.sendWorkOrdersToMM( objects as MM_SEND_TYPES.MMSendWorkOrderPayload[] ); case ERPObjType.WORK_ORDER_OPERATIONS: return await this.sendWorkOrderOperationsToMM( objects as MM_SEND_TYPES.MMSendWorkOrderOperationPayload[] ); case ERPObjType.REASONS: return await this.sendReasonsToMM( objects as MM_SEND_TYPES.MMSendReason[] ); case ERPObjType.LABOR_TICKETS: return await this.sendLaborTicketsToMM( objects as MM_SEND_TYPES.MMSendLaborTicket[] ); default: throw new Error(`Unsupported type: ${ERPObjType[type]}`); } } /** * Update the labor ticket ERP ID by laborTicketRef * @param laborTicketRef The MM API labor ticket unique ID * @param erpObjectId The ERP labor ticket ID to update * @returns The updated labor ticket */ async updateLaborTicketIdByRef( laborTicketRef: string, laborTicketId: string ): Promise { return await this.patchData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/${laborTicketRef}`, { laborTicketId } ); } /** * Add failed labor ticket refs to the MM API * @param failedTicketRefs The failed labor ticket references * @returns The response from the MM API */ async addFailedLaborTicketRefs( system: string, failedTicketRefs: Array ): Promise { const numericFailedTicketRefs = failedTicketRefs.map((ref) => typeof ref === "number" ? ref : Number(ref) ); const invalidRefs = failedTicketRefs .map((ref, idx) => ({ ref, idx, n: numericFailedTicketRefs[idx] })) .filter(({ n }) => !Number.isFinite(n)); if (invalidRefs.length > 0) { // Fail fast: retry-queue operations require numeric refs, and silently dropping // values can lead to stuck retry queues and hard-to-debug behavior. const sample = invalidRefs.slice(0, 10).map(({ ref, idx }) => ({ idx, ref, type: typeof ref, })); throw new Error( `addFailedLaborTicketRefs: Found ${invalidRefs.length} non-numeric laborTicketRef(s) (sample=${JSON.stringify( sample )})` ); } return await this.postData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/failed`, { failedTicketRefs: numericFailedTicketRefs }, { system } ); } async fetchFailedLaborTickets( system: string ): Promise { const failedTickets = (await this.getData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/failed`, { system } )) as { erpLaborTickets: MMReceiveLaborTicket[] }; return failedTickets.erpLaborTickets.map( (ticket) => new MMReceiveLaborTicket(ticket) ); } /** * Send connector logs to the MM API * @param logEntry Single log entry to send * @returns Promise with the API response */ async sendConnectorLog(logEntry: { source: string; level: string; message: string; }): Promise { return await this.postData( "/connector/logs", logEntry, {}, { baseUrl: UrlBase.ApiBase } ); } /** * Send bulk connector logs to the MM API * @param logs Array of log entries to send * @returns Promise with the API response */ async sendBulkConnectorLogs( logs: { source: string; level: string; message: string }[] ): Promise { return await this.postData( "/connector/logs", { logs }, {}, { baseUrl: UrlBase.ApiBase } ); } async deleteFailedLaborTicketIds( system: string, laborTicketRefs: Array ): Promise { const numericLaborTicketRefs = laborTicketRefs.map((ref) => typeof ref === "number" ? ref : Number(ref) ); const invalidRefs = laborTicketRefs .map((ref, idx) => ({ ref, idx, n: numericLaborTicketRefs[idx] })) .filter(({ n }) => !Number.isFinite(n)); if (invalidRefs.length > 0) { const sample = invalidRefs.slice(0, 10).map(({ ref, idx }) => ({ idx, ref, type: typeof ref, })); throw new Error( `deleteFailedLaborTicketIds: Found ${invalidRefs.length} non-numeric laborTicketRef(s) (sample=${JSON.stringify( sample )})` ); } return await this.postData( `${this.resourceURLs[ERPObjType.LABOR_TICKETS]}/failed/remove`, { laborTicketRefs: numericLaborTicketRefs }, { system } ); } /** * Fetch machine groups from the MM API * @returns Promise with the machine groups data */ async fetchMachineGroups(): Promise { return (await this.getData( this.resourceURLs[MACHINE_GROUPS], {}, { baseUrl: UrlBase.ApiBase } )) as unknown as MMMachineGroupsResponse; } /** * Fetch a single reason by reasonRef via GraphQL (e.g. when labor ticket has reasonRef but no reasons array). * @param reasonRef MM reason reference (bigint) * @returns The reason row with reasonId, code, etc., or null if not found */ async fetchReasonByRef( reasonRef: string | number ): Promise { const refNum = typeof reasonRef === "string" ? Number(reasonRef) : reasonRef; if (!Number.isFinite(refNum)) { return null; } const response = (await this.graphqlRequest( "getErpReason", `query getErpReason($reasonRef: bigint!) { erpReasons(where: { reasonRef: { _eq: $reasonRef } }, limit: 1) { annotationTypeRef category code companyId createdAt description entityType reasonId reasonRef rejectReasonRef updatedAt } }`, { reasonRef: refNum } )) as MMGraphQLReasonResponse; const list = response.data?.erpReasons ?? []; return list.length > 0 ? list[0] : null; } /** * Fetch a specific work order using GraphQL * @param workOrderId Work order identifier to fetch * @returns Promise with the work order data (array with limit 1; may be empty if not found) */ async fetchWorkOrderFromGraphQL( workOrderId: string ): Promise { const escapedWorkOrderId = escapeGraphqlStringLiteral(workOrderId); const query = `query getErpWorkOrder { ` + `workOrder: erpWorkOrders(` + `where: { workOrderId: { _eq: "${escapedWorkOrderId}" } }, ` + `limit: 1` + `) { ` + `${ERP_WORK_ORDER_GRAPHQL_FIELDS} ` + `} }`; return (await this.graphqlRequest( "getErpWorkOrder", query )) as unknown as MMGraphQLWorkOrderResponse; } /** * Fetch ALL work orders for the company (token-scoped) using paged GraphQL queries. * * Pages by offset with a stable composite-key ordering until a short page is returned. * Any page failure throws (via the shared GraphQL error handling) — callers never * receive a silently truncated result. * * @param options.pageSize Rows per GraphQL page (default 500) * @returns All work order rows as {@link MMReceiveWorkOrder} (quantityRequired coerced to number) */ async fetchAllErpWorkOrdersFromGraphQL(options?: { pageSize?: number; }): Promise { const pageSize = normalizePositiveInteger( options?.pageSize, DEFAULT_GRAPHQL_PAGE_SIZE ); const allRows: MMReceiveWorkOrder[] = []; let offset = 0; for (;;) { const query = `query getErpWorkOrdersPage { ` + `workOrders: erpWorkOrders(` + `limit: ${pageSize}, offset: ${offset}, ` + `order_by: [{ workOrderId: asc }, { lot: asc }, { split: asc }, { sub: asc }]` + `) { ` + `${ERP_WORK_ORDER_GRAPHQL_FIELDS} ` + `} }`; const response = (await this.graphqlRequest( "getErpWorkOrdersPage", query )) as unknown as MMGraphQLWorkOrdersPageResponse; // A malformed page must throw, not read as empty: callers (work order deletion // reconciliation) treat "no rows" as "these jobs were deleted in the ERP". const rows: MMGraphQLWorkOrderRow[] | undefined = response?.data?.workOrders; if (!Array.isArray(rows)) { throw new Error( `fetchAllErpWorkOrdersFromGraphQL: malformed GraphQL response at offset ${offset} — data.workOrders is not an array` ); } for (const row of rows) { const quantityRequired = Number(row.quantityRequired ?? 0); allRows.push( new MMReceiveWorkOrder({ ...row, // Finite check, not `|| 0`: NaN falls to 0 either way, but Infinity must too. quantityRequired: Number.isFinite(quantityRequired) ? quantityRequired : 0, }) ); } if (rows.length < pageSize) { break; } offset += pageSize; } return allRows; } /** * Fetch ALL work order operations for the company (token-scoped) using paged GraphQL * queries. Same contract as {@link fetchAllErpWorkOrdersFromGraphQL}: stable * composite-key ordering, any page failure or malformed page throws — callers never * receive a silently truncated result. * * @param options.pageSize Rows per GraphQL page (default 500) * @returns All rows as {@link MMReceiveWorkOrderOperation} (nullable numeric fields kept * as `number | null`, `sequenceNumber` rendered as a string like the REST import) */ async fetchAllErpWorkOrderOperationsFromGraphQL(options?: { pageSize?: number; }): Promise { const pageSize = normalizePositiveInteger( options?.pageSize, DEFAULT_GRAPHQL_PAGE_SIZE ); const allRows: MMReceiveWorkOrderOperation[] = []; let offset = 0; for (;;) { const query = `query getErpWorkOrderOperationsPage { ` + `workOrderOperations: erpWorkOrderOperations(` + `limit: ${pageSize}, offset: ${offset}, ` + `order_by: ${ERP_WORK_ORDER_OPERATION_GRAPHQL_ORDER_BY}` + `) { ` + `${ERP_WORK_ORDER_OPERATION_GRAPHQL_FIELDS} ` + `} }`; const response = (await this.graphqlRequest( "getErpWorkOrderOperationsPage", query )) as unknown as MMGraphQLWorkOrderOperationsPageResponse; // A malformed page must throw, not read as empty: reconciliation treats "no rows" // as "these operations were deleted in the ERP". const rows: MMGraphQLWorkOrderOperationRow[] | undefined = response?.data?.workOrderOperations; if (!Array.isArray(rows)) { throw new Error( `fetchAllErpWorkOrderOperationsFromGraphQL: malformed GraphQL response at offset ${offset} — data.workOrderOperations is not an array` ); } for (const row of rows) { allRows.push(toReceiveWorkOrderOperation(row)); } if (rows.length < pageSize) { break; } offset += pageSize; } return allRows; } /** * Fetch the work order operations of a **given set of work orders** (token-scoped) via * `erpWorkOrderOperations(where: { workOrderId: { _in: ... } })`. The filtered * counterpart of {@link fetchAllErpWorkOrderOperationsFromGraphQL} for callers that need * MM's operation identities (lot/split/sub, resourceId, status) for a bounded set of jobs * — e.g. an ordering authority resolving its rows before a dispatch-priority write — on * tenants where fetching every operation is not workable. * * Ids are trimmed, blanks dropped and duplicates removed (exact text match, like MM). * The list is sent in chunks of `chunkSize` ids, each chunk paged by `pageSize` with the * same stable composite-key ordering as the fetch-all query. Any page failure or * malformed page throws — callers never receive a silently truncated result. * * @param workOrderIds Work order ids to fetch operations for; an empty list returns `[]` without a request * @param options.chunkSize Ids per `_in` list (default 200) * @param options.pageSize Rows per GraphQL page (default 500) * @returns All matching rows as {@link MMReceiveWorkOrderOperation}, in chunk then key order */ async fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL( workOrderIds: readonly string[], options?: { chunkSize?: number; pageSize?: number } ): Promise { const ids = normalizeWorkOrderIdList(workOrderIds); if (ids.length === 0) { return []; } const chunkSize = normalizePositiveInteger( options?.chunkSize, DEFAULT_WORK_ORDER_IDS_CHUNK_SIZE ); const pageSize = normalizePositiveInteger( options?.pageSize, DEFAULT_GRAPHQL_PAGE_SIZE ); const allRows: MMReceiveWorkOrderOperation[] = []; for (const chunk of chunkList(ids, chunkSize)) { let offset = 0; for (;;) { const op = buildWorkOrderOperationsByWorkOrderIdsQuery( chunk, pageSize, offset ); const response = (await this.graphqlRequest( op.operationName, op.query, op.variables )) as unknown as MMGraphQLWorkOrderOperationsPageResponse; const rows: MMGraphQLWorkOrderOperationRow[] | undefined = response?.data?.workOrderOperations; if (!Array.isArray(rows)) { throw new Error( `fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL: malformed GraphQL response at offset ${offset} — data.workOrderOperations is not an array` ); } for (const row of rows) { allRows.push(toReceiveWorkOrderOperation(row)); } if (rows.length < pageSize) { break; } offset += pageSize; } } return allRows; } //#region Dispatch priorities (erpWorkOrderOperationDispatchPriorities) /** * Fetch dispatch-priority rows (token-scoped) from `erpWorkOrderOperationDispatchPriorities`, * filtered by scope and/or work order, paged and ordered by scope then priority. * See `dispatch-priorities.ts` for the table's semantics. * * A filter that names scopes or work orders but is empty after trimming returns `[]` * without a request (an empty `_in` must not degrade to "match everything"). * Any page failure or malformed page throws. * * @param filter.scopeResourceId / scopeResourceIds Restrict to these scopes * @param filter.workOrderId / workOrderIds Restrict to these work orders * @param filter.unresolvedOnly Only rows whose `workOrderOperation` relationship is null * @param filter.pageSize Rows per GraphQL page (default 500) */ async fetchDispatchPrioritiesFromGraphQL( filter: FetchDispatchPrioritiesFilter = {} ): Promise { // `?? {}`: the default parameter covers undefined; a caller outside TypeScript may pass null. const where = buildDispatchPrioritiesWhere(filter ?? {}); if (where === null) { return []; } const pageSize = normalizePositiveInteger( filter?.pageSize, DEFAULT_GRAPHQL_PAGE_SIZE ); const allRows: MMReceiveDispatchPriority[] = []; let offset = 0; for (;;) { const op = buildDispatchPrioritiesPageQuery(where, pageSize, offset); const response = (await this.graphqlRequest( op.operationName, op.query, op.variables )) as unknown as MMGraphQLDispatchPrioritiesPageResponse; const rows = response?.data?.rows; if (!Array.isArray(rows)) { throw new Error( `fetchDispatchPrioritiesFromGraphQL: malformed GraphQL response at offset ${offset} — data.rows is not an array` ); } for (const row of rows) { allRows.push(parseDispatchPriorityRow(row)); } if (rows.length < pageSize) { break; } offset += pageSize; } return allRows; } /** * **Scope-level replace** of dispatch priorities: in one transaction, delete every row * with this `scopeResourceId` and insert `rows` as the scope's complete new list. Never a * per-row upsert — a list is valid only as a whole, and rows for operations that left the * list must disappear with it. An empty `rows` list clears the scope (same as * {@link removeDispatchPriorityScope}). * * Before any request, rows are validated (see `assertValidDispatchPriorityRows`: the SDK * requires `authorityOperationId`, which MM does not) and deduplicated on MM's unique key * (scope + workOrderId + sequenceNumber + lot + split + sub), keeping the better-ranked row * — the lower `priority`, or the earlier of the two on an exact tie — and reporting the * other in `droppedDuplicates`; without this, one collision would fail the whole scope. * * Writes go through the MM GraphQL proxy, not erp-api, and bypass the local dedupe cache * by construction — an unchanged scope is still re-asserted on every call. * * **Assumes a single writer per company**, which is MachineMetrics' deployment model: one * connector, single-threaded, writing in sequence. Concurrent replacements of one scope * are not serialized by the database and would silently leave the scope holding the union * of two complete lists; no lock is available at this layer to prevent it. See * `dispatch-priorities.ts` for the measurements and what would be needed to lift this. * * Failure semantics: a `GraphQLError` means MM evaluated and rejected the document on the * attempt that returned it, so that attempt wrote nothing (the document is atomic). A * transport failure (`HTTPError`: gateway error, timeout) leaves the outcome unknown — the * scope may or may not have been replaced. One caveat: with `MM_API_RETRY_ATTEMPTS` above * its default of 0 the HTTP client re-posts on timeout, and a retry can race its own * timed-out first attempt; the uniqueness violation it then reports means the first * attempt committed. Either way the next cycle's replace re-asserts the scope, so callers * should log and retry on the next sweep rather than reason about partial state. * * @param scopeResourceId The scope (ERP resource id of a machine or group); rows carrying a different `scopeResourceId` throw * @param rows The scope's complete ordered list * @throws {Error} on invalid rows, an empty scope id, or a malformed response * @throws {GraphQLError} when MM rejects the document (nothing is written) * @throws {HTTPError} on a transport failure (outcome unknown) */ async replaceDispatchPriorityScope( scopeResourceId: string, rows: readonly MM_SEND_TYPES.MMSendDispatchPriority[] ): Promise { const scope = typeof scopeResourceId === "string" ? scopeResourceId.trim() : ""; if (scope === "") { throw new Error( "replaceDispatchPriorityScope: scopeResourceId is required" ); } if (!Array.isArray(rows)) { throw new Error("replaceDispatchPriorityScope: rows must be an array"); } assertValidDispatchPriorityRows(scope, rows); const { kept, dropped } = dedupeDispatchPriorityRows(rows); // Sent in rank order: Hasura assigns dispatchPriorityRef in insert order and the reads // tie-break on it, so the authority's ordering survives even where two of its ranks // collapse to one value in the float32 column. Such a collapse is reported, not fatal — // failing here would leave the scope's previous list standing indefinitely. const sent = sortByRank(kept); const collapsedRanks = ranksCollapsedByStorage(sent); if (collapsedRanks.length > 0) { logger.warn( `replaceDispatchPriorityScope: ${collapsedRanks.length} rank(s) land on a value MM already stores for another row; sent in rank order so MM's own tie-break keeps the authority's ordering`, { scopeResourceId: scope, collapsedRanks: collapsedRanks.length, // Capped: consecutive epoch-millisecond ranks collapse on nearly every row. examples: collapsedRanks.slice(0, COLLAPSED_RANK_LOG_SAMPLE), } ); } if (dropped.length > 0) { logger.warn( `replaceDispatchPriorityScope: dropped ${dropped.length} row(s) naming an MM operation another row was kept for`, { scopeResourceId: scope, droppedDuplicates: dropped.length, // Capped: a badly-built list could collide on every row. examples: dropped.slice(0, DROPPED_DUPLICATE_LOG_SAMPLE).map((d) => ({ index: d.index, authorityOperationId: String(d.row.authorityOperationId).trim(), workOrderId: String(d.row.workOrderId).trim(), sequenceNumber: normalizeDispatchSequenceNumber( d.row.sequenceNumber ), priority: d.row.priority, keptInstead: String(d.duplicateOf.authorityOperationId).trim(), keptPriority: d.duplicateOf.priority, identityKey: d.identityKey, })), } ); } // Rows may carry a blank scopeResourceId (validation only rejects a *different* one); // the scope of the call is authoritative. An empty list is a valid document too and // clears the scope (Hasura accepts `objects: []`). const objects = sent.map((row) => ({ ...row.toGraphqlInsertInput(), scopeResourceId: scope, })); const op = buildReplaceDispatchPriorityScopeMutation(scope, objects); const response = await this.graphqlRequest( op.operationName, op.query, op.variables ); const deletedRows = readAffectedRows( response, "deleted", "replaceDispatchPriorityScope" ); const insertedRows = readAffectedRows( response, "inserted", "replaceDispatchPriorityScope" ); logger.info( `replaceDispatchPriorityScope: replaced ${deletedRows} row(s) with ${insertedRows}`, { scopeResourceId: scope, deletedRows, insertedRows, droppedDuplicates: dropped.length, } ); return { scopeResourceId: scope, deletedRows, insertedRows, droppedDuplicates: dropped, }; } /** * Remove every dispatch-priority row of a scope, so the App falls back to MM's own * ordering for it instead of a known-stale list. For a scope the authority stopped * issuing, or one the connector excluded this cycle. A scope whose read merely * **failed** should not be removed — its previous rows stay valid until replaced. * * The scope is addressed **exactly as given**, not trimmed: deletes, like reads, work in * MM's own text, so anything {@link fetchDispatchPriorityScopes} returns can be passed * straight back. A scope with stray whitespace therefore removes nothing and reports * `deletedRows: 0` rather than removing a different scope. * * @throws {Error} on an empty scope id or a malformed response * @throws {GraphQLError} when MM rejects the mutation */ async removeDispatchPriorityScope( scopeResourceId: string ): Promise { // Verbatim, not trimmed: a delete addresses MM's text exactly, the way reads report it, // so every scope the roster returns can be removed. Only the write path canonicalizes. const scope = typeof scopeResourceId === "string" ? scopeResourceId : ""; if (scope === "") { throw new Error( "removeDispatchPriorityScope: scopeResourceId is required" ); } const op = buildRemoveDispatchPriorityScopeMutation(scope); const response = await this.graphqlRequest( op.operationName, op.query, op.variables ); const deletedRows = readAffectedRows( response, "deleted", "removeDispatchPriorityScope" ); logger.info(`removeDispatchPriorityScope: removed ${deletedRows} row(s)`, { scopeResourceId: scope, deletedRows, }); return { scopeResourceId: scope, deletedRows }; } /** * The distinct scopes that currently hold dispatch-priority rows (token-scoped), ordered * by scope and returned **exactly as MM holds them**. Reads one column with `distinct_on`, * so it does not pull every row of every scope. Every entry can be passed straight to * {@link removeDispatchPriorityScope}, which addresses text verbatim too. * * For the roster comparison an authority connector runs each cycle: scopes MM holds but * the authority no longer issues are the ones to clear with * {@link removeDispatchPriorityScope}. * * @param options.pageSize Scopes per GraphQL page (default 500) * @throws {Error} on a malformed page — a short read here would look like "the authority * stopped issuing these scopes" and clear rows that should have stood */ async fetchDispatchPriorityScopes(options?: { pageSize?: number; }): Promise { const pageSize = normalizePositiveInteger( options?.pageSize, DEFAULT_GRAPHQL_PAGE_SIZE ); const scopes: string[] = []; let offset = 0; for (;;) { const op = buildDispatchPriorityScopesPageQuery(pageSize, offset); const response = (await this.graphqlRequest( op.operationName, op.query, op.variables )) as unknown as MMGraphQLDispatchPriorityScopesPageResponse; const rows = response?.data?.rows; if (!Array.isArray(rows)) { throw new Error( `fetchDispatchPriorityScopes: malformed GraphQL response at offset ${offset} — data.rows is not an array` ); } for (const row of rows) { const scope = row?.scopeResourceId; if (typeof scope !== "string" || scope === "") { throw new Error( `fetchDispatchPriorityScopes: row at offset ${offset} has no scopeResourceId` ); } scopes.push(scope); } if (rows.length < pageSize) { break; } offset += pageSize; } return scopes; } //#endregion Dispatch priorities /** * Returns ERP labor resources mapped to MM machines via **MES** `/mes/settings/machine-settings`, * not GraphQL `erpResources`. Rows include machines with a finite `machineRef` and non-empty * `laborResourceId`. Optional `name`, `type`, `machineGroupId`, and `isResourceGroup` pass through when the API sends them. * * The bearer token (`mmApiAuthToken`) determines company scope; `companyId` must still be passed and * non-empty (trimmed) for backwards compatibility but is **not** sent as a query parameter. * * @param companyId - Expected MM company identifier; trimmed; empty throws. * @returns Mapped rows for machines with ERP labor mapping in MES settings. * @throws {Error} When `companyId` is missing after trim, `mmApiBaseUrl` / `mmApiAuthToken` are missing, * or the REST response is not a JSON array. */ async fetchMappedErpResourcesForCompany( companyId: string ): Promise { const id = companyId.trim(); if (!id) { throw new Error( "fetchMappedErpResourcesForCompany: companyId is required" ); } const rows = await fetchMesMachineSettingsRows(); return mapMesMachineSettingsToMappedErpResourceRows(rows); } /** * Deduped ERP resource ids from **all** MES machine rows: each row’s `laborResourceId` (when set) * plus every id in `scheduledAs`, for connectors that need “anything that might run” scheduling / WO op * filtering. No `laborTicketEnabled` filter. * * The bearer token scopes company; `companyId` must be non-empty (trimmed) for API compatibility only. * * @throws {Error} When `companyId` is missing after trim or MES fetch fails. */ async fetchMesSchedulableErpResourceIds( companyId: string ): Promise { const id = companyId.trim(); if (!id) { throw new Error( "fetchMesSchedulableErpResourceIds: companyId is required" ); } return fetchMesSchedulableErpResourceIds(); } /** * GraphQL `aggregatedPartCounts` for one `machineRef`: **one HTTP request**; the API may return **multiple bucket rows** * (time buckets) for that machine. * * @param options.machineRef - Numeric MM machine reference (`machineRef` in GraphQL). * @param options.windowStartAt - Window start as an ISO-8601 string (e.g. `2025-01-01T00:00:00.000Z`). Must match MM GraphQL expectations. * @param options.windowEndAt - Window end, same format as `windowStartAt`. * @param options.metricKey - Metric to aggregate; defaults to `"part-count"` (`MM_DEFAULT_PART_COUNT_METRIC_KEY`). * @param options.bucketWidth - Optional. GraphQL duration string for bucket size (API-specific; e.g. hour/day windowing). * @param options.bucketResolution - Optional. Numeric resolution when the API supports sub-bucket refinement. * @param options.windowWidth - Optional. Alternative window sizing hint when supported by the schema. * @returns One row per bucket from `data.aggregatedPartCounts`. * @throws {Error} When the response lacks `data.aggregatedPartCounts`, a row fails parsing, or GraphQL returns errors. * @throws {RangeError} When `machineRef` or `bucketResolution` is not finite (via query builder). * * For multiple machines, call once per `machineRef` (for example `Promise.all`) so filtering and error policy stay explicit at the call site. */ async fetchAggregatedPartCountsForMachine(options: { machineRef: number; windowStartAt: string; windowEndAt: string; metricKey?: string; bucketWidth?: string; bucketResolution?: number; windowWidth?: string; }): Promise { const metricKey = options.metricKey?.trim() || MM_DEFAULT_PART_COUNT_METRIC_KEY; const query = buildAggregatedPartCountsQuery({ machineRef: options.machineRef, metricKey, windowStartAt: options.windowStartAt, windowEndAt: options.windowEndAt, bucketWidth: options.bucketWidth, bucketResolution: options.bucketResolution, windowWidth: options.windowWidth, }); const response = (await this.graphqlRequest( "aggregatedPartCountsForMachine", query )) as { data?: { aggregatedPartCounts?: unknown } }; const list = response.data?.aggregatedPartCounts; if (!Array.isArray(list)) { throw new Error( "fetchAggregatedPartCountsForMachine: missing data.aggregatedPartCounts array" ); } return list.map(parseAggregatedPartCountBucketRow); } /** * Resolves the REST **`machine_id`** for **part adjustment** and other MM REST calls that take * `machine_id`, using GraphQL `machines` where `machineRef` matches. Docs often describe this as a UUID; * this method does not validate a particular string format—only that the GraphQL field is a non-empty string. * * @param machineRef - Numeric MM `machineRef`. Non-finite values throw. * @returns Trimmed `machine_id` string, or `null` if no row, unmapped machine, or when `machineId` is * missing, empty after trim, or not a string (avoids coercing arbitrary JSON values to strings). * @throws {RangeError} When `machineRef` is not finite. * @throws {Error} When GraphQL returns errors. */ async fetchMachineIdForMachineRef( machineRef: number ): Promise { const ref = Math.trunc(Number(machineRef)); if (!Number.isFinite(ref)) { throw new RangeError( "fetchMachineIdForMachineRef: machineRef must be finite" ); } const query = `query mmMachineIdByRef { machines(where: { machineRef: { _eq: ${ref} } }, limit: 1) { machineId } }`; const response = (await this.graphqlRequest("mmMachineIdByRef", query)) as { data?: { machines?: unknown }; }; const list = response.data?.machines; if (!Array.isArray(list) || list.length === 0) { return null; } const row = list[0]; if (typeof row !== "object" || row === null) { return null; } const o = row as Record; const mid = o.machineId; if (typeof mid !== "string") { return null; } const s = mid.trim(); return s || null; } /** * Cleanup all HTTP connections and resources * Call this when the service is no longer needed */ async destroy(): Promise { await this.tokenMgr.destroy(); await this.api.destroy(); } //#endregion public methods }