/** * These represent types of objects that can be sent to the MM API. * Each class implements {@link IToRESTApiObject} / {@link IToFullApiObject} and a static * {@link fromPlainObject} for cache/SQLite-style rows (and optional custom-field keys). */ import { applyNonEmptyCustomFieldsToRest, toFullApiObjectFromRest } from "./custom-fields.js"; import { dispatchPriorityInputSchema } from "../dispatch-priority-schemas.js"; import type { MMWorkOrderOperationStatusValue, MMWorkOrderStatusValue, } from "../../../types/erp-types.js"; /** * Interface for entities that can be sent to the MM ERP API. * * Returns a JSON-serializable map. Core fields are usually strings (or `string | null` for * optional dates); implementations may add `customFields` (object) via * {@link applyNonEmptyCustomFieldsToRest}. */ export interface IToRESTApiObject { toRESTApiObject(): Record; } /** * Optional additive interface for entities that need to send richer JSON payloads * (e.g. `customFields` objects) to the MM ERP API. * * MMApiClient will prefer this method when available and fall back to `toRESTApiObject()`. */ export interface IToFullApiObject { toFullApiObject(): Record; } export class MMSendPerson implements IToRESTApiObject, IToFullApiObject { constructor( public personId: string, public firstName: string, public lastName: string, public isActive: boolean, public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { personId: this.personId, firstName: this.firstName, lastName: this.lastName, isActive: this.isActive ? "1" : "0", }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } static fromPlainObject(data: Record): MMSendPerson { return new MMSendPerson( data.personId || "", data.firstName || "", data.lastName || "", data.isActive === "1", data.customFieldsSchemaRef, data.customFields ); } } export class MMSendResource implements IToRESTApiObject, IToFullApiObject { constructor( public resourceId: string, // Unique identifier that the ERP uses to describe a resource public name: string, // A colloquial name for the resource, sometimes also just the resource_id public isResourceGroup: boolean, // Whether or not this record represents an individual machine/piece of hardware, or a group/type of machines public description: string, // Text description of the resource (optional) public type: string, // The type of resource (optional) public productionBurdenRateHourly: number, // The cost associated with running this machine in production/hour (optional) public setupBurdenRateHourly: number, // The cost associated with running this machine in setup/hour (optional) public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { resourceId: this.resourceId, name: this.name, isResourceGroup: this.isResourceGroup ? "1" : "0", description: this.description, type: this.type, productionBurdenRateHourly: this.productionBurdenRateHourly.toString(), setupBurdenRateHourly: this.setupBurdenRateHourly.toString(), }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } /** * Optional `customFields` / `customFieldsSchemaRef` when present on the row (e.g. merged from * connector config). Do not use `|| {}` for `customFields` so full API payloads omit empty objects. */ static fromPlainObject(data: Record): MMSendResource { return new MMSendResource( data.resourceId || "", data.name || "", data.isResourceGroup === "1", data.description || "", data.type || "", parseFloat(data.productionBurdenRateHourly || "0"), parseFloat(data.setupBurdenRateHourly || "0"), data.customFieldsSchemaRef, data.customFields ); } } export class MMSendPart implements IToRESTApiObject, IToFullApiObject { constructor( public partNumber: string, public partRevision: string, public method: string, public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { partNumber: this.partNumber, partRevision: this.partRevision, method: this.method, }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } static fromPlainObject(data: Record): MMSendPart { return new MMSendPart( data.partNumber || "", data.partRevision || "", data.method || "", data.customFieldsSchemaRef, data.customFields ); } } export class MMSendPartOperation implements IToRESTApiObject, IToFullApiObject { // `partName` trails the custom-field params so existing positional callers keep working. constructor( public partNumber: string, public partRevision: string, public method: string, public sequenceNumber: string, public resourceId: string, public cycleTimeMs: number, public setupTimeMs: number, public description: string, public quantityPerPart: number, public customFieldsSchemaRef?: number | null, public customFields?: Record | null, public partName?: string | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { partNumber: this.partNumber, partRevision: this.partRevision, method: this.method, sequenceNumber: this.sequenceNumber, resourceId: this.resourceId, cycleTimeMs: this.cycleTimeMs.toString(), setupTimeMs: this.setupTimeMs.toString(), description: this.description, quantityPerPart: this.quantityPerPart.toString(), }; if (this.partName) { base.partName = this.partName; } return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } static fromPlainObject( data: Record ): MMSendPartOperation { return new MMSendPartOperation( data.partNumber || "", data.partRevision || "", data.method || "", data.sequenceNumber || "", data.resourceId || "", parseInt(data.cycleTimeMs || "0"), parseInt(data.setupTimeMs || "0"), data.description || "", parseFloat(data.quantityPerPart || "1"), data.customFieldsSchemaRef, data.customFields, data.partName ); } } export class MMSendWorkOrder implements IToRESTApiObject, IToFullApiObject { constructor( public workOrderId: string, public lot: string, public split: string, public sub: string, /** One of {@code MMWorkOrderStatus} (preferred) or a free-form string. */ public status: MMWorkOrderStatusValue, public dueDate: string | null, public description: string, public scheduledStartDate: string | null, public scheduledEndDate: string | null, public closedDate: string | null, public quantityRequired: number, public partNumber: string, public partRevision: string, public method: string, public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { workOrderId: this.workOrderId, lot: this.lot, split: this.split, sub: this.sub, status: this.status, dueDate: this.dueDate, description: this.description, scheduledStartDate: this.scheduledStartDate, scheduledEndDate: this.scheduledEndDate, closedDate: this.closedDate, quantityRequired: this.quantityRequired.toString(), partNumber: this.partNumber, partRevision: this.partRevision, method: this.method, }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } static fromPlainObject(data: Record): MMSendWorkOrder { return new MMSendWorkOrder( data.workOrderId || "", data.lot || "", data.split || "", data.sub || "", data.status || "", data.dueDate ?? null, data.description || "", data.scheduledStartDate ?? null, data.scheduledEndDate ?? null, data.closedDate ?? null, parseFloat(data.quantityRequired || "0"), data.partNumber || "", data.partRevision || "", data.method || "", data.customFieldsSchemaRef, data.customFields ); } } export class MMSendWorkOrderOperation implements IToRESTApiObject, IToFullApiObject { constructor( public workOrderId: string, public lot: string, public split: string, public sub: string, public sequenceNumber: string, public resourceId: string, public startQuantity: number, public finishQuantity: number, public expectedRejectRate: number, public scheduledStartDate: string | null, public scheduledFinishDate: string | null, public closedDate: string | null, public cycleTimeMs: number, public setupTimeMs: number, public productionburdenRateHourly: number, public setupburdenRatehourly: number, public operationType: string, public quantityPerPart: number, /** One of {@code MMWorkOrderOperationStatus} (preferred) or a free-form string. */ public status: MMWorkOrderOperationStatusValue, public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { workOrderId: this.workOrderId, lot: this.lot, split: this.split, sub: this.sub, sequenceNumber: this.sequenceNumber, resourceId: this.resourceId, startQuantity: this.startQuantity.toString(), finishQuantity: this.finishQuantity.toString(), expectedRejectRate: this.expectedRejectRate.toString(), scheduledStartDate: this.scheduledStartDate, scheduledFinishDate: this.scheduledFinishDate, closedDate: this.closedDate, cycleTimeMs: this.cycleTimeMs.toString(), setupTimeMs: this.setupTimeMs.toString(), productionburdenRateHourly: this.productionburdenRateHourly.toString(), setupburdenRatehourly: this.setupburdenRatehourly.toString(), operationType: this.operationType, quantityPerPart: this.quantityPerPart.toString(), status: this.status, }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } static fromPlainObject( data: Record ): MMSendWorkOrderOperation { return new MMSendWorkOrderOperation( data.workOrderId || "", data.lot || "", data.split || "", data.sub || "", data.sequenceNumber || "", data.resourceId || "", parseFloat(data.startQuantity || "0"), parseFloat(data.finishQuantity || "0"), parseFloat(data.expectedRejectRate || "0"), data.scheduledStartDate ?? null, data.scheduledFinishDate ?? null, data.closedDate ?? null, parseInt(data.cycleTimeMs || "0"), parseInt(data.setupTimeMs || "0"), parseFloat(data.productionburdenRateHourly || "0"), parseFloat(data.setupburdenRatehourly || "0"), data.operationType || "", parseFloat(data.quantityPerPart || "1"), data.status || "", data.customFieldsSchemaRef, data.customFields ); } } /** * Partial work order operation payload for labor-ticket NATS sync: natural keys plus only * `status`, `closedDate`, and custom field properties — no quantities, rates, or schedules. */ export type MMSendWorkOrderOperationLaborTicketSyncPatch = { closedDate?: string | null; status?: MMWorkOrderOperationStatusValue; customFields?: Record | null; customFieldsSchemaRef?: number | null; }; export class MMSendWorkOrderOperationLaborTicketSync implements IToRESTApiObject, IToFullApiObject { public customFieldsSchemaRef?: number | null; public customFields?: Record | null; constructor( public workOrderId: string, public lot: string, public split: string, public sub: string, public sequenceNumber: string, public resourceId: string, private readonly patch: MMSendWorkOrderOperationLaborTicketSyncPatch ) { if (this.patch.customFieldsSchemaRef !== undefined) { this.customFieldsSchemaRef = this.patch.customFieldsSchemaRef; } if (this.patch.customFields !== undefined) { this.customFields = this.patch.customFields; } } toRESTApiObject(): Record { const base: Record = { workOrderId: this.workOrderId, lot: this.lot, split: this.split, sub: this.sub, sequenceNumber: this.sequenceNumber, resourceId: this.resourceId, }; if (this.patch.closedDate !== undefined) { base.closedDate = this.patch.closedDate; } if (this.patch.status !== undefined && this.patch.status !== "") { base.status = this.patch.status; } return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } } /** * Status-only work order upsert: identity keys plus `status`. The MM import derives * `update_columns` from the fields present in each record, so this changes nothing but the * status — no other column can be clobbered, and `closedDate` is never touched (so the * payload is independent of erp-api's opt-in `?clearColumns` handling). Used by deletion * reconciliation to mark work orders DELETED. */ export class MMSendWorkOrderStatusPatch implements IToRESTApiObject, IToFullApiObject { constructor( public workOrderId: string, public lot: string, public split: string, public sub: string, public status: MMWorkOrderStatusValue ) {} toRESTApiObject(): Record { return { workOrderId: this.workOrderId, lot: this.lot, split: this.split, sub: this.sub, status: this.status, }; } toFullApiObject(): Record { return this.toRESTApiObject(); } } /** * Status-only work order operation upsert: the five identity keys plus `status`. Same * partial-update semantics as {@link MMSendWorkOrderStatusPatch}. Used by deletion * reconciliation to mark operations DELETED. */ export class MMSendWorkOrderOperationStatusPatch implements IToRESTApiObject, IToFullApiObject { constructor( public workOrderId: string, public lot: string, public split: string, public sub: string, public sequenceNumber: string, public status: MMWorkOrderOperationStatusValue ) {} toRESTApiObject(): Record { return { workOrderId: this.workOrderId, lot: this.lot, split: this.split, sub: this.sub, sequenceNumber: this.sequenceNumber, status: this.status, }; } toFullApiObject(): Record { return this.toRESTApiObject(); } } /** Full-row work order send vs status-only patch (same MM endpoint). */ export type MMSendWorkOrderPayload = MMSendWorkOrder | MMSendWorkOrderStatusPatch; /** Full-row WOO send vs partial patches (labor-ticket close-out, status-only) — same MM endpoint. */ export type MMSendWorkOrderOperationPayload = | MMSendWorkOrderOperation | MMSendWorkOrderOperationLaborTicketSync | MMSendWorkOrderOperationStatusPatch; export class MMSendReason implements IToRESTApiObject, IToFullApiObject { constructor( public reasonId: string, public category: string, public code: string, public description: string, public entityType: string, public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} toRESTApiObject(): Record & { customFields?: Record; } { const base: Record = { reasonId: this.reasonId, category: this.category, code: this.code, description: this.description, entityType: this.entityType, }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { return toFullApiObjectFromRest(this.toRESTApiObject(), this); } static fromPlainObject(data: Record): MMSendReason { return new MMSendReason( data.reasonId || "", data.category || "", data.code || "", data.description || "", data.entityType || "", data.customFieldsSchemaRef, data.customFields ); } } export class MMSendLaborTicket implements IToRESTApiObject, IToFullApiObject { constructor( public workOrderId: string, public lot: string, public split: string, public sub: string, public sequenceNumber: string, public resourceId: string, public personId: string, public laborTicketId: string, public transactionDate: string | null, public clockIn: string | null, public clockOut: string | null, public goodParts: number, public badParts: number, public type: string, public comment: string, public state: string, public customFieldsSchemaRef?: number | null, public customFields?: Record | null, public laborTicketRef?: number | null, /** Present on real-time NATS creates; often absent on ERP import/sync paths */ public machineRef?: string | null, /** * MM reason code (e.g. "NCTQ"). When present, erp-api's import handler writes the * rel_labor_ticket_reason association (insertErpLaborTicketsWithReasonsMutation), so * NATS-created/closed tickets carry the same reason association as Dashboard-created * ones. Without it, the to-erp poller recomputes a reason-less business signature that * never matches the NATS-recorded signature, defeating ledger dedup. */ public reasonId?: string | null ) {} toRESTApiObject(): Record & { customFields?: Record; machineRef?: string; } { const base: Record = { workOrderId: this.workOrderId, lot: this.lot, split: this.split, sub: this.sub, sequenceNumber: this.sequenceNumber, resourceId: this.resourceId, personId: this.personId, laborTicketId: this.laborTicketId, transactionDate: this.transactionDate, clockIn: this.clockIn, clockOut: this.clockOut, goodParts: this.goodParts.toString(), badParts: this.badParts.toString(), type: this.type, comment: this.comment, state: this.state, ...(this.machineRef != null && this.machineRef !== "" ? { machineRef: this.machineRef } : {}), // Only send reasonId when set. erp-api treats an absent reasonId as "no change" and // an explicit null as "delete the existing reason" — so omitting it for reason-less // tickets avoids clobbering associations. ...(this.reasonId != null && this.reasonId !== "" ? { reasonId: this.reasonId } : {}), }; return applyNonEmptyCustomFieldsToRest(base, this.customFields); } toFullApiObject(): Record { const fullObj = toFullApiObjectFromRest(this.toRESTApiObject(), this); // Include laborTicketRef for signature generation (not sent to API, but needed for dedup) if (this.laborTicketRef !== undefined) { fullObj.laborTicketRef = this.laborTicketRef; } return fullObj; } static fromPlainObject( data: Record ): MMSendLaborTicket { const machineRefRaw = data.machineRef; const machineRef = machineRefRaw != null && machineRefRaw !== "" ? String(machineRefRaw) : undefined; // Accept a top-level reasonId, or derive it from an enriched reasons[] association // (the NATS listener attaches reasons: [{ reason: { reasonId, ... } }] before writing). const reasonId = data.reasonId ?? data.reasons?.[0]?.reason?.reasonId ?? null; return new MMSendLaborTicket( data.workOrderId || "", data.lot || "", data.split || "", data.sub || "", data.sequenceNumber || "", data.resourceId || "", data.personId || "", data.laborTicketId || "", data.transactionDate ?? null, data.clockIn ?? null, data.clockOut ?? null, parseInt(data.goodParts || "0"), parseInt(data.badParts || "0"), data.type || "", data.comment || "", data.state || "", data.customFieldsSchemaRef, data.customFields, data.laborTicketRef, machineRef, reasonId ); } } /** * Flattened interface for Work Order related entities that maintains referential integrity * by processing parts, part operations, work orders, and work order operations together */ export interface MMSendWorkOrderBatch { parts: MMSendPart[]; partOperations: MMSendPartOperation[]; workOrders: MMSendWorkOrder[]; workOrderOperations: MMSendWorkOrderOperation[]; } //#region MMSendDispatchPriority export class MMSendDispatchPriority { constructor( /** The operation's id as issued by the ordering authority. Required by the SDK (MM does not enforce it). */ public authorityOperationId: string, /** The queue the ordering is valid within: an ERP resource id (machine or group). */ public scopeResourceId: string, public workOrderId: string, /** Routing step (MM stores it numerically). Not the sort key. */ public sequenceNumber: string | number, public lot: string, public split: string, public sub: string, /** Ordinal sort key within the scope; no contiguity or integrality promise. */ public priority: number, /** The authority's pin to one machine within a group scope; null when ranked at large or the scope is a machine. */ public preallocatedMachineResourceId: string | null = null, public customFieldsSchemaRef?: number | null, public customFields?: Record | null ) {} /** * Hasura `erpWorkOrderOperationDispatchPriorities_insert_input` object. * * Built from this row **as `dispatchPriorityInputSchema` parses it**, not from the raw * fields, so a row constructed directly with a numeric `lot` or `authorityOperationId` * is canonicalized exactly as one from `fromPlainObject` would be. Validation and the * wire payload therefore agree by construction: nothing can pass * `assertValidDispatchPriorityRows` and then fail here. `companyId` is a session preset * and must not be sent. * * @throws {ZodError} if the row is not usable — the same rule validation applies */ toGraphqlInsertInput(): Record { const row = dispatchPriorityInputSchema.parse(this); const input: Record = { authorityOperationId: row.authorityOperationId, scopeResourceId: row.scopeResourceId, workOrderId: row.workOrderId, // Sent already rounded to float32: exactly the value the real column will hold, so the // identity key, the rank order and the stored row agree whatever path Hasura takes. sequenceNumber: Math.fround(row.sequenceNumber), lot: row.lot, split: row.split, sub: row.sub, priority: Math.fround(row.priority), preallocatedMachineResourceId: row.preallocatedMachineResourceId, }; // As on the operations path (50bdd4e): a schema ref travels only with values. A ref // attached to no custom fields is metadata about nothing and is not sent. if (row.customFields && Object.keys(row.customFields).length > 0) { input.customFields = row.customFields; if (row.customFieldsSchemaRef !== null) { input.customFieldsSchemaRef = row.customFieldsSchemaRef; } } return input; } /** * From a cache/JSON-style row, validated by `dispatchPriorityInputSchema`. Text fields * accept strings and numbers, numeric fields accept numbers and numeric text, and * anything else throws naming the field. * * Unlike the older send models, which coerce a missing field to `""` and carry on, this * refuses to build a row it cannot vouch for: a `0` silently becoming `""` is an identity * nobody wrote, and a `false` becoming rank 0 is an ordering nobody wrote. * * @throws {ZodError} naming the field when the row is not usable */ static fromPlainObject(data: Record): MMSendDispatchPriority { const row = dispatchPriorityInputSchema.parse(data); return new MMSendDispatchPriority( row.authorityOperationId, row.scopeResourceId, row.workOrderId, row.sequenceNumber, row.lot, row.split, row.sub, row.priority, row.preallocatedMachineResourceId, row.customFieldsSchemaRef, row.customFields ); } } //#endregion MMSendDispatchPriority