import { RecoverLaborTicketIdAction, type IERPLaborTicketHandler, type RecoverLaborTicketIdResult, type RecoverLaborTicketIdReturn, } from "../types/erp-connector.js"; import type { MMReceiveLaborTicket } from "../services/mm-api-service/types/receive-types.js"; import type { MMApiClient } from "../services/mm-api-service/mm-api-service.js"; import { sdkLogger as logger } from "../services/reporting-service/logger.js"; import { summarizeError } from "./error-utils.js"; /** * Thrown when a connector defers recovery and the SDK should skip ERP create for this cycle. * Polling records failure and enqueues retry; NATS returns an error response. */ export class LaborTicketRecoveryDeferredError extends Error { readonly code = "LABOR_RECOVERY_DEFERRED"; constructor(reason?: string) { super( reason ?? "Labor ticket recovery deferred; skipping ERP create for this cycle" ); this.name = "LaborTicketRecoveryDeferredError"; } } /** * Thrown when ERP create failed overall but an ERP laborTicketId was assigned. * The SDK patches MM before rethrowing so retries use the update path. */ export class LaborTicketCreatePartialSuccessError extends Error { readonly code = "LABOR_CREATE_PARTIAL_SUCCESS"; readonly laborTicketId: string; readonly laborTicket?: MMReceiveLaborTicket; constructor( laborTicketId: string, message: string, options?: { laborTicket?: MMReceiveLaborTicket; cause?: unknown } ) { super( message, options?.cause !== undefined ? { cause: options.cause } : undefined ); this.name = "LaborTicketCreatePartialSuccessError"; this.laborTicketId = laborTicketId; this.laborTicket = options?.laborTicket; } } function readNonEmptyId(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; } /** Extracts an ERP laborTicketId from a partial-create failure. Internal helper. */ function extractPartialLaborTicketId(error: unknown): string | null { if (error instanceof LaborTicketCreatePartialSuccessError) { return readNonEmptyId(error.laborTicketId); } return null; } function coerceRecoverLaborTicketIdAction( action: RecoverLaborTicketIdAction | string ): RecoverLaborTicketIdAction { if (typeof action === "string") { switch (action.toUpperCase()) { case RecoverLaborTicketIdAction.RECOVER: return RecoverLaborTicketIdAction.RECOVER; case RecoverLaborTicketIdAction.DEFER: return RecoverLaborTicketIdAction.DEFER; default: return RecoverLaborTicketIdAction.CREATE; } } return action; } export function normalizeRecoverLaborTicketIdResult( result: RecoverLaborTicketIdReturn | undefined ): RecoverLaborTicketIdResult { if (result === null || result === undefined) { return { action: RecoverLaborTicketIdAction.CREATE }; } if (typeof result === "string") { const laborTicketId = result.trim(); return laborTicketId ? { action: RecoverLaborTicketIdAction.RECOVER, laborTicketId } : { action: RecoverLaborTicketIdAction.CREATE }; } const action = coerceRecoverLaborTicketIdAction(result.action); if (action === RecoverLaborTicketIdAction.RECOVER) { const laborTicketId = "laborTicketId" in result ? result.laborTicketId.trim() : ""; return laborTicketId ? { action: RecoverLaborTicketIdAction.RECOVER, laborTicketId } : { action: RecoverLaborTicketIdAction.CREATE }; } if (action === RecoverLaborTicketIdAction.DEFER) { return { action: RecoverLaborTicketIdAction.DEFER, reason: "reason" in result ? result.reason : undefined, }; } return { action: RecoverLaborTicketIdAction.CREATE }; } export async function callRecoverLaborTicketId( connector: IERPLaborTicketHandler, laborTicket: MMReceiveLaborTicket ): Promise { if (!connector.recoverLaborTicketId) { return { action: RecoverLaborTicketIdAction.CREATE }; } const raw = await connector.recoverLaborTicketId(laborTicket); return normalizeRecoverLaborTicketIdResult(raw); } /** * Shared pre-create recovery orchestration for polling and NATS labor ticket paths. */ export class LaborTicketRecovery { /** * Invokes `recoverLaborTicketId` when implemented, immediately before ERP create. * * @returns Recovered ERP `laborTicketId`, or `null` to proceed with create. * @throws {LaborTicketRecoveryDeferredError} When the connector returns `DEFER`. */ static async attemptBeforeCreate( connector: IERPLaborTicketHandler, laborTicket: MMReceiveLaborTicket ): Promise { if (!connector.recoverLaborTicketId) { return null; } const laborTicketRef = laborTicket.laborTicketRef; logger.info( "[LABOR_RECOVERY] Attempting to recover laborTicketId before ERP create", { laborTicketRef } ); try { const recovery = await callRecoverLaborTicketId(connector, laborTicket); if (recovery.action === RecoverLaborTicketIdAction.DEFER) { logger.warn( "[LABOR_RECOVERY] Deferred; skipping ERP create for this cycle", { laborTicketRef, reason: recovery.reason } ); throw new LaborTicketRecoveryDeferredError(recovery.reason); } if (recovery.action === RecoverLaborTicketIdAction.RECOVER) { return recovery.laborTicketId; } return null; } catch (error) { if (error instanceof LaborTicketRecoveryDeferredError) { throw error; } logger.warn( "[LABOR_RECOVERY] Recovery lookup failed; proceeding with ERP create", { laborTicketRef, error: summarizeError(error) } ); return null; } } /** * On create failure, patch MM when the connector threw LaborTicketCreatePartialSuccessError. * No-op for any other error type. Never throws; callers rethrow the original create error. */ static async patchMmOnCreateFailure( mmApiClient: MMApiClient, laborTicket: MMReceiveLaborTicket, error: unknown ): Promise { try { const laborTicketId = extractPartialLaborTicketId(error); const laborTicketRef = laborTicket.laborTicketRef; if (!laborTicketId || laborTicketRef == null || laborTicketRef === "") { return; } await mmApiClient.updateLaborTicketIdByRef( String(laborTicketRef), laborTicketId ); logger.warn( "[LABOR_PARTIAL_CREATE] Patched MM laborTicketId despite create failure", { laborTicketRef, laborTicketId } ); } catch (patchError) { logger.warn( "[LABOR_PARTIAL_CREATE] Salvage step failed; original create error still propagates", { error: summarizeError(patchError) } ); } } }