@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.
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/services/data-sync-service/configuration-manager.d.ts.map +1 -1
- package/dist/services/data-sync-service/configuration-manager.js +30 -30
- package/dist/services/data-sync-service/configuration-manager.js.map +1 -1
- package/dist/services/data-sync-service/data-sync-service.d.ts.map +1 -1
- package/dist/services/data-sync-service/data-sync-service.js +9 -0
- package/dist/services/data-sync-service/data-sync-service.js.map +1 -1
- package/dist/services/data-sync-service/jobs/clean-up-expired-cache.d.ts.map +1 -1
- package/dist/services/data-sync-service/jobs/clean-up-expired-cache.js +1 -2
- package/dist/services/data-sync-service/jobs/clean-up-expired-cache.js.map +1 -1
- package/dist/services/data-sync-service/jobs/from-erp.d.ts.map +1 -1
- package/dist/services/data-sync-service/jobs/from-erp.js +7 -13
- package/dist/services/data-sync-service/jobs/from-erp.js.map +1 -1
- package/dist/services/data-sync-service/jobs/retry-failed-labor-tickets.d.ts.map +1 -1
- package/dist/services/data-sync-service/jobs/retry-failed-labor-tickets.js +1 -2
- package/dist/services/data-sync-service/jobs/retry-failed-labor-tickets.js.map +1 -1
- package/dist/services/data-sync-service/jobs/run-migrations.d.ts.map +1 -1
- package/dist/services/data-sync-service/jobs/run-migrations.js +1 -2
- package/dist/services/data-sync-service/jobs/run-migrations.js.map +1 -1
- package/dist/services/data-sync-service/jobs/to-erp.d.ts.map +1 -1
- package/dist/services/data-sync-service/jobs/to-erp.js +12 -3
- package/dist/services/data-sync-service/jobs/to-erp.js.map +1 -1
- package/dist/services/data-sync-service/nats-labor-ticket-listener.d.ts +30 -0
- package/dist/services/data-sync-service/nats-labor-ticket-listener.d.ts.map +1 -0
- package/dist/services/data-sync-service/nats-labor-ticket-listener.js +290 -0
- package/dist/services/data-sync-service/nats-labor-ticket-listener.js.map +1 -0
- package/dist/services/mm-api-service/company-info.d.ts +13 -0
- package/dist/services/mm-api-service/company-info.d.ts.map +1 -0
- package/dist/services/mm-api-service/company-info.js +60 -0
- package/dist/services/mm-api-service/company-info.js.map +1 -0
- package/dist/services/mm-api-service/index.d.ts +7 -0
- package/dist/services/mm-api-service/index.d.ts.map +1 -1
- package/dist/services/mm-api-service/index.js +5 -0
- package/dist/services/mm-api-service/index.js.map +1 -1
- package/dist/services/mm-api-service/mm-api-service.d.ts +6 -0
- package/dist/services/mm-api-service/mm-api-service.d.ts.map +1 -1
- package/dist/services/mm-api-service/mm-api-service.js +15 -3
- package/dist/services/mm-api-service/mm-api-service.js.map +1 -1
- package/dist/services/mm-api-service/types/receive-types.d.ts +3 -0
- package/dist/services/mm-api-service/types/receive-types.d.ts.map +1 -1
- package/dist/services/mm-api-service/types/receive-types.js +1 -0
- package/dist/services/mm-api-service/types/receive-types.js.map +1 -1
- package/dist/services/nats-service/nats-service.d.ts +114 -0
- package/dist/services/nats-service/nats-service.d.ts.map +1 -0
- package/dist/services/nats-service/nats-service.js +244 -0
- package/dist/services/nats-service/nats-service.js.map +1 -0
- package/dist/services/nats-service/test-nats-subscriber.d.ts +6 -0
- package/dist/services/nats-service/test-nats-subscriber.d.ts.map +1 -0
- package/dist/services/nats-service/test-nats-subscriber.js +79 -0
- package/dist/services/nats-service/test-nats-subscriber.js.map +1 -0
- package/dist/services/reporting-service/logger.d.ts.map +1 -1
- package/dist/services/reporting-service/logger.js +31 -6
- package/dist/services/reporting-service/logger.js.map +1 -1
- package/dist/types/erp-connector.d.ts +1 -8
- package/dist/types/erp-connector.d.ts.map +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/error-formatter.d.ts +19 -0
- package/dist/utils/error-formatter.d.ts.map +1 -0
- package/dist/utils/error-formatter.js +184 -0
- package/dist/utils/error-formatter.js.map +1 -0
- package/dist/utils/http-client.js +2 -4
- package/dist/utils/http-client.js.map +1 -1
- package/dist/utils/index.d.ts +5 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +4 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/local-data-store/jobs-shared-data.d.ts +0 -2
- package/dist/utils/local-data-store/jobs-shared-data.d.ts.map +1 -1
- package/dist/utils/local-data-store/jobs-shared-data.js +0 -2
- package/dist/utils/local-data-store/jobs-shared-data.js.map +1 -1
- package/dist/utils/mm-labor-ticket-helpers.d.ts +4 -3
- package/dist/utils/mm-labor-ticket-helpers.d.ts.map +1 -1
- package/dist/utils/mm-labor-ticket-helpers.js +7 -12
- package/dist/utils/mm-labor-ticket-helpers.js.map +1 -1
- package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.d.ts +0 -15
- package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.d.ts.map +1 -1
- package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.js +46 -180
- package/dist/utils/standard-process-drivers/labor-ticket-erp-synchronizer.js.map +1 -1
- package/dist/utils/standard-process-drivers/mm-entity-processor.d.ts +1 -7
- package/dist/utils/standard-process-drivers/mm-entity-processor.d.ts.map +1 -1
- package/dist/utils/standard-process-drivers/mm-entity-processor.js +1 -7
- package/dist/utils/standard-process-drivers/mm-entity-processor.js.map +1 -1
- package/dist/utils/standard-process-drivers/standard-process-drivers.d.ts +2 -8
- package/dist/utils/standard-process-drivers/standard-process-drivers.d.ts.map +1 -1
- package/dist/utils/standard-process-drivers/standard-process-drivers.js +18 -27
- package/dist/utils/standard-process-drivers/standard-process-drivers.js.map +1 -1
- package/dist/utils/time-utils.d.ts.map +1 -1
- package/dist/utils/time-utils.js +0 -7
- package/dist/utils/time-utils.js.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +3 -0
- package/src/services/data-sync-service/configuration-manager.ts +37 -50
- package/src/services/data-sync-service/data-sync-service.ts +10 -0
- package/src/services/data-sync-service/jobs/clean-up-expired-cache.ts +1 -2
- package/src/services/data-sync-service/jobs/from-erp.ts +7 -13
- package/src/services/data-sync-service/jobs/retry-failed-labor-tickets.ts +1 -2
- package/src/services/data-sync-service/jobs/run-migrations.ts +1 -2
- package/src/services/data-sync-service/jobs/to-erp.ts +12 -3
- package/src/services/data-sync-service/nats-labor-ticket-listener.ts +342 -0
- package/src/services/mm-api-service/company-info.ts +87 -0
- package/src/services/mm-api-service/index.ts +8 -0
- package/src/services/mm-api-service/mm-api-service.ts +20 -3
- package/src/services/mm-api-service/types/receive-types.ts +1 -0
- package/src/services/nats-service/nats-service.ts +351 -0
- package/src/services/nats-service/test-nats-subscriber.ts +96 -0
- package/src/services/reporting-service/logger.ts +39 -7
- package/src/types/erp-connector.ts +1 -8
- package/src/types/index.ts +0 -8
- package/src/utils/error-formatter.ts +205 -0
- package/src/utils/http-client.ts +3 -4
- package/src/utils/index.ts +6 -5
- package/src/utils/local-data-store/jobs-shared-data.ts +0 -2
- package/src/utils/mm-labor-ticket-helpers.ts +8 -11
- package/src/utils/standard-process-drivers/labor-ticket-erp-synchronizer.ts +64 -220
- package/src/utils/standard-process-drivers/mm-entity-processor.ts +1 -7
- package/src/utils/standard-process-drivers/standard-process-drivers.ts +19 -33
- package/src/utils/time-utils.ts +0 -11
- package/dist/types/flattened-work-order.d.ts +0 -99
- package/dist/types/flattened-work-order.d.ts.map +0 -1
- package/dist/types/flattened-work-order.js +0 -2
- package/dist/types/flattened-work-order.js.map +0 -1
- package/dist/utils/env.d.ts +0 -8
- package/dist/utils/env.d.ts.map +0 -1
- package/dist/utils/env.js +0 -58
- package/dist/utils/env.js.map +0 -1
- package/dist/utils/erp-timezone-utils.d.ts +0 -20
- package/dist/utils/erp-timezone-utils.d.ts.map +0 -1
- package/dist/utils/erp-timezone-utils.js +0 -75
- package/dist/utils/erp-timezone-utils.js.map +0 -1
- package/src/types/flattened-work-order.ts +0 -108
- package/src/utils/env.ts +0 -75
- 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
|
+
|
package/src/utils/http-client.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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,
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
24
|
-
|
|
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 =
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
73
|
+
{ laborTicket }
|
|
111
74
|
);
|
|
112
|
-
|
|
75
|
+
return undefined;
|
|
113
76
|
}
|
|
114
77
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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.
|
|
96
|
+
`syncLaborTicketsToERP: ${failedLaborTicketRefs.length} failed labor ticket ids`
|
|
163
97
|
);
|
|
164
|
-
if (failedLaborTicketRefs.
|
|
165
|
-
const failedTicketRefs = Array.from(failedLaborTicketRefs);
|
|
98
|
+
if (failedLaborTicketRefs.length > 0) {
|
|
166
99
|
logger.info(
|
|
167
|
-
`syncLaborTicketsToERP: Reporting ${
|
|
100
|
+
`syncLaborTicketsToERP: Reporting ${failedLaborTicketRefs.length} labor ticket failures:`,
|
|
168
101
|
{
|
|
169
|
-
failedLaborTicketRefs
|
|
102
|
+
failedLaborTicketRefs,
|
|
170
103
|
}
|
|
171
104
|
);
|
|
172
105
|
const addFailedResult = await mmApiClient.addFailedLaborTicketRefs(
|
|
173
106
|
connectorType,
|
|
174
|
-
|
|
107
|
+
failedLaborTicketRefs
|
|
175
108
|
);
|
|
176
109
|
logger.info("syncLaborTicketsToERP: addFailedResult:", {
|
|
177
110
|
addFailedResult,
|
|
178
111
|
});
|
|
179
112
|
}
|
|
180
113
|
|
|
181
|
-
|
|
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:
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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,
|