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/scripts/rewrite-mail-business-summa...

680 lines
19 KiB

/**
* Rewrite MailBusinessSummary.tsx fully in UTF-8.
* ASCII-only script source; Chinese via \\u escapes only.
* Run: pnpm exec tsx scripts/rewrite-mail-business-summary.ts
*/
import fs from "fs";
import path from "path";
const out = path.join(process.cwd(), "src", "components", "MailBusinessSummary.tsx");
const ZH = {
cardTitle: "\u8fd9\u5c01\u90ae\u4ef6\u5728\u5e72\u4ec0\u4e48",
splitPrefix: "\u5df2\u62c6\u51fa ",
splitSuffix: " \u6761\u6307\u4ee4\uff1a",
from: "\u53d1\u4ef6\u4eba",
action: "\u4e1a\u52a1\u52a8\u4f5c",
hint: "\u90ae\u4ef6\u8868\u8ff0",
next: "\u7cfb\u7edf\u4e0b\u4e00\u6b65",
vessel: "\u8239\u540d\u822a\u6b21 ",
woMock: "\u5de5\u5355\u5df2\u63d0\u4ea4\uff08mock\uff09",
woOk: "\u5de5\u5355\u5df2\u63d0\u4ea4",
doMock: "DO \u5df2\u767b\u8bb0\uff08mock\uff09",
doOk: "DO \u5df2\u4e0a\u4f20",
needFba: "\u8bf7\u586b\u5199\u76ee\u6807 FBACode",
xferMock: "\u6279\u91cf\u8f6c\u4ed3\u5df2\u63d0\u4ea4\uff08mock\uff09",
xferOk: "\u6279\u91cf\u8f6c\u4ed3\u5df2\u63d0\u4ea4",
msgType: "\u62c6\u67dc\u65f6\u81ea\u52a8\u8f6c\u4ed3",
gate: "\u95f8\u95e8\uff1a",
semi: "\uff1b",
actXfer: "\u8f6c\u4ed3",
btnWo: "\u786e\u8ba4\u63d0\u4ea4\u5de5\u5355",
btnXfer: "\u786e\u8ba4\u6279\u91cf\u8f6c\u4ed3",
btnDo: "\u786e\u8ba4\u4e0a\u4f20 DO",
};
const file = `"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
App,
Button,
Card,
Descriptions,
Space,
Tag,
Typography,
} from "antd";
import {
CcDoUploadPanel,
buildCcDoUploadFormValue,
} from "@/components/CcDoUploadPanel";
import {
CcForecastFormOrder,
buildCcForecastFormValue,
} from "@/components/CcForecastFormOrder";
import { CcCustomerInstructionPanel } from "@/components/CcCustomerInstructionPanel";
import { InstructionUnitShell } from "@/components/MailInstructionUnitShell";
import {
CcTransferIndexTruck,
buildCcTransferFormValue,
} from "@/components/CcTransferIndexTruck";
import {
CcWorkOrderForm,
buildCcWorkOrderFormValue,
} from "@/components/CcWorkOrderForm";
import { TypeTag } from "@/components/TypeTag";
import {
confirmDoUploadApi,
confirmTransferApi,
confirmWorkOrderApi,
getTransferGateApi,
listShippingLinesApi,
} from "@/lib/client-api";
import {
buildMailIntent,
extractTransferPairs,
filterTransferShipments,
} from "@/services/parse/mail-intent";
import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template";
import {
extractForecastInstructionText,
extractMailInstructions,
} from "@/services/parse/split-instructions";
import type { MailMessage } from "@/types";
import type {
ContainerHeader,
MailRecord,
MailType,
TypeEvidence,
} from "@/types/mail";
import type { TransferGateDecision } from "@/services/cc/container-status";
function asHeader(mail: MailMessage): ContainerHeader | null {
return mail.parse_result?.container_header ?? null;
}
function asMailRecord(mail: MailMessage): MailRecord | null {
const direct = mail.parse_result?.mail_record;
if (direct) return direct;
const lineage = mail.parse_result?.lineage as
| { mail_record?: MailRecord }
| undefined;
return lineage?.mail_record ?? null;
}
type EvidenceExt = TypeEvidence & {
mailbox?: { from?: string; inbox?: string; extracted?: string[] };
};
export function MailBusinessSummary({
mail,
onConfirmed,
}: {
mail: MailMessage;
onConfirmed?: () => void;
}) {
const { message } = App.useApp();
const header = asHeader(mail);
const record = asMailRecord(mail);
const evidence = (mail.type_evidence || {
total: 0,
mail_type: mail.mail_type,
signals: [],
}) as EvidenceExt;
// Stable deps for useEffect
const shipmentsRaw = mail.parse_result?.shipments;
const attachmentsRaw = mail.attachments;
const shipmentsKey = JSON.stringify(shipmentsRaw ?? []);
const filenamesKey = (attachmentsRaw || []).map((a) => a.filename).join("?");
const shipments = useMemo(
() => shipmentsRaw ?? [],
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by shipmentsKey
[shipmentsKey],
);
const filenames = useMemo(
() => (attachmentsRaw || []).map((a) => a.filename),
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by filenamesKey
[filenamesKey],
);
const bodyText = mail.body_text || mail.ocr_text || "";
const containerNo = header?.F_ContainerNo || mail.container_no || "";
const workOrderActionsKey = (record?.work_order_actions || []).join("|");
const intentSignalsKey = JSON.stringify(evidence.signals ?? []);
const intent = useMemo(
() =>
evidence.intent ||
buildMailIntent({
mailType: mail.mail_type as MailType,
subject: mail.subject,
body: bodyText,
filenames,
shipments,
evidence,
containerNo,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps -- evidence default object unstable
[
evidence.intent,
evidence.mail_type,
evidence.total,
intentSignalsKey,
mail.mail_type,
mail.subject,
bodyText,
filenames,
shipments,
containerNo,
],
);
const summaryText = record?.summary || intent.narrative;
const transferPairs = useMemo(
() =>
intent.transfer_pairs?.length
? intent.transfer_pairs
: extractTransferPairs(shipments),
[intent.transfer_pairs, shipments],
);
const transferShipments = useMemo(
() => filterTransferShipments(shipments),
[shipments],
);
const instructions = useMemo(
() =>
extractMailInstructions({
subject: mail.subject,
body: bodyText,
filenames,
forceForecast: record?.kind === "BL_FORECAST",
workOrderActions: record?.work_order_actions,
}),
[
mail.subject,
bodyText,
filenames,
record?.kind,
workOrderActionsKey,
],
);
const forecastInstruction = useMemo(() => {
const vessel = record?.modules.vessel_voyage;
return extractForecastInstructionText({
subject: mail.subject,
body: bodyText,
modulesInstruction: vessel
? \`${ZH.vessel}\${vessel}\`
: header?.F_Instruction || null,
});
}, [
mail.subject,
bodyText,
header?.F_Instruction,
record?.modules.vessel_voyage,
]);
const hasForecastUi = instructions.some((u) => u.uiKind === "forecast");
const hasTransferUi = instructions.some((u) => u.uiKind === "transfer");
const canConfirmOps =
mail.status === "PENDING_CONFIRM" || mail.status === "FAILED";
const blPlusModules = useMemo(
() => extractBlPlusFromMail(mail.subject, bodyText)?.modules ?? null,
[mail.subject, bodyText],
);
const forecastValue = useMemo(() => {
const modules = {
...(blPlusModules || {}),
...(record?.modules || {}),
};
const base = buildCcForecastFormValue({
customerName:
record?.modules.customer_name ||
blPlusModules?.customer_name ||
undefined,
header,
modules,
});
return {
...base,
F_Instruction: forecastInstruction || base.F_Instruction,
};
}, [
header,
record?.modules,
record?.modules.customer_name,
blPlusModules,
forecastInstruction,
]);
const transferValue = useMemo(
() =>
buildCcTransferFormValue({
customerName: record?.modules.customer_name,
containerNo,
modules: record?.modules,
shipments: transferShipments,
transferPairs,
}),
[
record?.modules.customer_name,
containerNo,
record?.modules,
transferShipments,
transferPairs,
],
);
const currentWorkOrderUnit = useMemo(
() => instructions.find((u) => u.uiKind === "work_order" && u.isCurrent),
[instructions],
);
const [workOrderValue, setWorkOrderValue] = useState(() =>
buildCcWorkOrderFormValue({
subject: currentWorkOrderUnit?.segmentSubject || mail.subject,
body: currentWorkOrderUnit?.segmentText || bodyText,
filenames,
containerNo,
actions: record?.work_order_actions,
}),
);
const [doValue, setDoValue] = useState(() =>
buildCcDoUploadFormValue({
subject: mail.subject,
body: bodyText,
containerNo,
filenames,
}),
);
const [gate, setGate] = useState<TransferGateDecision | null>(null);
const [submitting, setSubmitting] = useState(false);
// Reset forms when mail / current work-order segment changes
useEffect(() => {
setWorkOrderValue(
buildCcWorkOrderFormValue({
subject: currentWorkOrderUnit?.segmentSubject || mail.subject,
body: currentWorkOrderUnit?.segmentText || bodyText,
filenames,
containerNo,
actions: record?.work_order_actions,
}),
);
setDoValue(
buildCcDoUploadFormValue({
subject: mail.subject,
body: bodyText,
containerNo,
filenames,
}),
);
}, [
mail.id,
mail.version,
mail.subject,
bodyText,
containerNo,
workOrderActionsKey,
filenames,
currentWorkOrderUnit?.id,
currentWorkOrderUnit?.segmentText,
]);
useEffect(() => {
if (mail.mail_type !== "TRANSFER" && !hasTransferUi) return;
let cancelled = false;
void getTransferGateApi(mail.id).then((res) => {
if (!cancelled && res.ok) setGate(res.data.gate);
});
return () => {
cancelled = true;
};
}, [mail.id, mail.mail_type, hasTransferUi]);
const [shippingLines, setShippingLines] = useState<
{ value: string; label: string }[]
>([]);
useEffect(() => {
if (!hasForecastUi) return;
let cancelled = false;
void listShippingLinesApi().then((res) => {
if (!cancelled && res.ok) {
setShippingLines(
res.data.items.map((item) => ({ value: item.id, label: item.name })),
);
}
});
return () => {
cancelled = true;
};
}, [hasForecastUi]);
const submitWorkOrder = async () => {
setSubmitting(true);
const res = await confirmWorkOrderApi(mail.id, {
version: mail.version,
entity: workOrderValue,
});
setSubmitting(false);
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success(
res.data.mode === "mock" ? "${ZH.woMock}" : "${ZH.woOk}",
);
onConfirmed?.();
};
const submitDo = async () => {
setSubmitting(true);
const res = await confirmDoUploadApi(mail.id, {
version: mail.version,
container_no: doValue.F_ContainerNo,
container_task_id: doValue.F_ContainerTaskId || undefined,
filenames: doValue.filenames,
});
setSubmitting(false);
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success(
res.data.mode === "mock" ? "${ZH.doMock}" : "${ZH.doOk}",
);
onConfirmed?.();
};
const submitTransfer = async () => {
const target =
transferValue.targetFBACode || transferValue.F_ConvertFBACode || "";
if (!target) {
message.error("${ZH.needFba}");
return;
}
const rows = transferShipments.length
? transferShipments
: shipments.slice(0, 1);
setSubmitting(true);
const res = await confirmTransferApi(mail.id, {
version: mail.version,
container_no:
transferValue.F_ContainerNo ||
header?.F_ContainerNo ||
mail.container_no ||
"",
payload: {
keyValue:
gate && "containerTaskId" in gate ? gate.containerTaskId || "" : "",
strDetialList: rows.map((r) => ({
F_FBACode: r.F_FBACode || "",
F_ConvertFBACode: target,
})),
F_ConvertChargeMode: transferValue.F_ConvertChargeMode || "2",
F_RemovePlanDetail: transferValue.F_RemovePlanDetail ? "1" : "0",
},
});
setSubmitting(false);
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success(
res.data.mode === "mock" ? "${ZH.xferMock}" : "${ZH.xferOk}",
);
onConfirmed?.();
};
return (
<>
<Card title="${ZH.cardTitle}">
<Typography.Paragraph style={{ marginBottom: 12, fontSize: 15 }}>
{summaryText}
</Typography.Paragraph>
{instructions.length > 1 ? (
<div style={{ marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ marginRight: 8 }}>
${ZH.splitPrefix}{instructions.length}${ZH.splitSuffix}
</Typography.Text>
<Space wrap size={[4, 4]}>
{instructions.map((u) => (
<Tag key={u.id}>{u.label}</Tag>
))}
</Space>
</div>
) : null}
<Descriptions
bordered
size="small"
column={{ xs: 1, sm: 2, md: 2 }}
items={[
{
key: "from",
label: "${ZH.from}",
children: (
<span className="mono">
{evidence.mailbox?.from || mail.from_addr || "-"}
</span>
),
},
{
key: "action",
label: "${ZH.action}",
children: (
<Space>
<TypeTag type={mail.mail_type} />
<Typography.Text>{intent.action}</Typography.Text>
</Space>
),
},
{
key: "hint",
label: "${ZH.hint}",
children: intent.content_hint || "-",
},
{
key: "next",
label: "${ZH.next}",
span: { xs: 1, sm: 2, md: 2 },
children: intent.next_step || "-",
},
]}
/>
</Card>
{instructions.map((unit, index) => {
let body: ReactNode = null;
let actions: ReactNode = null;
if (unit.uiKind === "forecast") {
body = (
<CcForecastFormOrder
mode="readonly"
value={forecastValue}
shipments={shipments}
shippingLines={shippingLines}
/>
);
} else if (unit.uiKind === "transfer") {
const blocked = gate && !gate.canTransfer;
body = (
<>
{gate ? (
<Alert
style={{ marginBottom: 12 }}
type={gate.canTransfer ? "success" : "warning"}
showIcon
message={gate.reason}
/>
) : null}
{blocked ? (
<CcWorkOrderForm
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
value={{
...workOrderValue,
F_MessageType:
workOrderValue.F_MessageType || "${ZH.msgType}",
F_Remark: [
workOrderValue.F_Remark,
gate.reason ? \`${ZH.gate}\${gate.reason}\` : "",
]
.filter(Boolean)
.join("${ZH.semi}"),
mailActions: workOrderValue.mailActions?.length
? workOrderValue.mailActions
: ["${ZH.actXfer}"],
}}
onChange={(p) =>
setWorkOrderValue((v) => ({ ...v, ...p }))
}
/>
) : (
<CcTransferIndexTruck
mode="readonly"
value={transferValue}
shipments={transferShipments}
transferPairs={transferPairs}
totalShipmentCount={shipments.length}
/>
)}
</>
);
if (canConfirmOps && unit.isCurrent) {
actions = blocked ? (
<Button
type="primary"
loading={submitting}
onClick={() => void submitWorkOrder()}
>
${ZH.btnWo}
</Button>
) : (
<Button
type="primary"
loading={submitting}
onClick={() => void submitTransfer()}
>
${ZH.btnXfer}
</Button>
);
}
} else if (unit.uiKind === "work_order") {
const woForUnit = unit.isCurrent
? workOrderValue
: buildCcWorkOrderFormValue({
subject: unit.segmentSubject || mail.subject,
body: unit.segmentText || bodyText,
filenames,
containerNo,
});
body = (
<CcWorkOrderForm
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
value={woForUnit}
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
/>
);
if (canConfirmOps && unit.isCurrent) {
actions = (
<Button
type="primary"
loading={submitting}
onClick={() => void submitWorkOrder()}
>
${ZH.btnWo}
</Button>
);
}
} else if (unit.uiKind === "do_upload") {
const doForUnit = buildCcDoUploadFormValue({
subject: unit.segmentSubject || mail.subject,
body: unit.segmentText || bodyText,
containerNo,
filenames:
unit.source === "attachment"
? [unit.segmentText]
: filenames,
});
body = (
<CcDoUploadPanel
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
value={unit.isCurrent ? doValue : doForUnit}
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
/>
);
if (canConfirmOps && unit.isCurrent) {
actions = (
<Button
type="primary"
loading={submitting}
onClick={() => void submitDo()}
>
${ZH.btnDo}
</Button>
);
}
} else if (unit.uiKind === "customer_instruction") {
body = (
<CcCustomerInstructionPanel
value={{
roundAt: unit.roundAt,
roundFrom: unit.roundFrom,
subject: unit.segmentSubject,
body: unit.segmentText || bodyText,
}}
/>
);
}
return (
<InstructionUnitShell
key={unit.id}
unit={unit}
index={index}
total={instructions.length}
>
{body}
{actions ? (
<div style={{ marginTop: 12, textAlign: "right" }}>{actions}</div>
) : null}
</InstructionUnitShell>
);
})}
</>
);
}
`;
fs.writeFileSync(out, file, "utf8");
const check = fs.readFileSync(out, "utf8");
const bad = [...check.matchAll(/\?{3,}/g)].filter((m) => {
const i = m.index || 0;
const line = check.slice(check.lastIndexOf("\n", i) + 1, check.indexOf("\n", i));
return !line.includes('join("?")');
});
console.log(
JSON.stringify(
{
bytes: Buffer.byteLength(check, "utf8"),
hasTitle: check.includes(ZH.cardTitle),
hasFrom: check.includes(ZH.from),
hasBtnWo: check.includes(ZH.btnWo),
hasDearLogic: check.includes("currentWorkOrderUnit"),
leftoverQ: bad.length,
},
null,
2,
),
);
if (!check.includes(ZH.cardTitle) || bad.length) process.exit(1);