import { ERPObjType } from "../../types/erp-types.js"; import { IERPLaborTicketHandler, IERPTimesheetHandler, IERPWorkOrderReconciliationHandler } from "../../types/erp-connector.js"; import { type ReconcileDeletedWorkOrdersOptions, type ReconcileDeletedWorkOrdersResult } from "./work-order-reconciler.js"; import type { FlattenedWorkOrderRow } from "../../types/flattened-work-order.js"; import { BatchCacheManager } from "../../services/caching-service/batch-cache-manager.js"; import { IToRESTApiObject, MMSendWorkOrderBatch, MMSendWorkOrder, MMSendLaborTicket } from "../../services/mm-api-service/index.js"; import { type FilterLaborTicketsForMmImportOptions, type FilterLaborTicketsForMmImportResult, type SyncedWorkOrderOperationRow } from "../labor-import-from-erp.js"; import { LaborWorkOrderGapFillTracker, type LaborTicketWorkOrderOpRef } from "../labor-work-order-gap-fill.js"; /** * The result of writing entities to the MachineMetrics API on complete success. */ export interface WriteEntitiesToMMResult { message: string; upsertedEntities: number; localDedupeCount: number; apiDedupeCount: number; } /** * The result of {@link StandardProcessDrivers.retryRejectedIndividually}. */ export interface RetryRejectedIndividuallyResult { /** Entities that imported successfully when retried alone (collateral rejects of a bad sub-batch neighbor). */ succeeded: IToRESTApiObject[]; /** Residual failures: genuinely rejected entities (or entities with no matching original record) with their validation message. Must not be silently dropped. */ failed: Array<{ entity: IToRESTApiObject; message: string; }>; } export declare class MMBatchValidationError extends Error { readonly upsertedEntities: number; readonly localDedupeCount: number; readonly apiDedupeCount: number; readonly errorCount: number; readonly httpStatus: number; readonly batchErrors: Array<{ message: string; affectedEntities: IToRESTApiObject[]; }>; constructor(options: { message: string; upsertedEntities: number; localDedupeCount: number; apiDedupeCount: number; errorCount: number; httpStatus: number; batchErrors: Array<{ message: string; affectedEntities: IToRESTApiObject[]; }>; }); } /** * A collection of standardized process drivers that orchestrate various synchronization * and integration processes between systems. Use of these drivers is optional, * and useful as a reference implementation for how to implement the various processes. */ export interface FillWorkOrderGapsFromLaborTicketsOptions { /** Namespaces the persisted watch list (one per connector type). */ connectorType: string; /** This cycle's from-ERP labor tickets (any state), before the labor filter. */ laborTickets: ReadonlyArray; /** Work order operation rows the bulk work-order sync wrote this cycle. */ bulkSyncedWorkOrderOperations: ReadonlyArray; /** * Connector-owned: fetch the given work orders from the ERP and map them to a full * {@link MMSendWorkOrderBatch} (every operation of each work order, plus its part, part * operations and work order rows). Ids the ERP does not know are simply absent from the * result (reported as `unresolvedWorkOrderIds`; they stay on the watch list until the * TTL). Must throw on an ERP failure rather than return a partial batch. */ fetchWorkOrders: (workOrderIds: string[]) => Promise; /** * Let CLOSED tickets drive a fetch too (default false: OPEN tickets only, which bounds * the fetch by concurrent clock-ins). Off, a work order observed only through closed * tickets is never fetched and those tickets stay rejected; the driver reports such ids * as `closedOnlyLaborWorkOrderIds` and warns. On, the bound becomes the closed tickets * in the connector's labor window, so enable it only when that window is narrow. */ includeClosedTickets?: boolean; /** Default {@link isTerminalWorkOrderForGapFill} (terminal status or a closed date). */ isTerminalWorkOrder?: (workOrder: MMSendWorkOrder) => boolean; /** * Replaces {@link StandardProcessDrivers.writeWorkOrderBatchToMM} as the writer, for * connectors whose bulk writer does something the SDK writer does not yet (e.g. routing * RELEASED rows through `?clearColumns=closedDate`). The batch is already FK-complete * and in referential order. * * Must throw unless every entity in the batch was written, as * {@link StandardProcessDrivers.writeWorkOrderBatchToMM} does on partial success. A * resolved promise is taken as "the close-out landed" and removes the terminal work * orders from the watch list; they sit outside the bulk window, so one removed after a * write that did not land is never fetched again and keeps a stale status in MM. */ writeBatch?: (batch: MMSendWorkOrderBatch) => Promise; /** Forwarded to the default writer; ignored when `writeBatch` is given. */ batchCacheManager?: BatchCacheManager | null; /** Default: a {@link LaborWorkOrderGapFillTracker} for `connectorType`. */ tracker?: LaborWorkOrderGapFillTracker; /** Clock override (tests). */ now?: Date; /** Default {@code DEFAULT_TRACKED_WORK_ORDER_TTL_DAYS}. */ trackedTtlDays?: number; /** * Default {@code DEFAULT_MAX_TRACKED_WORK_ORDERS}. Caps the open entries; pending * close-outs are never evicted to honor it. */ maxTrackedWorkOrders?: number; /** Default {@link DEFAULT_GAP_FILL_FETCH_WARN_THRESHOLD}. */ fetchWarnThreshold?: number; } export interface FillWorkOrderGapsFromLaborTicketsResult { /** * Every work order operation of the fetched work orders, in the shape of * `FilterLaborTicketsForMmImportOptions.syncedWorkOrderOperations`. Concatenate with the * bulk rows before the labor write. Empty when nothing was fetched. */ keySetWorkOrderOps: SyncedWorkOrderOperationRow[]; /** Work order ids referenced by OPEN tickets with no bulk-synced operation. */ openLaborWorkOrderIds: string[]; /** * Work order ids referenced only by non-OPEN tickets with no bulk-synced operation. * Fetched only when `includeClosedTickets` is set; otherwise their tickets will be * rejected by the labor filter this cycle. */ closedOnlyLaborWorkOrderIds: string[]; /** Work order ids handed to `fetchWorkOrders` (open labor ∪ watch list). */ fetchedWorkOrderIds: string[]; /** * Requested ids for which `fetchWorkOrders` returned no work order row (deleted in the * ERP, or outside the connector's query scope). They stay on the watch list until the TTL. */ unresolvedWorkOrderIds: string[]; /** The batch written to MM, or null when nothing needed writing. */ writtenBatch: MMSendWorkOrderBatch | null; /** The writer's return value (the SDK writer's per-entity results by default), or null. */ writeResult: unknown; /** * Terminal work orders with rows outside the bulk window; those rows and their full * operation sets were written this cycle. */ closingWorkOrderIds: string[]; /** * The watch list as persisted when this call returned: work orders to re-fetch until * their terminal state is imported. Work orders fetched terminal this cycle are still * listed when their close-out write did not happen (nothing to write yet), and gone once * it has; they are counted in `dropped.terminal` either way. */ trackedWorkOrderIds: string[]; dropped: { expired: number; /** Tracked work orders whose every fetched row the bulk sync covered this cycle. */ coveredByBulkSync: number; /** Fetched terminal this cycle (leave the list once their close-out write lands). */ terminal: number; evicted: number; }; } export declare class StandardProcessDrivers { /** * Synchronizes updated labor tickets from MachineMetrics to an ERP system: * * Initializes a checkpoint that tracks the last time labor tickets were synced, * fetches labor tickets from MM and converts them to the company timezone, * creates or updates the labor ticket in the ERP via connector-specific implementations, * updates the labor ticket id in MM on newly created labor tickets, * and saves the checkpoint */ static syncLaborTicketsToERP(connectorType: string, connector: IERPLaborTicketHandler, options?: { onlyClosed?: boolean; }): Promise; /** * Synchronizes timesheet rows (employee clock in/out) from MachineMetrics to an ERP system. * Run after {@link syncLaborTicketsToERP} in the same job so labor is written/closed before clock-out. */ static syncTimesheetsToERP(connectorType: string, handler: IERPTimesheetHandler): Promise; /** * Marks MM work orders — and their operations — as DELETED when they no longer exist / * are active in the ERP. Intended to run rarely: the `reconcile-work-orders` job fires * 1 hour after process start, then every `RECONCILE_WORK_ORDERS_INTERVAL` (default * 24 hours). Runs are idempotent, so the occasional extra post-restart run is harmless. * * Division of labor: the SDK fetches all work orders from MM and restricts candidates * to status RELEASED or ACTIVE (case-insensitive); the connector's * {@link IERPWorkOrderReconciliationHandler} selects which candidates to delete * (commonly by delegating to {@code selectWorkOrdersMissingFromErp()}, plus any * ERP/site-specific rules); the SDK then validates the selection and writes a * status-only patch (identity keys + status DELETED) for each — the MM import derives * its upsert columns from the fields present, so no other column is touched. * * The operation phase follows: open operations of DELETED work orders are cascaded to * DELETED (MM has no server-side cascade), and — when the handler implements * `selectWorkOrderOperationsToDelete()` — open operations of surviving jobs are offered * to it so an operation deleted from a surviving job is caught too. Same validation * and guard as work orders. * * Safety behavior: * - The handler MUST throw on ERP fetch failure — never return a guessed selection. * - The selection is validated as a subset of the candidates; foreign rows reject the * whole run. * - A blast-radius guard throws {@link ReconcileDeletedWorkOrdersAbortedError} (nothing * written) when the deletion set exceeds `maxDeleteFraction` of candidates * (default 20%, only enforced above `deleteGuardMinCount` deletions). * - `dryRun` logs the would-be deletions without writing to MM, including whether a * real run would have tripped the guard. * * @throws ReconcileDeletedWorkOrdersAbortedError when the blast-radius guard trips * @throws Error on handler contract violations or underlying MM API failures */ static reconcileDeletedWorkOrders(connectorType: string, handler: IERPWorkOrderReconciliationHandler, options?: ReconcileDeletedWorkOrdersOptions): Promise; /** * Retries labor tickets that have failed to be created or updated in the ERP during the sync: * * Fetches failed labor tickets from MM, processes them, and updates the labor ticket id in MM. * If the labor ticket is successfully created or updated, it is added to the list of successful labor ticket ids, * which are then deleted from the list of failed labor ticket ids. */ static retryFailedLaborTickets(connectorType: string, connector: IERPLaborTicketHandler): Promise; /** * Filters ERP-mapped labor tickets for MM FK/import rules, then writes them via {@link writeEntitiesToMM}. */ static writeLaborTicketsToMmFromErpImport(tickets: MMSendLaborTicket[], filterOptions: FilterLaborTicketsForMmImportOptions, batchCacheManager?: BatchCacheManager | null, writeOptions?: { connectorType?: string; chunkSize?: number; }): Promise; /** * Best-effort advance of the {@code labor_tickets / export} checkpoint past the just-finished * import window. Failures here are logged and swallowed — the to-erp ledger/signature dedupe * is the real correctness guarantee; this is a belt-and-suspenders skip-window optimization. */ static advanceLaborTicketsExportCheckpoint(connectorType: string, timestampIso: string): Promise; /** * Writes a batch of records to MM for a specific entity type by: * 1. Deduplicating against cached records (localDedupeCount) * 2. Sending non-duplicate records to the MM API (apiDedupeCount) * 3. Storing successful records in cache * * An example usage pattern including comprehensive error handling: * ``` * try { * const result = await StandardProcessDrivers.writeEntitiesToMM( * ERPObjType.WORK_ORDERS, * workOrderRecords, * batchCacheManager * ); * * // HTTP 200 - Complete success * console.log(`✅ Success: ${result.message}`); * console.log(`📊 Metrics: ${result.upsertedEntities} upserted, ${result.localDedupeCount} locally deduplicated, ${result.apiDedupeCount} API deduplicated`); * * } catch (error) { * if (error instanceof MMBatchValidationError) { * // HTTP 207 - Partial success with some batches failing due to validation errors * // HTTP 500 - A specific type representing complete failure due to validation issues; * // other 500 types represent failure due to other issues and are not converted to MMBatchValidationError * // * // MM imports in ATOMIC SUB-BATCHES: each batchErrors entry contains ALL entities of its * // failing sub-batch — valid records rejected as collateral of a bad neighbor, not just the * // offending record. * // * // REQUIRED: your connector must recover the entities in batchErrors[].affectedEntities — * // never advance a record-tracking cursor/checkpoint past them, or they freeze in MM with * // stale values until their ERP rows happen to change again. Pick one of: * // 1. retry individually: StandardProcessDrivers.retryRejectedIndividually(entityType, error, mmRecords) * // (collateral records succeed; log the residual failures at ERROR level) * // 2. persist the matching ORIGINAL records and re-queue them on a later sync cycle * // (never re-send affectedEntities themselves — they are lossy reconstructions) * // 3. hold your cursor/checkpoint at/before the first rejected entity so they are re-fetched * // See CONNECTOR_SDK_UTILITIES.md → "Partial-failure handling for from-ERP writes". * * console.error(`Batch processing completed with errors (HTTP ${error.httpStatus}): ${error.errorCount} rejected`); * * if (error.httpStatus === 207) { * // Partial success — recover the rejected sub-batches (option 1 shown): * const { failed } = await StandardProcessDrivers.retryRejectedIndividually( * ERPObjType.WORK_ORDERS, * error, * workOrderRecords, * batchCacheManager * ); * for (const residual of failed) { * // Genuinely invalid records: log loudly so the rejection is visible in production. * console.error(`Work order permanently rejected: ${residual.message}`, residual.entity); * } * } else { * // HTTP 500 — complete failure, nothing was written; safe to rethrow and retry the * // whole batch next cycle (do not advance tracking state). * throw error; * } * * } else { * // Other underlying errors (network issues, authentication, etc.) — nothing is known about * // individual entities; rethrow without advancing tracking state. * console.error('🚨 Unexpected error during MM API call:', error); * throw error; * } * } * ``` * * @param entityType The type of entity being processed * @param mmRecords The records to process * @param batchCacheManager The batch cache manager instance. * - Omit / pass `undefined` to use a default cache manager (recommended). * - Pass `null` to explicitly disable caching + local deduplication (generally not recommended; primarily useful for debugging/emergency bypass). * @param options Optional settings: * - `connectorType`: marks written labor tickets in the local sync ledger (labor tickets only). * - `chunkSize`: max records per MM API request. When set (> 0), large record sets are written * in sequential chunks so each request stays within the API timeout. Chunking is OFF by * default — connectors that chunk at their own layer are unaffected unless they opt in here * or via env `MM_API_WRITE_CHUNK_SIZE`. On chunked writes, structured per-chunk failures do * not stop the remaining chunks — one aggregated MMBatchValidationError is thrown at the end, * and a structured 500 on one chunk surfaces as 207 when any other chunk succeeded. * - `queryParams`: extra query params forwarded to the MM API write request (work orders and * work order operations today), e.g. `{ clearColumns: 'closedDate' }` to opt specific columns * into null-clearing. * * @returns WriteEntitiesToMMResult on complete success (HTTP 200) * @throws MMBatchValidationError on partial success (HTTP 207) or complete failure (HTTP 500) with structured error details * @throws Error on other underlying issues (network, authentication, etc.) */ static writeEntitiesToMM(entityType: ERPObjType, mmRecords: IToRESTApiObject[], batchCacheManager?: BatchCacheManager | null, options?: { connectorType?: string; chunkSize?: number; queryParams?: Record; }): Promise; /** * Re-imports the entities rejected by a {@link writeEntitiesToMM} call one record at a time. * * MM imports in atomic sub-batches, so `error.batchErrors[].affectedEntities` contains valid * records rejected as collateral of a bad neighbor. The same entity may appear under several * `batchErrors` entries (MM reports one entry per validation message, each echoing the whole * sub-batch); each entity is retried only once. Retried individually the valid records succeed; the * returned `failed` list is only the genuinely-invalid residue, each paired with its validation * message. Callers must not silently drop the residue: log it at ERROR level, and never advance * a record-tracking cursor/checkpoint past a rejected entity that has not been recovered — see * CONNECTOR_SDK_UTILITIES.md → "Partial-failure handling for from-ERP writes". * * The entities inside `batchErrors` are reconstructions of the API's echoed data and default any * field absent from the echo, so they are never re-sent as-is. Instead each rejected entity is * matched back to `originalRecords` by primary key and the caller's ORIGINAL object is retried. * An entity with no matching original lands in `failed` unretried — re-sending its reconstruction * could overwrite real MM values (e.g. a work-order-operation labor-ticket patch would round-trip * into a full row with zeroed quantities). * * Individual retries bypass the local dedupe read entirely — a stale cache entry must never * turn a rejected record into a silent no-op "success" — and records that succeed are stored * in the cache exactly as a normal write would store them. * * Non-validation errors (network, auth, timeout) abort the retry loop and are rethrown as-is — * they say nothing about the entities themselves, so treating them as residual validation * failures would misclassify transient outages as permanently-bad records. * * @param entityType The entity type that was being written when the error was thrown * @param error The MMBatchValidationError thrown by {@link writeEntitiesToMM} * @param originalRecords The records passed to the failing {@link writeEntitiesToMM} call (a superset is fine); rejected entities are matched back to these by primary key and the originals are re-sent. * Composite drivers that build their MM records internally (e.g. {@link syncWorkOrderBatchFromFlattened}) never expose them, so this helper only fits call sites that hold the records array they wrote. * @param batchCacheManager Same semantics as {@link writeEntitiesToMM} (omit for a default manager, `null` to disable caching) * @param options Same semantics as {@link writeEntitiesToMM}: `connectorType` (labor-ledger marking) * and `queryParams` (forwarded to each individual retry write, so opt-in behaviors like * `clearColumns` survive the recovery path). `chunkSize` has no effect — retries are always * single-record. * * @returns `{ succeeded, failed }` — see {@link RetryRejectedIndividuallyResult} * @throws Error on non-validation issues (network, authentication, etc.); entities not yet retried remain unrecovered */ static retryRejectedIndividually(entityType: ERPObjType, error: MMBatchValidationError, originalRecords: IToRESTApiObject[], batchCacheManager?: BatchCacheManager | null, options?: { connectorType?: string; queryParams?: Record; }): Promise; /** * Writes a batch of Work Order related entities to MM maintaining referential integrity by: * 1. Processing parts first (base entities) * 2. Processing part operations (depends on parts) * 3. Processing work orders (depends on parts) * 4. Processing work order operations (depends on work orders and part operations) * * This ensures all foreign key constraints are satisfied and prevents referential integrity errors. * * @param workOrderBatch The batch containing all related entities * @param batchCacheManager The batch cache manager instance. * - Omit / pass `undefined` to use a default cache manager (recommended). * - Pass `null` to explicitly disable caching + local deduplication (generally not recommended; primarily useful for debugging/emergency bypass). * * @returns Combined results from all entity processing * @throws MMBatchValidationError on partial success or complete failure with structured error details * @throws Error on other underlying issues (network, authentication, etc.) */ static writeWorkOrderBatchToMM(workOrderBatch: MMSendWorkOrderBatch, batchCacheManager?: BatchCacheManager | null): Promise<{ parts: WriteEntitiesToMMResult; partOperations: WriteEntitiesToMMResult; workOrders: WriteEntitiesToMMResult; workOrderOperations: WriteEntitiesToMMResult; }>; /** * Groups flattened work-order rows into the four MM entity sets and constructs the MM * send objects, without writing anything. This is the mapping half of * {@link syncWorkOrderBatchFromFlattened}; connectors call it directly when they need the * batch for something other than the regular sync, e.g. as the return value of the * `fetchWorkOrders` callback of {@link fillWorkOrderGapsFromLaborTickets}. * * Throws when any work order or operation lacks a non-blank string status (a connector * mapping bug), before anything is constructed. */ static buildWorkOrderBatchFromFlattened(flattenedData: ReadonlyArray): MMSendWorkOrderBatch; /** * Processes flattened work order data and syncs it to MM maintaining referential integrity. * * This method expects flattened data where each row contains both work order and operation information * with camelCase field names matching the GraphQL schema. It will automatically extract and deduplicate * parts, part operations, work orders, and work order operations, then process them in the correct order * to maintain referential integrity. * * Timezone expectation: all datetime fields provided in `flattenedData` MUST already be expressed * as ISO-8601 UTC strings (trailing `Z` or explicit offset). The SDK does not attempt to infer or * convert local ERP timestamps on this path; it simply validates and forwards the values to MM. * Connector implementations should convert ERP-local times to UTC before invoking this method. * * Custom fields: per-entity `*CustomFields` / `*CustomFieldsSchemaRef` row properties are * forwarded to the matching MM entity (see `FlattenedWorkOrderRow`). For deduplicated entities * (parts, part operations, work orders) a non-null value on any of the entity's rows beats * `null`, which beats `undefined`; between differing non-null values the first row wins, and * the schema ref is taken from the row that supplied the values. * * @param flattenedData Array of flattened rows containing both work order and operation data (see `FlattenedWorkOrderRow`) * @param batchCacheManager The batch cache manager instance. * - Omit / pass `undefined` to use a default cache manager (recommended). * - Pass `null` to explicitly disable caching + local deduplication (generally not recommended; primarily useful for debugging/emergency bypass). * * @returns Combined results from all entity processing with detailed logging information * @throws MMBatchValidationError on partial success or complete failure with structured error details * @throws Error on other underlying issues (network, authentication, etc.) */ static syncWorkOrderBatchFromFlattened(flattenedData: ReadonlyArray, batchCacheManager?: BatchCacheManager | null): Promise<{ parts: WriteEntitiesToMMResult; partOperations: WriteEntitiesToMMResult; workOrders: WriteEntitiesToMMResult; workOrderOperations: WriteEntitiesToMMResult; }>; /** * Supplementary work-order import driven by labor tickets: fills the operations that the * cycle's labor tickets reference but the windowed bulk work-order sync did not carry, so * {@link writeLaborTicketsToMmFromErpImport} accepts those tickets instead of rejecting * them as `notInSyncedWorkOrderOperation` (or MM rejecting them on * `work_order_operation_fk`). See `labor-work-order-gap-fill.ts` for the policy. * * Per cycle, before the labor write: * 1. plan — work order ids referenced by OPEN tickets with no bulk-synced operation * (every state with `includeClosedTickets`), plus the persisted watch list of * previously filled work orders (re-fetched until their terminal state is imported); * 2. fetch — `options.fetchWorkOrders(ids)` (connector: ERP query + mapping to an * {@link MMSendWorkOrderBatch}; `buildWorkOrderBatchFromFlattened` helps connectors * with flattened rows); * 3. write — the FK-complete subset that has no competing writer this cycle * ({@link computeWorkOrderGapFillWriteScope}), through `options.writeBatch` when given * and {@link writeWorkOrderBatchToMM} otherwise; * 4. return every operation of the fetched work orders as `keySetWorkOrderOps`, for the * connector to concatenate with the bulk rows in * `FilterLaborTicketsForMmImportOptions.syncedWorkOrderOperations`. * * Throws on a fetch or write failure, or when the watch list cannot be read (before * fetching anything, so the persisted list is never overwritten from an empty one), like * every SDK driver. A connector that prefers the * labor write to proceed without the widened key set wraps the call and treats the * failure as best-effort; the watch list is persisted before the write, so a terminal * work order whose close-out write failed is fetched again next cycle either way. * * This path is additive to the connector's regular work-order sync: labor tickets do not * describe the shop schedule. Work orders it writes sit outside the bulk window by * definition, so a `IERPWorkOrderReconciliationHandler` must judge deletion by ERP * state, not by bulk-window membership (see the reconciliation section of * `CONNECTOR_SDK_UTILITIES.md`); the returned `trackedWorkOrderIds` are the ids to * re-check explicitly. */ static fillWorkOrderGapsFromLaborTickets(options: FillWorkOrderGapsFromLaborTicketsOptions): Promise; } //# sourceMappingURL=standard-process-drivers.d.ts.map