@machinemetrics/mm-erp-sdk 0.3.0-beta.0 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/data-sync-service/configuration-manager.d.ts.map +1 -1
  6. package/dist/services/data-sync-service/configuration-manager.js +30 -30
  7. package/dist/services/data-sync-service/configuration-manager.js.map +1 -1
  8. package/dist/services/data-sync-service/data-sync-service.d.ts.map +1 -1
  9. package/dist/services/data-sync-service/data-sync-service.js +9 -0
  10. package/dist/services/data-sync-service/data-sync-service.js.map +1 -1
  11. package/dist/services/data-sync-service/jobs/clean-up-expired-cache.d.ts.map +1 -1
  12. package/dist/services/data-sync-service/jobs/clean-up-expired-cache.js +1 -2
  13. package/dist/services/data-sync-service/jobs/clean-up-expired-cache.js.map +1 -1
  14. package/dist/services/data-sync-service/jobs/from-erp.d.ts.map +1 -1
  15. package/dist/services/data-sync-service/jobs/from-erp.js +7 -13
  16. package/dist/services/data-sync-service/jobs/from-erp.js.map +1 -1
  17. package/dist/services/data-sync-service/jobs/retry-failed-labor-tickets.d.ts.map +1 -1
  18. package/dist/services/data-sync-service/jobs/retry-failed-labor-tickets.js +1 -2
  19. package/dist/services/data-sync-service/jobs/retry-failed-labor-tickets.js.map +1 -1
  20. package/dist/services/data-sync-service/jobs/run-migrations.d.ts.map +1 -1
  21. package/dist/services/data-sync-service/jobs/run-migrations.js +1 -2
  22. package/dist/services/data-sync-service/jobs/run-migrations.js.map +1 -1
  23. package/dist/services/data-sync-service/jobs/to-erp.d.ts.map +1 -1
  24. package/dist/services/data-sync-service/jobs/to-erp.js +12 -3
  25. package/dist/services/data-sync-service/jobs/to-erp.js.map +1 -1
  26. package/dist/services/data-sync-service/nats-labor-ticket-listener.d.ts +30 -0
  27. package/dist/services/data-sync-service/nats-labor-ticket-listener.d.ts.map +1 -0
  28. package/dist/services/data-sync-service/nats-labor-ticket-listener.js +290 -0
  29. package/dist/services/data-sync-service/nats-labor-ticket-listener.js.map +1 -0
  30. package/dist/services/mm-api-service/company-info.d.ts +13 -0
  31. package/dist/services/mm-api-service/company-info.d.ts.map +1 -0
  32. package/dist/services/mm-api-service/company-info.js +60 -0
  33. package/dist/services/mm-api-service/company-info.js.map +1 -0
  34. package/dist/services/mm-api-service/index.d.ts +7 -0
  35. package/dist/services/mm-api-service/index.d.ts.map +1 -1
  36. package/dist/services/mm-api-service/index.js +5 -0
  37. package/dist/services/mm-api-service/index.js.map +1 -1
  38. package/dist/services/mm-api-service/mm-api-service.d.ts +6 -0
  39. package/dist/services/mm-api-service/mm-api-service.d.ts.map +1 -1
  40. package/dist/services/mm-api-service/mm-api-service.js +15 -3
  41. package/dist/services/mm-api-service/mm-api-service.js.map +1 -1
  42. package/dist/services/mm-api-service/types/receive-types.d.ts +3 -0
  43. package/dist/services/mm-api-service/types/receive-types.d.ts.map +1 -1
  44. package/dist/services/mm-api-service/types/receive-types.js +1 -0
  45. package/dist/services/mm-api-service/types/receive-types.js.map +1 -1
  46. package/dist/services/nats-service/nats-service.d.ts +114 -0
  47. package/dist/services/nats-service/nats-service.d.ts.map +1 -0
  48. package/dist/services/nats-service/nats-service.js +244 -0
  49. package/dist/services/nats-service/nats-service.js.map +1 -0
  50. package/dist/services/nats-service/test-nats-subscriber.d.ts +6 -0
  51. package/dist/services/nats-service/test-nats-subscriber.d.ts.map +1 -0
  52. package/dist/services/nats-service/test-nats-subscriber.js +79 -0
  53. package/dist/services/nats-service/test-nats-subscriber.js.map +1 -0
  54. package/dist/services/reporting-service/logger.d.ts.map +1 -1
  55. package/dist/services/reporting-service/logger.js +31 -6
  56. package/dist/services/reporting-service/logger.js.map +1 -1
  57. package/dist/types/erp-connector.d.ts +1 -8
  58. package/dist/types/erp-connector.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +0 -1
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/utils/error-formatter.d.ts +19 -0
  62. package/dist/utils/error-formatter.d.ts.map +1 -0
  63. package/dist/utils/error-formatter.js +184 -0
  64. package/dist/utils/error-formatter.js.map +1 -0
  65. package/dist/utils/http-client.js +2 -4
  66. package/dist/utils/http-client.js.map +1 -1
  67. package/dist/utils/index.d.ts +5 -1
  68. package/dist/utils/index.d.ts.map +1 -1
  69. package/dist/utils/index.js +4 -1
  70. package/dist/utils/index.js.map +1 -1
  71. package/dist/utils/local-data-store/jobs-shared-data.d.ts +0 -2
  72. package/dist/utils/local-data-store/jobs-shared-data.d.ts.map +1 -1
  73. package/dist/utils/local-data-store/jobs-shared-data.js +0 -2
  74. package/dist/utils/local-data-store/jobs-shared-data.js.map +1 -1
  75. package/dist/utils/mm-labor-ticket-helpers.d.ts +4 -3
  76. package/dist/utils/mm-labor-ticket-helpers.d.ts.map +1 -1
  77. package/dist/utils/mm-labor-ticket-helpers.js +7 -12
  78. package/dist/utils/mm-labor-ticket-helpers.js.map +1 -1
  79. package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.d.ts +0 -15
  80. package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.d.ts.map +1 -1
  81. package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.js +46 -180
  82. package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.js.map +1 -1
  83. package/dist/utils/standard-process-drivers/mm-entity-processor.d.ts +1 -7
  84. package/dist/utils/standard-process-drivers/mm-entity-processor.d.ts.map +1 -1
  85. package/dist/utils/standard-process-drivers/mm-entity-processor.js +1 -7
  86. package/dist/utils/standard-process-drivers/mm-entity-processor.js.map +1 -1
  87. package/dist/utils/standard-process-drivers/standard-process-drivers.d.ts +2 -8
  88. package/dist/utils/standard-process-drivers/standard-process-drivers.d.ts.map +1 -1
  89. package/dist/utils/standard-process-drivers/standard-process-drivers.js +18 -27
  90. package/dist/utils/standard-process-drivers/standard-process-drivers.js.map +1 -1
  91. package/dist/utils/time-utils.d.ts.map +1 -1
  92. package/dist/utils/time-utils.js +0 -7
  93. package/dist/utils/time-utils.js.map +1 -1
  94. package/package.json +5 -4
  95. package/src/index.ts +3 -0
  96. package/src/services/data-sync-service/configuration-manager.ts +37 -50
  97. package/src/services/data-sync-service/data-sync-service.ts +10 -0
  98. package/src/services/data-sync-service/jobs/clean-up-expired-cache.ts +1 -2
  99. package/src/services/data-sync-service/jobs/from-erp.ts +7 -13
  100. package/src/services/data-sync-service/jobs/retry-failed-labor-tickets.ts +1 -2
  101. package/src/services/data-sync-service/jobs/run-migrations.ts +1 -2
  102. package/src/services/data-sync-service/jobs/to-erp.ts +12 -3
  103. package/src/services/data-sync-service/nats-labor-ticket-listener.ts +342 -0
  104. package/src/services/mm-api-service/company-info.ts +87 -0
  105. package/src/services/mm-api-service/index.ts +8 -0
  106. package/src/services/mm-api-service/mm-api-service.ts +20 -3
  107. package/src/services/mm-api-service/types/receive-types.ts +1 -0
  108. package/src/services/nats-service/nats-service.ts +351 -0
  109. package/src/services/nats-service/test-nats-subscriber.ts +96 -0
  110. package/src/services/reporting-service/logger.ts +39 -7
  111. package/src/types/erp-connector.ts +1 -8
  112. package/src/types/index.ts +0 -8
  113. package/src/utils/error-formatter.ts +205 -0
  114. package/src/utils/http-client.ts +3 -4
  115. package/src/utils/index.ts +6 -5
  116. package/src/utils/local-data-store/jobs-shared-data.ts +0 -2
  117. package/src/utils/mm-labor-ticket-helpers.ts +8 -11
  118. package/src/utils/standard-process-drivers/labor-ticket-erp-synchronizer.ts +64 -220
  119. package/src/utils/standard-process-drivers/mm-entity-processor.ts +1 -7
  120. package/src/utils/standard-process-drivers/standard-process-drivers.ts +19 -33
  121. package/src/utils/time-utils.ts +0 -11
  122. package/dist/types/flattened-work-order.d.ts +0 -99
  123. package/dist/types/flattened-work-order.d.ts.map +0 -1
  124. package/dist/types/flattened-work-order.js +0 -2
  125. package/dist/types/flattened-work-order.js.map +0 -1
  126. package/dist/utils/env.d.ts +0 -8
  127. package/dist/utils/env.d.ts.map +0 -1
  128. package/dist/utils/env.js +0 -58
  129. package/dist/utils/env.js.map +0 -1
  130. package/dist/utils/erp-timezone-utils.d.ts +0 -20
  131. package/dist/utils/erp-timezone-utils.d.ts.map +0 -1
  132. package/dist/utils/erp-timezone-utils.js +0 -75
  133. package/dist/utils/erp-timezone-utils.js.map +0 -1
  134. package/src/types/flattened-work-order.ts +0 -108
  135. package/src/utils/env.ts +0 -75
  136. package/src/utils/erp-timezone-utils.ts +0 -99
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Error formatter utility for standardizing error messages across connectors
3
+ * Extracts human-readable messages from various error types (axios, custom errors, etc.)
4
+ */
5
+
6
+ export interface FormattedError {
7
+ message: string;
8
+ code: string;
9
+ httpStatus?: number;
10
+ metadata?: Record<string, any>;
11
+ }
12
+
13
+ /**
14
+ * Extract meaningful error message from various error types
15
+ */
16
+ export function formatError(error: any): FormattedError {
17
+ if (!error) {
18
+ return {
19
+ message: 'Unknown error occurred',
20
+ code: 'UNKNOWN_ERROR',
21
+ };
22
+ }
23
+
24
+ // Handle axios errors FIRST (before checking for FormattedError)
25
+ // because axios errors also have message and code properties
26
+ if (error.isAxiosError || error.name === 'AxiosError') {
27
+ return formatAxiosError(error);
28
+ }
29
+
30
+ // If it's already a FormattedError, return as-is
31
+ // Check for httpStatus or metadata to distinguish from axios errors
32
+ if (error.message && error.code && typeof error.message === 'string' && typeof error.code === 'string' && !error.config) {
33
+ return error as FormattedError;
34
+ }
35
+
36
+ // Handle standard Error objects
37
+ if (error instanceof Error) {
38
+ return {
39
+ message: error.message || 'An error occurred',
40
+ code: 'ERROR',
41
+ metadata: {
42
+ name: error.name,
43
+ },
44
+ };
45
+ }
46
+
47
+ // Handle string errors
48
+ if (typeof error === 'string') {
49
+ return {
50
+ message: error,
51
+ code: 'ERROR',
52
+ };
53
+ }
54
+
55
+ // Last resort: try to extract any useful info
56
+ const message = error.message || error.toString?.() || 'Unknown error occurred';
57
+ return {
58
+ message: typeof message === 'string' ? message : JSON.stringify(message),
59
+ code: error.code || 'UNKNOWN_ERROR',
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Format axios-specific errors with detailed context
65
+ */
66
+ function formatAxiosError(error: any): FormattedError {
67
+ const httpStatus = error.response?.status;
68
+ const method = error.config?.method?.toUpperCase();
69
+ const url = error.config?.url;
70
+
71
+ // First check if connector already extracted the message
72
+ let message = error._extractedMessage;
73
+
74
+ // If not, try to extract from response data
75
+ if (!message) {
76
+ const extractedMessage = extractErrorMessage(error.response?.data);
77
+ message = extractedMessage || error.response?.statusText || error.message || 'Request failed';
78
+ }
79
+
80
+ // Determine error code based on status
81
+ const code = categorizeHttpError(httpStatus);
82
+
83
+ const metadata: Record<string, any> = {
84
+ method,
85
+ url,
86
+ };
87
+
88
+ // Add additional context for specific error types
89
+ if (httpStatus === 401 || httpStatus === 403) {
90
+ metadata.hint = 'Check authentication credentials';
91
+ } else if (httpStatus === 404) {
92
+ metadata.hint = 'Resource not found - check endpoint URL';
93
+ } else if (httpStatus && httpStatus >= 500) {
94
+ metadata.hint = 'ERP system may be temporarily unavailable';
95
+ }
96
+
97
+ return {
98
+ message,
99
+ code,
100
+ httpStatus,
101
+ metadata,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Extract meaningful error message from response data
107
+ * Handles various ERP-specific response formats
108
+ */
109
+ function extractErrorMessage(data: any): string | null {
110
+ if (!data) return null;
111
+
112
+ // Common error message fields across different ERPs
113
+ const possibleFields = [
114
+ 'ErrorMessage', // Epicor
115
+ 'error.message', // Common REST format
116
+ 'error', // Simple format
117
+ 'Message', // .NET style
118
+ 'message', // JavaScript style
119
+ 'errorMessage', // Camel case
120
+ 'error_message', // Snake case
121
+ 'errors[0].message', // Array of errors
122
+ 'title', // Problem details format
123
+ 'detail', // Problem details format
124
+ ];
125
+
126
+ for (const field of possibleFields) {
127
+ const value = getNestedValue(data, field);
128
+ if (value && typeof value === 'string') {
129
+ return value;
130
+ }
131
+ }
132
+
133
+ // If data is a string, return it
134
+ if (typeof data === 'string') {
135
+ // Try to parse as JSON first
136
+ try {
137
+ const parsed = JSON.parse(data);
138
+ return extractErrorMessage(parsed);
139
+ } catch {
140
+ return data;
141
+ }
142
+ }
143
+
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Get nested value from object using dot notation
149
+ */
150
+ function getNestedValue(obj: any, path: string): any {
151
+ const parts = path.split('.');
152
+ let current = obj;
153
+
154
+ for (const part of parts) {
155
+ // Handle array notation like 'errors[0]'
156
+ const arrayMatch = part.match(/(\w+)\[(\d+)\]/);
157
+ if (arrayMatch) {
158
+ const [, key, index] = arrayMatch;
159
+ current = current?.[key]?.[parseInt(index, 10)];
160
+ } else {
161
+ current = current?.[part];
162
+ }
163
+
164
+ if (current === undefined || current === null) {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ return current;
170
+ }
171
+
172
+ /**
173
+ * Categorize HTTP errors into standard codes
174
+ */
175
+ function categorizeHttpError(status?: number): string {
176
+ if (!status) return 'NETWORK_ERROR';
177
+
178
+ if (status === 400) return 'VALIDATION_ERROR';
179
+ if (status === 401) return 'AUTHENTICATION_ERROR';
180
+ if (status === 403) return 'AUTHORIZATION_ERROR';
181
+ if (status === 404) return 'NOT_FOUND';
182
+ if (status === 409) return 'CONFLICT';
183
+ if (status === 422) return 'VALIDATION_ERROR';
184
+ if (status === 429) return 'RATE_LIMIT';
185
+ if (status >= 500) return 'ERP_SERVER_ERROR';
186
+ if (status >= 400) return 'CLIENT_ERROR';
187
+
188
+ return 'HTTP_ERROR';
189
+ }
190
+
191
+ /**
192
+ * Format error for logging (shorter, cleaner version)
193
+ */
194
+ export function formatErrorForLogging(error: any): string {
195
+ const formatted = formatError(error);
196
+
197
+ let message = `[${formatted.code}] ${formatted.message}`;
198
+
199
+ if (formatted.httpStatus) {
200
+ message = `[${formatted.httpStatus}] ${message}`;
201
+ }
202
+
203
+ return message;
204
+ }
205
+
@@ -64,9 +64,10 @@ class AxiosClient implements HTTPClient {
64
64
  * It can be convenient to set `baseURL` for an instance of axios to pass relative URLs to methods of that instance.
65
65
  */
66
66
  constructor(baseUrl: string, retryAttempts: number) {
67
+ const timeout = parseInt(process.env.MM_API_TIMEOUT || "30000");
67
68
  this.client = axios.create({
68
69
  baseURL: baseUrl,
69
- timeout: 30000,
70
+ timeout: timeout,
70
71
  headers: {
71
72
  "Content-Type": "application/json",
72
73
  },
@@ -108,7 +109,7 @@ class AxiosClient implements HTTPClient {
108
109
  params: config.params,
109
110
  signal: controller.signal,
110
111
  };
111
- // MLW TODO
112
+
112
113
  logger.info("HTTP request starting", {
113
114
  url: config.url,
114
115
  method: config.method,
@@ -127,10 +128,8 @@ class AxiosClient implements HTTPClient {
127
128
  try {
128
129
  for (let attempt = 0; attempt <= this.retryAttempts; attempt++) {
129
130
  try {
130
- // MLW TODO
131
131
  logger.info(`HTTP request attempt ${attempt + 1}/${this.retryAttempts + 1}`);
132
132
  const response = await this.client.request<T>(axiosConfig);
133
- // MLW TODO
134
133
  logger.info("HTTP request succeeded", { status: response.status });
135
134
  return {
136
135
  data: response.data,
@@ -17,11 +17,6 @@ export {
17
17
  export { getTimezoneOffsetAndPersist } from "./time-utils.js";
18
18
  export { formatDateWithTZOffset, convertToLocalTime, toISOWithOffset } from "./timezone.js";
19
19
  export { applyTimezoneOffsetsToFields } from "./time-utils.js";
20
- export {
21
- convertUtcDateTimeToErpLocal,
22
- convertErpLocalDateTimeToUtc,
23
- getERPTimezone,
24
- } from "./erp-timezone-utils.js";
25
20
  export * from "./time-utils.js";
26
21
 
27
22
  /**
@@ -69,6 +64,12 @@ export type { HTTPClient, HTTPRequestConfig, HTTPResponse } from "./http-client.
69
64
  export * from "./mm-labor-ticket-helpers.js";
70
65
  export { getErrorType } from './error-utils.js';
71
66
 
67
+ /**
68
+ * Error formatting utilities
69
+ */
70
+ export { formatError, formatErrorForLogging } from './error-formatter.js';
71
+ export type { FormattedError } from './error-formatter.js';
72
+
72
73
  /**
73
74
  * MM Connector Logger utilities
74
75
  */
@@ -72,8 +72,6 @@ export const setInitialLoadComplete = (complete: boolean): void => {
72
72
  };
73
73
 
74
74
  /**
75
- * @deprecated The cached numeric offset cannot account for DST changes.
76
- * Prefer using the cached timezone name with Luxon helpers for conversions.
77
75
  * Gets the company's cached current timezone offset (e.g., -5)
78
76
  * @returns The cached timezone offset or 0 if not found
79
77
  */
@@ -1,13 +1,15 @@
1
+ import { convertToLocalTime, toISOWithOffset } from "./timezone.js";
1
2
  import { MMReceiveLaborTicket } from "../services/mm-api-service/types/receive-types.js";
2
- import { convertUtcDateTimeToErpLocal, getERPTimezone } from "./erp-timezone-utils.js";
3
3
 
4
4
  /**
5
- * Converts key datetime fields from UTC (MachineMetrics API) to the ERP's local timezone.
6
- * All inputs/outputs are ISO-8601 strings, and invalid inputs throw errors.
5
+ * Apply timezone offsets to datetime fields specifically for the MMApiReceiveLaborTicket object from the Zulu-based MM API
6
+ * (presumably) before sending to the ERP
7
7
  * @param laborTicket The MMApiReceiveLaborTicket object to convert
8
+ * @param timezoneOffset The timezone offset to apply
8
9
  */
9
10
  export function convertLaborTicketToLocalTimezone(
10
- laborTicket: MMReceiveLaborTicket
11
+ laborTicket: MMReceiveLaborTicket,
12
+ timezoneOffset: number
11
13
  ): MMReceiveLaborTicket {
12
14
  const timeFields = [
13
15
  "clockIn",
@@ -18,14 +20,9 @@ export function convertLaborTicketToLocalTimezone(
18
20
  "workOrderOperationClosedDate",
19
21
  ] as const;
20
22
 
21
- const timezone = getERPTimezone();
22
23
  timeFields.forEach((field) => {
23
- const value = laborTicket[field];
24
- if (value) {
25
- laborTicket[field] = convertUtcDateTimeToErpLocal(value, timezone);
26
- } else {
27
- laborTicket[field] = null;
28
- }
24
+ const localTime = convertToLocalTime(laborTicket[field], timezoneOffset);
25
+ laborTicket[field] = localTime ? toISOWithOffset(localTime, timezoneOffset) : null;
29
26
  });
30
27
  return laborTicket;
31
28
  }
@@ -2,16 +2,13 @@ import { IERPLaborTicketHandler } from "../../types/erp-connector.js";
2
2
  import { MMApiClient } from "../../services/mm-api-service/mm-api-service.js";
3
3
  import { MMReceiveLaborTicket } from "../../services/mm-api-service/types/receive-types.js";
4
4
  import { convertLaborTicketToLocalTimezone } from "../mm-labor-ticket-helpers.js";
5
+ import { getCachedTimezoneOffset } from "../local-data-store/jobs-shared-data.js";
5
6
  import logger from "../../services/reporting-service/logger.js";
6
7
 
7
8
  /**
8
9
  * Handles synchronization of labor tickets between MachineMetrics and ERP systems
9
10
  */
10
11
  export class LaborTicketERPSynchronizer {
11
- // Small allowance to mitigate tiny clock offsets / timestamp granularity differences
12
- // between the connector host and MM's timestamps.
13
- private static readonly CHECKPOINT_SKEW_MS = 2_000;
14
-
15
12
  /**
16
13
  * Synchronizes updated labor tickets from MachineMetrics to an ERP system
17
14
  */
@@ -21,9 +18,9 @@ export class LaborTicketERPSynchronizer {
21
18
  ): Promise<void> {
22
19
  try {
23
20
  const mmApiClient = new MMApiClient();
24
- const failedLaborTicketRefs = new Set<string>();
25
- const attemptedSignatures = new Set<string>();
21
+ const failedLaborTicketRefs: string[] = [];
26
22
 
23
+ // Initialize and fetch checkpoint (quick operations, don't need prolonged lock)
27
24
  await mmApiClient.initializeCheckpoint({
28
25
  system: connectorType,
29
26
  table: "labor_tickets",
@@ -33,168 +30,95 @@ export class LaborTicketERPSynchronizer {
33
30
  },
34
31
  });
35
32
 
36
- /**
37
- * We cannot safely checkpoint to "now" without draining, because updates can arrive mid-run.
38
- * And we cannot checkpoint to "most recent updatedAt from the initial fetch" because we
39
- * update MM (setting laborTicketId on creates), which bumps updatedAt and causes the same
40
- * ticket to be re-pulled next cycle.
41
- *
42
- * We also cannot dedupe only by laborTicketRef, because the same ticket can legitimately
43
- * change multiple times within a single run (parts counts, times, reasons, state, etc.).
44
- * And we cannot dedupe by (laborTicketRef, updatedAt) because updatedAt is exactly what our
45
- * own MM write-back (setting laborTicketId) mutates, creating a false "new version".
46
- *
47
- * Remedy: drain the window up to a moving barrier time (local "now"), re-fetching until MM
48
- * reports no additional unattempted tickets in that window, and dedupe within the run by a
49
- * stable business signature (operational fields only; excludes metadata like updatedAt and
50
- * excludes SDK-mutated fields like laborTicketId). Then checkpoint to a value that is not
51
- * meaningfully ahead of MM's own timestamps (small skew clamp).
52
- */
53
- const maxPasses = 10;
54
- let pass = 0;
55
- let barrierTime = new Date().toISOString();
56
- let maxMmTimestampWithinBarrier: string | null = null;
57
-
58
- while (pass < maxPasses) {
59
- pass += 1;
60
- barrierTime = new Date().toISOString();
61
-
62
- const laborTicketsUpdates = await mmApiClient.fetchLaborTicketUpdates({
63
- system: connectorType,
64
- checkpointType: "export",
65
- });
66
-
67
- if (laborTicketsUpdates.length === 0) {
68
- if (pass === 1) {
69
- logger.info("syncLaborTicketsToERP:No updated labor tickets found");
70
- }
71
- break;
72
- }
33
+ const fallbackTimestamp = new Date().toISOString();
34
+ const laborTicketsUpdates = await mmApiClient.fetchLaborTicketUpdates({
35
+ system: connectorType,
36
+ checkpointType: "export",
37
+ });
73
38
 
74
- // Track the latest MM timestamp we see that is <= the current barrier.
75
- for (const ticket of laborTicketsUpdates) {
76
- const ts = this.getComparableTicketTimestamp(ticket);
77
- if (!ts) continue;
78
- if (new Date(ts) > new Date(barrierTime)) continue;
79
- if (
80
- !maxMmTimestampWithinBarrier ||
81
- new Date(ts) > new Date(maxMmTimestampWithinBarrier)
82
- ) {
83
- maxMmTimestampWithinBarrier = ts;
84
- }
85
- }
39
+ if (laborTicketsUpdates.length === 0) {
40
+ logger.info("syncLaborTicketsToERP:No updated labor tickets found");
41
+ return;
42
+ }
86
43
 
87
- if (pass === 1) {
88
- logger.info(
89
- `ToERP: Found ${laborTicketsUpdates.length} Labor Ticket Ids and Refs to process`,
90
- {
91
- laborTickets: laborTicketsUpdates.map(
92
- (ticket: MMReceiveLaborTicket) => ({
93
- ref: ticket.laborTicketRef,
94
- id: ticket.laborTicketId,
95
- })
96
- ),
97
- }
98
- );
44
+ logger.info(
45
+ `ToERP: Found ${laborTicketsUpdates.length} Labor Ticket Ids and Refs to process`,
46
+ {
47
+ laborTickets: laborTicketsUpdates.map(
48
+ (ticket: MMReceiveLaborTicket) => ({
49
+ ref: ticket.laborTicketRef,
50
+ id: ticket.laborTicketId,
51
+ })
52
+ ),
99
53
  }
54
+ );
100
55
 
101
- // Build a unique pending list by a stable "business signature", and only consider tickets
102
- // whose timestamps are <= the barrierTime (prevents checkpoint gaps if clocks differ).
103
- const pending: Array<{ sig: string; ticket: MMReceiveLaborTicket }> = [];
104
- const dedupeWithinFetch = new Set<string>();
56
+ // Find the most recent updatedAt timestamp from labor tickets. This will be used to update
57
+ // the checkpoint to ensure there is no gap of time for the next sync.
58
+ const mostRecentUpdate = laborTicketsUpdates.reduce(
59
+ (latest: string | null, ticket: MMReceiveLaborTicket) => {
60
+ if (!latest || !ticket.updatedAt) return latest;
61
+ return new Date(ticket.updatedAt) > new Date(latest)
62
+ ? ticket.updatedAt
63
+ : latest;
64
+ },
65
+ null as string | null
66
+ );
105
67
 
106
- for (const ticket of laborTicketsUpdates) {
107
- if (!ticket.laborTicketRef) {
68
+ await Promise.all(
69
+ laborTicketsUpdates.map(async (laborTicket: MMReceiveLaborTicket) => {
70
+ if (!laborTicket.laborTicketRef) {
108
71
  logger.error(
109
72
  "syncLaborTicketsToERP: laborTicketRef is not set for laborTicket pulled from MM:",
110
- { laborTicket: ticket }
73
+ { laborTicket }
111
74
  );
112
- continue;
75
+ return undefined;
113
76
  }
114
77
 
115
- const ts = this.getComparableTicketTimestamp(ticket);
116
- if (ts && new Date(ts) > new Date(barrierTime)) {
117
- // Defer "future" tickets (relative to local wall clock) to a later run/pass.
118
- continue;
78
+ try {
79
+ return await this.processLaborTicket(
80
+ connector,
81
+ mmApiClient,
82
+ laborTicket
83
+ );
84
+ } catch (error) {
85
+ failedLaborTicketRefs.push(laborTicket.laborTicketRef);
86
+ logger.error(
87
+ `syncLaborTicketsToERP: Error processing laborTicketRef ${laborTicket.laborTicketRef}:`,
88
+ { error }
89
+ );
90
+ return undefined;
119
91
  }
120
-
121
- const sig = this.laborTicketBusinessSignature(ticket);
122
- if (attemptedSignatures.has(sig) || dedupeWithinFetch.has(sig)) continue;
123
- dedupeWithinFetch.add(sig);
124
- pending.push({ sig, ticket });
125
- }
126
-
127
- if (pending.length === 0) {
128
- // Nothing new to do in this barrier window; safe to checkpoint and exit.
129
- break;
130
- }
131
-
132
- logger.info(
133
- `syncLaborTicketsToERP: pass=${pass}/${maxPasses}, barrier=${barrierTime}, pending=${pending.length}`
134
- );
135
-
136
- await Promise.all(
137
- pending.map(async ({ sig, ticket }) => {
138
- // Mark attempted up-front so we don't re-attempt within this run even if MM reorders.
139
- attemptedSignatures.add(sig);
140
-
141
- try {
142
- return await this.processLaborTicket(connector, mmApiClient, ticket);
143
- } catch (error) {
144
- failedLaborTicketRefs.add(ticket.laborTicketRef);
145
- logger.error(
146
- `syncLaborTicketsToERP: Error processing laborTicketRef ${ticket.laborTicketRef}:`,
147
- { error }
148
- );
149
- return undefined;
150
- }
151
- })
152
- );
153
- }
154
-
155
- if (pass >= maxPasses) {
156
- logger.warn(
157
- `syncLaborTicketsToERP: Reached max passes (${maxPasses}). Checkpointing anyway to avoid infinite loops.`
158
- );
159
- }
92
+ })
93
+ );
160
94
 
161
95
  logger.info(
162
- `syncLaborTicketsToERP: ${failedLaborTicketRefs.size} failed labor ticket ids`
96
+ `syncLaborTicketsToERP: ${failedLaborTicketRefs.length} failed labor ticket ids`
163
97
  );
164
- if (failedLaborTicketRefs.size > 0) {
165
- const failedTicketRefs = Array.from(failedLaborTicketRefs);
98
+ if (failedLaborTicketRefs.length > 0) {
166
99
  logger.info(
167
- `syncLaborTicketsToERP: Reporting ${failedTicketRefs.length} labor ticket failures:`,
100
+ `syncLaborTicketsToERP: Reporting ${failedLaborTicketRefs.length} labor ticket failures:`,
168
101
  {
169
- failedLaborTicketRefs: failedTicketRefs,
102
+ failedLaborTicketRefs,
170
103
  }
171
104
  );
172
105
  const addFailedResult = await mmApiClient.addFailedLaborTicketRefs(
173
106
  connectorType,
174
- failedTicketRefs
107
+ failedLaborTicketRefs
175
108
  );
176
109
  logger.info("syncLaborTicketsToERP: addFailedResult:", {
177
110
  addFailedResult,
178
111
  });
179
112
  }
180
113
 
181
- const checkpointTimestamp = this.computeCheckpointTimestamp({
182
- barrierTime,
183
- maxMmTimestampWithinBarrier,
184
- skewMs: this.CHECKPOINT_SKEW_MS,
185
- });
186
-
187
- await mmApiClient.saveCheckpoint({
114
+ mmApiClient.saveCheckpoint({
188
115
  system: connectorType,
189
116
  table: "labor_tickets",
190
117
  checkpointType: "export",
191
118
  checkpointValue: {
192
- timestamp: checkpointTimestamp,
119
+ timestamp: mostRecentUpdate || fallbackTimestamp,
193
120
  },
194
121
  });
195
- logger.info("syncLaborTicketsToERP: Checkpoint saved:", {
196
- checkpointTimestamp,
197
- });
198
122
  } catch (error) {
199
123
  logger.error("syncLaborTicketsToERP: Error:", error);
200
124
  }
@@ -291,10 +215,10 @@ export class LaborTicketERPSynchronizer {
291
215
  ): Promise<MMReceiveLaborTicket> {
292
216
  let laborTicketResult: MMReceiveLaborTicket;
293
217
 
294
- // Convert MM's UTC timestamps into the ERP's local timezone before invoking connector code.
295
- // Connector implementations should treat the incoming values as localized wall time and avoid
296
- // applying any further timezone shifts.
297
- laborTicketResult = convertLaborTicketToLocalTimezone(laborTicket);
218
+ laborTicketResult = convertLaborTicketToLocalTimezone(
219
+ laborTicket,
220
+ getCachedTimezoneOffset()
221
+ );
298
222
 
299
223
  logger.info(
300
224
  `processing laborTicket, id=${laborTicket.laborTicketId}, ref=${laborTicket.laborTicketRef}`
@@ -334,84 +258,4 @@ export class LaborTicketERPSynchronizer {
334
258
 
335
259
  return laborTicketResult;
336
260
  }
337
-
338
- /**
339
- * A stable identity for "did the business-relevant contents of this labor ticket change?"
340
- *
341
- * IMPORTANT: Excludes fields that the SDK itself mutates (e.g. laborTicketId, updatedAt),
342
- * otherwise we'd reprocess the write-back bump on the next cycle.
343
- */
344
- private static laborTicketBusinessSignature(ticket: MMReceiveLaborTicket): string {
345
- const reasons = (ticket.reasons ?? [])
346
- .map((r) => {
347
- const reason: any = (r as any)?.reason ?? {};
348
- return (
349
- reason.reasonId ??
350
- reason.code ??
351
- reason.reasonRef ??
352
- reason.rejectReasonRef ??
353
- ""
354
- );
355
- })
356
- .filter((v) => v !== "" && v !== null && v !== undefined)
357
- .map(String)
358
- .sort();
359
-
360
- const signatureObject = {
361
- laborTicketRef: ticket.laborTicketRef ?? null,
362
- clockIn: ticket.clockIn ?? null,
363
- clockOut: ticket.clockOut ?? null,
364
- goodParts: ticket.goodParts ?? null,
365
- badParts: ticket.badParts ?? null,
366
- state: ticket.state ?? null,
367
- type: ticket.type ?? null,
368
- transactionDate: ticket.transactionDate ?? null,
369
- workOrderId: ticket.workOrderId ?? null,
370
- sequenceNumber: ticket.sequenceNumber ?? null,
371
- personId: ticket.personId ?? null,
372
- resourceId: ticket.resourceId ?? null,
373
- lot: ticket.lot ?? null,
374
- split: ticket.split ?? null,
375
- sub: ticket.sub ?? null,
376
- comment: ticket.comment ?? null,
377
- workOrderOperationClosedDate: ticket.workOrderOperationClosedDate ?? null,
378
- reasons,
379
- };
380
-
381
- return JSON.stringify(signatureObject);
382
- }
383
-
384
- /**
385
- * Selects a comparable timestamp for windowing decisions.
386
- * Prefer updatedAt; fall back to other stable timestamps if needed.
387
- */
388
- private static getComparableTicketTimestamp(
389
- ticket: MMReceiveLaborTicket
390
- ): string | null {
391
- return (
392
- ticket.updatedAt ??
393
- ticket.createdAt ??
394
- ticket.transactionDate ??
395
- ticket.clockOut ??
396
- ticket.clockIn ??
397
- null
398
- );
399
- }
400
-
401
- private static addMsToIso(iso: string, ms: number): string {
402
- return new Date(new Date(iso).getTime() + ms).toISOString();
403
- }
404
-
405
- private static computeCheckpointTimestamp(options: {
406
- barrierTime: string;
407
- maxMmTimestampWithinBarrier: string | null;
408
- skewMs: number;
409
- }): string {
410
- const { barrierTime, maxMmTimestampWithinBarrier, skewMs } = options;
411
-
412
- if (!maxMmTimestampWithinBarrier) return barrierTime;
413
-
414
- const mmPlusSkew = this.addMsToIso(maxMmTimestampWithinBarrier, skewMs);
415
- return new Date(mmPlusSkew) < new Date(barrierTime) ? mmPlusSkew : barrierTime;
416
- }
417
261
  }
@@ -29,13 +29,7 @@ import { ErrorProcessor } from "./error-processor.js";
29
29
  */
30
30
  export class MMEntityProcessor {
31
31
  /**
32
- * Writes entities to MM API with deduplication and caching.
33
- *
34
- * IMPORTANT: All datetime fields on `mmRecords` MUST already be expressed as
35
- * ISO-8601 UTC strings (either trailing `Z` or an explicit offset). The SDK
36
- * does not apply timezone conversion on this path. It forwards values directly
37
- * to MachineMetrics and only reserializes them for validation. Connector code
38
- * is responsible for converting local ERP times to UTC before invoking this API.
32
+ * Writes entities to MM API with deduplication and caching
39
33
  */
40
34
  static async writeEntities(
41
35
  entityType: ERPObjType,