import { describe, it, expect } from "vitest"; import { normalizeLaborTicketWorkOrderOpKey, isLotSplitAwareRowSet, buildSyncedWorkOrderOperationKeySet, filterLaborTicketsForMmImport, } from "./labor-import-from-erp.js"; import { tryLaborTicketLedgerErpImportKey } from "./standard-process-drivers/labor-ticket-normalization.js"; import { MMSendLaborTicket } from "../services/mm-api-service/types/send-types.js"; function lt( overrides: Partial<{ workOrderId: string; sub: string; sequenceNumber: string; resourceId: string; personId: string; }> = {} ): MMSendLaborTicket { return new MMSendLaborTicket( overrides.workOrderId ?? "job-1", "0", "0", overrides.sub ?? "0", overrides.sequenceNumber ?? "10", overrides.resourceId ?? "RES1", overrides.personId ?? "EMP1", "99", null, null, null, 0, 0, "production", "", "CLOSED" ); } describe("tryLaborTicketLedgerErpImportKey", () => { it("returns a prefixed key when work order and ERP labor id are present", () => { expect(tryLaborTicketLedgerErpImportKey(" job-1 ", "42")).toBe( "erp-import:job-1:42" ); }); it("returns null when either part is missing", () => { expect(tryLaborTicketLedgerErpImportKey("", "42")).toBeNull(); expect(tryLaborTicketLedgerErpImportKey("j1", null)).toBeNull(); }); }); describe("normalizeLaborTicketWorkOrderOpKey", () => { it("uppercases work order id and defaults empty sub to 0", () => { expect(normalizeLaborTicketWorkOrderOpKey("abc", "", "5")).toBe("ABC:0:5"); expect(normalizeLaborTicketWorkOrderOpKey("abc", "1", "5")).toBe("ABC:1:5"); }); it("treats a whitespace-only sub as empty, matching the filter's trimmed ticket sub", () => { expect(normalizeLaborTicketWorkOrderOpKey("job-1", " ", "10")).toBe( normalizeLaborTicketWorkOrderOpKey("job-1", "", "10") ); expect(normalizeLaborTicketWorkOrderOpKey("job-1", " ", "10")).toBe("JOB-1:0:10"); }); it("includes lot and split in the key only when asked", () => { expect(normalizeLaborTicketWorkOrderOpKey("job-1", "0", "10", { lot: " 2 ", split: null })).toBe("JOB-1:2::0:10"); expect(normalizeLaborTicketWorkOrderOpKey("job-1", "0", "10")).toBe("JOB-1:0:10"); }); it("builds lot / split-aware keys only when every row carries both", () => { const aware = [ { work_order_id: "J1", lot: "1", split: "0", sub: "0", sequence_number: "10" }, { work_order_id: "J1", lot: "2", split: "0", sub: "0", sequence_number: "10" }, ]; expect(isLotSplitAwareRowSet(aware)).toBe(true); expect([...buildSyncedWorkOrderOperationKeySet(aware)]).toEqual(["J1:1:0:0:10", "J1:2:0:0:10"]); const mixed = [aware[0], { work_order_id: "J2", sub: "0", sequence_number: "10" }]; expect(isLotSplitAwareRowSet(mixed)).toBe(false); expect([...buildSyncedWorkOrderOperationKeySet(mixed)]).toEqual(["J1:0:10", "J2:0:10"]); expect(isLotSplitAwareRowSet([])).toBe(false); }); it("normalizes numeric ids", () => { expect(normalizeLaborTicketWorkOrderOpKey(12, 0, 3)).toBe("12:0:3"); }); }); describe("filterLaborTicketsForMmImport", () => { it("accepts tickets matching allowlists and synced WO op keys", async () => { const keys = new Set([ normalizeLaborTicketWorkOrderOpKey("job-1", "0", "10"), ]); const { accepted, rejected } = await filterLaborTicketsForMmImport([lt()], { syncedWorkOrderOperationKeys: keys, resourceIds: ["RES1"], operatorIds: ["EMP1"], }); expect(accepted).toHaveLength(1); expect(rejected.missingFields).toBe(0); expect(rejected.notInResourceAllowlist).toBe(0); expect(rejected.notInOperatorAllowlist).toBe(0); expect(rejected.notInSyncedWorkOrderOperation).toBe(0); }); it("rejects when work order operation key is missing", async () => { const { accepted, rejected } = await filterLaborTicketsForMmImport([lt()], { syncedWorkOrderOperationKeys: new Set(), resourceIds: ["RES1"], operatorIds: ["EMP1"], }); expect(accepted).toHaveLength(0); expect(rejected.notInSyncedWorkOrderOperation).toBe(1); }); it("matches case-insensitive resource and operator", async () => { const keys = new Set([ normalizeLaborTicketWorkOrderOpKey("job-1", "0", "10"), ]); const { accepted } = await filterLaborTicketsForMmImport( [lt({ resourceId: "res1", personId: "emp1" })], { syncedWorkOrderOperationKeys: keys, resourceIds: ["RES1"], operatorIds: ["EMP1"], } ); expect(accepted).toHaveLength(1); expect(accepted[0]!.resourceId).toBe("res1"); expect(accepted[0]!.personId).toBe("EMP1"); }); it("builds WO op keys from syncedWorkOrderOperations rows", async () => { const { accepted, rejected } = await filterLaborTicketsForMmImport([lt()], { syncedWorkOrderOperations: [ { work_order_id: "job-1", sub: "0", sequence_number: "10" }, ], resourceIds: ["RES1"], operatorIds: ["EMP1"], }); expect(accepted).toHaveLength(1); expect(rejected.notInSyncedWorkOrderOperation).toBe(0); }); }); describe("filterLaborTicketsForMmImport lot / split awareness", () => { const ticketWithLot = (lot: string) => new MMSendLaborTicket("job-1", lot, "0", "0", "10", "RES1", "EMP1", "99", null, null, null, 0, 0, "production", "", "CLOSED"); it("rejects a ticket for a lot the synced rows do not carry, when the rows opt in", async () => { const result = await filterLaborTicketsForMmImport([ticketWithLot("1"), ticketWithLot("2")], { syncedWorkOrderOperations: [{ work_order_id: "job-1", lot: "1", split: "0", sub: "0", sequence_number: "10" }], resourceIds: ["RES1"], operatorIds: ["EMP1"], }); expect(result.accepted.map((t) => t.lot)).toEqual(["1"]); expect(result.rejected.notInSyncedWorkOrderOperation).toBe(1); }); it("ignores lot / split when the synced rows do not opt in", async () => { const result = await filterLaborTicketsForMmImport([ticketWithLot("1"), ticketWithLot("2")], { syncedWorkOrderOperations: [{ work_order_id: "job-1", sub: "0", sequence_number: "10" }], resourceIds: ["RES1"], operatorIds: ["EMP1"], }); expect(result.accepted).toHaveLength(2); }); });