# MM ERP Connector SDK

A TypeScript SDK for building ERP connectors that integrate with MachineMetrics' data synchronization platform.

## Overview

This SDK provides the core infrastructure for:

- **Data Sync Service**: Automated job scheduling for bidirectional ERP data synchronization
- **ERP Connector Interface**: Standardized interface for implementing ERP connectors
- **MM API Integration**: Client for interacting with MachineMetrics APIs
- **Timezone handling**: Company timezone cached at startup; conversion helpers for the ERP boundary (see **Timezone handling**)
- **Utility Functions**: Common data transformation and HTTP utilities

## Documentation

- **Building a new connector?** Start with the [MachineMetrics ERP connector guide](https://developers.machinemetrics.com/docs/erp-connector/) on the developer hub — it orients you across scaffolding, implementation, and deployment.
- **SDK API detail (this repo):** [`CONNECTOR_SDK_UTILITIES.md`](./CONNECTOR_SDK_UTILITIES.md) for the SDK classes/services/utilities, and [`MM_ENTITY_KEYS_REFERENCE.md`](./MM_ENTITY_KEYS_REFERENCE.md) for entity keys and referential integrity.
- **What has shipped:** [`VERSION_HISTORY.md`](./VERSION_HISTORY.md) — one high-level line per version merged to `main`.
- **Connector implementation workflow:** the `generic-erp-connector` template's `CONNECTOR-GUIDE.md` (how a generated project wires the SDK together).

## Quick Start

### 1. Install the SDK

```bash
npm install @machinemetrics/mm-erp-sdk
```

### 2. Implement an ERP Connector

```typescript
import path from "path";
import { fileURLToPath } from "url";
import {
  IERPConnector,
  IERPLaborTicketHandler,
  ERPObjType,
  MMReceiveLaborTicket,
  RecoverLaborTicketIdAction,
  RecoverLaborTicketIdReturn,
  ApplicationInitializer,
  runDataSyncService,
} from "@machinemetrics/mm-erp-sdk";

export default class MyERPConnector
  implements IERPConnector, IERPLaborTicketHandler
{
  get type(): string {
    return "JOB_BOSS"; // replace with your ERP type
  }

  async startUp(): Promise<void> {
    try {
      await ApplicationInitializer.initialize();
      // Bree workers re-import the built connector module from disk each cycle,
      // so runDataSyncService needs the path to this connector's compiled JS.
      const connectorPath = path.resolve(
        path.dirname(fileURLToPath(import.meta.url)),
        "my-erp-connector.js"
      );
      await runDataSyncService(connectorPath);
    } catch (error) {
      console.error("Startup failed:", error);
      process.exitCode = 1;
    }
  }

  async syncFromERP(): Promise<void> {
    // Implement your data sync from ERP logic
  }

  async syncToERP(): Promise<void> {
    // Implement your data sync to ERP logic
  }

  async createLaborTicketInERP(
    laborTicket: MMReceiveLaborTicket
  ): Promise<{ laborTicket: MMReceiveLaborTicket; erpUid: string; warnings?: string[] }> {
    // Implement labor ticket creation. Optionally return non-fatal ERP `warnings`;
    // see "Non-fatal ERP warnings" in CONNECTOR_SDK_UTILITIES.md.
  }

  // Optional: recover an existing ERP laborTicketId before create when MM lost the link
  async recoverLaborTicketId(
    laborTicket: MMReceiveLaborTicket
  ): Promise<RecoverLaborTicketIdReturn> {
    return { action: RecoverLaborTicketIdAction.CREATE };
  }

  async updateLaborTicketInERP(
    laborTicket: MMReceiveLaborTicket
  ): Promise<MMReceiveLaborTicket & { warnings?: string[] }> {
    // Implement labor ticket updates. Optionally set non-fatal ERP `warnings` on the
    // returned ticket.
  }

  // Implement other required methods...
}
```

### 3. Start the Data Sync Service

```typescript
import MyERPConnector from "./my-erp-connector";

const connector = new MyERPConnector();
await connector.startUp();
```

> `runDataSyncService(connectorPath)` requires a path to the connector's **compiled JS** module (not the `.ts` source). See the template `CONNECTOR-GUIDE.md` for the build/runtime path resolution details.

## Data Sync Jobs

The SDK includes automated jobs that run on configurable intervals:

- **from-erp**: Syncs data from ERP to MachineMetrics
- **to-erp**: Syncs labor tickets and employee timesheets from MachineMetrics to ERP
- **retry-failed-labor-tickets**: Retries failed labor ticket operations
- **clean-up-expired-cache**: Maintains cache hygiene
- **reconcile-work-orders**: Marks MM work orders — and their operations — as DELETED when they no longer exist / are active in the ERP. Runs for every connector but no-ops unless the connector implements `reconcileWorkOrders()` — see **Work Order Deletion Reconciliation** below.

Configure job intervals via environment variables (Bree-compatible strings, e.g. `"5 min"`, `"1h"`):

```bash
FROM_ERP_INTERVAL="5 min"
TO_ERP_INTERVAL="5 min"
RETRY_LABOR_TICKETS_INTERVAL="30 min"
CACHE_EXPIRATION_CHECK_INTERVAL="60 min"
RECONCILE_WORK_ORDERS_INTERVAL="24 hours"
```

### Execution model

Jobs are scheduled with **Bree**. Each job cycle runs as an **independent worker process** that constructs a **fresh connector instance** — so in-memory state does **not** persist across cycles, and cycles are not guaranteed to run serially (labor tickets may be processed concurrently within a cycle). Persist durable state in the SDK's SQLite stores or MM checkpoints, not in instance/module memory. See `CONNECTOR_SDK_UTILITIES.md` (`runDataSyncService`) for the full contract.

## Labor Ticket Synchronization

The SDK provides bidirectional labor ticket synchronization with built-in protection against circular writes (ERP → MM → ERP):

- **Labor sync ledger** (SQLite-backed): records a per-ticket business signature so the `to-erp` export skips tickets whose business-relevant content is unchanged since they were last processed. Tickets imported from the ERP before MM assigns a `laborTicketRef` are tracked under a stable `erp-import:{workOrderId}:{laborTicketId}` key.
- **ERP labor ticket ID recovery** (optional): connectors may implement `recoverLaborTicketId` on `IERPLaborTicketHandler` to look up an existing ERP row before create when MM has no `laborTicketId`. When recovery returns an id (`RECOVER`), polling and NATS patch MM then call `updateLaborTicketInERP`. When recovery returns `DEFER`, the SDK skips create for that cycle and retries later. Duplicate prevention depends on the connector implementing recovery correctly; connectors without the hook are unchanged.
- **Partial ERP create** (optional): when `createLaborTicketInERP` fails but an ERP id was assigned, throw `LaborTicketCreatePartialSuccessError` so the SDK patches MM before rethrowing. See `CONNECTOR_SDK_UTILITIES.md`.
- **ERP → MM import filtering**: `StandardProcessDrivers.writeLaborTicketsToMmFromErpImport()` applies MES/operator allowlists and FK rules, pre-marks accepted tickets in the ledger, writes them to MM, and advances the labor-tickets export checkpoint.
- **Hashed batch cache**: `BatchCacheManager` / `StandardProcessDrivers.writeEntitiesToMM()` deduplicate repeated upserts within a batch (local + API-side).
- **Signature semantics**: the business signature excludes identity fields (`laborTicketRef`, ERP-import keys) and SDK-mutated fields, so the same business state produces a stable hash across import and the next export. A dashboard edit changes the signature, so the change is exported.

See `CONNECTOR_SDK_UTILITIES.md` for the helper APIs, and the template `CONNECTOR-GUIDE.md` for the connector workflow.

### Persistent Storage

The SDK persists durable state — the labor sync ledger, the dedup/batch cache, and SDK runtime state — in a SQLite database at the path given by `SQLITE_DB_PATH` (default `./local.sqlite3`):

```bash
SQLITE_DB_PATH=/app/data/local.sqlite3
```

This file must survive process/container restarts for the sync ledger and dedup cache to remain correct across cycles. How that path is made durable (e.g. a mounted volume or backed-up disk) is a deployment concern owned by your connector's deployment setup, not by the SDK.

## Timesheet Export

The SDK provides employee clock in/out export from MM to the ERP, separate from shop-floor labor tickets:

- `IERPTimesheetHandler` — connector-implemented hook that receives `MMReceiveTimesheet` rows.
- `StandardProcessDrivers.syncTimesheetsToERP()` — fetches pending rows from `/erp/v1/timesheets/updates`, processes them in `timesheetRef` order, and checkpoints progress. The checkpoint advances past every row, so rows that succeeded are not sent again; failed rows are stored with the checkpoint in MM and retried next cycle, and a person's later rows wait behind their failed one so clock events reach the ERP in order. A row that has been failing for 24h, while others succeed, is logged and given up on. It assumes labor ticket export has already run for the cycle, so shop-floor labor is written/closed before clock-out.

## Work-Order Gap Filling from Labor Tickets

Connectors that import labor tickets from the ERP alongside a **windowed** work-order sync can hit tickets whose operation the window no longer carries (a long-running job aged out while operators were still clocked in). `StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets()` closes that gap each cycle: it plans which work orders to fetch (OPEN labor plus a persisted watch list), calls the connector back to fetch and map them, writes only what has no competing writer, and returns the operation rows that widen the same cycle's labor filter. It is additive to the regular work-order sync, never a replacement.

```ts
const gap = await StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({
  connectorType: "my-erp",
  laborTickets,                              // this cycle's from-ERP tickets, pre-filter
  bulkSyncedWorkOrderOperations: bulkOps,    // rows the bulk work-order sync wrote
  fetchWorkOrders: async (ids) =>
    StandardProcessDrivers.buildWorkOrderBatchFromFlattened(await fetchJobsFromErp(ids)),
});

await StandardProcessDrivers.writeLaborTicketsToMmFromErpImport(
  laborTickets,
  { syncedWorkOrderOperations: [...bulkOps, ...gap.keySetWorkOrderOps] },
  cache,
  { connectorType: "my-erp" }
);
```

Deletion reconciliation handlers must judge by ERP state rather than bulk-window membership, or they will delete what the gap fill wrote. See `CONNECTOR_SDK_UTILITIES.md` → **Work-order gap filling from labor tickets**.

## Work Order Deletion Reconciliation

An opt-in, rarely-run job (default every 24 hours) that marks MM work orders — and their work order operations — as **DELETED** when they no longer exist / are active in the ERP. The SDK does the generic work; the connector selects which candidates to delete — giving it room for ERP- or site-specific rules (including "never remove" lists).

```typescript
class MyConnector implements IERPConnector, IERPWorkOrderReconciliationHandler {
  async reconcileWorkOrders(): Promise<void> {
    await StandardProcessDrivers.reconcileDeletedWorkOrders(this.type, this);
  }

  async selectWorkOrdersToDelete(candidates: MMReceiveWorkOrder[]): Promise<MMReceiveWorkOrder[]> {
    // Query the ERP for ALL currently active/valid jobs; MUST throw on any fetch failure.
    const activeErpKeys = erpJobs.map((job) => ({ workOrderId: job.id, lot: job.lot, split: job.split, sub: job.sub }));
    // Common case: delegate the matching to the SDK helper; add ERP/site-specific rules as needed.
    return selectWorkOrdersMissingFromErp(candidates, activeErpKeys);
  }

  // Optional but strongly recommended: catches an operation deleted from a job that survives.
  async selectWorkOrderOperationsToDelete(candidates: MMReceiveWorkOrderOperation[]): Promise<MMReceiveWorkOrderOperation[]> {
    // Query the ERP for ALL operations of all currently active jobs; MUST throw on any fetch failure.
    const activeErpOpKeys = erpOps.map((op) => ({ workOrderId: op.jobId, lot: op.lot, split: op.split, sub: op.sub, sequenceNumber: op.seq }));
    return selectWorkOrderOperationsMissingFromErp(candidates, activeErpOpKeys);
  }
}
```

The `reconcile-work-orders` job runs on its schedule for every connector; it no-ops (info log) unless the connector implements `reconcileWorkOrders()`, so connectors that don't opt in are unaffected beyond that log line.

How reconciliation works:

1. The SDK fetches all work orders from MM (paged GraphQL).
2. The SDK restricts candidates to status `RELEASED` or `ACTIVE` (case-insensitive; see the `MMWorkOrderStatus` enum). Completed, cancelled, and already-deleted work orders are never touched.
3. The connector's `selectWorkOrdersToDelete(candidates)` returns the subset to delete. The `selectWorkOrdersMissingFromErp(candidates, activeErpKeys)` helper covers the common case — keys compared on `workOrderId + lot + split + sub`, trimmed and case-insensitive (numbers accepted and stringified), failing fast on malformed keys — and the connector can filter further or match its own way.
4. The SDK validates the selection is a subset of the candidates (foreign rows reject the whole run), then sends a **status-only patch** for each selected work order — identity keys plus `status: "DELETED"`. The MM import derives its upsert columns from the fields present, so nothing else is written back: no column can be clobbered, and `closedDate` is never touched (the patch is independent of erp-api's opt-in `?clearColumns` handling).
5. **Work order operations.** The SDK then fetches all work order operations from MM and, in one write:
   - **cascades** — every open (`RELEASED`/`ACTIVE`, no `closedDate`) operation whose parent work order is `DELETED` in MM (by this run or an earlier one) is marked `DELETED` too. MM has no server-side cascade from a work order's status to its operations, and production-schedule sync selects operations by their *own* status, so without this the operations of a deleted job would keep getting scheduled. The cascade consults neither the handler nor the blast-radius guard — the parent deletion was already guarded.
   - **reconciles surviving jobs' operations** — when the connector implements `selectWorkOrderOperationsToDelete(candidates)`, the open operations of surviving candidate work orders are offered to it (keys `workOrderId + lot + split + sub + sequenceNumber`, the sequence number compared numerically so `"010"`, `10` and `"10"` match MM's numeric storage; helper `selectWorkOrderOperationsMissingFromErp`), the selection is validated and guarded exactly like work orders, and the selected operations are marked `DELETED` with the same status-only patch (five identity keys + `status`). This is the only way to catch an operation deleted from a job that itself still exists. When the method is absent the job logs that fact once per run and skips this part.

Safety behavior:

- **Blast-radius guard** — the run throws `ReconcileDeletedWorkOrdersAbortedError` (nothing written for that phase, and the job cycle fails visibly) when more than `RECONCILE_MAX_DELETE_FRACTION` (default 20%) of candidates would be deleted, once the deletion count exceeds `RECONCILE_DELETE_GUARD_MIN_COUNT` (default 10). The guard is evaluated separately for the work order selection and the work order operation selection (same settings; `error.entity` tells which tripped — when it is `workOrderOperations`, the work order deletions of the same run already stand). This protects against a bad/partial ERP fetch mass-deleting jobs; raise the fraction temporarily for an intentional mass purge. The error carries the counts and the identified keys for connector-side handling (e.g. cloud logging).
- **Simple schedule** — the job runs 1 hour after process start, then every `RECONCILE_WORK_ORDERS_INTERVAL` (default 24 hours). Bree's timers reset on restart, so a restart-prone container reconciles about an hour after each deployment rather than never; runs are idempotent, so the occasional extra run is harmless.
- **Dry run** — `RECONCILE_WORK_ORDERS_DRY_RUN=true` logs the would-be deletions (work orders, cascaded operations, and selected operations) without writing to MM, including whether a real run would have tripped either blast-radius guard. Recommended for first rollout at a site.

A work order or operation recreated in the ERP after being marked DELETED is resurrected by the normal from-erp import: after a deletion write the SDK clears the local dedupe cache entries for that entity type, so the re-import is sent to MM even when the recreated payload is byte-identical to the original (the cost is a one-time re-send on the following from-erp cycles, the same as a routine cache-TTL expiry). A late labor-ticket close-out keeps writing `closedDate` and, as before, `status: COMPLETED` — except when the ticket's `workOrderOperation.status` reports the operation as DELETED, in which case the status is left alone. MM's labor-ticket export does not currently include that status, so this guard only engages when a caller supplies it; a COMPLETED operation with a `closedDate` is equally hidden, unschedulable, and outside reconciliation, so the exposure is cosmetic. Labor tickets that reference a deleted job may still fail to export to the ERP — handling that (e.g. checking whether the job still exists before writing, or dropping the ticket from the retry queue) is the connector's responsibility.

Status vocabulary: `MMWorkOrderStatus` and `MMWorkOrderOperationStatus` document the values MM connectors use (`RELEASED`, `NOT RELEASED`, `ACTIVE`, `COMPLETED`, `CANCELLED`, `DELETED`, plus `PENDING` for work orders). The MM API accepts any string, so the SDK does not reject others — but send-type `status` parameters are typed `MMWorkOrderStatusValue` / `MMWorkOrderOperationStatusValue` (enum member or string) so the preferred values show up in editor completion, and all SDK comparisons go through `normalizeMMStatus()` (case-insensitive; `_`→space; `CANCELED`→`CANCELLED`).

## Timezone handling

MachineMetrics exchanges datetimes as **ISO-8601 strings in UTC** (trailing `Z` or an explicit `+00:00` offset). The ERP boundary is where timezone conversion happens.

### Cached timezone (no connector fetch)

`ApplicationInitializer.initialize()` fetches company info from MM (`/accounts/current`) and persists it in SQLite runtime state, including the company's **IANA timezone name**. After initialization succeeds, connector code reads it synchronously — no extra HTTP, no ERP-specific timezone configuration:

- `getCachedTimezoneName()` / `getERPTimezone()` — IANA name (e.g. `America/New_York`)
- `getCachedTimezoneOffset()` — current offset in hours for that zone (DST-aware)

**Connectors do not fetch or configure a timezone.** Call `ApplicationInitializer.initialize()` once at startup; the conversion helpers use the cached zone by default.

### MM company timezone vs ERP server timezone

The cached value is the **MM company's** timezone (`company.timezone` from `/accounts/current`). The SDK's default assumption is that **ERP-local wall time matches the MM company timezone**. If your ERP server's clock uses a different zone, pass an explicit `timezone?` argument to the conversion helpers.

Do not treat "ERP timezone" and "company timezone" as separate inputs the connector must obtain — the SDK already provides the company zone unless you need to override it.

### When to convert (depends on sync direction)

| Path | SDK converts? | Connector responsibility |
|------|---------------|--------------------------|
| **MM → ERP labor tickets** (`syncLaborTicketsToERP`, `retryFailedLaborTickets`, and the real-time NATS listener) | Yes — UTC → ERP-local **before** your handler runs | Treat the converted fields (`clockIn`, `clockOut`, `transactionDate`, `createdAt`, `updatedAt`, `workOrderOperationClosedDate`) as **localized wall time**. Do **not** apply further timezone shifts. |
| **MM → ERP timesheets** (`syncTimesheetsToERP`) | No | If the ERP expects local datetimes, call `convertUtcDateTimeToErpLocal()` in your handler. |
| **ERP → MM** (`writeEntitiesToMM`, `writeLaborTicketsToMmFromErpImport`) | No | Convert ERP-local datetimes to UTC with `convertErpLocalDateTimeToUtc()` **before** passing records to the SDK. |

Conversion is **field-specific**: apply it only where the ERP field semantics are local wall time. If an ERP field is already stored in UTC, converting would corrupt the value.

### Conversion helpers

All are exported from the package root. Inputs and outputs are ISO-8601 strings; invalid input throws.

```typescript
import {
  getERPTimezone,
  convertUtcDateTimeToErpLocal,
  convertErpLocalDateTimeToUtc,
} from "@machinemetrics/mm-erp-sdk";

// After ApplicationInitializer.initialize():
const zone = getERPTimezone(); // cached MM company IANA name

// MM UTC → ERP-local (e.g. timesheets, or custom MM→ERP paths)
const localIso = convertUtcDateTimeToErpLocal("2024-06-15T18:30:00.000Z");

// ERP-local → MM UTC (required before writeEntitiesToMM)
const utcIso = convertErpLocalDateTimeToUtc("2024-06-15T14:30:00");

// Override when ERP clock differs from MM company timezone:
convertErpLocalDateTimeToUtc(localIso, "Europe/Amsterdam");
```

Offset-based formatting helpers (`formatDateWithTZOffset`, `toISOWithOffset`, `convertToLocalTime`, `applyTimezoneOffsetsToFields`) remain available for legacy ERP formats; prefer the Luxon-based `convert*` helpers for new code.

See `CONNECTOR_SDK_UTILITIES.md` for full helper detail.

## ERP Resource Mapping (MES Machine Settings)

Machine-to-ERP-resource mapping is sourced from **MES machine settings** (`GET /mes/settings/machine-settings`):

- `MMApiClient.fetchMappedErpResourcesForCompany()` — machines mapped to ERP labor resources (`machineRef` + `laborResourceId`), sourced from MES machine settings.
- `MMApiClient.fetchMesSchedulableErpResourceIds()` / `getMesLaborImportResourceIds()` — schedulable and labor-import resource-id allowlists.

See `CONNECTOR_SDK_UTILITIES.md` for MES machine-settings helpers and `MM_ENTITY_KEYS_REFERENCE.md` for entity keys.

## Dispatch Priorities (scheduler ordering)

An ordering authority — an external scheduler such as PFM — can push its ranked list of work order operations per **scope** (an ERP resource id for a machine or a machine group) into MM's `erpWorkOrderOperationDispatchPriorities` table. The App orders that scope's queue by the rows' `priority` and falls back to its own ordering where no rows exist.

This table is **not** an `ERPObjType` and is not written through `writeEntitiesToMM`. Its unit of validity is the scope, so the SDK writes it with **scope-level replace** semantics through the MM GraphQL proxy — one atomic delete + insert per scope, never a per-row upsert, and independent of the local dedupe cache:

```typescript
const client = new MMApiClient();

// Resolve MM's operation identities for the work orders the authority ranked.
const mmOps = await client.fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL(workOrderIds);

// Build rows carrying MM's lot/split/sub for each ranked operation (hold rows that
// match zero or several MM operations — never guess), then replace the scope.
const result = await client.replaceDispatchPriorityScope("CELL-A", rows); // rows: MMSendDispatchPriority[]
// result: { scopeResourceId, deletedRows, insertedRows, droppedDuplicates }

// Scopes MM still holds that the authority no longer issued this cycle fall back to MM's own ordering:
for (const scope of await client.fetchDispatchPriorityScopes()) {
  if (!issuedThisCycle.has(scope)) await client.removeDispatchPriorityScope(scope);
}

// Diff / validation tooling:
const current = await client.fetchDispatchPrioritiesFromGraphQL({ scopeResourceId: "CELL-A" });
const unresolved = await client.fetchDispatchPrioritiesFromGraphQL({ unresolvedOnly: true });
```

Rows are validated before any request, and rows that collide on MM's unique key (scope + `workOrderId` + `sequenceNumber` + `lot` + `split` + `sub`) are dropped and reported rather than failing the scope. See `CONNECTOR_SDK_UTILITIES.md` → **Dispatch priorities** for the full semantics and `MM_ENTITY_KEYS_REFERENCE.md` for the key.

## Environment Configuration

These are the environment variables the SDK reads (via `CoreConfiguration`, `getSQLServerConfiguration`, `getErpApiConnectionParams`, and the SQLite/knex setup). Names and defaults below match the SDK source; defaults are shown where one exists.

### Required

```bash
# MM REST API base URL (e.g. https://api.machinemetrics.com). The SDK throws on startup if this is unset.
MM_MAPPING_AUTH_SERVICE_URL="https://api.machinemetrics.com"

# MM company auth token. ApplicationInitializer.initialize() fetches /accounts/current with this
# bearer token. If unset, startup retries every 10s (up to 36000 attempts) and initialization
# never completes — set this before calling initialize().
MM_MAPPING_SERVICE_TOKEN="your-company-auth-token"

# MM ERP mapping service REST API URL (e.g. https://erp-api.svc.machinemetrics.com). Required for
# entity sync, labor-ticket export, checkpoints, and other /erp/v1/* calls via MMApiClient.
MM_MAPPING_SERVICE_URL="https://erp-api.svc.machinemetrics.com"
```

### MachineMetrics API

```bash
MM_API_TIMEOUT_SEC="30"        # MM API request timeout, seconds (default: 30)
MM_API_RETRY_ATTEMPTS="0"      # MM API retry attempts (default: 0)
MM_API_WRITE_CHUNK_SIZE="0"    # Opt-in: max records per MM API write request; when > 0, larger sets
                               # are written in sequential chunks to avoid timeouts. Default 0 (off) —
                               # leave off for connectors that already chunk at their own layer
```

### General

```bash
NODE_ENV="production"      # Runtime environment (default: "development")
LOG_LEVEL="info"           # Log level (default: "info")
SDK_LOG_PREFIX="SDK:"      # Prefix applied to SDK log lines (default: "SDK:")
NATS_ENABLED="false"       # When "true", starts the real-time NATS labor-ticket listener alongside Bree jobs
```

### Job intervals

Interval strings accept any [Bree](https://github.com/breejs/bree)-compatible duration (e.g. `"5 min"`, `"1h"`).

```bash
FROM_ERP_INTERVAL="5 min"                  # from-ERP sync interval (default: "5 min", or POLL_INTERVAL if set)
TO_ERP_INTERVAL="5 min"                    # to-ERP sync interval (default: "5 min")
RETRY_LABOR_TICKETS_INTERVAL="30 min"      # retry-failed-labor-tickets interval (default: "30 min")
CACHE_EXPIRATION_CHECK_INTERVAL="60 min"   # expired-cache cleanup interval (default: "60 min")
RECONCILE_WORK_ORDERS_INTERVAL="24 hours"  # reconcile-work-orders interval, work orders + operations (default: "24 hours"; first run is 1 hour after process start; no-ops unless the connector opts in)
POLL_INTERVAL="5 min"                       # fallback default for FROM_ERP_INTERVAL (default: "")
```

Work order deletion reconciliation tuning (see **Work Order Deletion Reconciliation**):

```bash
RECONCILE_MAX_DELETE_FRACTION="0.2"        # abort when more than this fraction of candidates would be deleted; applied per phase (work orders / operations) (default: 0.2)
RECONCILE_DELETE_GUARD_MIN_COUNT="10"      # fraction guard applies only above this many deletions (default: 10)
RECONCILE_WORK_ORDERS_DRY_RUN="false"      # log would-be deletions without writing to MM (default: false)
```

### Caching & local storage

```bash
CACHE_TTL="604800"                  # Dedup cache TTL, seconds (default: 604800 = 7 days)
SQLITE_DB_PATH="/app/data/local.sqlite3"  # SQLite file for ledger/cache/runtime state (default: "./local.sqlite3")
```

### ERP API (generic REST/GraphQL)

```bash
ERP_PAGINATION_LIMIT="0"     # Default pagination limit for ERP API (default: 0)
ERP_API_TIMEOUT_SEC="30"     # ERP API request timeout, seconds (default: 30)
ERP_API_RETRY_ATTEMPTS="3"   # ERP API retry attempts (default: 3)
```

Connectors using `getErpApiConnectionParams()` also read:

```bash
ERP_API_URL="https://erp.example.com"
ERP_API_CLIENT_ID="client-id"
ERP_API_CLIENT_SECRET="client-secret"
ERP_API_ORGANIZATION_ID="org-id"
ERP_AUTH_BASE_URL="https://auth.example.com"
```

### SQL Server ERP (when using `SqlServerService` / `getSQLServerConfiguration()`)

```bash
ERP_SQLSERVER_USERNAME="username"
ERP_SQLSERVER_PASSWORD="password"
ERP_SQLSERVER_DATABASE="your-db"
ERP_SQLSERVER_HOST="localhost"                # alias: ERP_SQLSERVER_SERVER
ERP_SQLSERVER_PORT="1433"                     # default: 1433
ERP_SQLSERVER_CONNECTION_TIMEOUT="30000"      # default: 30000 (ms)
ERP_SQLSERVER_REQUEST_TIMEOUT="60000"         # default: 60000 (ms)
ERP_SQLSERVER_MAX="10"                        # connection pool max (default: 10)
ERP_SQLSERVER_MIN="0"                         # connection pool min (default: 0)
ERP_SQLSERVER_IDLE_TIMEOUT_MMILLIS="30000"    # pool idle timeout, ms (env name spelled as in the SDK; default: 30000)
ERP_SQLSERVER_ENCRYPT="false"                 # default: false
ERP_SQLSERVER_TRUST_SERVER="false"            # default: false
```

## Logging Reliability

- The SDK logger now captures rotate/write transport failures internally, but callers should still treat logging as best-effort. If you manage your own in-process scheduler (e.g., not using Bree), wrap any `job.isRunning = true` flags and subsequent `logger.*` calls in a `try/finally` block so the scheduler state clears even if logging throws.
- Bree-based connectors inherit process isolation, but custom schedulers run inside a single event loop. Always reset locks/timers inside `finally` clauses to avoid getting stuck when a synchronous dependency (logging, metrics, etc.) fails mid-cycle.

## API Reference

### Core Interfaces

- `IERPConnector`: Main connector interface (`syncFromERP`, `syncToERP`, lifecycle hooks, optional `reconcileWorkOrders`). The `type` getter is the ERP system identifier passed to MM API `system` parameters and `StandardProcessDrivers` as `connectorType`.
- `IERPLaborTicketHandler`: Create/update labor tickets in the ERP (`MMReceiveLaborTicket`). Optional `recoverLaborTicketId` (`RecoverLaborTicketIdAction`) when MM lost the ERP link — see **Labor Ticket Synchronization** and `CONNECTOR_SDK_UTILITIES.md`.
- `IERPTimesheetHandler`: Process employee clock in/out rows in the ERP (`MMReceiveTimesheet`)
- `IERPWorkOrderReconciliationHandler`: Select which MM candidate work orders to mark DELETED (commonly via the `selectWorkOrdersMissingFromErp` helper) — see **Work Order Deletion Reconciliation**.

### Utilities

- `ApplicationInitializer`: SDK initialization (configuration, migrations, company info including timezone)
- `runDataSyncService`: Start automated sync jobs
- `getCachedCompanyInfo()` / `getCachedCompanyId()` / `getCachedCompanyLocationRef()` / `getCachedTimezoneName()`: read company info persisted at startup
- `getERPTimezone()` / `convertUtcDateTimeToErpLocal()` / `convertErpLocalDateTimeToUtc()`: datetime boundary conversion (see **Timezone handling**)
- `formatDateWithTZOffset()` / `toISOWithOffset()` / `convertToLocalTime()` / `applyTimezoneOffsetsToFields()`: offset-based formatting for legacy ERP formats
- `StandardProcessDrivers`: Common sync operations, including:
  - `syncLaborTicketsToERP(connectorType, handler, { onlyClosed? })`
  - `syncTimesheetsToERP(connectorType, handler)`
  - `retryFailedLaborTickets(connectorType, handler)`
  - `writeLaborTicketsToMmFromErpImport(tickets, filterOptions, cache?, writeOptions?)`
  - `writeEntitiesToMM(entityType, records, cache?, options?)` — `options` accepts `connectorType` (labor-ledger marking) and `chunkSize` (opt-in write chunking; see **Environment Configuration** → `MM_API_WRITE_CHUNK_SIZE`). Throws `MMBatchValidationError` on partial success (HTTP 207) or structured complete failure (HTTP 500 with import error details; other 500s propagate unconverted); connectors must recover the rejected entities and never advance record-tracking state past them — see `CONNECTOR_SDK_UTILITIES.md` → **Partial-failure handling for from-ERP writes**.
  - `retryRejectedIndividually(entityType, error, originalRecords, cache?, options?)` — re-imports the entities from a `MMBatchValidationError` one at a time (sub-batch atomicity rejects valid records alongside a bad neighbor) and returns `{ succeeded, failed }`, where `failed` is the genuinely-invalid residue. Rejected entities are matched back to `originalRecords` (the array passed to the failing write) by primary key and the originals are re-sent — the error's own entities are lossy reconstructions of the API's echo; an entity with no matching original lands in `failed` unretried. `options` accepts `connectorType` only; non-validation errors (network, auth) abort the retry and are rethrown.
  - `reconcileDeletedWorkOrders(connectorType, handler, options?)`

### Services

- `SqlServerService` / `SqlServerHelper`: SQL Server ERP integration
- `PsqlService`: PSQL / Pervasive (ODBC) ERP integration, with `formatPsqlDate`, `formatPsqlTime`, `combinePsqlDateTime`, `isPsqlDateEmpty`, `cleanPsqlCharField` helpers
- `RestAPIService` / `GraphQLService`: ERP REST / GraphQL API clients
- `MMApiClient`: MachineMetrics API client (includes `fetchMappedErpResourcesForCompany`, `fetchMesSchedulableErpResourceIds`, `fetchTimesheetUpdates`, `fetchErpWorkOrderOperationsByWorkOrderIdsFromGraphQL`, and the dispatch-priority methods `replaceDispatchPriorityScope` / `removeDispatchPriorityScope` / `fetchDispatchPrioritiesFromGraphQL` / `fetchDispatchPriorityScopes`)

For SDK helper, type, and entity-key detail, see `CONNECTOR_SDK_UTILITIES.md` and `MM_ENTITY_KEYS_REFERENCE.md`. For the connector implementation workflow, see the template `CONNECTOR-GUIDE.md`.

## Labor ticket updates: the `onlyClosed` option

`syncLaborTicketsToERP` accepts an optional `onlyClosed` flag:

- `onlyClosed: true` — the SDK requests only closed labor tickets, forwarding `onlyClosed=true` to `/erp/v1/labor-tickets/export/updates`.
- omitted (default) — all labor tickets are processed.

```typescript
StandardProcessDrivers.syncLaborTicketsToERP(connectorType, handler, { onlyClosed?: boolean });
```
