import { describe, expect, it } from "vitest"; import { detectUiKindsFromText, extractForecastInstructionText, extractMailInstructions, parseInstructionRoundAtMs, splitBodySegments, stripMobileMailChrome, extractForwardChromeMeta, } from "@/services/parse/split-instructions"; import { matchKeywordRules } from "@/services/parse/instruction-lexicon"; const MAIL4_SUBJECT = "Fw: \u8f6c\u53d1\uff1a\u65b0\u589e\u9884\u62a5136\uff1a\u65b0\u8fb0\u6cfd+WHLC027G597465+WHSU8127240+\u6d1b\u6749\u77f6+40HQ+EDT2026.04-25 ETA2026.05-15\u8239\u540d\u822a\u6b21HMM EMERALD 013E+\u63d0\u62c6\u6d3e\u7ec4\u5408\u67dc-\u4e0d\u5e26\u6258\u67b6"; const MAIL4_BODY = [ "Dear,", "\u539f\u7bb1\u53f7YT2604021091=FBA199R49LD6-MDW2=76\u4ef6\u64cd\u4f5c\u6307\u4ee4", "\u6362\u6807\u540e\u5355\u53f7\uff1aFBA19HW52S0L-2G1MCB6X-HIA1=76\u4ef6", "1\uff1a\u8986\u76d6\u8d34FBA\u6807\u7b7e\uff0c\u4e00\u7bb1\u8d34\u4e24\u5f20", "3\uff1a\u8d34\u597d\u62cd\u7167\u56de\u4f20", ].join("\n"); const MAIL4_FILES = [ "YT2604021091=76\u4ef6\u6362\u6807\u8d70HIA1\u5361\u6d3e\u51fa\u5e93\u64cd\u4f5c\u6307\u4ee4.xlsx", "FBA19HW52S0L-2G1MCB6X-HIA1.pdf", ]; const MULTI_DATE_BODY = [ "\u65b0\u589e\u8f6c\u4ed3\uff0c\u8bf7\u7559\u610f", "\u67dc\u53f7\uff1aMATU2745683", "------------------ \u539f\u59cb\u7535\u5b50\u90ae\u4ef6 ------------------", "\u53d1\u4ef6\u4eba\uff1aa@b.com", "\u4e3b\u9898\uff1a\u65b0\u589e\u9884\u62a5136\uff1a\u65b0\u8fb0\u6cfd+WHSU8127240", "\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5", ].join("\n"); describe("splitBodySegments", () => { it("splits on quote header", () => { const segs = splitBodySegments(MULTI_DATE_BODY); expect(segs.length).toBeGreaterThanOrEqual(2); expect(segs[0]).toContain("\u65b0\u589e\u8f6c\u4ed3"); }); }); describe("matchKeywordRules", () => { it("maps hard words to ui kinds", () => { expect(matchKeywordRules("\u8bf7\u8d34\u6807\u5e76\u62cd\u7167", "body").map((h) => h.uiKind)).toEqual( expect.arrayContaining(["work_order"]), ); expect(matchKeywordRules("\u65b0\u589e\u9884\u62a5", "subject").map((h) => h.uiKind)).toContain( "forecast", ); expect(matchKeywordRules("\u65b0\u589e\u8f6c\u4ed3", "body").map((h) => h.uiKind)).toContain( "transfer", ); }); }); describe("detectUiKindsFromText", () => { it("subject forecast", () => { expect(detectUiKindsFromText(MAIL4_SUBJECT, "", [])).toContain("forecast"); }); it("body work_order", () => { expect(detectUiKindsFromText("", MAIL4_BODY, [])).toContain("work_order"); }); it("DO filename", () => { expect(detectUiKindsFromText("", "", ["WHSU8127240-DO.pdf"])).toContain( "do_upload", ); }); }); describe("do_upload dedupe", () => { it("does not emit two DO cards when forecast body + DO.pdf both hit", () => { const body = [ "Dear,", "原箱号YT2604021091=76件操作指令", "换标后单号:FBA19HW52S0L=76件", "3:贴好拍照回传", "------------------ 原始邮件邮件 ------------------", "发件人:a@b.com", "日期:2026-05-08 19:15:53", "主题:新增预报136:新辰泽+WHSU8127240", "Dear,", "请查收新增预报: 新辰泽 +WHLC027G597465+WHSU8127240+ 洛杉矶", "+40HQ+EDT2026.04-25 ETA2026.05-15 船名航次 HMM EMERALD 013E+", "提拆派组合柜 - 不带托架DO也同步上传至附件请注意查收!", ].join("\n"); const units = extractMailInstructions({ subject: MAIL4_SUBJECT, body, filenames: ["WHSU8127240-DO.pdf", ...MAIL4_FILES], }); const dos = units.filter((u) => u.uiKind === "do_upload"); expect(dos).toHaveLength(1); expect(dos[0].isCurrent).toBe(true); expect(dos[0].source).toBe("body_segment"); }); }); describe("extractMailInstructions display order", () => { it("sorts units newest round first by roundAt", () => { const body = [ "更新派送单 地址更新 麻烦按此份为准!!!", "---- 转发的邮件 ----", "发件人江江<1817055288@qq.com>", "日期 2026年3月28日 下午 5:56", "主题 更新地址", "在 2026-03-20 10:00:00 , \"op\" 写道:", "Dear,", "DO请查收见附件", "在 2026-03-15 09:00:00 , \"op\" 写道:", "Dear,", "请查收新增预报: 新辰泽 +EGLV143661466958+TRHU8524770", ].join("\n"); const units = extractMailInstructions({ subject: "Fw: 新增预报136:TRHU8524770+EGLV143661466958+40HQ", body, filenames: ["TRHU8524770-DO-1.pdf"], }); const kinds = units.map((u) => u.uiKind); const ci = kinds.indexOf("work_order"); const doIdx = kinds.indexOf("do_upload"); const fc = kinds.indexOf("forecast"); expect(ci).toBeGreaterThanOrEqual(0); expect(doIdx).toBeGreaterThanOrEqual(0); expect(fc).toBeGreaterThanOrEqual(0); expect(ci).toBeLessThan(doIdx); expect(doIdx).toBeLessThan(fc); }); it("mail4 work_order rounds newest first", () => { const body = [ "Dear,", "原箱号YT2604021091=76件操作指令", "在 2026-05-25 13:45:03 , \"新辰泽\" 写道:", "Dear,", "WHSU8127240= 原箱号 换标", "在 2026-05-20 15:55:44 , \"新辰泽\" 写道:", "Dear,", "换标 167件操作指令", ].join("\n"); const units = extractMailInstructions({ subject: MAIL4_SUBJECT, body, filenames: [], }); const wo = units.filter((u) => u.uiKind === "work_order"); const times = wo.map((u) => parseInstructionRoundAtMs(u.roundAt) ?? 0); for (let i = 1; i < times.length; i++) { expect(times[i - 1]).toBeGreaterThanOrEqual(times[i]); } }); }); describe("parseInstructionRoundAtMs", () => { it("parses Chinese date", () => { expect(parseInstructionRoundAtMs("2026年3月28日 17:56")).toBeTruthy(); }); }); describe("extractMailInstructions multi-date", () => { it("mail4: forecast + work_order as separate units", () => { const units = extractMailInstructions({ subject: MAIL4_SUBJECT, body: [ MAIL4_BODY, "在 2026-05-01 09:00:00 , \"op\" 写道:", "Dear,", "请查收新增预报: 新辰泽 +WHSU8127240", ].join("\n"), filenames: MAIL4_FILES, }); const kinds = units.map((u) => u.uiKind); expect(kinds).toContain("forecast"); expect(kinds).toContain("work_order"); const wo = units.find((u) => u.uiKind === "work_order"); expect(wo?.segmentText).toMatch(/操作指令|换标/); }); it("keeps transfer(current) + forecast(history) as two units", () => { const units = extractMailInstructions({ subject: "MATU2745683", body: MULTI_DATE_BODY, filenames: [], }); expect(units.some((u) => u.uiKind === "transfer" && u.isCurrent)).toBe(true); expect(units.some((u) => u.uiKind === "forecast")).toBe(true); expect(units.length).toBeGreaterThanOrEqual(2); }); it("does not duplicate subject forecast when body already has forecast", () => { const units = extractMailInstructions({ subject: MAIL4_SUBJECT, body: "\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5\uff1a\u65b0\u8fb0\u6cfd+WHSU8127240+\u63d0\u62c6\u6d3e", filenames: [], }); const forecasts = units.filter((u) => u.uiKind === "forecast"); expect(forecasts).toHaveLength(1); expect(forecasts[0].source).toBe("body_segment"); }); it("mail4 current unit is work_order not forecast", () => { const units = extractMailInstructions({ subject: MAIL4_SUBJECT, body: MAIL4_BODY, filenames: MAIL4_FILES, }); const current = units.filter((u) => u.isCurrent); expect(current.some((u) => u.uiKind === "work_order")).toBe(true); expect(current.every((u) => u.uiKind !== "forecast")).toBe(true); }); it("splits nested QQ inline quote headers on mail98-style thread", () => { const body = [ "Forwarded message:", "From:朱甜甜", "Date:2026-07-22 23:18:44(美国山区 (GMT-07:00))", "TCNU4412465 WL003 FBA19DYHCDLS-XPB2-37件", "安排卡转海,麻烦安排贴标操作。", "朱甜甜 在 2026年7月10日 周五 17:07 写道:", "TCNU4412465", "WL103262 50433。6件", "这两票客户需要美时7/10 下午2点前送达", "朱甜甜 在 2026年7月1日 周三 17:15 写道:", "TCNU4412465", "这两票总共31件记得叫仓库拍一下照片过来哈。", "朱甜甜 在 2026年6月13日 周六 15:47 写道:", "TCNU4412465+ ONEYNB3BW9236300+船名航次:WAN HAI 511-V.E110+ETA:6/22", "拆柜清单", "派送要求: 1,分别按票打板 千万不要混打", ].join("\n"); const segs = splitBodySegments(body); expect(segs.length).toBeGreaterThanOrEqual(4); const units = extractMailInstructions({ subject: "Fw: Fw:贴标操作:派送指令:拍照指令:do请查收:TCNU4412465+ ONEYNB3BW9236300+船名航次:WAN HAI 511-V.E110+ETA:6/22+UPS-6件+亚马逊卡派-1275件+私人地址-30件+拆柜清单", body, filenames: [], }); const wo = units.filter((u) => u.uiKind === "work_order"); expect(wo.length).toBeGreaterThanOrEqual(2); expect(units.some((u) => u.uiKind === "work_order")).toBe(true); expect(units.some((u) => u.uiKind === "forecast")).toBe(true); expect(wo.some((u) => /贴标/.test(u.segmentText))).toBe(true); expect(wo.some((u) => /拍一下照片|拍照/.test(u.segmentText))).toBe(true); const forecast = units.find((u) => u.uiKind === "forecast"); expect(forecast?.isCurrent).toBe(false); expect(forecast?.roundAt).toMatch(/2026年7月1日|2026年6月13日/); }); it("adds forecast from ops subject when current body is work_order", () => { const subject = "Fw: Fw:贴标操作:派送指令:拍照指令:do请查收:TCNU4412465+ ONEYNB3BW9236300+船名航次:WAN HAI 511-V.E110+ETA:6/22+UPS-6件+亚马逊卡派-1275件+私人地址-30件+拆柜清单"; const body = [ "Forwarded message:", "From:朱甜甜", "Date:2026-07-22 23:18:44(美国山区 (GMT-07:00))", "Subject:贴标操作:派送指令:拍照指令:do请查收:TCNU4412465+ ONEYNB3BW9236300+船名航次:WAN HAI 511-V.E110+ETA:6/22+UPS-6件+亚马逊卡派-1275件+私人地址-30件+拆柜清单", "TCNU4412465 WL003 FBA19DYHCDLS-XPB2-37件", "安排卡转海,麻烦安排贴标操作。", "注意:ups默认一件贴2张面单", ].join("\n"); const units = extractMailInstructions({ subject, body, filenames: ["WL100244-37-XPB2(1).pdf"], }); expect(units.some((u) => u.uiKind === "work_order")).toBe(true); expect(units.some((u) => u.uiKind === "forecast")).toBe(true); expect(units.find((u) => u.uiKind === "forecast")?.isCurrent).toBe(false); }); it("splits reply-original boundary into forecast + redirect-delivery", () => { const body = [ "Forwarded message:", "From:km16@kmfba.com", "Date:2026-07-21 02:44:04(美国山区 (GMT-07:00))", "Subject:回复:ZJYC COSU6504130784 FCIU7089474 7.10 派拆柜派送", " FCIU7089474 MR03002964 改地址派送到 7400 E. Slauson Ave commerce. CA 90040", "", "km16", "km16@kmfba.com", "", "---- 回复的原邮件 ----", "发送日期2026年6月26日 14:09", "主题ZJYC COSU6504130784 FCIU7089474 7.10 派拆柜派送", "", "FCIU7089474 提柜 COSCO 40HQ ETA:7.10 卡派拆柜派送", "km16", "", "km16@kmfba.com", ].join("\n"); const units = extractMailInstructions({ subject: "Fw: Fw:回复:ZJYC COSU6504130784 FCIU7089474 7.10 派拆柜派送", body, filenames: [], }); expect(units.some((u) => u.uiKind === "work_order")).toBe(true); expect(units.some((u) => u.uiKind === "forecast")).toBe(true); expect( units.some( (u) => /改地址派送到/.test(u.segmentText) && u.uiKind === "work_order" && u.keywords.includes("改地址派送"), ), ).toBe(true); expect( units.some( (u) => u.uiKind === "forecast" && /提柜 COSCO 40HQ ETA:7.10 卡派拆柜派送/.test(u.segmentText), ), ).toBe(true); }); }); describe("extractForecastInstructionText", () => { it("isolates forecast from label body", () => { const t = extractForecastInstructionText({ subject: MAIL4_SUBJECT, body: MAIL4_BODY, }); expect(t).not.toMatch(/\u8986\u76d6\u8d34/); }); it("prefers modulesInstruction over foreign body segments", () => { const t = extractForecastInstructionText({ subject: "Fw: 转发:派送要求更新: 拆柜清单更新: DO请查收+拆柜清单更新: WHSU5574991+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+拆柜清单", body: [ "拆柜清单更新 以此为准", "注意:", "1.WL103516 以上几票需要拦截", "2.WL101167 这2票改自提", "OOCL SINGAPORE / 065W+ETA : 6/28+FedEx-29 件 + 拦截 -209 件 + 拆柜清单", ].join("\n"), modulesInstruction: "船名航次 OOCL SINGAPORE / 065W", }); expect(t).toBe("船名航次 OOCL SINGAPORE / 065W"); expect(t).not.toMatch(/拦截|改自提|拆柜清单更新|WL103516/); }); it("does not treat ETA/船名 work-order body as forecast Instruction", () => { const t = extractForecastInstructionText({ subject: "DO请查收+拆柜清单更新: WHSU5574991+船名航次:OOCL SINGAPORE / 065W", body: "拆柜清单更新 以此为准\n注意:需要拦截\n船名航次:OOCL SINGAPORE / 065W+ETA:6/28", }); expect(t).toMatch(/船名航次.*OOCL SINGAPORE/); expect(t).not.toMatch(/拦截|拆柜清单更新|注意/); }); it("extracts 时间/发件人/主题 from QQ forward chrome, ignores 收件人", () => { const body = [ "Dear, 请处理。", "", "---- 转发的邮件 ----", "发件人cs3", "日期 2026年07月09日 15:32", "收件人fancy、receiving、hui", "、luo", "主题 TIIU8073522-90022", "", "贴标后拍照回传", ].join("\n"); const meta = extractForwardChromeMeta(body); expect(meta.from).toMatch(/cs3.*cs3@xinfenginc\.com/); expect(meta.at).toMatch(/2026年07月09日\s*15:32/); expect(meta.subject).toBe("TIIU8073522-90022"); // 收件人不得进 from/subject expect(meta.from).not.toMatch(/fancy|receiving|hui@/); expect(meta.subject).not.toMatch(/fancy|收件人/); const units = extractMailInstructions({ subject: "Fw: TIIU8073522-90022", body, }); const cur = units.find((u) => u.isCurrent) || units[0]; expect(cur?.segmentSubject).toBe("TIIU8073522-90022"); expect(cur?.roundFrom).toMatch(/cs3/); expect(cur?.roundAt).toMatch(/2026年07月09日/); expect(cur?.segmentText || "").not.toMatch(/收件人fancy|转发的邮件/); }); it("strips QQ desktop 转发的邮件 header block", () => { const body = [ "Dear,", "请贴标后拍照回传,谢谢。", "", "---- 转发的邮件 ----", "发件人cs3", "日期 2026年07月09日 15:32", "收件人fancy、receiving、hui", "、luo、date、op10、cs1", "主题 TIIU8073522-90022", "", "原业务说明可在此继续", ].join("\n"); const t = stripMobileMailChrome(body); expect(t).not.toMatch(/转发的邮件/); expect(t).not.toMatch(/发件人cs3|收件人fancy|usasinogroup|主题 TIIU|日期 2026/); expect(t).toMatch(/请贴标后拍照回传/); expect(t).toMatch(/原业务说明可在此继续/); }); it("strips QQ/iPhone mobile forward chrome from instruction", () => { const body = [ "Fw: 转发:新增预报136:新辰泽+WHLC027G597465+WHSU8127240+洛杉矶+40HQ", "船名航次HMM EMERALD 013E+提拆派组合柜-不带托架", "【发件人75440098<75440098@qq.com>", "收件人3457189498<3457189498@qq.com>", "大小 1.4M", "时间 2026年7月14日 16:00", "发自我的iPhone】", "", "请查收新增预报资料", ].join("\n"); const t = extractForecastInstructionText({ subject: "Fw: 转发:新增预报136:新辰泽+WHSU8127240", body, }); expect(t).not.toMatch(/发件人75440098|收件人3457189498|1\.4M|发自我的iPhone|2026年7月14日/); expect(t).toMatch(/新增预报|船名航次|WHSU8127240|请查收新增预报/); }); });