import { ERPObjType } from "../../types/erp-types.js"; import { BatchCacheManager } from "../../services/caching-service/batch-cache-manager.js"; import { IToRESTApiObject, MMSendPerson, MMSendResource, MMSendPart, MMSendPartOperation, MMSendWorkOrder, MMSendWorkOrderOperationPayload, MMSendLaborTicket, MMSendReason, MMApiBaseResponse, MM200NonLaborTicketResponse, MM200LaborTicketResponse, MM207NonLaborTicketResponse, MM207LaborTicketResponse, } from "../../services/mm-api-service/index.js"; import { MMApiClient } from "../../services/mm-api-service/mm-api-service.js"; import { HTTPError } from "../http-client.js"; import { WriteEntitiesToMMResult, MMBatchValidationError, } from "./standard-process-drivers.js"; import { ErrorProcessor } from "./error-processor.js"; import { LaborTicketSyncLedgerManager } from "../../services/caching-service/labor-ticket-sync-ledger-manager.js"; import { LaborTicketERPSynchronizer } from "./labor-ticket-erp-synchronizer.js"; import { MMReceiveLaborTicket } from "../../services/mm-api-service/types/receive-types.js"; import { sdkLogger as logger } from "../../services/reporting-service/logger.js"; import { tryLaborTicketLedgerErpImportKey } from "./labor-ticket-normalization.js"; import { CoreConfiguration } from "../../services/data-sync-service/configuration-manager.js"; /** * Handles processing of entities to the MachineMetrics API */ export class MMEntityProcessor { /** * Writes entities to MM API with deduplication and caching. * * Optionally writes large record sets in sequential chunks to keep each MM * API call within its timeout budget. Chunking is OFF by default so it never * interferes with connectors that chunk at their own layer: it activates only * when `options.chunkSize` is set (> 0) or, absent that, when env * `MM_API_WRITE_CHUNK_SIZE` / `CoreConfiguration.mmApiWriteChunkSize` is set * (> 0). When a `batchCacheManager` is provided, successfully written chunks * are cached immediately, so a failure partway through is safe to retry — * already-written records dedupe locally. With `batchCacheManager: null` a * retry re-sends already-written chunks (MM upserts are idempotent, but * reported counts will differ). * * IMPORTANT: All datetime fields on `mmRecords` MUST already be expressed as * ISO-8601 UTC strings (either trailing `Z` or an explicit offset). The SDK * does not apply timezone conversion on this path. It forwards values directly * to MachineMetrics and only reserializes them for validation. Connector code * is responsible for converting local ERP times to UTC before invoking this API. */ static async writeEntities( entityType: ERPObjType, mmRecords: IToRESTApiObject[], batchCacheManager: BatchCacheManager | null, options?: { connectorType?: string; chunkSize?: number; queryParams?: Record; } ): Promise { const { toProcess, result } = await this._prepareAndDedupe( entityType, mmRecords, batchCacheManager ); if (toProcess.length === 0) { result.message = "All records were deduplicated locally - no records sent to MM API"; return result; } const chunks = this._chunkRecords(toProcess, options?.chunkSize); if (chunks.length === 1) { return await this._writeChunk( entityType, chunks[0], result, batchCacheManager, options ); } return await this._writeChunked( entityType, chunks, result, batchCacheManager, options ); } /** * Splits records into chunks of the configured size. * Chunk size resolution: explicit `chunkSize` argument, then * `CoreConfiguration.mmApiWriteChunkSize`. Fractional values are floored to * a whole number of records per request; unset, non-finite, or values that * floor to <= 0 disable chunking (the default), returning the record set as * a single chunk. */ private static _chunkRecords( toProcess: IToRESTApiObject[], chunkSizeOverride?: number ): IToRESTApiObject[][] { const configured = chunkSizeOverride ?? CoreConfiguration.inst().mmApiWriteChunkSize; const chunkSize = Number.isFinite(configured) ? Math.floor(configured) : 0; if (chunkSize !== configured) { logger.warn("writeEntities: non-integer chunkSize normalized", { configured, chunkSize, }); } if (chunkSize <= 0 || toProcess.length <= chunkSize) { return [toProcess]; } const chunks: IToRESTApiObject[][] = []; for (let i = 0; i < toProcess.length; i += chunkSize) { chunks.push(toProcess.slice(i, i + chunkSize)); } return chunks; } /** * Sends a single chunk and handles its response. Same contract as the * original single-shot write path. */ private static async _writeChunk( entityType: ERPObjType, toProcess: IToRESTApiObject[], result: WriteEntitiesToMMResult, batchCacheManager: BatchCacheManager | null, options?: { connectorType?: string; queryParams?: Record } ): Promise { let mmApiResponse: MMApiBaseResponse; try { mmApiResponse = await this._sendToAPI( entityType, toProcess, options?.queryParams ); } catch (exception) { this._handleException(exception, entityType, result); } return await this._handleResponse( mmApiResponse, entityType, toProcess, result, batchCacheManager, options ); } /** * Writes chunks sequentially, aggregating results across chunks. * * Structured per-chunk failures (MMBatchValidationError from a 207 or a * structured 500) do not stop processing — remaining chunks are still * written, and one aggregated MMBatchValidationError is thrown at the end. * Non-structured exceptions (network, auth, timeout) abort immediately and * are rethrown as-is, discarding the counts accumulated from earlier chunks. * When a `batchCacheManager` is in use, those chunks are already cached, so * a retry of the full record set dedupes them locally; with * `batchCacheManager: null` a retry re-sends them (harmless for MM's * idempotent upserts, but the caller cannot tell part of the batch landed). */ private static async _writeChunked( entityType: ERPObjType, chunks: IToRESTApiObject[][], result: WriteEntitiesToMMResult, batchCacheManager: BatchCacheManager | null, options?: { connectorType?: string; queryParams?: Record } ): Promise { const totalRecords = chunks.reduce((sum, c) => sum + c.length, 0); logger.info(`writeEntities: writing ${entityType} in chunks`, { totalRecords, chunkCount: chunks.length, chunkSize: chunks[0].length, }); const chunkErrors: MMBatchValidationError[] = []; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const chunkResult: WriteEntitiesToMMResult = { message: "", upsertedEntities: 0, localDedupeCount: 0, apiDedupeCount: 0, }; try { const success = await this._writeChunk( entityType, chunk, chunkResult, batchCacheManager, options ); result.upsertedEntities += success.upsertedEntities; result.apiDedupeCount += success.apiDedupeCount; } catch (exception) { if (!(exception instanceof MMBatchValidationError)) { logger.warn( `writeEntities: non-recoverable error on chunk ${i + 1}/${chunks.length}; aborting remaining chunks`, { entityType, upsertedSoFar: result.upsertedEntities, error: exception, } ); throw exception; } logger.warn( `writeEntities: chunk ${i + 1}/${chunks.length} completed with errors; continuing`, { entityType, httpStatus: exception.httpStatus, errorCount: exception.errorCount, errorMessage: exception.message, } ); result.upsertedEntities += exception.upsertedEntities; result.apiDedupeCount += exception.apiDedupeCount; chunkErrors.push(exception); } } if (chunkErrors.length > 0) { const errorCount = chunkErrors.reduce((sum, e) => sum + e.errorCount, 0); // Complete failure (500) only when every chunk failed completely with a // structured 500 and nothing was upserted; any success means partial (207). const httpStatus = chunkErrors.length === chunks.length && result.upsertedEntities === 0 && chunkErrors.every((e) => e.httpStatus === 500) ? 500 : 207; throw new MMBatchValidationError({ message: `${chunkErrors.length} of ${chunks.length} chunks reported errors; first error: ${chunkErrors[0].message}`, upsertedEntities: result.upsertedEntities, localDedupeCount: result.localDedupeCount, apiDedupeCount: result.apiDedupeCount, errorCount, httpStatus, batchErrors: chunkErrors.flatMap((e) => e.batchErrors), }); } result.message = `Entities processed successfully in ${chunks.length} chunks`; return result; } // ============================================================================ // PRIVATE HELPER METHODS // ============================================================================ /** * Deduplicates records against cache * Returns the records to process and the result object * If the batchCacheManager is provided, the records are deduplicated against the cache * If the batchCacheManager is not provided, the records are not deduplicated */ private static async _prepareAndDedupe( entityType: ERPObjType, mmRecords: IToRESTApiObject[], batchCacheManager: BatchCacheManager | null ): Promise<{ toProcess: IToRESTApiObject[]; result: WriteEntitiesToMMResult; }> { let toProcess: IToRESTApiObject[] = []; const result: WriteEntitiesToMMResult = { message: "", upsertedEntities: 0, localDedupeCount: 0, apiDedupeCount: 0, }; if (batchCacheManager) { const { nonDuplicates, duplicates } = (await batchCacheManager.dedupeBatch(entityType, mmRecords)) as { nonDuplicates: IToRESTApiObject[]; duplicates: IToRESTApiObject[]; }; toProcess = nonDuplicates; result.localDedupeCount = duplicates.length; } else { toProcess = mmRecords; } return { toProcess, result }; } /** * Sends records to MM API */ private static async _sendToAPI( entityType: ERPObjType, toProcess: IToRESTApiObject[], queryParams?: Record ): Promise { const mmApiClient = new MMApiClient(); try { switch (entityType) { case ERPObjType.PERSONS: return await mmApiClient.sendPersonsToMM(toProcess as MMSendPerson[]); case ERPObjType.RESOURCES: return await mmApiClient.sendResourcesToMM( toProcess as MMSendResource[] ); case ERPObjType.PARTS: return await mmApiClient.sendPartsToMM(toProcess as MMSendPart[]); case ERPObjType.PART_OPERATION: return await mmApiClient.sendPartOperationsToMM( toProcess as MMSendPartOperation[] ); case ERPObjType.WORK_ORDERS: return await mmApiClient.sendWorkOrdersToMM( toProcess as MMSendWorkOrder[], queryParams ); case ERPObjType.WORK_ORDER_OPERATIONS: return await mmApiClient.sendWorkOrderOperationsToMM( toProcess as MMSendWorkOrderOperationPayload[], queryParams ); case ERPObjType.LABOR_TICKETS: return await mmApiClient.sendLaborTicketsToMM( toProcess as MMSendLaborTicket[] ); case ERPObjType.REASONS: return await mmApiClient.sendReasonsToMM(toProcess as MMSendReason[]); default: throw new Error(`Unknown entity type: ${entityType}`); } } finally { await mmApiClient.destroy(); } } /** * Handles exceptions from API calls, converting structured 500 errors to MMBatchValidationError * @throws MMBatchValidationError for structured 500 errors * @throws The original exception for all other errors */ private static _handleException( exception: unknown, entityType: ERPObjType, result: WriteEntitiesToMMResult ): never { // Handle structured 500 errors that may be appropriate for partial failure const structuredCompleteErrorSet = ErrorProcessor.extractErrorDetailsFrom500Exception(exception, entityType); if (structuredCompleteErrorSet) { const ex = exception as Record; const data = ex?.data as Record; const errorMessage = typeof data?.error === "string" ? data.error : "All entities failed to import"; throw new MMBatchValidationError({ message: errorMessage, upsertedEntities: 0, localDedupeCount: result.localDedupeCount, apiDedupeCount: 0, errorCount: structuredCompleteErrorSet.errorCount, httpStatus: (exception as HTTPError).status, batchErrors: structuredCompleteErrorSet.batchErrors, }); } // For all other exceptions, re-throw as-is throw exception; } /** * Handles the MM API response based on HTTP status code, caching successful records * and throwing exceptions for partial failures * * @returns The final result for success cases * @throws MMBatchValidationError for partial success cases * @throws Error for unknown status codes */ private static async _handleResponse( mmApiResponse: MMApiBaseResponse, entityType: ERPObjType, toProcess: IToRESTApiObject[], result: WriteEntitiesToMMResult, batchCacheManager: BatchCacheManager | null, options?: { connectorType?: string } ): Promise { if (mmApiResponse.httpStatus === 200) { // Complete success - cache all records if (batchCacheManager) { await batchCacheManager.storeBatch(entityType, toProcess); } if (entityType === ERPObjType.LABOR_TICKETS) { const success = mmApiResponse as MM200LaborTicketResponse; result.message = success.message || "Entities processed successfully"; result.upsertedEntities = (success.updated || 0) + (success.inserted || 0); // Mark labor tickets in sync ledger to prevent circular writes from to-erp job if (options?.connectorType) { await this._markLaborTicketsInLedger( toProcess as MMSendLaborTicket[], options.connectorType ); } } else { const success = mmApiResponse as MM200NonLaborTicketResponse; result.message = success.message || "Entities processed successfully"; result.upsertedEntities = success.affectedRows || 0; } result.apiDedupeCount = toProcess.length - result.upsertedEntities; return result; } else if (mmApiResponse.httpStatus === 207) { // Partial success - cache only successful records before throwing exception const partialResponse = mmApiResponse as | MM207NonLaborTicketResponse | MM207LaborTicketResponse; const { errorCount, batchErrors } = ErrorProcessor.extractErrorDetails( partialResponse, entityType ); if (batchCacheManager) { await ErrorProcessor.cacheSuccessfulRecordsOnPartialFailure( entityType, toProcess, batchErrors, batchCacheManager ); } let upsertedEntities = 0; if (entityType === ERPObjType.LABOR_TICKETS) { const partial = partialResponse as MM207LaborTicketResponse; upsertedEntities = (partial.data.updated || 0) + (partial.data.inserted || 0); // Mark successfully written labor tickets in dedup cache // Extract successful tickets (those not in batchErrors) const failedTicketIds = new Set( batchErrors.flatMap((error) => error.affectedEntities .map((e) => (e as MMSendLaborTicket).laborTicketId) .filter((id) => id) ) ); const successfulTickets = (toProcess as MMSendLaborTicket[]).filter( (ticket) => !failedTicketIds.has(ticket.laborTicketId) ); if (options?.connectorType) { await this._markLaborTicketsInLedger( successfulTickets, options.connectorType ); } } else { const partial = partialResponse as MM207NonLaborTicketResponse; upsertedEntities = partial.data.affectedRows || 0; } throw new MMBatchValidationError({ message: partialResponse.message || "Entities processed with partial failures", upsertedEntities: upsertedEntities, localDedupeCount: result.localDedupeCount, apiDedupeCount: toProcess.length - upsertedEntities - errorCount, errorCount: errorCount, httpStatus: mmApiResponse.httpStatus, batchErrors: batchErrors, }); } else { throw new Error( `writeEntitiesToMM: Unknown HTTP status code: ${mmApiResponse.httpStatus}. An exception was expected.` ); } } /** * PUBLIC: marks labor tickets imported from ERP in the local sync ledger so the next * to-erp cycle can dedupe them by signature. Safe to call before AND after the MM POST * (upsertAttempt is idempotent on (system, laborTicketRef)). */ public static async markLaborTicketsInLedger( tickets: MMSendLaborTicket[], connectorType: string, label: string = "labor-ledger-mark" ): Promise<{ written: number; skippedNoKey: number; failed: number }> { const stats = { written: 0, skippedNoKey: 0, failed: 0 }; if (tickets.length === 0) { logger.info(`${label}: nothing to mark`); return stats; } logger.info(`${label}: marking labor tickets in sync ledger`, { ticketCount: tickets.length, connectorType, }); const ledger = new LaborTicketSyncLedgerManager(); try { for (const ticket of tickets) { try { const fullTicket = ticket.toFullApiObject() as any; const mmTicket = new MMReceiveLaborTicket(fullTicket); const signature = LaborTicketERPSynchronizer.laborTicketBusinessSignature(mmTicket); const explicitRef = ticket.laborTicketRef ?? fullTicket.laborTicketRef; let laborTicketRef: string; if (explicitRef != null && explicitRef !== "") { laborTicketRef = String(explicitRef); } else { const erpKey = tryLaborTicketLedgerErpImportKey( mmTicket.workOrderId, mmTicket.laborTicketId ); if (!erpKey) { stats.skippedNoKey++; logger.warn( `${label}: missing laborTicketRef and ERP import key; skipping`, { laborTicketId: ticket.laborTicketId, workOrderId: ticket.workOrderId, } ); continue; } laborTicketRef = erpKey; } await ledger.upsertAttempt({ system: connectorType, laborTicketRef, signature, outcome: "success", mmUpdatedAt: null, mmComparableTs: null, errorSummary: null, attemptedAtIso: new Date().toISOString(), }); stats.written++; } catch (ticketError) { stats.failed++; logger.warn(`${label}: per-ticket ledger upsert failed`, { laborTicketId: ticket.laborTicketId, error: ticketError, }); } } } catch (error) { logger.warn(`${label}: ledger pass failed`, { ticketCount: tickets.length, error, }); } finally { await ledger.destroy(); } logger.info(`${label}: ledger marking complete`, stats); return stats; } private static async _markLaborTicketsInLedger( tickets: MMSendLaborTicket[], connectorType: string ): Promise { await MMEntityProcessor.markLaborTicketsInLedger( tickets, connectorType, "labor-ledger-mark:post-write" ); } }