/** * MachineMetrics-side allowlisting and FK-safe filtering for labor tickets imported from an ERP. * Connectors map ERP rows to {@link MMSendLaborTicket}; this module enforces MM mapping rules * before calling {@link StandardProcessDrivers.writeEntitiesToMM}. */ import { CoreConfiguration } from "../services/data-sync-service/configuration-manager.js"; import { MMSendLaborTicket } from "../services/mm-api-service/types/send-types.js"; import { HTTPClientFactory } from "./http-client.js"; import { fetchMesMachineSettingsRows } from "../services/mm-api-service/mes-machine-settings.js"; import { sdkLogger as logger } from "../services/reporting-service/logger.js"; export type { MesMachineSettingRow } from "../services/mm-api-service/mes-machine-settings.js"; /** * Minimal shape of a work order operation row from an ERP batch (connectors pass through * unchanged). `work_order_id`, `sub` and `sequence_number` are required for the labor key. * * `lot` and `split` are optional. When EVERY row of a set carries both (see * {@link isLotSplitAwareRowSet}), the labor key becomes MM's full operation identity * (`workOrderId + lot + split + sub + sequenceNumber`), so a ticket for the wrong lot is * rejected by the filter instead of by MM's `work_order_operation_fk`, and the work-order * gap fill fetches it. Otherwise lot / split are ignored, as before, so connectors whose * tickets and operations disagree on an unset lot keep working. * * `part_number`, `part_revision` and `method` are optional and only read by the work-order * gap fill, to see which parts (and part operations) the bulk sync wrote through work orders * the gap fill did not fetch. */ export interface SyncedWorkOrderOperationRow { work_order_id?: unknown; lot?: unknown; split?: unknown; sub?: unknown; sequence_number?: unknown; part_number?: unknown; part_revision?: unknown; method?: unknown; } /** * Whether a synced row set opts into lot / split-aware labor keys: non-empty and every row * carries both `lot` and `split` (any value, including ""; `undefined` opts out). A set mixing * aware and unaware rows is unaware as a whole, so a connector cannot be half-precise. */ export function isLotSplitAwareRowSet( rows: ReadonlyArray ): boolean { return ( rows.length > 0 && rows.every((row) => row?.lot !== undefined && row?.split !== undefined) ); } export interface FilterLaborTicketsForMmImportOptions { /** * Raw work order operations synced in the same from-ERP batch. The SDK builds FK keys via * {@link buildSyncedWorkOrderOperationKeySet}. Prefer this so connectors only fetch + forward rows. */ syncedWorkOrderOperations?: ReadonlyArray; /** * Precomputed keys when the connector already has a set (optional; ignored if `syncedWorkOrderOperations` is non-empty). * Always matched in the short form (`workOrderId + sub + sequenceNumber`); pass rows to get lot / split-aware matching. */ syncedWorkOrderOperationKeys?: ReadonlySet; /** Epicor/MES resource IDs allowed for labor import; if omitted, fetched via {@link getMesLaborImportResourceIds} */ resourceIds?: string[]; /** Operator ERP IDs allowed; if omitted, fetched via {@link getMappedOperatorErpIdsForImport} */ operatorIds?: string[]; } /** * Resolves the work-order-operation key set used for labor import FK matching. */ export function resolveSyncedWorkOrderOperationKeysForLaborImport( options: FilterLaborTicketsForMmImportOptions ): Set { const rows = options.syncedWorkOrderOperations; if (rows && rows.length > 0) { return buildSyncedWorkOrderOperationKeySet(rows); } const keys = options.syncedWorkOrderOperationKeys; if (keys && keys.size > 0) { return new Set(keys); } if (keys) { return new Set(keys); } return new Set(); } export interface FilterLaborTicketsForMmImportResult { accepted: MMSendLaborTicket[]; rejected: { missingFields: number; notInResourceAllowlist: number; notInOperatorAllowlist: number; notInSyncedWorkOrderOperation: number; }; } /** * Composite key for matching a labor ticket to a synced work order operation * (`workOrderId + sub + sequenceNumber`). Normalizes the work order id to uppercase ASCII * for stable joins across ERP batch vs labor rows. * * With `lotSplit` the key is MM's full operation identity, `workOrderId + lot + split + * sub + sequenceNumber` (lot / split trimmed, null → ""). Both sides of a comparison must * use the same form; {@link isLotSplitAwareRowSet} decides which one a row set gets. */ export function normalizeLaborTicketWorkOrderOpKey( workOrderId: unknown, sub: unknown, sequenceNumber: unknown, lotSplit?: { lot?: unknown; split?: unknown } ): string { const wo = workOrderId === undefined || workOrderId === null ? "" : String(workOrderId).trim().toUpperCase(); // Whitespace-only counts as empty: the filter trims the ticket's sub before its own // empty check, so a padded CHAR sub on either side must land on the same "0" key. const trimmedSub = sub === undefined || sub === null ? "" : String(sub).trim(); const subPart = trimmedSub === "" ? "0" : trimmedSub; const seq = sequenceNumber === undefined || sequenceNumber === null ? "" : String(sequenceNumber).trim(); if (!lotSplit) { return `${wo}:${subPart}:${seq}`; } const part = (value: unknown): string => value === undefined || value === null ? "" : String(value).trim(); return `${wo}:${part(lotSplit.lot)}:${part(lotSplit.split)}:${subPart}:${seq}`; } /** The key of a synced row, in the form the whole set uses. */ export function syncedWorkOrderOperationRowKey( op: SyncedWorkOrderOperationRow, lotSplitAware: boolean ): string { return normalizeLaborTicketWorkOrderOpKey( op.work_order_id, op.sub, op.sequence_number, lotSplitAware ? { lot: op.lot, split: op.split } : undefined ); } /** * Build a set of keys for work order operations produced during the same from-ERP batch as * labor import. Keys include lot / split when the set opts in ({@link isLotSplitAwareRowSet}). */ export function buildSyncedWorkOrderOperationKeySet( workOrderOps: ReadonlyArray ): Set { const aware = isLotSplitAwareRowSet(workOrderOps); const keys = new Set(); for (const op of workOrderOps) { keys.add(syncedWorkOrderOperationRowKey(op, aware)); } return keys; } function toLowerSet(ids: string[]): Set { return new Set(ids.map((id) => id.toLowerCase())); } function employeeNumMapFromIds(operatorIds: string[]): Map { const m = new Map(); for (const id of operatorIds) { m.set(id.toLowerCase(), id); } return m; } /** * Loads machine-settings from MES (`GET /mes/settings/machine-settings`) via * {@link fetchMesMachineSettingsRows}, then returns deduped `laborResourceId` values where labor is enabled. * * Throws if `mmApiBaseUrl` / `mmApiAuthToken` are missing or the MES request fails — same requirements as * {@link MMApiClient.fetchMappedErpResourcesForCompany}. */ export async function getMesLaborImportResourceIds(): Promise { const rows = await fetchMesMachineSettingsRows(); const seen = new Set(); const out: string[] = []; for (const row of rows) { if (row.laborTicketEnabled !== true) continue; const rid = row.laborResourceId; if (rid == null || String(rid).trim() === "") continue; const s = String(rid).trim(); const low = s.toLowerCase(); if (seen.has(low)) continue; seen.add(low); out.push(s); } logger.info( `getMesLaborImportResourceIds: ${out.length} labor resource id(s) from machine-settings` ); return out; } const OPERATORS_GRAPHQL = { operationName: "operators", query: "query operators { operators(where: {erpId: {_is_null: false}}) { erpId } }", }; /** * Mapped operator ERP IDs from the mapping auth service GraphQL proxy (same as legacy connector query). */ export async function getMappedOperatorErpIdsForImport(): Promise { const config = CoreConfiguration.inst(); const baseUrl = config.mmApiBaseUrl; const token = config.mmApiAuthToken; if (!baseUrl || !token) { logger.warn( "getMappedOperatorErpIdsForImport: missing mmApiBaseUrl or mmApiAuthToken; returning empty allowlist" ); return []; } const client = HTTPClientFactory.getInstance({ baseUrl, retryAttempts: config.mmApiRetryAttempts, timeoutMs: config.mmApiTimeoutSeconds * 1000, }); try { const response = await client.request<{ data?: { operators?: Array<{ erpId?: string }> }; }>({ url: "/proxy/graphql", method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, data: OPERATORS_GRAPHQL, }); const operators = response.data?.data?.operators; if (!Array.isArray(operators)) { logger.warn( "getMappedOperatorErpIdsForImport: unexpected GraphQL response shape" ); return []; } const ids = operators .map((o) => o.erpId) .filter((id): id is string => typeof id === "string" && id.trim() !== ""); logger.info( `getMappedOperatorErpIdsForImport: ${ids.length} operator erpId(s) from GraphQL` ); return ids; } catch (error) { logger.error("getMappedOperatorErpIdsForImport: request failed", { error }); return []; } } /** * Applies resource allowlist, operator allowlist, and synced work order operation keys. * Resolves canonical casing for personId using the operator allowlist (case-insensitive match). */ export async function filterLaborTicketsForMmImport( tickets: readonly MMSendLaborTicket[], options: FilterLaborTicketsForMmImportOptions ): Promise { const woOpKeys = resolveSyncedWorkOrderOperationKeysForLaborImport(options); // Precomputed keys (no rows) are always in the short form. const lotSplitAware = !!options.syncedWorkOrderOperations && options.syncedWorkOrderOperations.length > 0 && isLotSplitAwareRowSet(options.syncedWorkOrderOperations); const resourceIds = options.resourceIds ?? (await getMesLaborImportResourceIds()); const operatorIds = options.operatorIds ?? (await getMappedOperatorErpIdsForImport()); const resourceSet = toLowerSet(resourceIds); const operatorSet = toLowerSet(operatorIds); const employeeCanon = employeeNumMapFromIds(operatorIds); const rejected = { missingFields: 0, notInResourceAllowlist: 0, notInOperatorAllowlist: 0, notInSyncedWorkOrderOperation: 0, }; const accepted: MMSendLaborTicket[] = []; for (const ticket of tickets) { const wo = ticket.workOrderId?.trim() ?? ""; const res = ticket.resourceId?.trim() ?? ""; const person = ticket.personId?.trim() ?? ""; const seq = ticket.sequenceNumber?.trim() ?? ""; const sub = ticket.sub?.trim() ?? ""; if (!wo || !res || !person || seq === "") { rejected.missingFields++; continue; } if (!resourceSet.has(res.toLowerCase())) { rejected.notInResourceAllowlist++; continue; } if (!operatorSet.has(person.toLowerCase())) { rejected.notInOperatorAllowlist++; continue; } const woOpKey = normalizeLaborTicketWorkOrderOpKey( wo, sub || "0", seq, lotSplitAware ? { lot: ticket.lot, split: ticket.split } : undefined ); if (!woOpKeys.has(woOpKey)) { rejected.notInSyncedWorkOrderOperation++; continue; } const canonicalPerson = employeeCanon.get(person.toLowerCase()) ?? person; if (canonicalPerson !== ticket.personId) { accepted.push( new MMSendLaborTicket( ticket.workOrderId, ticket.lot, ticket.split, ticket.sub, ticket.sequenceNumber, ticket.resourceId, canonicalPerson, ticket.laborTicketId, ticket.transactionDate, ticket.clockIn, ticket.clockOut, ticket.goodParts, ticket.badParts, ticket.type, ticket.comment, ticket.state, ticket.customFieldsSchemaRef, ticket.customFields, ticket.laborTicketRef, ticket.machineRef ) ); } else { accepted.push(ticket); } } logger.info("filterLaborTicketsForMmImport: summary", { input: tickets.length, accepted: accepted.length, syncedWorkOrderOperationKeyCount: woOpKeys.size, lotSplitAware, rejected, }); return { accepted, rejected }; }