## Connector SDK utilities

This document is a **connector-programmer-oriented** index of the SDK's classes/services/utilities for building ERP connectors, starting with the standard process drivers and continuing with the surrounding services and helpers.

Unless noted otherwise, these are part of the **public SDK API** (exported from `src/index.ts`) and are intended for connector code to import from the SDK package.

This document describes **what the SDK exposes**. The connector implementation workflow (how a generated project wires these together) lives in the template `CONNECTOR-GUIDE.md`. Method-level detail (parameters, error semantics, examples) lives in the JSDoc on each method — the package ships `src/`, so it is available in-editor.

---

## Standard process drivers (`StandardProcessDrivers`)

Reference implementations of the common sync processes. Use is optional but recommended — they encode the checkpointing, dedupe, retry, and safety behavior connectors would otherwise re-implement.

### ERP → MM (import)

- **`writeEntitiesToMM(entityType, records, cache?, options?)`** — the core batch write. Dedupes locally against the hashed cache, posts the remainder to the MM API, and caches successes. Returns `WriteEntitiesToMMResult` (`upsertedEntities`, `localDedupeCount`, `apiDedupeCount`). Throws `MMBatchValidationError` on partial success (HTTP 207) or validation-caused complete failure (HTTP 500) — the error carries per-batch details and counts; other failures (network, auth) throw as-is. Cache parameter: omit for a default cache manager (recommended); pass `null` to disable caching/dedupe.
- **`writeWorkOrderBatchToMM(workOrderBatch, cache?)`** — writes an `MMSendWorkOrderBatch` in referential-integrity order (parts → part operations → work orders → work order operations). Returns the four `WriteEntitiesToMMResult`s.
- **`syncWorkOrderBatchFromFlattened(flattenedData, cache?)`** — accepts flattened rows (`FlattenedWorkOrderRow`, one row per work order operation), extracts and dedupes the four entity types, and writes them in referential-integrity order. All datetimes MUST already be ISO-8601 UTC. Every row must carry a `status` (work order) and `opStatus` or `status` (operation) — see `MMWorkOrderStatus` / `MMWorkOrderOperationStatus`; a row without one fails the whole batch before any write (no invented default). Per-entity `*CustomFields` / `*CustomFieldsSchemaRef` row properties are forwarded to the matching entity — see **Flattened work order types**.
- **`writeLaborTicketsToMmFromErpImport(tickets, filterOptions, cache?, writeOptions?)`** — labor-ticket import with circular-write protection: applies MES/operator allowlists and FK rules, pre-marks accepted tickets in the sync ledger, writes them, and advances the `labor_tickets` export checkpoint. See **Labor import from ERP utilities**.
- **`buildWorkOrderBatchFromFlattened(flattenedData)`** — the mapping half of `syncWorkOrderBatchFromFlattened`: groups flattened rows into an `MMSendWorkOrderBatch` without writing. Same status validation. Use it wherever a batch is needed for something other than the regular sync, e.g. the `fetchWorkOrders` callback below.
- **`fillWorkOrderGapsFromLaborTickets(options)`** — supplementary work-order import driven by labor tickets: fetches (via a connector callback) and writes the work orders that this cycle's labor tickets reference but the windowed bulk work-order sync did not carry, and returns the operation rows that widen the same cycle's labor filter. See **Work-order gap filling from labor tickets**.

### MM → ERP (export)

- **`syncLaborTicketsToERP(connectorType, handler, { onlyClosed? })`** — checkpoint-driven export of updated MM labor tickets through an `IERPLaborTicketHandler` (recover/create/update; see that section). Timestamps are converted to the ERP-local timezone before handlers run. Failures land in the retry queue.
- **`retryFailedLaborTickets(connectorType, handler)`** — re-processes the failed labor ticket queue; successes are removed from it.
- **`syncTimesheetsToERP(connectorType, handler)`** — employee clock in/out export through an `IERPTimesheetHandler`, processed in `timesheetRef` order. Failed rows are stored with the checkpoint and retried next cycle without re-sending rows that succeeded; a person's later rows wait behind their failed one. A row that has been failing for 24h, while others succeed, is given up on. Run after `syncLaborTicketsToERP` in the same cycle so labor is written/closed before clock-out.

### Reconciliation

- **`reconcileDeletedWorkOrders(connectorType, handler, options?)`** — marks MM work orders (and their operations) DELETED when the connector no longer selects them as active in the ERP. Returns `ReconcileDeletedWorkOrdersResult`; throws `ReconcileDeletedWorkOrdersAbortedError` when the blast-radius guard trips. See **Work order deletion reconciliation** below for the handler contract, helpers, guards, and env vars.

### Associated exports

- `WriteEntitiesToMMResult`, `MMBatchValidationError` — write results / typed batch failure.
- `ReconcileDeletedWorkOrdersOptions` / `ReconcileDeletedWorkOrdersResult` / `ReconcileDeletedWorkOrdersStatus`, `ReconcileDeletedWorkOrdersAbortedError` — reconciliation surface.
- `selectWorkOrdersMissingFromErp`, `selectWorkOrderOperationsMissingFromErp`, `normalizeWorkOrderKey`, `normalizeWorkOrderOperationKey`, `isReconcileCandidateStatus`, `isReconcileCandidateWorkOrderOperation`, `RECONCILE_CANDIDATE_STATUSES` — reconciliation matching helpers.
- `MMWorkOrderStatus`, `MMWorkOrderOperationStatus`, `MMWorkOrderStatusValue`, `MMWorkOrderOperationStatusValue`, `normalizeMMStatus` — status vocabulary (documentary; the MM API is free-form) and the canonicalizer every SDK status comparison uses.
- `LaborTicketRecoveryDeferredError`, `LaborTicketCreatePartialSuccessError`, `RecoverLaborTicketIdAction` and related types — labor export handler surface (see the `IERPLaborTicketHandler` section).

---

## Initialization and runtime orchestration

- **`ApplicationInitializer.initialize()`**
  - Runs SDK startup tasks: loads `CoreConfiguration`, runs database migrations, performs SQLite startup checks, and fetches company info from MM (including IANA timezone), persisting it for cross-process reads.
  - Recommended to call once at process startup before jobs run.
  - After this succeeds, timezone conversion helpers use the cached company zone automatically — see **Timezone and datetime utilities**.

- **`runDataSyncService(connectorPath)`**
  - Starts the SDK’s scheduled jobs (from-ERP, to-ERP, retry labor tickets, cache cleanup) using the configured intervals.
  - **`connectorPath` is required** and must resolve to the connector’s **built JavaScript** module on disk (e.g. `dist/connector/connector.js`). The SDK passes it to workers via `CONNECTOR_PATH`; a path to a `.ts` source or a missing file fails at runtime.
  - **Execution model**: jobs are scheduled with **Bree**. Each job cycle runs as an **independent worker process** that re-imports the connector module from disk (via `CONNECTOR_PATH`) and constructs a **fresh connector instance**. Consequences:
    - In-memory state does **not** persist across cycles — a value held in a field or module variable on one cycle is gone on the next. Durable state belongs in the SQLite stores or MM checkpoints.
    - Cycles are not guaranteed to be serial across jobs: different jobs run in separate processes, and within the to-ERP labor cycle tickets may be processed concurrently (`Promise.all`). Add your own throttling/serialization inside handlers if your ERP requires it.
    - Some jobs run under an SDK-managed SQLite lock to avoid concurrent local-DB writes across workers.

- **`CoreConfiguration.inst()`**
  - Central singleton for SDK configuration (MM API base URLs/tokens, job intervals, ERP API timeouts, cache TTL, etc.).
  - Also configures the SDK logger during construction.

---

## ERP API client utilities (REST + GraphQL)

- **`RestAPIService`**
  - Thin REST client wrapper for ERP APIs with pagination helpers.
  - Common methods: `get()`, `post()`, `patch()`, `destroy()`.
  - Timeout behavior:
    - Defaults to `ERP_API_TIMEOUT_SEC` (via `CoreConfiguration.erpApiTimeoutSeconds`)
    - Override per instance via the constructor `timeoutSeconds` argument, or via `ERPApiConfig.timeoutSeconds`
  - Retry behavior:
    - Defaults to `ERP_API_RETRY_ATTEMPTS` (via `CoreConfiguration.erpApiRetryAttempts`)
    - Override per instance via the constructor `retryAttempts` argument, or via `ERPApiConfig.retryAttempts`

- **`GraphQLService`**
  - GraphQL client wrapper that returns `APIResponse<T>` and preserves GraphQL-layer errors in response metadata.
  - Common methods: `query()`, `mutate()`, `destroy()`.
  - Timeout behavior:
    - Defaults to `ERP_API_TIMEOUT_SEC` (via `CoreConfiguration.erpApiTimeoutSeconds`)
    - Override per instance via the constructor `timeoutSeconds` argument, or via `ERPApiConfig.timeoutSeconds`
  - Retry behavior:
    - Defaults to `ERP_API_RETRY_ATTEMPTS` (via `CoreConfiguration.erpApiRetryAttempts`)
    - Override per instance via the constructor `retryAttempts` argument, or via `ERPApiConfig.retryAttempts`

- **`OAuthClient`**
  - Helper for OAuth flows used by some ERP APIs (client-credential style patterns).

- **`buildLogicalCondition()`**
  - Helper for constructing logical filter expressions used by the SDK’s REST query param format.

- **`ErrorHandler` / `GraphQLError` / `HTTPError`**
  - SDK-standard error normalization for REST/GraphQL calls.
  - `ErrorHandler.handle(error)` throws a standardized `HTTPError` or `GraphQLError`.

---

## MachineMetrics API integration (low-level client + types)

- **`MMApiClient`**
  - Low-level client for MM “mapping service” endpoints and related MM APIs.
  - Useful when a connector needs to call MM endpoints directly (outside the standard drivers).
  - Note: this client manages auth token behavior internally.
  - Timeout behavior:
    - Defaults to `MM_API_TIMEOUT_SEC` (via `CoreConfiguration.mmApiTimeoutSeconds`)
  - Retry behavior:
    - Defaults to `MM_API_RETRY_ATTEMPTS` (via `CoreConfiguration.mmApiRetryAttempts`)

- **Send/receive entity types**
  - These types encapsulate the expected payload shape and serialization behavior for MM endpoints.
  - Send (ERP → MM, one class per writable `ERPObjType`; see the table below): `MMSendPerson`, `MMSendResource`, `MMSendPart`, `MMSendPartOperation`, `MMSendWorkOrder`, `MMSendWorkOrderOperation` (plus `MMSendWorkOrderOperationLaborTicketSync`; both accepted as `MMSendWorkOrderOperationPayload`), `MMSendReason`, `MMSendLaborTicket`.
  - Receive (MM → ERP): `MMReceiveLaborTicket`, `MMReceiveTimesheet`, `MMReceiveWorkOrder`, etc.
  - `MMSendLaborTicket.machineRef` / `MMReceiveLaborTicket.machineRef` are optional — present on real-time/NATS payloads, typically absent on ERP-import paths.
  - **Timesheets are receive-only**: there is no `TIMESHEETS` member of `ERPObjType` and no `MMSendTimesheet` — timesheets flow MM → ERP (`fetchTimesheetUpdates` / `syncTimesheetsToERP`) and cannot be written to MM via `writeEntitiesToMM`.
  - **Dispatch priorities are not an `ERPObjType` either**: `MMSendDispatchPriority` / `MMReceiveDispatchPriority` are written and read through the MM GraphQL proxy with scope-level replace semantics — see **Dispatch priorities** below. `writeEntitiesToMM` does not accept them.

- **Entities delivered to MM** (`writeEntitiesToMM` / `MMApiClient.send*ToMM`)

  | `ERPObjType` | Send type | MM endpoint |
  |--------------|-----------|-------------|
  | `PERSONS` | `MMSendPerson` | `/erp/v1/persons` |
  | `RESOURCES` | `MMSendResource` | `/erp/v1/resources` |
  | `PARTS` | `MMSendPart` | `/erp/v1/parts` |
  | `PART_OPERATION` | `MMSendPartOperation` | `/erp/v1/part-operations` |
  | `WORK_ORDERS` | `MMSendWorkOrder` | `/erp/v1/production-orders` |
  | `WORK_ORDER_OPERATIONS` | `MMSendWorkOrderOperationPayload` | `/erp/v1/production-order-operations` |
  | `REASONS` | `MMSendReason` | `/erp/v1/reasons` |
  | `LABOR_TICKETS` | `MMSendLaborTicket` | `/erp/v1/labor-tickets` |

  See `MM_ENTITY_KEYS_REFERENCE.md` for each entity's primary key and referential-integrity ordering.

  These endpoints import in atomic sub-batches and can partially fail (HTTP 207), which the SDK raises as `MMBatchValidationError` — connectors must not record sync progress past the rejected entities. See **Partial-failure handling for from-ERP writes**.

- **ERP resource mapping (`MMApiClient`)**
  - `fetchMappedErpResourcesForCompany(companyId)` returns `MMMappedErpResourceRow[]` for machines that have both a finite `machineRef` and a non-empty `laborResourceId`.
    - **Source**: `GET /mes/settings/machine-settings` (MES).
    - `companyId` is required (trimmed, non-empty) for API compatibility but is **not** sent as a query parameter; company scope comes from the bearer token.
    - Maps MES `laborResourceId` → `resourceId` on each row.
  - `fetchMesSchedulableErpResourceIds(companyId)` returns deduped ERP resource ids across **all** MES machine rows: each row’s `laborResourceId` plus every id in `scheduledAs`. No `laborTicketEnabled` filter.

- **Timesheet export (`MMApiClient`)**
  - `fetchTimesheetUpdates({ system, checkpointType })` returns pending timesheet (employee clock in/out) rows from `/erp/v1/timesheets/updates`.

- **Work order fetch (`MMApiClient`)**
  - `fetchWorkOrderFromGraphQL(workOrderId)` returns a single work order row via the MM GraphQL proxy.
  - `fetchAllErpWorkOrdersFromGraphQL({ pageSize? })` returns **all** company work orders as `MMReceiveWorkOrder[]` via paged GraphQL queries (default page size 500). Any page failure throws — callers never receive a silently truncated result. Used by work order deletion reconciliation.
  - `fetchAllErpWorkOrderOperationsFromGraphQL({ pageSize? })` — same contract for work order operations, returned as `MMReceiveWorkOrderOperation[]` (field names match the REST import payload one-to-one; `sequenceNumber` rendered as a string; nullable numeric columns kept as `number | null` — `null` means the ERP never provided the value, distinct from a real 0).
  - `fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL(workOrderIds, { chunkSize?, pageSize? })` — the **filtered** counterpart: operations of the given work orders only (`workOrderId _in`), same row mapping and same throw-on-malformed-page contract. Ids are trimmed, blanks dropped, duplicates removed (exact text match, like MM); the list is sent in chunks (default 200 ids) and each chunk paged (default 500 rows). An empty list returns `[]` without a request. For callers that need MM's operation identities (`lot`/`split`/`sub`, `resourceId`, `status`) for a bounded set of jobs on tenants where fetching every operation is not workable.

- **Types**
  - `MMMappedErpResourceRow` — `{ machineRef, resourceId, isResourceGroup, machineGroupId, name, type }`.
  - `MesMachineSettingRow` — raw MES `machine-settings` row shape.

---

## Dispatch priorities (`MMApiClient`)

MM's `erpWorkOrderOperationDispatchPriorities` table holds an **ordering authority's** (a scheduler's) ranked list of work order operations per **scope** — an ERP resource id for a machine or a machine group. The App orders that scope's queue by `priority` where rows exist and falls back to its own ordering where they don't.

**Semantics that differ from every other entity — read before writing to this table:**

- **Scope-level replace, never per-row upsert.** A list is valid only as a whole: it is complete and consistent for its scope. Ingest deletes every row of the scope and inserts the new list in **one transaction**. A per-row upsert would leave rows behind for operations that left the list (completed, re-planned elsewhere, de-released) and the App would keep showing them.
- **Written through the MM GraphQL proxy, not erp-api.** erp-api has no endpoint for the table; a Hasura mutation document runs delete + insert atomically (verified: a uniqueness violation in the insert rolls back the delete too). This is the only SDK write path that does not go through `/erp/v1/...` — intentional, do not "fix" it to match the others.
- **The local dedupe cache is not involved.** The write never enters `_sendToAPI`, so an unchanged scope is re-asserted on every call and a replace that drops rows is never skipped as a duplicate. Both are required by replace semantics.
- **Assumes a single writer per company.** This matches MachineMetrics' deployment model — one connector writes a company's data, single-threaded and in sequence — and under that assumption the atomic delete + insert is sufficient. The assumption is load-bearing, so it is worth knowing what it buys: nothing in the database serializes two concurrent replacements of one scope, so both could delete and then insert disjoint operation identities, leaving the scope holding the union of two lists that were each meant to be complete, with every mutation still reporting success. Measured against a live tenant, three concurrent writers on one scope merged in 6 of 8 attempts. The GraphQL proxy exposes no advisory-lock or custom-action affordance, so if concurrent writers ever become a requirement it needs an MM-side endpoint or backend function that serializes the replace.
- **MM's unique key is the operation identity per scope, not the authority's id**: `work_order_operation_dispatch_priority_uq` = (`companyId`, `scopeResourceId`, `workOrderId`, `sequenceNumber`, `lot`, `split`, `sub`). `authorityOperationId` is nullable and non-unique in MM. The SDK therefore (a) **requires** `authorityOperationId` on every row and (b) **collapses rows that collide** on that key before sending, keeping the **better-ranked** one (lower `priority`) wherever it sits in the list, logging the loser with both ranks and returning it in `droppedDuplicates`. Selection between different ranks ignores array position: nothing requires the caller to pass rows in rank order, dedupe does not reorder the list, and MM re-sorts by `priority` on read. Position is used for one thing only — when two ranks are exactly equal, the earlier row is kept, so the outcome is deterministic. Sorting happens after selection, not during it: the write path sends the survivors in rank order (see **Rank order** below), which never changes which duplicate won. Without (b), one collision would fail the whole scope.
- **Rows must carry MM's `lot`/`split`/`sub`.** The columns are `NOT NULL` (default `''`) and both the unique key and the `workOrderOperation` relationship compare all of `workOrderId + sequenceNumber + lot + split + sub`. Empty strings will insert fine but resolve to nothing on tenants that store real values (Epicor connectors store `"0"`/`"0"`/assembly-seq). An authority that does not issue these dimensions must resolve them from MM's operations first — `fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL` exists for that — and hold rows that match zero or several operations rather than guess.
- **No foreign keys.** Rows whose operation or scope resource MM does not have are accepted; their relationships resolve to null. `fetchDispatchPrioritiesFromGraphQL({ unresolvedOnly: true })` finds them.
- `companyId` is a session preset (not in the insert input). `sequenceNumber` and `priority` are `real` (float32) columns in MM, as is the `sequence_number` they join to on operations. The SDK compares them the way MM does — the identity key holds the sequence number as MM will store it, and the rank check compares priorities as MM will store them — so two rows collide in the SDK exactly when they would collide in MM. The routing-step bounds (0, or 0.001 to 10,000,000) are domain sanity on top of that, not what correctness rests on.

- **`replaceDispatchPriorityScope(scopeResourceId, rows: MMSendDispatchPriority[])`** — the normal per-cycle write. Validates every row before any request against `dispatchPriorityInputSchema` (Zod), throwing with the row index and the offending field; a row carrying a *different* `scopeResourceId` is rejected too, dedupes on MM's key, trims the text columns, stamps `scopeResourceId` on every object, and sends the delete + insert document. An empty list clears the scope through the same document. Returns `ReplaceDispatchPriorityScopeResult` `{ scopeResourceId, deletedRows, insertedRows, droppedDuplicates }`.
  - **Failure semantics**: a `GraphQLError` means MM evaluated and rejected the document on that attempt — that attempt wrote nothing. A transport failure (`HTTPError`: gateway error, timeout) leaves the outcome unknown; the scope may or may not have been replaced. With `MM_API_RETRY_ATTEMPTS` above its default of 0, a retried replace can race its own timed-out first attempt, and the uniqueness violation it then reports means the first attempt committed. In every case the right response is to log and let the next sweep re-assert the scope — never mark the scope as "written" on an error.
  - **Rank order**: rows are sent sorted by rank. Hasura assigns `dispatchPriorityRef` in insert order and every SDK read tie-breaks on it, so the authority's ordering survives even where two of its ranks are so large that MM's `real` column stores them as one value (epoch-millisecond ranks collapse at a granularity of about 131,072). Such a collapse is logged as a warning, not treated as a failure — failing would leave the scope's previous list standing.
- **`removeDispatchPriorityScope(scopeResourceId)`** — delete the scope's rows so the App falls back to MM's own ordering: for a scope the authority stopped issuing, or one the connector excluded this cycle. **A scope whose read merely failed must not be removed** — its previous rows stay valid until replaced. Returns `{ scopeResourceId, deletedRows }`.
- **`fetchDispatchPriorityScopes({ pageSize? })`** — the distinct scopes that currently hold rows, ordered by scope, read with `distinct_on` so it does not pull every row of every scope. This is the roster side of the cycle: scopes MM holds but the authority no longer issues are the ones to clear with `removeDispatchPriorityScope`. A malformed page throws rather than returning fewer scopes, since a short read would read as "the authority stopped issuing these" and clear rows that should have stood. Scopes come back **exactly as MM holds them**, and `removeDispatchPriorityScope` addresses a scope exactly as given, so every entry can be passed straight back; a scope with stray whitespace removes nothing and reports `deletedRows: 0` rather than removing a different scope. Only the write path canonicalizes (trims) a scope.
- **`fetchDispatchPrioritiesFromGraphQL(filter?)`** — `MMReceiveDispatchPriority[]` ordered by scope then priority, paged (default 500). Filter: `scopeResourceId` / `scopeResourceIds`, `workOrderId` / `workOrderIds` (merged into one `_in` each), `unresolvedOnly`. A list filter that is empty after trimming returns `[]` without a request — an empty `_in` must never degrade to "match everything". For the connector's diff-by-scope and validation tooling.
- **`MMSendDispatchPriority`** — `(authorityOperationId, scopeResourceId, workOrderId, sequenceNumber, lot, split, sub, priority, preallocatedMachineResourceId?, customFieldsSchemaRef?, customFields?)`. `preallocatedMachineResourceId` is the authority's pin to one machine within a group scope (null when ranked at large or the scope is a machine). Custom fields are sent only when non-empty.
- **`MMReceiveDispatchPriority`** — the row as read: adds MM's surrogate `dispatchPriorityRef`, `companyId`, `createdAt`, `updatedAt`; `authorityOperationId` is `string | null` because MM allows null; `sequenceNumber` is a string like the other receive models.
- **`dispatchPriorityInputSchema`** (exported, Zod) and its inferred type **`DispatchPriorityInput`** — the row rule the write path enforces. Use `safeParse` per row to **hold** a bad row and report it, the way an authority connector should, rather than letting one bad row fail the whole scope inside `replaceDispatchPriorityScope`.
- **Exported types**: `FetchDispatchPrioritiesFilter` (the read filter), `ReplaceDispatchPriorityScopeResult` and `RemoveDispatchPriorityScopeResult` (the write results), `DispatchPriorityOperationIdentity` (the five key columns `dispatchPriorityIdentityKey` reads), `DispatchPriorityDroppedDuplicate` (`{ row, index, duplicateOf, identityKey }`) and `DedupeDispatchPriorityRowsResult` (`{ kept, dropped }`).
- **Key helpers** (exported): `dispatchPriorityIdentityKey(row)` — `workOrderId|sequenceNumber|lot|split|sub`, trimmed, the sequence number rendered **as MM stores it** (float32, exact, so `10.1` appears as `10.100000381469727`: two rows share a key exactly when MM would hold the same number), `\` and `|` escaped within each part so a literal separator cannot collide two operations, and **not** case-folded because MM compares text exactly. Treat the key as opaque; use `normalizeDispatchSequenceNumber` for display; `dedupeDispatchPriorityRows(rows)` — `{ kept, dropped }` as used by the write, better-ranked row winning; defined **per scope** — do not feed it a list spanning scopes; `normalizeDispatchSequenceNumber(value)`.

Typical cycle for an authority connector: read the authority's list per scope → collect the work order ids → `fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL(ids)` → for each ranked row pick the single MM operation matching on `workOrderId + sequenceNumber` (plus whatever dimensions the authority did issue), take `lot`/`split`/`sub` from it, hold and report rows with zero or several matches → `replaceDispatchPriorityScope(scope, rows)` per scope that was read successfully → `fetchDispatchPriorityScopes()`, and `removeDispatchPriorityScope(scope)` for the scopes MM still holds that the authority no longer issues.

Compare sequence numbers **numerically**, not as raw strings: `MMReceiveWorkOrderOperation.sequenceNumber` is the string form of MM's float column, so pass both sides through `normalizeDispatchSequenceNumber()` (or `normalizeWorkOrderOperationKey` for a full operation key) before matching — `"010"`, `"10"` and `10` are the same operation to MM. Multi-assembly jobs commonly have the same `sequenceNumber` under several `sub` values; `status` and `resourceId` on the fetched operations are the natural disambiguators when the authority issues none.

---

## MES machine settings utilities

Standalone helpers (also used by `MMApiClient` and labor-import filtering). Require `MM_MAPPING_AUTH_SERVICE_URL`, `MM_MAPPING_SERVICE_TOKEN`, and `MM_API_*` settings via `CoreConfiguration`.

- **`collectMesSchedulableErpResourceIds(rows)`** / **`fetchMesSchedulableErpResourceIds()`**
  - Collects `laborResourceId` + all `scheduledAs` entries across rows.
  - Case-insensitive dedupe; first-seen casing preserved. Does not filter on `laborTicketEnabled`.

- **`getMesLaborImportResourceIds()`**
  - Labor-import subset: rows where `laborTicketEnabled === true` and `laborResourceId` is non-empty.
  - Used by `filterLaborTicketsForMmImport` when `resourceIds` is omitted.

`MesMachineSettingRow` fields: `machineRef`, `laborResourceId`, `laborTicketEnabled`, `scheduledAs`, `machineGroupId`, `isResourceGroup`, `name`, `type`.

---

## Labor import from ERP utilities

MM-side allowlisting and FK-safe filtering for labor tickets imported from an ERP. Connectors map ERP rows to `MMSendLaborTicket`; these utilities enforce MM mapping rules before a write to MM.

- **`filterLaborTicketsForMmImport(tickets, options)`**
  - Returns `{ accepted, rejected }`, where `rejected` counts tickets dropped for:
    - `missingFields` — required `workOrderId`, `resourceId`, `personId`, `sequenceNumber` missing
    - `notInResourceAllowlist` — `resourceId` not in the MES labor-import ids
    - `notInOperatorAllowlist` — `personId` not in mapped operators
    - `notInSyncedWorkOrderOperation` — work order operation key not in the synced batch
  - Resolves canonical `personId` casing from the operator allowlist (case-insensitive match).

- **`FilterLaborTicketsForMmImportOptions`**
  - `syncedWorkOrderOperations` — work order ops from the same from-ERP batch (preferred). Rows need `work_order_id`, `sub`, `sequence_number`. When **every** row also carries `lot` and `split` (`isLotSplitAwareRowSet`), the FK key becomes MM's full operation identity and a ticket for the wrong lot is rejected here instead of by MM's six-column `work_order_operation_fk`; otherwise lot / split are ignored, as before. `part_number` / `part_revision` / `method` are optional and only read by the work-order gap fill.
  - `syncedWorkOrderOperationKeys` — precomputed key set (ignored when `syncedWorkOrderOperations` is non-empty).
  - `resourceIds` — overrides the MES allowlist (default: `getMesLaborImportResourceIds()`).
  - `operatorIds` — overrides the operator allowlist (default: `getMappedOperatorErpIdsForImport()`).

- **`getMappedOperatorErpIdsForImport()`**
  - GraphQL proxy: `operators(where: { erpId: { _is_null: false } }) { erpId }`. Returns `[]` (with a warning) if config is missing or the request fails.

- **Work order operation key helpers**
  - `normalizeLaborTicketWorkOrderOpKey(workOrderId, sub, sequenceNumber, lotSplit?)` — `{WO}:{sub}:{seq}` (work order id uppercased; empty or whitespace-only `sub` → `"0"`), or `{WO}:{lot}:{split}:{sub}:{seq}` with `lotSplit`.
  - `isLotSplitAwareRowSet(rows)` / `syncedWorkOrderOperationRowKey(row, aware)` — whether a row set opts into the full identity, and a row's key in the form its set uses.
  - `buildSyncedWorkOrderOperationKeySet(workOrderOps)` — key set from batch work order operation rows.
  - `resolveSyncedWorkOrderOperationKeysForLaborImport(options)` — resolves the key set from options.

- **Types**: `SyncedWorkOrderOperationRow`, `FilterLaborTicketsForMmImportResult`.

`StandardProcessDrivers.writeLaborTicketsToMmFromErpImport()` composes this filter with `writeEntitiesToMM` and labor-ledger pre-marking; the standalone helpers are exposed for connectors that need custom orchestration or visibility into rejection counts.

---

## Work-order gap filling from labor tickets

Supplementary to the regular work-order sync, for connectors that import **labor tickets from the ERP** in the same cycle as a **windowed** work-order batch (newest N jobs, a lookback floor, a plant-scoped query, a BAQ cursor, …). A long-running job ages out of that window while operators are still clocked into it; its tickets then fail the labor filter (`notInSyncedWorkOrderOperation`) or MM's `work_order_operation_fk`. Labor tickets do not describe the shop schedule, so this never replaces the bulk sync — it fills the operations the tickets need.

The SDK owns the MM-side policy; the connector owns the ERP query and the ERP → `MMSendWorkOrderBatch` mapping.

- **`StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets(options)`** — run once per from-ERP cycle, after the bulk work-order write and before `writeLaborTicketsToMmFromErpImport`. Per cycle it:
  1. **plans** the work order ids to fetch: ids referenced by **OPEN** tickets with no bulk-synced operation (bounded by concurrent clock-ins, not by ticket volume; with `includeClosedTickets`, tickets of every state, bounded by the connector's labor window instead), plus the persisted **watch list** of previously filled work orders (re-fetched every cycle until their terminal state is imported; pruned by TTL, and after the fetch once the bulk sync covers every fetched row of the work order again — not merely its id, since the window may carry one lot or sub-assembly of a job but not its siblings — and the gap fill has nothing left to write for it, so a failed write of a missing operation is retried even after its ticket closes);
  2. **fetches** through `options.fetchWorkOrders(ids)` — the connector returns a full `MMSendWorkOrderBatch` for those ids (every operation of each work order plus its part, part operations and work order rows; unknown ids simply absent; **must throw** on an ERP failure). `buildWorkOrderBatchFromFlattened` builds it from flattened rows;
  3. **writes** the FK-complete subset that has no competing writer this cycle (see `computeWorkOrderGapFillWriteScope` below), through `options.writeBatch` when given and `writeWorkOrderBatchToMM` otherwise;
  4. **returns** `keySetWorkOrderOps` — every operation of the fetched work orders in the `syncedWorkOrderOperations` row shape. Concatenate them with the bulk rows in the labor filter options.
- **Options**: `connectorType` (namespaces the watch list), `laborTickets` (all states, pre-filter), `bulkSyncedWorkOrderOperations`, `fetchWorkOrders`, optional `includeClosedTickets` (default false; see **Closed-only tickets** below), optional `isTerminalWorkOrder` (default `isTerminalWorkOrderForGapFill`: `COMPLETED` / `CANCELLED` / `DELETED` status or a non-blank `closedDate`; a work order id counts as terminal only when every fetched lot / split / sub row of it is), optional `writeBatch` (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; it **must throw** unless every entity was written, as `writeWorkOrderBatchToMM` does on partial success, since a resolved promise removes the terminal work orders from the watch list), `batchCacheManager`, `tracker`, `trackedTtlDays` (30), `maxTrackedWorkOrders` (1000; caps the open entries — pending close-outs are never evicted to honor it, since one dropped before its write lands is never fetched again), `fetchWarnThreshold` (200; a warning, not a cap).
- **Result**: `keySetWorkOrderOps`, `openLaborWorkOrderIds`, `closedOnlyLaborWorkOrderIds` (ids referenced only by non-OPEN tickets with no bulk-synced operation; fetched only with `includeClosedTickets`), `fetchedWorkOrderIds`, `unresolvedWorkOrderIds` (requested ids the connector returned no work order for; they stay on the watch list until the TTL, since the ERP may hide a job transiently and dropping one on absence could leave a stale status in MM for good), `writtenBatch` (null when nothing needed writing), `writeResult`, `closingWorkOrderIds` (terminal work orders whose uncovered rows were written in full this cycle), `trackedWorkOrderIds` (the watch list as persisted at return: work orders fetched terminal this cycle are still listed until their close-out write has landed), `dropped` counts.
- **Failure contract**: throws on a fetch or write failure like every driver, and before fetching anything when the watch list cannot be read from the store — continuing with an empty list would save it over the persisted one and lose entries no current ticket references. Stored content that is not a JSON array cannot be recovered, so it is the exception: the driver warns, starts empty, and the next save replaces it. The watch list is saved *before* the write, with terminal entries retained, so a work order whose close-out write failed is fetched again next cycle; it leaves the watch list only after the write returns. That relies on the writer throwing when the write did not fully land, which is why a custom `writeBatch` must throw on partial failure rather than report it in its return value. A connector that wants the labor write to proceed without the widened key set wraps the call itself.
- **Closed-only tickets**: by default only OPEN tickets choose what to fetch, so the ERP load is bounded by how many operators are clocked in rather than by the ticket volume in the labor window. The cost is a work order observed only through CLOSED tickets — an operation that opened and closed between two labor polls, or a watch list lost to a container recreate while the job was already closed — is never fetched, and its tickets stay rejected while they remain in the labor window. The driver reports those ids as `closedOnlyLaborWorkOrderIds` and warns each cycle. Set `includeClosedTickets: true` to fetch them too; the bound then becomes the closed tickets in the labor window, so a wide lookback or an initial load can name many aged-out work orders at once (the `fetchWarnThreshold` warning is the guard; there is no cap).
- **Disabling**: a connector whose gap-fill mapping cannot agree with its bulk mapping for a given site (e.g. site-specific status or scheduling rules only the bulk transform applies) simply does not call the driver for that site, with an info log saying why.

**Write scope (`computeWorkOrderGapFillWriteScope`)** — the competing-writer gate, decided per MM work order row (`workOrderId + lot + split + sub`) by whether the bulk sync wrote it this cycle. A row is covered when a bulk row matches its `work_order_id` and `sub` (empty sub → "0", as in the labor key) and, where the bulk rows carry `lot` / `split`, those too; bulk rows without lot / split cover every lot / split of that id + sub. Matching is exact and trimmed; case folding belongs to the labor filter, not to writer ownership. The fetch and the watch list stay per work order id (the ERP job), but every coverage decision is per row: a sub-assembly or lot the bulk window did not carry gets its parents written even when a sibling was covered, and a tracked id leaves the watch list for bulk coverage only when all of its fetched rows are covered and none of its operations is being written this cycle (`bulkCoveredWorkOrderIds` in the result):

| Fetched row is … | Written this cycle |
|---|---|
| **covered** by the bulk sync | only its operations whose key is in `missingOpKeys`; parts, part operations and the work order row stay with the bulk writer (the two mappings may derive fields differently, and rewriting siblings would make them overwrite each other every cycle) |
| **uncovered** (no other writer) | its missing operations **plus** the work order row and its part / part operations, so the operation's FK parents exist |
| **terminal and uncovered** ("closing"; the id is terminal when every fetched row of it is, and closes out each row the bulk sync did not cover) | that row and its **full** operation set (the work order row even when the fetch returned no operations), so terminal status and closed date reach MM before the id leaves the watch list; covered rows of the same id stay with the bulk writer |

Parts are shared across work orders, so they get one more rule: when an uncovered work order being written and a bulk-written work order use the same part, the part row is left to the bulk sync and only the part operations the written operations need, minus the sequences the bulk sync wrote for that part, are included (`sharedPartIdentities` in the result). Bulk ownership is seen through the covered fetched rows and, when the bulk rows carry `part_number` / `part_revision` / `method`, through every bulk row. Supply those fields to make the gate complete; without them a bulk work order for the same part that was not fetched cannot be seen. A missing operation on a covered row is written alone plus its part operation when no bulk row is known to own it, since the bulk mapping may have dropped both together.

`keySetWorkOrderOps` is always every operation of the fetched batch: a ticket that CLOSED since last cycle no longer produces a missing key, and a key set narrowed to the written ops would reject its clock-out.

**Pure helpers** (exported for custom orchestration; every key comes from the labor-import helpers so "missing" cannot drift from what the filter rejects): `findOpenLaborWorkOrderIdsMissingOps(tickets, bulkOps)` (OPEN only → what to fetch), `findLaborWorkOrderIdsMissingOps(tickets, bulkOps)` (every state → what to fetch with `includeClosedTickets`), `findMissingWorkOrderOpKeys(tickets, bulkOps)` (all states → what to write), `buildBulkSyncWorkOrderIdSet` / `isWorkOrderCoveredByBulkSync`, `planWorkOrderGapFillFetch`, `updateTrackedAfterWorkOrderGapFillFetch`, `isTerminalWorkOrderForGapFill`, `toSyncedWorkOrderOperationRows`, `computeWorkOrderGapFillWriteScope({ fullBatch, bulkSyncedWorkOrderOperations, missingOpKeys, terminalWorkOrderIds })`, `mmPartIdentityKey` (the one definition of "same MM part" for SDK-side grouping, shared with `buildWorkOrderBatchFromFlattened`). Tickets the labor filter would reject as `missingFields` (blank work order id or sequence number) never drive a fetch. Keys follow the bulk row set's form: lot / split take part when every bulk row carries them, exactly as in the labor filter. Types: `LaborTicketWorkOrderOpRef`, `TrackedLaborReferencedWorkOrder`, `FillWorkOrderGapsFromLaborTicketsOptions` / `Result`, and the per-helper `*Input` / `*Result` types.

**Watch list persistence** — `LaborWorkOrderGapFillTracker(connectorType)` (`load()` / `save(entries)`) stores the list in the SDK's cross-process runtime state store (`sdk_runtime_state`, key `laborWorkOrderGapFill:v1:<connectorType>`), so it never shares a row with record tracking. On edge devices this SQLite file is wiped by any container recreate; the list rebuilds from open labor, so the loss is tolerable. `load()` / `save()` are an unlocked read-modify-write: the SDK only runs the driver from the `from-erp` job, which Bree never overlaps, and a connector must not run it concurrently for the same `connectorType`.

**Interaction with deletion reconciliation** — work orders written by this path sit outside the bulk window by definition. An `IERPWorkOrderReconciliationHandler` that judges "still active in the ERP" by membership in its bulk window would mark them DELETED, and the next gap-fill cycle would rewrite them. Judge by ERP state instead (look the candidate keys up in the ERP); the driver's `trackedWorkOrderIds` and the tracker's `load()` give the ids to re-check explicitly.

**Known limits**: the gate sees bulk ownership only through what the bulk rows say, so connectors that omit `lot` / `split` or part identity on those rows get coarser (id + sub, fetched-rows-only) protection. Field values on a filled work order come from the gap-fill mapping until the bulk window covers it again.

---

## Work order deletion reconciliation (`IERPWorkOrderReconciliationHandler`)

Opt-in handler for the rarely-run `reconcile-work-orders` job (see the README's **Work Order Deletion Reconciliation** section for the full contract, safety guards, and env vars). Two phases per run: work orders, then work order operations.

- **`selectWorkOrdersToDelete(candidates)`** — from the MM candidate rows (status `RELEASED` or `ACTIVE`, case-insensitive; see `MMWorkOrderStatus`), return the subset that should be marked DELETED. Common case: fetch all active ERP job keys and delegate to the **`selectWorkOrdersMissingFromErp(candidates, activeErpKeys)`** helper (keys `ErpWorkOrderKey`: `workOrderId`, `lot`, `split`, `sub`, compared trimmed and case-insensitively via `normalizeWorkOrderKey`; throws on malformed keys), optionally filtering with ERP/site-specific rules. **MUST throw on any ERP fetch failure** — never return a guessed selection.
- **`selectWorkOrderOperationsToDelete?(candidates)`** — optional, strongly recommended. Candidates are `MMReceiveWorkOrderOperation` rows with status `RELEASED`/`ACTIVE`, no `closedDate`, whose parent work order is a surviving candidate in MM. Return the subset gone from the ERP — commonly via **`selectWorkOrderOperationsMissingFromErp(candidates, activeErpOpKeys)`** (keys `ErpWorkOrderOperationKey` = `ErpWorkOrderKey` + `sequenceNumber`, compared via `normalizeWorkOrderOperationKey` — numeric-looking sequence numbers are canonicalized so `"010"`, `10` and `"10"` match MM's numeric storage; strings or finite numbers accepted; throws on malformed keys). Same MUST-throw-on-fetch-failure contract. This catches an operation deleted from a job that still exists — invisible to the work-order phase. Without it the run logs once and skips this selection.
- The SDK owns everything around the selection: it validates the returned rows are a subset of the candidates (foreign rows reject the run) and writes a status-only patch per selected row (`MMSendWorkOrderStatusPatch` / `MMSendWorkOrderOperationStatusPatch`: identity keys + `status: "DELETED"`). The MM import derives its upsert columns from the fields present, so no other column is touched — including `closedDate`, keeping deletions independent of erp-api's opt-in `?clearColumns` handling. Independently of the handler, it also **cascades**: open operations of work orders DELETED in MM (by this run or earlier) are marked DELETED — MM has no server-side cascade, and schedule sync selects operations by their own status — without counting against the guard. After a deletion write the SDK clears the local dedupe-cache entries for that entity type, so a job/operation later recreated in the ERP — even with a byte-identical payload — is re-imported rather than locally deduped.
- Entry point: `StandardProcessDrivers.reconcileDeletedWorkOrders(connectorType, handler, options?)` returns a `ReconcileDeletedWorkOrdersResult` (`status`: `completed` / `dry-run`; work order counts/keys; `workOrderOperationsReconciled`, `workOrderOperationCandidateCount`, `workOrderOperationsToDelete`, `cascadedWorkOrderOperationsToDelete`, `markedDeletedWorkOrderOperationCount`). When a blast-radius guard trips, it throws `ReconcileDeletedWorkOrdersAbortedError` (`entity`: `workOrders` / `workOrderOperations`, counts, fraction, and the identified keys) instead of returning — matching the SDK convention of typed exceptions for abnormal outcomes (cf. `MMBatchValidationError`). An operation-phase abort leaves the run's work order deletions in place.
- Connectors that also run `fillWorkOrderGapsFromLaborTickets` must judge deletion by ERP state, not by bulk-window membership: gap-filled work orders are outside that window by definition and would otherwise be deleted and rewritten every cycle. See **Work-order gap filling from labor tickets**.
- Opting in: implement `reconcileWorkOrders()` on the connector (typically delegating to the driver). The Bree job runs on its schedule for every connector and no-ops with an info log when the method is absent.

---

## MM → ERP labor ticket handler (`IERPLaborTicketHandler`)

Connectors implement this interface for shop-floor labor tickets exported from MM to the ERP. The SDK invokes it from **polling** (`syncLaborTicketsToERP`, `retryFailedLaborTickets`) and **real-time NATS** (`NatsLaborTicketListener`). On every path, ticket datetimes are localized to the ERP timezone before your methods run — see **Timezone and datetime utilities**.

### Required methods

- **`createLaborTicketInERP(laborTicket)`** — create a new ERP labor ticket; return `{ laborTicket, erpUid, warnings? }`.
- **`updateLaborTicketInERP(laborTicket)`** — update an existing ERP row when `laborTicketId` is already set on the MM ticket; return the ticket, optionally with `warnings` set on it.

On NATS tickets (creates included), when MM sent a `reasonRef` but no `reasons`, the listener looks the reason up and attaches it as `reasons: [{ reason }]` before calling these methods. The lookup is best-effort: if it fails, the ticket arrives without `reasons`.

### Non-fatal ERP warnings (`warnings`, optional)

Either method may report `warnings: string[]`: operator-readable messages the ERP raised while still accepting the transaction (e.g. Epicor BPM InfoMessages such as `C028: No inspections found...`).

- **Where**: beside the ticket for create (`{ laborTicket, erpUid, warnings }`), on the returned ticket for update. The asymmetry keeps both methods backward compatible; connectors with nothing to report return exactly what they did before.
- **Never fatal**: warnings do not fail the operation or trigger a retry.
- **NATS**: the listener returns them on its reply as a top-level `warnings` array for the submitting UI to show; the key is omitted when there are none. Only non-empty strings are passed through. If the MM write after the ERP call fails, the reply is an error and carries no warnings.
- **Polling** (`syncLaborTicketsToERP` / `retryFailedLaborTickets`): there is no operator to show them to, so they are logged.
- Warnings are never written to MM.

```typescript
async updateLaborTicketInERP(laborTicket: MMReceiveLaborTicket) {
  const { warnings } = await this.api.saveLaborTicket(laborTicket);
  // Attach by mutation, not spread: MMReceiveLaborTicket has prototype getters.
  return Object.assign(laborTicket, warnings.length ? { warnings } : {});
}
```

### Optional recovery (`recoverLaborTicketId`)

- **`recoverLaborTicketId?(laborTicket)`** — look up an existing ERP `laborTicketId` for an MM ticket that has no `laborTicketId` yet.
- The SDK calls this **only on the create path** (`!laborTicket.laborTicketId`), immediately before `createLaborTicketInERP`. It is not called on updates.
- **Return values** (structured result preferred; legacy shorthand still accepted):

| `RecoverLaborTicketIdAction` | Meaning | SDK behavior |
|------------------------------|---------|--------------|
| `RECOVER` (+ `laborTicketId`) or non-empty `string` | Existing ERP row found | Patch MM (`updateLaborTicketIdByRef`), then `updateLaborTicketInERP` — **no create** |
| `CREATE` or `null` | No existing row | `createLaborTicketInERP` |
| `DEFER` (+ optional `reason`) | Inconclusive lookup; do not create yet | Skip create for this cycle; polling enqueues retry, NATS returns error |

```typescript
import { RecoverLaborTicketIdAction } from "@machinemetrics/mm-erp-sdk";

return { action: RecoverLaborTicketIdAction.RECOVER, laborTicketId: existingId };
return { action: RecoverLaborTicketIdAction.CREATE };
return { action: RecoverLaborTicketIdAction.DEFER, reason: "ERP lookup timeout" };
```

- If the method is omitted, behavior is unchanged (always create when `laborTicketId` is unset).
- **Natural key**: the full `MMReceiveLaborTicket` is passed so each connector can build an ERP-specific lookup (employee, clock-in, work order/operation, type, site, etc.). The SDK does not define a universal key and does **not** validate returned ids against the ERP.
- **Lookup errors** (thrown): fail open — log and proceed with create. Use `RecoverLaborTicketIdAction.DEFER` when the connector expects a row may exist but lookup was inconclusive (timeouts, replication lag).
- **Incorrect recovered id**: the SDK trusts the connector; a wrong id patches MM and routes to update, which may fail and leave MM linked to a bad id. Only return ids the connector is confident exist.
- Logs use the tag `[LABOR_RECOVERY]`. Deferred failures surface as `LaborTicketRecoveryDeferredError` (`code: LABOR_RECOVERY_DEFERRED`).

Use this when a connector can find prior ERP creates after MM lost the `laborTicketId` link. Duplicate prevention is **opt-in** and depends on correct connector implementation.

### Partial create failure (`LaborTicketCreatePartialSuccessError`)

When `createLaborTicketInERP` fails but an ERP `laborTicketId` was already assigned, throw `LaborTicketCreatePartialSuccessError`. The SDK catches create failures, patches MM via `updateLaborTicketIdByRef` only for that error type, then rethrows so the cycle still fails and retries on the update path. Any other thrown error is unchanged.

```typescript
import { LaborTicketCreatePartialSuccessError } from "@machinemetrics/mm-erp-sdk";

throw new LaborTicketCreatePartialSuccessError(erpUid, "Post-insert step failed", {
  cause: originalError,
});
```

---

## Timezone and datetime utilities

MachineMetrics exchanges datetimes as **ISO-8601 UTC** strings. Conversion happens at the ERP boundary. The SDK caches the MM company IANA timezone during `ApplicationInitializer.initialize()` (`/accounts/current` → SQLite runtime state); connectors read it synchronously afterward — **no connector-side timezone fetch or configuration is required**.

The cached zone is the **MM company** timezone. The SDK assumes ERP-local wall time matches that zone unless you pass an explicit `timezone?` override to the conversion helpers.

### When the SDK converts vs when the connector must

| Path | SDK converts? | Connector responsibility |
|------|---------------|--------------------------|
| `syncLaborTicketsToERP` / `retryFailedLaborTickets` / `NatsLaborTicketListener` → `recoverLaborTicketId?` / `createLaborTicketInERP` / `updateLaborTicketInERP` | **Yes** — `convertLaborTicketToLocalTimezone()` runs before your handler (the NATS listener localizes a copy; the UTC original is what gets written back to MM) | Incoming converted fields are already ERP-local. Do **not** shift them again. |
| `syncTimesheetsToERP` → `processTimesheetInERP` | **No** | Convert with `convertUtcDateTimeToErpLocal()` if the ERP expects local datetimes. |
| `writeEntitiesToMM` / `writeLaborTicketsToMmFromErpImport` | **No** | Convert ERP-local datetimes to UTC with `convertErpLocalDateTimeToUtc()` before calling the SDK. |

`convertLaborTicketToLocalTimezone()` converts exactly these `MMReceiveLaborTicket` fields: `clockIn`, `clockOut`, `transactionDate`, `createdAt`, `updatedAt`, `workOrderOperationClosedDate` (missing values are normalized to `null`). Other datetime-like fields are untouched.

Apply conversion only where the ERP field semantics are local wall time — not for every datetime in every direction.

### Cached timezone accessors

All of these have the same strict startup contract: they **throw** until `ApplicationInitializer.initialize()` has populated company info (in-memory or persisted job-state).

- `getCachedTimezoneName()` — IANA name from company info loaded at startup.
- `getCachedTimezoneOffset()` — offset in hours, derived from the IANA name at read time (DST-aware; not stored).
- `getERPTimezone()` — same IANA name (thin wrapper over `getCachedTimezoneName()`).

### Conversion helpers (Luxon-based, exported from package root)

- `convertUtcDateTimeToErpLocal(utcIso, timezone?)` — MM UTC → ERP-local ISO-8601.
- `convertErpLocalDateTimeToUtc(localIso, timezone?)` — ERP-local → MM UTC ISO-8601.

Both default `timezone` to the cached MM company zone.

### Formatting helpers (legacy / offset-based)

- `convertToLocalTime()`, `formatDateWithTZOffset()`, `toISOWithOffset()`
- `applyTimezoneOffsetsToFields()` — apply offset formatting across a record.
- `calculateTimeDifferenceInHours()`, `getSecondsOfDay()`

Prefer the Luxon-based `convert*` helpers for new connector code.

---

## Cached company info

Company info is fetched from MM during `ApplicationInitializer.initialize()` and persisted in SDK runtime state (same SQLite file as `SQLITE_DB_PATH`). These exported accessors read the cached values so connector code does not re-fetch them each cycle:

- **`getCachedCompanyInfo()`** — `{ timezone, locationRef, companyId }`.
- **`getCachedCompanyId()`** — cached company id.
- **`getCachedCompanyLocationRef()`** — cached company location ref.
- **`getCachedTimezoneName()`** — IANA timezone name (same value as `getCachedCompanyInfo().timezone`).

---

## Data transformation utilities

Stateless helpers for shaping ERP/MM records before sending them to MM. All are exported from the SDK package.

- **Row/field shaping**
  - `getUniqueRows(rows)` — de-duplicate rows.
  - `removeExtraneousFields(obj, allowed)` — drop fields outside an allowlist.
  - `getPayloadWithoutIDField(obj)` — strip the id field from a payload.
  - `trimObjectValues(obj)` — trim string values.
  - `cleanupNumbers(obj)` — normalize numeric fields.
- **Field enrichment**
  - `addNewFieldFromExternalSource(...)` — add a field sourced from external data.
  - `addNewFieldFromLookupField(...)` — derive a field via a lookup on the record.
- **Error formatting**
  - `getErrorType(error)`, `formatError(error)`, `formatErrorForLogging(error)` return a normalized shape; the `FormattedError` type is exported.

---

## Flattened work order types

When importing work orders from an ERP, connectors typically build a flat row per work-order operation and let `StandardProcessDrivers` split it into the MM entity hierarchy. The exported types describe that shape:

- **`FlattenedWorkOrderRow`** — full flattened row; composed from the field groups below.
- **`FlattenedWorkOrderRowBase`**, **`FlattenedWorkOrderFields`**, **`FlattenedWorkOrderPartFields`**, **`FlattenedWorkOrderPartOperationFields`**, **`FlattenedWorkOrderOperationFields`** — the constituent field groups (work order, part, part operation, and operation fields).

**Custom fields.** One flattened row fans out to up to four MM entities, so custom fields are scoped per entity rather than as a single `customFields` property. Each pair is optional and maps onto the matching MM send type's `customFields` / `customFieldsSchemaRef`:

| Row properties | MM entity |
| --- | --- |
| `partCustomFields` / `partCustomFieldsSchemaRef` | `MMSendPart` |
| `partOperationCustomFields` / `partOperationCustomFieldsSchemaRef` | `MMSendPartOperation` |
| `workOrderCustomFields` / `workOrderCustomFieldsSchemaRef` | `MMSendWorkOrder` |
| `opCustomFields` / `opCustomFieldsSchemaRef` | `MMSendWorkOrderOperation` |

`undefined` keeps the value MM already holds, `null` clears it, and MM replaces the object wholesale (so `opCustomFields` must include any keys the labor-ticket path writes on the operation). Parts, part operations, and work orders are deduplicated across rows — parts and part operations across *every* work order in the batch — and their custom fields resolve across the entity's rows with precedence value > `null` > `undefined`; between differing non-null values the first row wins, so rows for the same entity should agree. Each pair resolves together: the winning row supplies both the values and the schema ref they were written against, so the two are never taken from different rows.

See `MM_ENTITY_KEYS_REFERENCE.md` for how these fields map to MM entity keys and processing order.

---

## Local state shared across jobs

Cross-process runtime state (initial-load flag, company info) is stored in SQLite (`sdk_runtime_state`, same file as `SQLITE_DB_PATH`).

- **Initial load tracking**
  - `getInitialLoadComplete()`
  - `setInitialLoadComplete(complete)`

- **Work-order gap-fill watch list** — `LaborWorkOrderGapFillTracker` (key `laborWorkOrderGapFill:v1:<connectorType>`); see **Work-order gap filling from labor tickets**.

Company info (including timezone) is loaded during `ApplicationInitializer.initialize()` — see **Cached company info** above.

---

## Caching / deduplication utilities (protect MM API)

The SDK includes a SQLite-backed hashed cache used to deduplicate repeated upserts and reduce load on MM endpoints.

- **`BatchCacheManager`**
  - Batch-level dedupe and store operations:
  - `dedupeBatch(entityType, objects)` and `storeBatch(entityType, objects)`
  - **Lifecycle**: if your connector creates a cache manager instance for a short-lived process, call `destroy()` when done (it holds a Knex connection).

- **`RecordTrackingManager`**
  - Simple record-tracking table helper (e.g., storing checkpoints / “last processed” markers).
  - **Contract with partial write failures**: never advance a tracking record past entities a from-ERP write rejected — see **Partial-failure handling for from-ERP writes** below.
  - **Lifecycle**: call `destroy()` to close DB connections when appropriate.

- **Labor sync ledger** (used by `StandardProcessDrivers` labor sync and `writeLaborTicketsToMmFromErpImport`)
  - SQLite-backed; persists a business signature per `(system, laborTicketRef)` so unchanged tickets are skipped on later cycles.
  - ERP-imported tickets without a `laborTicketRef` use the stable key `erp-import:{workOrderId}:{laborTicketId}`.
  - Not exposed as a standalone public class; use `StandardProcessDrivers` labor-sync APIs.

---

## Partial-failure handling for from-ERP writes (`MMBatchValidationError`)

The MM import API writes each request in **atomic sub-batches**: one record with a validation error (e.g. a work order operation whose parent work order was never imported, violating `work_order_fk`) rejects **every record in its sub-batch**, valid neighbors included. `writeEntitiesToMM` (and the drivers built on it) surfaces this by throwing `MMBatchValidationError`:

- `httpStatus` — `207` (partial success) or `500` (structured complete failure).
- `upsertedEntities` / `localDedupeCount` / `apiDedupeCount` / `errorCount` — write metrics.
- `batchErrors[]` — one entry per **validation message**: `{ message, affectedEntities }`. `affectedEntities` contains **all** entities of the failing sub-batch, not only the offending record, and a sub-batch with several validation messages appears once per message — so the same entity can repeat across entries, and `errorCount` (the sum of `affectedEntities` lengths) can count a record more than once.

The dedupe cache accounts for this: on partial failure only records **outside** the failed sub-batches are cached, and `retryRejectedIndividually` bypasses the dedupe read entirely, so its retries are never suppressed locally. (Edge case: a rejected record whose primary key cannot be recovered from the API's echo may still land in the cache — one more reason to prefer option 1 below over silent re-queuing.)

### The invariant: never record progress past a rejected entity

Connectors legitimately differ in how they track from-ERP sync progress — a `RecordTrackingManager` cursor, MM checkpoints, timestamp high-water marks. Whatever the mechanism, the contract with `MMBatchValidationError` is the same:

> **Never record progress past an entity reported in `batchErrors`.** A rejected record is only re-fetched if its ERP row changes again; advancing the cursor past it freezes the entity in MM with stale values indefinitely.

On catching `MMBatchValidationError`, do one of:

1. **Retry individually** — re-import the rejected records one at a time via `StandardProcessDrivers.retryRejectedIndividually(entityType, error, originalRecords)`, passing the same records array the failing write was given. Because sub-batch atomicity fails valid records alongside a bad neighbor, most retries succeed; only the residual failures then need a decision.
2. **Re-queue for a later cycle** — persist the **original records** matched back from `affectedEntities` by primary key (or persist just their keys and re-fetch from the ERP) and replay them on a subsequent run, before or independently of cursor advancement. Never persist and re-send the `affectedEntities` objects themselves — they are lossy reconstructions of the API's echo (see the helper section below).
3. **Hold the cursor/checkpoint back** — leave it at (or before) the first rejected entity so the records are re-fetched next cycle.

Trade-off: holding a cursor back is the simplest but can **wedge the sync on a permanently-invalid record** — every cycle re-fetches and re-fails it, and (depending on cursor design) nothing behind it ever syncs. Retry-then-skip-loudly (option 1, then an ERROR-level log for residual failures) avoids the wedge at the cost of leaving the residuals stale until their ERP rows change. Whichever you choose, **log rejected entities at ERROR/WARN level with their identifying keys** — a partial-success response logged at INFO is invisible in production, which is exactly how frozen entities go unnoticed.

Reference implementation of retry-then-skip-loudly: `importPaginated` in the SyteLine connector (`ms-dynamics-integration`, `src/base/utils/query.ts`).

### `retryRejectedIndividually(entityType, error, originalRecords, batchCacheManager?, options?)`

Re-imports every entity from `error.batchErrors[].affectedEntities` one record at a time and returns `{ succeeded, failed }`, where `failed` pairs each residual entity with its validation message. Records that only failed as collateral of a bad sub-batch neighbor succeed here; what remains in `failed` is genuinely rejected and must not be silently dropped (log it, and apply the invariant above). Non-validation errors (network, auth) abort the retry loop and are rethrown — they say nothing about the entities themselves. Cache-manager, `connectorType`, and `queryParams` semantics match `writeEntitiesToMM` — the latter so opt-in behaviors like `clearColumns` survive the recovery path; `chunkSize` has no effect since retries are always single-record.

`originalRecords` must be the records array the failing write was given (a superset is fine). The entities inside `batchErrors` are reconstructions of the API's echoed data that default any absent field — re-sending one could overwrite real MM values (e.g. a work-order-operation labor-ticket patch would round-trip into a full row with zeroed quantities). The helper therefore matches each rejected entity back to `originalRecords` by primary key and re-sends the original object; an entity with no matching original is returned in `failed` without being retried. The same entity may appear under several `batchErrors` entries (MM reports one entry per validation message); it is retried only once.

---

## ERP database integrations (direct DB connectors)

- **SQL Server**
  - `SqlServerService`: connection + query execution helpers
  - `SqlServerHelper`: common SQL Server helper operations
  - `getSQLServerConfiguration()`: standardized env var parsing for SQL Server connection params

- **PSQL / Pervasive (ODBC)**
  - `PsqlService`
  - Helpers: `formatPsqlDate`, `formatPsqlTime`, `combinePsqlDateTime`, `isPsqlDateEmpty`, `cleanPsqlCharField`

- **Knex configuration**
  - `knexDatabaseConfig` is exported for advanced usage (e.g., custom migrations/tools).

---

## Logging helpers (connector-facing)

- **`logger`**
  - SDK logger instance (Winston-based) used throughout the SDK.

- **MM cloud connector logging**
  - `MMConnectorLogger`: sends connector logs/progress to the MM cloud
  - `LogEntry`: structured log payload (includes a `dedupeKey`)
  - `FileLogDeduper`: optional dedupe layer (file-backed) to suppress repeated log spam across jobs
  - **Lifecycle**: `MMConnectorLogger.destroy()` closes the underlying MM API client.

---

## HTTP utilities

- **`HTTPClientFactory`**
  - Creates an SDK-standard `HTTPClient` (Axios-backed) with retry + timeout behavior.

- **`HTTPError`**
  - Standardized error class used by SDK HTTP utilities (includes HTTP status, code, and response data when available).

---

## See also

- **Connector implementation workflow**: template `CONNECTOR-GUIDE.md`
- **Entity key/reference guide**: `MM_ENTITY_KEYS_REFERENCE.md`
- **Public exports**: `src/index.ts` (what connector code can import)

