import { ERPObjType } from "../../types/erp-types.js"; import { BatchCacheManager } from "../../services/caching-service/batch-cache-manager.js"; import { IToRESTApiObject, MM207NonLaborTicketResponse, MM207LaborTicketResponse, } from "../../services/mm-api-service/index.js"; import { EntityTransformer } from "../../services/mm-api-service/types/entity-transformer.js"; import { sdkLogger as logger } from "../../services/reporting-service/logger.js"; /** * Handles error processing and record management utilities for MM API operations */ export class ErrorProcessor { /** * Creates a set of primary keys for all failed records from batch errors * @param entityType The type of entity being processed * @param batchErrors Array of batch errors containing failed entities * @returns Set of primary keys for failed records */ static createFailedRecordKeySet( entityType: ERPObjType, batchErrors: Array<{ message: string; affectedEntities: IToRESTApiObject[]; // All entities in the failing batch }> ): Set { const failedKeySet = new Set(); batchErrors.forEach((batchError) => { batchError.affectedEntities.forEach((affectedEntity) => { try { const primaryKey = EntityTransformer.extractPrimaryKey( entityType, affectedEntity ); failedKeySet.add(primaryKey); } catch (error) { logger.warn( `Failed to extract primary key from error entity: ${error}` ); // Continue processing other records even if one fails } }); }); return failedKeySet; } /** * Filters out failed records, returning only successful ones * @param entityType The type of entity being processed * @param allRecords All records (typed objects) that were sent to the API * @param failedKeySet Set of primary keys for records that failed * @returns Array of records that succeeded */ static filterSuccessfulRecords( entityType: ERPObjType, allRecords: IToRESTApiObject[], failedKeySet: Set ): IToRESTApiObject[] { const successfulRecords: IToRESTApiObject[] = []; allRecords.forEach((record) => { try { const primaryKey = EntityTransformer.extractPrimaryKey( entityType, record ); if (!failedKeySet.has(primaryKey)) { successfulRecords.push(record); } } catch (error) { logger.warn( `Failed to extract primary key from record during filtering: ${error}` ); // If we can't extract the key, we can't determine if it failed, so skip it } }); return successfulRecords; } /** * Orchestrates the caching of successful records on partial failure * @param entityType The type of entity being processed * @param toProcess All records that were sent to the API (all are now guaranteed to be typed objects) * @param batchErrors Array of batch errors containing failed entities * @param batchCacheManager The cache manager instance */ static async cacheSuccessfulRecordsOnPartialFailure( entityType: ERPObjType, toProcess: IToRESTApiObject[], batchErrors: Array<{ message: string; affectedEntities: IToRESTApiObject[]; // All entities in the failing batch }>, batchCacheManager: BatchCacheManager ): Promise { // Create a set of failed record keys for efficient lookup const failedKeySet = this.createFailedRecordKeySet(entityType, batchErrors); // Filter to get only successful records const successfulRecords = this.filterSuccessfulRecords( entityType, toProcess, failedKeySet ); logger.info( `Caching ${successfulRecords.length} successful records out of ${toProcess.length} total records` ); // All records are now guaranteed to be typed objects, so we can cache them directly if (successfulRecords.length > 0) { await batchCacheManager.storeBatch(entityType, successfulRecords); } } /** * Extracts error count and batch errors from MM API response for partial failures (HTTP 207) * This supports all entities, including the slightly different format for labor tickets. * In case of labor tickets, the updateErrors and insertErrors arrays are combined into affectedEntities. * @param mmApiResponse The full MM API response object * @param entityType The type of entity being processed (determines response structure) * @returns Object containing errorCount and batchErrors * See MM207NonLaborTicketResponse and MM207LaborTicketResponse for response structure details */ static extractErrorDetails( mmApiResponse: MM207NonLaborTicketResponse | MM207LaborTicketResponse, entityType: ERPObjType ): { errorCount: number; batchErrors: Array<{ message: string; affectedEntities: IToRESTApiObject[]; // All entities in the failing batch }>; } { // Type the data property with the expected structure for HTTP 207 responses const data = mmApiResponse.data as | { errors?: Array<{ message: string; batchData?: (IToRESTApiObject | Record)[]; }>; updateErrors?: Array<{ message: string; batchData?: (IToRESTApiObject | Record)[]; }>; insertErrors?: Array<{ message: string; batchData?: (IToRESTApiObject | Record)[]; }>; } | undefined; let allErrors: Array<{ message: string; batchData?: (IToRESTApiObject | Record)[]; }> = []; if (entityType === ERPObjType.LABOR_TICKETS) { // Labor tickets: combine updateErrors and insertErrors const updateErrors = data?.updateErrors || []; const insertErrors = data?.insertErrors || []; // Defensive validation with actionable warnings if (!data?.updateErrors && !data?.insertErrors) { logger.warn( "Labor tickets partial success response missing both updateErrors and insertErrors arrays" ); } allErrors = [...updateErrors, ...insertErrors]; } else { // Regular entities: use errors array const errors = data?.errors || []; // Defensive validation if (!data?.errors) { logger.warn( `${entityType} partial success response missing errors array` ); } allErrors = errors; } const batchErrors = allErrors .filter( (error: { message: string; batchData?: (IToRESTApiObject | Record)[]; }) => { return error.batchData && error.batchData.length > 0; } ) .map( (error: { message: string; batchData?: (IToRESTApiObject | Record)[]; }) => { // Reconstruct typed objects from plain data const typedErrorEntities = (error.batchData || []).map((entity) => { if ( typeof entity === "object" && entity !== null && "toRESTApiObject" in entity && typeof entity.toRESTApiObject === "function" ) { // It's already a typed object, return as-is return entity as IToRESTApiObject; } else { // It's plain data, reconstruct as typed object return EntityTransformer.reconstructFromPlainData( entityType, entity as Record ); } }); return { message: error.message, affectedEntities: typedErrorEntities, }; } ); const errorCount = batchErrors.reduce((total: number, batchError) => { return total + batchError.affectedEntities.length; }, 0); return { errorCount, batchErrors, }; } /** * Extracts error details from a 500 HTTP exception when it contains structured error data * @param exception The caught exception from MM API call * @param entityType The type of entity being processed * @returns Object containing errorCount and batchErrors, or null if not a structured 500 error * See MM500NonLaborTicketException and MM500LaborTicketException for exception structure details */ static extractErrorDetailsFrom500Exception( exception: unknown, entityType: ERPObjType ): { errorCount: number; batchErrors: Array<{ message: string; affectedEntities: IToRESTApiObject[]; // All entities in the failing batch }>; } | null { try { // Cast exception to expected structure for easier access const ex = exception as Record; const data = ex?.data as Record; // Add diagnostic logging to understand the 500 error structure logger.info( "writeEntitiesToMM: Analyzing 500 exception structure for diagnostic purposes", { status: ex?.status, code: ex?.code, hasResponseData: !!data, responseDataKeys: data ? Object.keys(data) : [], errorMessage: data?.error, hasMessageObject: !!data?.message, messageObjectKeys: data?.message ? Object.keys(data.message as Record) : [], entityType, exceptionType: typeof exception, exceptionKeys: ex ? Object.keys(ex) : [], } ); // Check if this is a structured 500 error if ( ex?.status !== 500 || typeof data?.error !== "string" || !data.error.startsWith("Failed to import") ) { logger.info( "writeEntitiesToMM: Not a structured 500 error - will re-throw exception as-is", { status: ex?.status, errorMessage: data?.error, expectedStatus: 500, expectedMessagePrefix: "Failed to import", } ); return null; } logger.info( "writeEntitiesToMM: Detected structured 500 error - extracting error details" ); const messageObject = data?.message as Record; if (!messageObject) { logger.warn( "writeEntitiesToMM: Structured 500 error missing message object" ); return null; } let allErrors: unknown[] = []; if (entityType === ERPObjType.LABOR_TICKETS) { // Labor tickets: combine updateErrors and insertErrors const updateErrors = Array.isArray(messageObject?.updateErrors) ? messageObject.updateErrors : []; const insertErrors = Array.isArray(messageObject?.insertErrors) ? messageObject.insertErrors : []; logger.info("writeEntitiesToMM: Processing labor tickets 500 error", { updateErrorsCount: updateErrors.length, insertErrorsCount: insertErrors.length, }); if (updateErrors.length === 0 && insertErrors.length === 0) { logger.warn( "writeEntitiesToMM: Labor tickets 500 error missing both updateErrors and insertErrors arrays" ); } allErrors = [...updateErrors, ...insertErrors]; } else { // Regular entities: use errors array const errors = Array.isArray(messageObject?.errors) ? messageObject.errors : []; logger.info("writeEntitiesToMM: Processing regular entity 500 error", { errorsCount: errors.length, }); if (errors.length === 0) { logger.warn( `writeEntitiesToMM: ${entityType} 500 error missing errors array` ); } allErrors = errors; } const batchErrors = allErrors .filter((error) => { const err = error as Record; return Array.isArray(err?.batchData) && err.batchData.length > 0; }) .map((error) => { const err = error as Record; const batchData = err?.batchData as ( | IToRESTApiObject | Record )[]; // Reconstruct typed objects from plain data const typedErrorEntities = (batchData || []).map((entity) => { if ( typeof entity === "object" && entity !== null && "toRESTApiObject" in entity && typeof entity.toRESTApiObject === "function" ) { // It's already a typed object, return as-is return entity as IToRESTApiObject; } else { // It's plain data, reconstruct as typed object return EntityTransformer.reconstructFromPlainData( entityType, entity as Record ); } }); return { message: typeof err?.message === "string" ? err.message : "Unknown error", affectedEntities: typedErrorEntities, }; }); const errorCount = batchErrors.reduce((total: number, batchError) => { return total + batchError.affectedEntities.length; }, 0); logger.info("writeEntitiesToMM: Extracted 500 error details", { batchErrorsCount: batchErrors.length, totalErrorCount: errorCount, entityType, }); return { errorCount, batchErrors, }; } catch (error) { // If we can't even parse the structure safely, log what we can and return null logger.error( "writeEntitiesToMM: Failed to parse 500 exception structure safely", { error: error instanceof Error ? error.message : String(error), exceptionType: typeof exception, entityType, } ); return null; } } }