Harden work-order confirm after CC success and clean instruction body display.

Avoids false 500 when local sync/log fails post-CC, and strips email/quote noise from customer instruction panels.

Co-authored-by: Cursor <cursoragent@cursor.com>
main
你的GitHub用户名 4 weeks ago
parent 02ad9d83ff
commit cc6a162bbe

@ -1,6 +1,7 @@
"use client"; "use client";
import { Descriptions } from "antd"; import { Descriptions } from "antd";
import { cleanCustomerInstructionDisplayBody } from "@/services/parse/work-order-fields";
export type CcCustomerInstructionValue = { export type CcCustomerInstructionValue = {
roundAt?: string; roundAt?: string;
@ -14,6 +15,7 @@ export function CcCustomerInstructionPanel({
}: { }: {
value: CcCustomerInstructionValue; value: CcCustomerInstructionValue;
}) { }) {
const body = cleanCustomerInstructionDisplayBody(value.body || "");
return ( return (
<div className="cc-customer-instruction"> <div className="cc-customer-instruction">
<Descriptions <Descriptions
@ -53,7 +55,7 @@ export function CcCustomerInstructionPanel({
overflow: "auto", overflow: "auto",
}} }}
> >
{value.body || "(无正文)"} {body || "(无正文)"}
</pre> </pre>
</div> </div>
); );

@ -594,11 +594,7 @@ export function MailBusinessSummary({
shipments: unitShipments, shipments: unitShipments,
transferPairs: unitPairs, transferPairs: unitPairs,
}); });
const roundBody = (unit.segmentText || "") const roundBody = unit.segmentText || "";
.replace(/&nbsp;/gi, " ")
.replace(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi, "")
.replace(/\s+/g, " ")
.trim();
body = ( body = (
<> <>
<CcCustomerInstructionPanel <CcCustomerInstructionPanel
@ -606,7 +602,7 @@ export function MailBusinessSummary({
roundAt: unit.roundAt?.replace(/&nbsp;/gi, " "), roundAt: unit.roundAt?.replace(/&nbsp;/gi, " "),
roundFrom: unit.roundFrom, roundFrom: unit.roundFrom,
subject: unit.segmentSubject, subject: unit.segmentSubject,
body: roundBody || unit.segmentText || "", body: roundBody,
}} }}
/> />
<CcTransferInstructionTable <CcTransferInstructionTable

@ -208,11 +208,7 @@ export function TransferConfirmView({
); );
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const roundBody = (unit.segmentText || "") const roundBody = unit.segmentText || "";
.replace(/&nbsp;/gi, " ")
.replace(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi, "")
.replace(/\s+/g, " ")
.trim();
const submitAsWorkOrder = async (gateReason: string) => { const submitAsWorkOrder = async (gateReason: string) => {
const woDraft = buildCcWorkOrderFormValue({ const woDraft = buildCcWorkOrderFormValue({
@ -345,7 +341,7 @@ export function TransferConfirmView({
roundAt: unit.roundAt?.replace(/&nbsp;/gi, " "), roundAt: unit.roundAt?.replace(/&nbsp;/gi, " "),
roundFrom: unit.roundFrom, roundFrom: unit.roundFrom,
subject: unit.segmentSubject, subject: unit.segmentSubject,
body: roundBody || unit.segmentText || "", body: roundBody,
}} }}
/> />
<CcTransferInstructionTable <CcTransferInstructionTable

@ -1,8 +1,9 @@
import { prisma } from "@/services/db"; import { prisma } from "@/services/db";
import { writeAudit } from "@/services/audit"; import { writeAudit } from "@/services/audit";
import { logger } from "@/lib/logger";
import { runWithMailCcOwner } from "@/lib/cc-owner"; import { runWithMailCcOwner } from "@/lib/cc-owner";
import { runWithMailTrace } from "@/lib/mail-trace"; import { runWithMailTrace } from "@/lib/mail-trace";
import { assertTransition } from "@/services/state-machine"; import { assertTransition, canTransition } from "@/services/state-machine";
import { import {
saveCustomerMessage, saveCustomerMessage,
type CustomerMessageEntity, type CustomerMessageEntity,
@ -124,30 +125,65 @@ export async function confirmWorkOrder(input: {
input.baseline || null, input.baseline || null,
submitted, submitted,
); );
await markInstructionImportedAndResolveMail({ try {
mailId: input.mailId, await markInstructionImportedAndResolveMail({
instructionId: checked.unit.id, mailId: input.mailId,
actor: input.actor, instructionId: checked.unit.id,
mode: result.mode, actor: input.actor,
externalId: result.externalId || null, mode: result.mode,
successLastError: result.mode === "mock" ? "WO_MOCK_OK" : null, externalId: result.externalId || null,
submitted, successLastError: result.mode === "mock" ? "WO_MOCK_OK" : null,
supplementedFields: diff.supplementedFields, submitted,
fieldMarks: diff.fieldMarks, supplementedFields: diff.supplementedFields,
userSupplemented: diff.userSupplemented, fieldMarks: diff.fieldMarks,
}); userSupplemented: diff.userSupplemented,
// 按指令落导入日志(不等整封邮件全部指令完成) });
await recordInstructionImportLog({ } catch (err) {
mailId: input.mailId, // CC 已成功:禁止再抛 500 把页面打成「服务异常」;尽量落到可恢复状态
instructionId: checked.unit.id, logger.error(
kind: "work_order", { err, mailId: String(input.mailId), instructionId: checked.unit.id },
display: logDisplay, "work_order.local_resolve_after_cc_ok",
status: "SUCCESS", );
externalId: result.externalId || null, try {
lastError: result.mode === "mock" ? "WO_MOCK_OK" : null, const to: MailStatus = canTransition("IMPORTING", "PARTIAL_SUCCESS")
requestBody: result.request ? JSON.stringify(result.request) : null, ? "PARTIAL_SUCCESS"
responseBody: result.response ? JSON.stringify(result.response) : null, : "FAILED";
}); await prisma.mailMessage.update({
where: { id: input.mailId },
data: {
status: to,
lastError: `WO_LOCAL_SYNC_AFTER_CC: ${
err instanceof Error ? err.message : String(err)
}`.slice(0, 1000),
version: { increment: 1 },
},
});
} catch (persistErr) {
logger.error(
{ err: persistErr, mailId: String(input.mailId) },
"work_order.local_status_recover_failed",
);
}
}
// 按指令落导入日志(不等整封邮件全部指令完成);库缺列时也不要盖过 CC 成功
try {
await recordInstructionImportLog({
mailId: input.mailId,
instructionId: checked.unit.id,
kind: "work_order",
display: logDisplay,
status: "SUCCESS",
externalId: result.externalId || null,
lastError: result.mode === "mock" ? "WO_MOCK_OK" : null,
requestBody: result.request ? JSON.stringify(result.request) : null,
responseBody: result.response ? JSON.stringify(result.response) : null,
});
} catch (err) {
logger.error(
{ err, mailId: String(input.mailId), instructionId: checked.unit.id },
"work_order.import_log_after_cc_ok",
);
}
return { externalId: result.externalId || null, mode: result.mode }; return { externalId: result.externalId || null, mode: result.mode };
} }

@ -1,6 +1,6 @@
import { prisma } from "@/services/db"; import { prisma } from "@/services/db";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { assertTransition } from "@/services/state-machine"; import { assertTransition, canTransition } from "@/services/state-machine";
import { import {
listActionableInstructionUnits, listActionableInstructionUnits,
markInstructionImported, markInstructionImported,
@ -100,7 +100,10 @@ export async function markInstructionImportedAndResolveMail(input: {
filenames: mail.attachments.map((a) => a.filename), filenames: mail.attachments.map((a) => a.filename),
lineage, lineage,
}); });
assertTransition("IMPORTING", status); const nextStatus: MailStatus = canTransition("IMPORTING", status)
? status
: "PARTIAL_SUCCESS";
assertTransition("IMPORTING", nextStatus);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.parseResult.update({ await tx.parseResult.update({
where: { mailId: input.mailId }, where: { mailId: input.mailId },
@ -109,11 +112,11 @@ export async function markInstructionImportedAndResolveMail(input: {
await tx.mailMessage.update({ await tx.mailMessage.update({
where: { id: input.mailId }, where: { id: input.mailId },
data: { data: {
status, status: nextStatus,
lastError: input.successLastError ?? null, lastError: input.successLastError ?? null,
version: { increment: 1 }, version: { increment: 1 },
}, },
}); });
}); });
return status; return nextStatus;
} }

@ -117,11 +117,19 @@ export function resolveMailStatusAfterInstructionImport(input: {
lineage?: unknown; lineage?: unknown;
}): MailStatus { }): MailStatus {
const units = listActionableInstructionUnits(input); const units = listActionableInstructionUnits(input);
if (!units.length) return input.currentStatus; if (!units.length) {
// 从 IMPORTING 收尾时不得原样返回 IMPORTING / PENDING_CONFIRM(状态机会炸)
if (input.currentStatus === "IMPORTING") return "SUCCESS";
return input.currentStatus;
}
const imported = units.filter((u) => const imported = units.filter((u) =>
isInstructionImported(input.lineage, u.id), isInstructionImported(input.lineage, u.id),
); );
if (imported.length <= 0) return "PENDING_CONFIRM"; if (imported.length <= 0) {
// 指令 id 漂移时可能暂时对不上;IMPORTING 收尾一律按部分成功,避免 IllegalTransition → 500
if (input.currentStatus === "IMPORTING") return "PARTIAL_SUCCESS";
return "PENDING_CONFIRM";
}
if (imported.length >= units.length) { if (imported.length >= units.length) {
// 全部只是 mock 成功 → PARTIAL,允许再进 IMPORTING 写真实 CC // 全部只是 mock 成功 → PARTIAL,允许再进 IMPORTING 写真实 CC
const allMock = imported.every((u) => const allMock = imported.every((u) =>

@ -183,6 +183,35 @@ export function stripWorkOrderBodyNoise(text: string): string {
.trim(); .trim();
} }
const INLINE_EMAIL_RE =
/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g;
/**
* 客户指令 / 时间轮正文展示:去掉「在…写道」引用头、邮箱与签名噪声。
* 供详情面板回显,避免把邮件壳层当业务内容。
*/
export function cleanCustomerInstructionDisplayBody(text: string): string {
let s = stripWorkOrderBodyNoise(text || "");
// 再扫一遍:引用头若与正文同行、或残留邮箱
s = s
.replace(/^(?:>|&gt;)?\s*在\s*\d{4}[-/.年][^\n]{0,160}写道[::]?\s*/gim, "")
.replace(/在\s*\d{4}[-/.年][^\n]{0,160}写道[::]?/g, "")
.replace(INLINE_EMAIL_RE, "")
.replace(/<\s*>/g, "")
.replace(/[""\u201c\u201d]\s*[""\u201c\u201d]/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
// 去掉因删邮箱留下的空行 / 纯符号行
return s
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !isWorkOrderNoiseLine(line) && !/^[,,;;.。]+$/.test(line))
.join("\n")
.trim();
}
function isNumberedOpsLine(text: string): boolean { function isNumberedOpsLine(text: string): boolean {
const t = (text || "").trim(); const t = (text || "").trim();
if (!t) return false; if (!t) return false;

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
cleanCustomerInstructionDisplayBody,
extractWorkOrderFormFields, extractWorkOrderFormFields,
parseWorkOrderReferCode, parseWorkOrderReferCode,
parseWorkOrderRefNo, parseWorkOrderRefNo,
@ -143,6 +144,31 @@ describe("stripWorkOrderBodyNoise 邮箱/附件栏", () => {
expect(cleaned).not.toMatch(/km16|网易灵犀|签名由/i); expect(cleaned).not.toMatch(/km16|网易灵犀|签名由/i);
}); });
it("cleanCustomerInstructionDisplayBody drops quote header and emails", () => {
const raw = [
'在 2026-05-09 18:00:16, "luo" <luo@usasinogroup.com> 写道:',
"好的",
"luo",
"luo@usasinogroup.com",
].join("\n");
const cleaned = cleanCustomerInstructionDisplayBody(raw);
expect(cleaned).toBe("好的");
expect(cleaned).not.toMatch(/写道|@usasinogroup|luo@/i);
});
it("cleanCustomerInstructionDisplayBody keeps reject reason without shell", () => {
const raw = [
'在 2026-05-09 10:52:25, "luo" <luo@usasinogroup.com> 写道:',
"抱歉 仓库产能有限 该柜子我们目前无法接收 请您谅解",
"luo",
"luo@usasinogroup.com",
].join("\n");
const cleaned = cleanCustomerInstructionDisplayBody(raw);
expect(cleaned).toContain("产能有限");
expect(cleaned).toContain("无法接收");
expect(cleaned).not.toMatch(/写道|@usasinogroup/i);
});
it("keeps 注意 remark but strips trailing signature email", () => { it("keeps 注意 remark but strips trailing signature email", () => {
const { content, remark } = splitWorkOrderContentAndRemark( const { content, remark } = splitWorkOrderContentAndRemark(
[ [

Loading…
Cancel
Save