import { afterEach, describe, expect, it, vi } from "vitest"; // CoreConfiguration requires this env var; set before any SDK module loads. process.env.MM_MAPPING_AUTH_SERVICE_URL = "https://api.test.machinemetrics.com"; import { StandardProcessDrivers } from "./standard-process-drivers.js"; import { MMEntityProcessor } from "./mm-entity-processor.js"; import { ERPObjType } from "../../types/erp-types.js"; import type { LaborWorkOrderGapFillTracker, TrackedLaborReferencedWorkOrder } from "../labor-work-order-gap-fill.js"; import type { BatchCacheManager } from "../../services/caching-service/batch-cache-manager.js"; import type { FlattenedWorkOrderRow } from "../../types/flattened-work-order.js"; import { MMSendPart, MMSendPartOperation, MMSendWorkOrder, MMSendWorkOrderOperation, type MMSendWorkOrderBatch, } from "../../services/mm-api-service/types/send-types.js"; // What the gap fill WRITES and what it RETURNS are separate decisions, and conflating them // breaks labor import in two directions: // - the returned ops widen the labor filter's key set for this cycle, so they must cover // every op on the fetched work orders: a ticket that closed since last cycle no longer // produces a "missing" key, and narrowing to the written ops rejects its clock-out; // - the written ops need their part / part-op / work-order rows to exist, so a work order // the bulk sync is not covering has to be written as the full four-entity set. const bulkOp = (workOrderId: string, seq: string) => ({ work_order_id: workOrderId, sequence_number: seq, sub: "0" }); const ticket = (workOrderId: string, seq: string, state: "OPEN" | "CLOSED") => ({ workOrderId, sub: "0", sequenceNumber: seq, state }); const part = (p: string) => new MMSendPart(p, "A", "Standard"); const partOp = (p: string, seq: string) => new MMSendPartOperation(p, "A", "Standard", seq, "R1", 0, 0, "", 1); const workOrder = (id: string, terminal: boolean) => new MMSendWorkOrder(id, "0", "0", "0", terminal ? "COMPLETED" : "RELEASED", null, "", null, null, terminal ? "2026-09-20T00:00:00.000Z" : null, 1, `P-${id}`, "A", "Standard"); const op = (id: string, seq: string) => new MMSendWorkOrderOperation(id, "0", "0", "0", seq, "R1", 0, 0, 0, null, null, null, 0, 0, 0, 0, "Production", 1, "RELEASED"); const seqs = (ops: ReadonlyArray<{ sequenceNumber?: unknown; sequence_number?: unknown }>) => ops.map((o) => String(o.sequenceNumber ?? o.sequence_number)); /** A batch carrying `ops` for work order `id`, with every backing row. */ const batchFor = (id: string, terminal: boolean, ...ops: string[]): MMSendWorkOrderBatch => ({ parts: [part(`P-${id}`)], partOperations: ops.map((s) => partOp(`P-${id}`, s)), workOrders: [workOrder(id, terminal)], workOrderOperations: ops.map((s) => op(id, s)), }); const merge = (...batches: MMSendWorkOrderBatch[]): MMSendWorkOrderBatch => ({ parts: batches.flatMap((b) => b.parts), partOperations: batches.flatMap((b) => b.partOperations), workOrders: batches.flatMap((b) => b.workOrders), workOrderOperations: batches.flatMap((b) => b.workOrderOperations), }); const EMPTY: MMSendWorkOrderBatch = { parts: [], partOperations: [], workOrders: [], workOrderOperations: [] }; /** Yesterday, so a tracked entry is inside the TTL. */ const recently = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const tracked = (id: string): TrackedLaborReferencedWorkOrder => ({ workOrderId: id, lastLaborSeenAt: recently }); const stubTracker = (initial: TrackedLaborReferencedWorkOrder[] = []) => { const save = vi.fn<(t: ReadonlyArray) => void>(); const tracker = { load: vi.fn(() => initial), save } as unknown as LaborWorkOrderGapFillTracker; return { tracker, save }; }; const run = (opts: { tickets: ReturnType[]; bulkOps?: ReturnType[]; tracked?: TrackedLaborReferencedWorkOrder[]; fetched: MMSendWorkOrderBatch | Error; write?: (batch: MMSendWorkOrderBatch) => Promise; includeClosedTickets?: boolean; }) => { const { tracker, save } = stubTracker(opts.tracked); const fetchWorkOrders = vi.fn(async () => { if (opts.fetched instanceof Error) throw opts.fetched; return opts.fetched; }); const writeBatch = vi.fn(opts.write ?? (async () => ({ ok: true }))); const result = StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({ connectorType: "test", laborTickets: opts.tickets, bulkSyncedWorkOrderOperations: opts.bulkOps ?? [], fetchWorkOrders, writeBatch, tracker, includeClosedTickets: opts.includeClosedTickets, }); return { result, fetchWorkOrders, writeBatch, save }; }; describe("StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets", () => { afterEach(() => vi.restoreAllMocks()); it("returns every op on the fetched work orders so a ticket that closed since last cycle still imports", async () => { const { result, writeBatch } = run({ tracked: [tracked("J1")], tickets: [ticket("J1", "30", "CLOSED")], fetched: batchFor("J1", false, "30", "40"), }); const r = await result; expect(seqs(r.keySetWorkOrderOps)).toEqual(["30", "40"]); // Op 30 is handed to the labor filter, so it is written rather than assumed present. expect(seqs(writeBatch.mock.calls[0][0].workOrderOperations)).toEqual(["30"]); }); it("retries an operation whose earlier write failed, after the operator clocked out", async () => { const { result, writeBatch } = run({ tracked: [tracked("J1")], tickets: [ticket("J1", "30", "CLOSED")], fetched: batchFor("J1", false, "30"), }); const r = await result; const written = writeBatch.mock.calls[0][0]; expect(seqs(written.workOrderOperations)).toEqual(["30"]); expect(written.workOrders.map((w) => w.workOrderId)).toEqual(["J1"]); expect(seqs(r.keySetWorkOrderOps)).toEqual(["30"]); }); it("skips a closed ticket whose operation the bulk sync already carries", async () => { const { result, writeBatch, fetchWorkOrders } = run({ tickets: [ticket("J2", "20", "OPEN"), ticket("J2", "10", "CLOSED")], bulkOps: [bulkOp("J2", "10")], fetched: batchFor("J2", false, "10", "20"), }); const r = await result; expect(fetchWorkOrders).toHaveBeenCalledWith(["J2"]); expect(seqs(writeBatch.mock.calls[0][0].workOrderOperations)).toEqual(["20"]); expect(seqs(r.keySetWorkOrderOps)).toEqual(["10", "20"]); }); it("writes the full entity set for a work order the bulk sync is not covering", async () => { const { result, writeBatch } = run({ tickets: [ticket("J1", "30", "OPEN")], fetched: batchFor("J1", false, "30"), }); await result; const written = writeBatch.mock.calls[0][0]; expect(seqs(written.workOrderOperations)).toEqual(["30"]); expect(written.workOrders.map((w) => w.workOrderId)).toEqual(["J1"]); expect(written.parts.map((p) => p.partNumber)).toEqual(["P-J1"]); expect(seqs(written.partOperations)).toEqual(["30"]); }); it("carries terminal status onto the work order when a filled work order closes out", async () => { const { result, writeBatch } = run({ tracked: [tracked("J1")], tickets: [], fetched: batchFor("J1", true, "30", "40"), }); const r = await result; const written = writeBatch.mock.calls[0][0]; expect(written.workOrders[0].status).toBe("COMPLETED"); // No ticket is OPEN by close-out time, so the close-out writes the full op set. expect(seqs(written.workOrderOperations)).toEqual(["30", "40"]); expect(r.closingWorkOrderIds).toEqual(["J1"]); expect(r.trackedWorkOrderIds).toEqual([]); }); it("writes only the missing operation for a terminal work order the bulk sync covered this cycle", async () => { const { result, writeBatch } = run({ tickets: [ticket("J2", "20", "OPEN")], bulkOps: [bulkOp("J2", "10")], fetched: batchFor("J2", true, "10", "20"), }); const r = await result; const written = writeBatch.mock.calls[0][0]; expect(seqs(written.workOrderOperations)).toEqual(["20"]); expect(written.workOrders).toEqual([]); expect(written.parts).toEqual([]); expect(seqs(written.partOperations)).toEqual(["20"]); expect(r.closingWorkOrderIds).toEqual([]); expect(seqs(r.keySetWorkOrderOps)).toEqual(["10", "20"]); }); it("keeps a terminal work order on the watch list until its close-out write lands", async () => { const { result, save } = run({ tracked: [tracked("J1")], tickets: [], fetched: batchFor("J1", true, "30"), }); await result; // First save retains the terminal entry, so a failure after it still re-fetches the // work order next cycle; the second drops it once the write has returned. expect(save).toHaveBeenCalledTimes(2); expect(save.mock.calls[0][0].map((t) => t.workOrderId)).toEqual(["J1"]); expect(save.mock.calls[1][0]).toEqual([]); }); it("propagates a write failure and leaves the terminal work order tracked", async () => { const { result, save } = run({ tracked: [tracked("J1")], tickets: [], fetched: batchFor("J1", true, "30"), write: async () => { throw new Error("work_order_fk violation"); }, }); await expect(result).rejects.toThrow(/work_order_fk/); expect(save).toHaveBeenCalledTimes(1); expect(save.mock.calls[0][0].map((t) => t.workOrderId)).toEqual(["J1"]); }); it("propagates a fetch failure without touching the watch list", async () => { const { result, save } = run({ tracked: [tracked("J1")], tickets: [], fetched: new Error("ERP unavailable"), }); await expect(result).rejects.toThrow(/ERP unavailable/); expect(save).not.toHaveBeenCalled(); }); it("writes the terminal state of a closing work order the fetch returned without operations", async () => { // The zero-operation fetch is not a shortcut: the work order row still carries the // close-out, and skipping it would leave the id tracked until the TTL with a stale status. const { result, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [], fetched: { ...EMPTY, parts: [part("P-J1")], workOrders: [workOrder("J1", true)] }, }); const r = await result; const written = writeBatch.mock.calls[0][0]; expect(written.workOrders.map((w) => w.workOrderId)).toEqual(["J1"]); expect(written.workOrderOperations).toEqual([]); expect(r.keySetWorkOrderOps).toEqual([]); expect(save).toHaveBeenCalledTimes(2); expect(save.mock.calls[1][0]).toEqual([]); expect(r.trackedWorkOrderIds).toEqual([]); }); it("keeps a still-open work order tracked when the fetch returned it without operations", async () => { const { result, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [], fetched: { ...EMPTY, parts: [part("P-J1")], workOrders: [workOrder("J1", false)] }, }); const r = await result; expect(writeBatch).not.toHaveBeenCalled(); expect(save).toHaveBeenCalledTimes(1); expect(r.trackedWorkOrderIds).toEqual(["J1"]); }); it("passes the bulk rows through so a part shared with a covered work order is not rewritten", async () => { const fetched: MMSendWorkOrderBatch = { parts: [part("P1")], partOperations: [partOp("P1", "10"), partOp("P1", "30")], workOrders: [workOrder("J1", false), workOrder("J2", false)].map((w) => { w.partNumber = "P1"; return w; }), workOrderOperations: [op("J1", "30"), op("J2", "10"), op("J2", "30")], }; const { result, writeBatch } = run({ tickets: [ticket("J1", "30", "OPEN")], bulkOps: [bulkOp("J2", "10"), bulkOp("J2", "30")], fetched, }); await result; const written = writeBatch.mock.calls[0][0]; expect(written.workOrders.map((w) => w.workOrderId)).toEqual(["J1"]); expect(written.parts).toEqual([]); expect(written.partOperations).toEqual([]); }); it("widens the key set without writing when a tracked work order needs no write this cycle", async () => { // Still on the watch list (no terminal state yet), no ticket references a missing op: // nothing to write, but its ops still widen the key set so any of its tickets import. const { result, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [], fetched: batchFor("J1", false, "30", "40"), }); const r = await result; expect(writeBatch).not.toHaveBeenCalled(); expect(seqs(r.keySetWorkOrderOps)).toEqual(["30", "40"]); expect(r.writtenBatch).toBeNull(); expect(r.trackedWorkOrderIds).toEqual(["J1"]); expect(save).toHaveBeenCalledTimes(1); }); it("drops a tracked work order once the fetch shows the bulk sync covers every row of it", async () => { const { result, fetchWorkOrders, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [ticket("J1", "30", "CLOSED")], bulkOps: [bulkOp("J1", "30")], fetched: batchFor("J1", false, "30"), }); const r = await result; expect(fetchWorkOrders).toHaveBeenCalledWith(["J1"]); expect(writeBatch).not.toHaveBeenCalled(); expect(save).toHaveBeenCalledWith([]); expect(r.trackedWorkOrderIds).toEqual([]); expect(r.dropped.coveredByBulkSync).toBe(1); }); it("keeps a covered work order tracked until its missing-operation write lands, then releases it", async () => { // The bulk sync covers J1 but its mapping dropped op 20. The gap write for op 20 // fails, then the operator clocks out: with OPEN-only fetching, only the watch list // brings J1 back for the retry. const bulkOps = [bulkOp("J1", "10")]; const fetched = batchFor("J1", false, "10", "20"); const failed = run({ tickets: [ticket("J1", "20", "OPEN")], bulkOps, fetched, write: async () => { throw new Error("write failed"); }, }); await expect(failed.result).rejects.toThrow(/write failed/); expect(seqs(failed.writeBatch.mock.calls[0][0].workOrderOperations)).toEqual(["20"]); expect(failed.save.mock.calls[0][0].map((t) => t.workOrderId)).toEqual(["J1"]); const retried = run({ tracked: [tracked("J1")], tickets: [ticket("J1", "20", "CLOSED")], bulkOps, fetched }); const r2 = await retried.result; expect(retried.fetchWorkOrders).toHaveBeenCalledWith(["J1"]); expect(seqs(retried.writeBatch.mock.calls[0][0].workOrderOperations)).toEqual(["20"]); expect(r2.dropped.coveredByBulkSync).toBe(0); expect(r2.trackedWorkOrderIds).toEqual(["J1"]); // Nothing missing any more: covered with nothing to write, so the entry is released. const released = run({ tracked: [tracked("J1")], tickets: [], bulkOps, fetched }); const r3 = await released.result; expect(released.writeBatch).not.toHaveBeenCalled(); expect(r3.dropped.coveredByBulkSync).toBe(1); expect(r3.trackedWorkOrderIds).toEqual([]); }); it("keeps tracking a work order whose sibling lot is still outside the bulk window", async () => { // The bulk window carries lot 1 of J1; lot 2, gap-filled earlier, is not in it. Pruning // by job id would stop fetching J1 and leave lot 2's eventual terminal state out of MM. const lot = (l: string) => new MMSendWorkOrder("J1", l, "0", "0", "RELEASED", null, "", null, null, null, 1, "P-J1", "A", "Standard"); const lotOp = (l: string) => new MMSendWorkOrderOperation("J1", l, "0", "0", "30", "R1", 0, 0, 0, null, null, null, 0, 0, 0, 0, "Production", 1, "RELEASED"); const { result, save } = run({ tracked: [tracked("J1")], tickets: [], bulkOps: [{ work_order_id: "J1", lot: "1", split: "0", sub: "0", sequence_number: "30" } as ReturnType], fetched: { parts: [part("P-J1")], partOperations: [partOp("P-J1", "30")], workOrders: [lot("1"), lot("2")], workOrderOperations: [lotOp("1"), lotOp("2")] }, }); const r = await result; expect(r.trackedWorkOrderIds).toEqual(["J1"]); expect(r.dropped.coveredByBulkSync).toBe(0); expect(save.mock.calls[0][0].map((t) => t.workOrderId)).toEqual(["J1"]); }); it("closes out the uncovered lot of a terminal work order whose other lot the bulk sync covers", async () => { const lot = (l: string) => new MMSendWorkOrder("J1", l, "0", "0", "COMPLETED", null, "", null, null, "2026-09-20T00:00:00.000Z", 1, "P-J1", "A", "Standard"); const lotOp = (l: string) => new MMSendWorkOrderOperation("J1", l, "0", "0", "30", "R1", 0, 0, 0, null, null, null, 0, 0, 0, 0, "Production", 1, "RELEASED"); const { result, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [], bulkOps: [{ work_order_id: "J1", lot: "1", split: "0", sub: "0", sequence_number: "30" } as ReturnType], fetched: { parts: [part("P-J1")], partOperations: [partOp("P-J1", "30")], workOrders: [lot("1"), lot("2")], workOrderOperations: [lotOp("1"), lotOp("2")] }, }); const r = await result; const written = writeBatch.mock.calls[0][0]; expect(written.workOrders.map((w) => w.lot)).toEqual(["2"]); expect(written.workOrderOperations.map((o) => o.lot)).toEqual(["2"]); expect(r.closingWorkOrderIds).toEqual(["J1"]); // Retained until the close-out lands, then removed. expect(save.mock.calls[0][0].map((t) => t.workOrderId)).toEqual(["J1"]); expect(save.mock.calls[1][0]).toEqual([]); }); it("does nothing but prune when neither open labor nor the watch list needs a fetch", async () => { const stale = { workOrderId: "OLD", lastLaborSeenAt: "2020-01-01T00:00:00.000Z" }; const { result, fetchWorkOrders, writeBatch, save } = run({ tracked: [stale], tickets: [ticket("J1", "10", "OPEN")], bulkOps: [bulkOp("J1", "10")], fetched: EMPTY, }); const r = await result; expect(fetchWorkOrders).not.toHaveBeenCalled(); expect(writeBatch).not.toHaveBeenCalled(); expect(save).toHaveBeenCalledWith([]); expect(r.keySetWorkOrderOps).toEqual([]); expect(r.dropped).toEqual({ expired: 1, coveredByBulkSync: 0, terminal: 0, evicted: 0 }); }); it("skips the save when there is nothing to fetch and nothing was tracked", async () => { const { result, save } = run({ tickets: [], fetched: EMPTY }); await result; expect(save).not.toHaveBeenCalled(); }); it("fetches open-labor work orders and the carried watch list together", async () => { const { result, fetchWorkOrders } = run({ tracked: [tracked("J2")], tickets: [ticket("J1", "10", "OPEN")], fetched: merge(batchFor("J1", false, "10"), batchFor("J2", false, "5")), }); const r = await result; expect(fetchWorkOrders).toHaveBeenCalledWith(["J1", "J2"]); expect(r.openLaborWorkOrderIds).toEqual(["J1"]); expect(r.trackedWorkOrderIds).toEqual(["J2", "J1"]); }); it("judges an id terminal only when every fetched lot / split / sub of it is", async () => { // One completed lot must not close out (and rewrite) the sibling lot operators are // still clocked into, nor drop the id from the watch list. const lot1Done = new MMSendWorkOrder("J1", "1", "0", "0", "COMPLETED", null, "", null, null, "2026-09-20T00:00:00.000Z", 1, "P-J1", "A", "Standard"); const lot2Open = new MMSendWorkOrder("J1", "2", "0", "0", "RELEASED", null, "", null, null, null, 1, "P-J1", "A", "Standard"); const fetched: MMSendWorkOrderBatch = { parts: [part("P-J1")], partOperations: [partOp("P-J1", "30")], workOrders: [lot1Done, lot2Open], workOrderOperations: [op("J1", "30"), op("J1", "40")], }; const { result, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [], fetched }); const r = await result; expect(r.closingWorkOrderIds).toEqual([]); expect(writeBatch).not.toHaveBeenCalled(); expect(r.trackedWorkOrderIds).toEqual(["J1"]); expect(save).toHaveBeenCalledTimes(1); expect(r.dropped.terminal).toBe(0); }); it("keeps a pending close-out when only another spelling of its id is bulk-covered", async () => { // Lot 1 came back as "abc" and is covered by the bulk sync; lot 2 came back as "ABC", // is outside the window, and is terminal. Its close-out write fails, so the shared // watch-list entry must survive for a retry rather than be dropped as covered. const lot = (id: string, l: string) => new MMSendWorkOrder(id, l, "0", "0", "COMPLETED", null, "", null, null, "2026-09-20T00:00:00.000Z", 1, "P-ABC", "A", "Standard"); const fetched: MMSendWorkOrderBatch = { parts: [part("P-ABC")], partOperations: [], workOrders: [lot("abc", "1"), lot("ABC", "2")], workOrderOperations: [], }; const bulkOps = [{ work_order_id: "abc", lot: "1", split: "0", sub: "0", sequence_number: "10" } as ReturnType]; const failed = run({ tracked: [tracked("ABC")], tickets: [], bulkOps, fetched, write: async () => { throw new Error("write failed"); }, }); await expect(failed.result).rejects.toThrow(/write failed/); expect(failed.writeBatch.mock.calls[0][0].workOrders.map((w) => `${w.workOrderId}/${w.lot}`)).toEqual(["ABC/2"]); expect(failed.save).toHaveBeenCalledTimes(1); expect(failed.save.mock.calls[0][0].map((t) => t.workOrderId)).toEqual(["ABC"]); const landed = run({ tracked: [tracked("ABC")], tickets: [], bulkOps, fetched }); const r = await landed.result; expect(r.closingWorkOrderIds).toEqual(["ABC"]); expect(r.dropped).toMatchObject({ coveredByBulkSync: 0, terminal: 1 }); expect(r.trackedWorkOrderIds).toEqual([]); }); it("judges terminal state across every spelling of a work order id", async () => { // Lot 1 came back as "j1" and completed, lot 2 as "J1" and still open. The watch list // keys ids case-insensitively, so closing out "j1" alone would drop lot 2's tracking. const lot = (id: string, l: string, terminal: boolean) => new MMSendWorkOrder(id, l, "0", "0", terminal ? "COMPLETED" : "RELEASED", null, "", null, null, terminal ? "2026-09-20T00:00:00.000Z" : null, 1, "P-J1", "A", "Standard"); const mixed = (lot2Terminal: boolean): MMSendWorkOrderBatch => ({ parts: [part("P-J1")], partOperations: [], workOrders: [lot("j1", "1", true), lot("J1", "2", lot2Terminal)], workOrderOperations: [], }); const open = run({ tracked: [tracked("J1")], tickets: [], fetched: mixed(false) }); const r1 = await open.result; expect(r1.closingWorkOrderIds).toEqual([]); expect(open.writeBatch).not.toHaveBeenCalled(); expect(r1.trackedWorkOrderIds).toEqual(["J1"]); const done = run({ tracked: [tracked("J1")], tickets: [], fetched: mixed(true) }); const r2 = await done.result; expect(r2.closingWorkOrderIds.sort()).toEqual(["J1", "j1"]); expect(done.writeBatch.mock.calls[0][0].workOrders.map((w) => w.lot).sort()).toEqual(["1", "2"]); expect(r2.trackedWorkOrderIds).toEqual([]); }); it("reports requested ids the connector returned no work order for, and keeps them tracked", async () => { const { result, fetchWorkOrders } = run({ tracked: [tracked("GONE")], tickets: [ticket("J1", "30", "OPEN")], fetched: batchFor("J1", false, "30"), }); const r = await result; expect(fetchWorkOrders).toHaveBeenCalledWith(["J1", "GONE"]); expect(r.unresolvedWorkOrderIds).toEqual(["GONE"]); expect(r.trackedWorkOrderIds.sort()).toEqual(["GONE", "J1"]); }); it("writes the terminal state of a closing work order the batch carries no operations for", async () => { const { result, writeBatch, save } = run({ tracked: [tracked("J1")], tickets: [ticket("J2", "10", "OPEN")], fetched: merge({ ...EMPTY, parts: [part("P-J1")], workOrders: [workOrder("J1", true)] }, batchFor("J2", false, "10")), }); const r = await result; const written = writeBatch.mock.calls[0][0]; expect(written.workOrders.map((w) => w.workOrderId).sort()).toEqual(["J1", "J2"]); expect(r.closingWorkOrderIds).toEqual(["J1"]); // Close-out written, so J1 leaves the list; J2 was added by open labor. expect(save.mock.calls[1][0].map((t) => t.workOrderId)).toEqual(["J2"]); }); it("by default does not fetch a work order referenced only by closed tickets, and says so", async () => { const { result, fetchWorkOrders } = run({ tickets: [ticket("J1", "30", "CLOSED"), ticket("J2", "10", "OPEN"), ticket("J2", "20", "CLOSED")], fetched: batchFor("J2", false, "10", "20"), }); const r = await result; expect(fetchWorkOrders).toHaveBeenCalledWith(["J2"]); expect(r.openLaborWorkOrderIds).toEqual(["J2"]); expect(r.closedOnlyLaborWorkOrderIds).toEqual(["J1"]); }); it("fetches and tracks closed-only work orders when includeClosedTickets is set", async () => { const { result, fetchWorkOrders, writeBatch } = run({ tickets: [ticket("J1", "30", "CLOSED")], fetched: batchFor("J1", false, "30"), includeClosedTickets: true, }); const r = await result; expect(fetchWorkOrders).toHaveBeenCalledWith(["J1"]); expect(r.closedOnlyLaborWorkOrderIds).toEqual(["J1"]); expect(seqs(writeBatch.mock.calls[0][0].workOrderOperations)).toEqual(["30"]); expect(seqs(r.keySetWorkOrderOps)).toEqual(["30"]); // Not terminal yet, so it stays on the watch list for close-out like an open-labor job. expect(r.trackedWorkOrderIds).toEqual(["J1"]); }); it("does not report a work order as unresolved when only its id casing differs", async () => { const { result } = run({ tickets: [ticket("j1", "30", "OPEN")], fetched: batchFor("J1", false, "30"), }); const r = await result; expect(r.fetchedWorkOrderIds).toEqual(["j1"]); expect(r.unresolvedWorkOrderIds).toEqual([]); }); it("keeps the persisted watch list under the cap, pending close-outs first", async () => { const { tracker, save } = stubTracker([tracked("A"), tracked("B"), tracked("C")]); const run = StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({ connectorType: "test", laborTickets: [ticket("N1", "10", "OPEN"), ticket("N2", "10", "OPEN")], bulkSyncedWorkOrderOperations: [], // A and B come back terminal; C, N1, N2 stay open. Five entries, cap of three. fetchWorkOrders: async () => merge(batchFor("A", true, "1"), batchFor("B", true, "1"), batchFor("C", false, "1"), batchFor("N1", false, "10"), batchFor("N2", false, "10")), writeBatch: async () => { throw new Error("write failed"); }, tracker, maxTrackedWorkOrders: 3, }); // The write fails on purpose: the list inspected is the one saved before the write. await expect(run).rejects.toThrow(/write failed/); const persisted = save.mock.calls[0][0].map((t) => t.workOrderId); expect(persisted).toHaveLength(3); expect(persisted).toEqual(expect.arrayContaining(["A", "B"])); }); it("never evicts a pending close-out to honor the cap", async () => { const { tracker, save } = stubTracker([tracked("A"), tracked("B"), tracked("C")]); const run = StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({ connectorType: "test", laborTickets: [], bulkSyncedWorkOrderOperations: [], // A and B come back terminal, C stays open; a cap of one. fetchWorkOrders: async () => merge(batchFor("A", true, "1"), batchFor("B", true, "1"), batchFor("C", false, "1")), writeBatch: async () => { throw new Error("write failed"); }, tracker, maxTrackedWorkOrders: 1, }); // The write fails, so both close-outs must survive to be retried next cycle. await expect(run).rejects.toThrow(/write failed/); const persisted = save.mock.calls[0][0].map((t) => t.workOrderId); expect(persisted.sort()).toEqual(["A", "B"]); }); it("throws before fetching or saving anything when the watch list cannot be read", async () => { const save = vi.fn(); const tracker = { load: vi.fn(() => { throw new Error("store unreadable"); }), save, } as unknown as LaborWorkOrderGapFillTracker; const fetchWorkOrders = vi.fn(async () => EMPTY); await expect( StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({ connectorType: "test", laborTickets: [ticket("J1", "10", "OPEN")], bulkSyncedWorkOrderOperations: [], fetchWorkOrders, writeBatch: async () => undefined, tracker, }) ).rejects.toThrow(/store unreadable/); expect(fetchWorkOrders).not.toHaveBeenCalled(); expect(save).not.toHaveBeenCalled(); }); it("rejects a fetch result that is not a work order batch", async () => { const { result } = run({ tracked: [tracked("J1")], tickets: [], fetched: { workOrders: [] } as unknown as MMSendWorkOrderBatch, }); await expect(result).rejects.toThrow(/parts, partOperations, workOrderOperations/); }); it("honors a connector-supplied terminal predicate", async () => { const { tracker, save } = stubTracker([tracked("J1")]); await StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({ connectorType: "test", laborTickets: [], bulkSyncedWorkOrderOperations: [], fetchWorkOrders: async () => batchFor("J1", false, "30"), isTerminalWorkOrder: (wo) => wo.description === "" , // everything is terminal writeBatch: async () => undefined, tracker, }); expect(save).toHaveBeenCalledTimes(2); expect(save.mock.calls[1][0]).toEqual([]); }); it("writes through writeWorkOrderBatchToMM with the given cache manager when no writer is supplied", async () => { const write = vi .spyOn(MMEntityProcessor, "writeEntities") .mockResolvedValue({ message: "ok", upsertedEntities: 1, localDedupeCount: 0, apiDedupeCount: 0 }); const cache = {} as BatchCacheManager; const { tracker } = stubTracker(); const r = await StandardProcessDrivers.fillWorkOrderGapsFromLaborTickets({ connectorType: "test", laborTickets: [ticket("J1", "30", "OPEN")], bulkSyncedWorkOrderOperations: [], fetchWorkOrders: async () => batchFor("J1", false, "30"), batchCacheManager: cache, tracker, }); expect(write.mock.calls.map((c) => c[0])).toEqual([ ERPObjType.PARTS, ERPObjType.PART_OPERATION, ERPObjType.WORK_ORDERS, ERPObjType.WORK_ORDER_OPERATIONS, ]); expect(write.mock.calls.every((c) => c[2] === cache)).toBe(true); expect(r.writeResult).toMatchObject({ workOrderOperations: { upsertedEntities: 1 } }); }); it("accepts a batch built from flattened rows as the fetch result", async () => { const rows: FlattenedWorkOrderRow[] = [ { partNumber: "P1", partRevision: "A", method: "Standard", sequenceNumber: "30", resourceId: "R1", workOrderId: "J1", lot: "0", split: "0", sub: "0", status: "RELEASED", opStatus: "RELEASED" } as FlattenedWorkOrderRow, ]; const { result, writeBatch } = run({ tickets: [ticket("J1", "30", "OPEN")], fetched: StandardProcessDrivers.buildWorkOrderBatchFromFlattened(rows), }); const r = await result; expect(seqs(r.keySetWorkOrderOps)).toEqual(["30"]); expect(writeBatch.mock.calls[0][0].workOrders[0]).toBeInstanceOf(MMSendWorkOrder); }); }); describe("StandardProcessDrivers.buildWorkOrderBatchFromFlattened", () => { it("returns constructed send objects without writing", () => { const write = vi.spyOn(MMEntityProcessor, "writeEntities"); const batch = StandardProcessDrivers.buildWorkOrderBatchFromFlattened([ { partNumber: "P1", partRevision: "A", method: "Standard", sequenceNumber: "10", resourceId: "R1", workOrderId: "WO-1", lot: "0", split: "0", sub: "0", status: "RELEASED", opStatus: "RELEASED" } as FlattenedWorkOrderRow, { partNumber: "P1", partRevision: "A", method: "Standard", sequenceNumber: "20", resourceId: "R1", workOrderId: "WO-1", lot: "0", split: "0", sub: "0", status: "RELEASED", opStatus: "RELEASED" } as FlattenedWorkOrderRow, ]); expect(write).not.toHaveBeenCalled(); expect(batch.parts).toHaveLength(1); expect(batch.partOperations).toHaveLength(2); expect(batch.workOrders).toHaveLength(1); expect(batch.workOrderOperations.map((o) => o.sequenceNumber)).toEqual(["10", "20"]); expect(batch.workOrders[0]).toBeInstanceOf(MMSendWorkOrder); vi.restoreAllMocks(); }); it("fails on a missing status before constructing anything", () => { expect(() => StandardProcessDrivers.buildWorkOrderBatchFromFlattened([ { partNumber: "P1", partRevision: "A", method: "Standard", sequenceNumber: "10", resourceId: "R1", workOrderId: "WO-1", lot: "0", split: "0", sub: "0", status: null as never, opStatus: "RELEASED" } as FlattenedWorkOrderRow, ]) ).toThrow(/1 work order\(s\) and 0 work order operation\(s\) lack a non-blank string status/); }); });