You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mail-yubao/tests/unit/split-instructions.test.ts

430 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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\" <op@x.com> 写道:",
"Dear,",
"DO请查收见附件",
"在 2026-03-15 09:00:00 , \"op\" <op@x.com> 写道:",
"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 , \"新辰泽\" <a@b.com> 写道:",
"Dear,",
"WHSU8127240= 原箱号 换标",
"在 2026-05-20 15:55:44 , \"新辰泽\" <a@b.com> 写道:",
"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\" <op@x.com> 写道:",
"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:朱甜甜<ztt@cnwally.com.cn>",
"Date:2026-07-22 23:18:44(美国山区 (GMT-07:00))",
"TCNU4412465 WL003 FBA19DYHCDLS-XPB2-37件",
"安排卡转海,麻烦安排贴标操作。",
"朱甜甜<ztt@cnwally.com.cn> 在 2026年7月10日 周五 17:07 写道:",
"TCNU4412465",
"WL103262 50433。6件",
"这两票客户需要美时7/10 下午2点前送达",
"朱甜甜<ztt@cnwally.com.cn> 在 2026年7月1日 周三 17:15 写道:",
"TCNU4412465",
"这两票总共31件记得叫仓库拍一下照片过来哈。",
"朱甜甜<ztt@cnwally.com.cn> 在 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:朱甜甜<ztt@cnwally.com.cn>",
"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<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<cs3@xinfenginc.com>",
"日期 2026年07月09日 15:32",
"收件人fancy<fancy@link-ever.com>、receiving<receiving@usasinogroup.com>、hui",
"<hui@usasinogroup.com>、luo<luo@usasinogroup.com>",
"主题 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<cs3@xinfenginc.com>",
"日期 2026年07月09日 15:32",
"收件人fancy<fancy@link-ever.com>、receiving<receiving@usasinogroup.com>、hui",
"<hui@usasinogroup.com>、luo<luo@usasinogroup.com>、date<date@usasi",
"nogroup.com>、op10<op10@xinfenginc.com>、cs1<cs1@xinfenginc.com>",
"主题 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|请查收新增预报/);
});
});