{label}
{required ? (
@@ -128,14 +137,6 @@ function Field({
);
}
-/** 只读时不展示没有解析到的可选格;编辑页仍留空格供补录,但不预填编造值 */
-function showOptional(readonly: boolean, value: unknown): boolean {
- if (!readonly) return true;
- if (value === undefined || value === null) return false;
- if (typeof value === "string") return value.trim() !== "";
- return true;
-}
-
function ChevronSteps({
step,
onChange,
@@ -165,14 +166,336 @@ function ChevronSteps({
);
}
+function ShipEditCellSync({
+ value,
+ disabled,
+ onCommit,
+ mono,
+ placeholder,
+}: {
+ value: string;
+ disabled?: boolean;
+ onCommit: (next: string) => void;
+ mono?: boolean;
+ placeholder?: string;
+}) {
+ const [draft, setDraft] = useState(value);
+ const [focused, setFocused] = useState(false);
+ useEffect(() => {
+ if (!focused) setDraft(value);
+ }, [value, focused]);
+ return (
+
setFocused(true)}
+ onChange={(e) => setDraft(e.target.value)}
+ onBlur={() => {
+ setFocused(false);
+ if (draft !== value) onCommit(draft);
+ }}
+ onPressEnter={(e) => (e.target as HTMLInputElement).blur()}
+ />
+ );
+}
+
+function buildEditableCcShipmentColumns(
+ onPatch: (rowIndex: number, patch: Partial
) => void,
+ onOpenEdit: (rowIndex: number) => void,
+): ShipmentColumn[] {
+ const text = (
+ key: keyof ShipmentRow,
+ title: string,
+ width: number,
+ opts?: { mono?: boolean; channel?: boolean; placeholder?: string },
+ ): ShipmentColumn => ({
+ key: String(key),
+ title,
+ width,
+ render: (row) => {
+ const raw =
+ opts?.channel
+ ? row.channel_raw || row.F_Transporter || ""
+ : key === "F_FBACode"
+ ? normalizeFbaCodeForCc(row.F_FBACode, row.F_Transporter || "") ||
+ row.F_FBACode ||
+ ""
+ : row[key] == null
+ ? ""
+ : String(row[key]);
+ return (
+ {
+ if (opts?.channel) {
+ onPatch(row.row_index, {
+ channel_raw: next,
+ F_Transporter: next,
+ });
+ return;
+ }
+ if (
+ key === "F_CTNS" ||
+ key === "F_CBM" ||
+ key === "F_Weight" ||
+ key === "F_Pcs"
+ ) {
+ const n = next.trim() === "" ? null : Number(next.replace(/,/g, ""));
+ onPatch(row.row_index, {
+ [key]:
+ n != null && Number.isFinite(n)
+ ? key === "F_CTNS"
+ ? Math.trunc(n)
+ : n
+ : key === "F_CTNS"
+ ? 0
+ : null,
+ } as Partial);
+ return;
+ }
+ onPatch(row.row_index, { [key]: next } as Partial);
+ }}
+ />
+ );
+ },
+ });
+
+ return [
+ { key: "idx", title: "#", width: 44, render: (row) => row.row_index + 1 },
+ { key: "ck", title: "选", width: 44, render: () => null },
+ text("F_Transporter", "Move Type", 110, {
+ channel: true,
+ placeholder: "派送方式*",
+ }),
+ text("F_FBACode", "FBACode", 88, { placeholder: "仓库代码" }),
+ text("F_ShipmentID", "Custmer Ref No.", 120, {
+ mono: true,
+ placeholder: "客户参考号",
+ }),
+ text("F_FBAID", "FBAId(Amazon)", 130, { mono: true, placeholder: "FBA ID" }),
+ text("F_PO", "PO(Amazon)", 100, { mono: true, placeholder: "PO" }),
+ text("F_Address", "Address", 200, { placeholder: "地址" }),
+ text("F_CTNS", "Qty.(CTNS)", 88, { placeholder: "件数*" }),
+ text("F_CBM", "Vol(CBM)", 88, { placeholder: "体积*" }),
+ text("F_Weight", "G.W.(KGS)", 88, { placeholder: "毛重*" }),
+ text("F_Pcs", "PCS", 64, { placeholder: "PCS" }),
+ text("F_City", "City", 90, { placeholder: "城市" }),
+ text("F_State", "State", 70, { placeholder: "州" }),
+ text("F_ZipCode", "ZipCode", 80, { placeholder: "邮编" }),
+ text("F_Contact", "Contact", 90, { placeholder: "联系人" }),
+ text("F_Mobile", "Mobile", 100, { placeholder: "电话" }),
+ text("F_Email", "Email", 140, { placeholder: "邮箱" }),
+ text("F_EnglishName", "英文名称", 120, { placeholder: "公司/英文名" }),
+ text("F_Remark", "行备注", 160, { placeholder: "备注" }),
+ text("F_ReferenceId", "ReferenceId", 110, {
+ mono: true,
+ placeholder: "Reference",
+ }),
+ {
+ key: "st",
+ title: "状态",
+ width: 64,
+ render: (row) => (row.row_status === "VALID" ? "有效" : "无效"),
+ },
+ {
+ key: "act",
+ title: "操作",
+ width: 72,
+ render: (row) => (
+
+ ),
+ },
+ ];
+}
+
+type ShipmentEditFormValues = {
+ F_Transporter: string;
+ F_FBACode: string;
+ F_ShipmentID: string;
+ F_FBAID: string;
+ F_PO: string;
+ F_Address: string;
+ F_CTNS: number | null;
+ F_CBM: number | null;
+ F_Weight: number | null;
+ F_Pcs: number | null;
+ F_City: string;
+ F_State: string;
+ F_ZipCode: string;
+ F_Contact: string;
+ F_Mobile: string;
+ F_Email: string;
+ F_EnglishName: string;
+ F_Remark: string;
+};
+
+function shipmentRowToEditForm(row: ShipmentRow): ShipmentEditFormValues {
+ return {
+ F_Transporter: row.channel_raw || row.F_Transporter || "",
+ F_FBACode: row.F_FBACode || "",
+ F_ShipmentID: row.F_ShipmentID || "",
+ F_FBAID: row.F_FBAID || "",
+ F_PO: row.F_PO || "",
+ F_Address: row.F_Address || "",
+ F_CTNS: row.F_CTNS ?? null,
+ F_CBM: row.F_CBM ?? null,
+ F_Weight: row.F_Weight ?? null,
+ F_Pcs: row.F_Pcs ?? null,
+ F_City: row.F_City || "",
+ F_State: row.F_State || "",
+ F_ZipCode: row.F_ZipCode || "",
+ F_Contact: row.F_Contact || "",
+ F_Mobile: row.F_Mobile || "",
+ F_Email: row.F_Email || "",
+ F_EnglishName: row.F_EnglishName || "",
+ F_Remark: row.F_Remark || "",
+ };
+}
+
+function ShipmentEditModal({
+ rowIndex,
+ row,
+ onCancel,
+ onSave,
+}: {
+ rowIndex: number;
+ row: ShipmentRow;
+ onCancel: () => void;
+ onSave: (values: ShipmentEditFormValues) => void;
+}) {
+ const [form] = Form.useForm();
+
+ const onOk = async () => {
+ const values = await form.validateFields().catch(() => null);
+ if (!values) return;
+ onSave(values);
+ };
+
+ return (
+ void onOk()}
+ okText="保存"
+ cancelText="取消"
+ width={640}
+ >
+
+
+
+
+
+
+
+
+ );
+}
+
export function CcForecastFormOrder({
mode,
value,
shipments,
shippingLines = [],
+ onFetchShippingLine,
+ fetchShippingLineLoading,
selected,
onSelectedChange,
onChange,
+ onShipmentsChange,
+ packingImportLoading,
+ onImportPackingFile,
errors = {},
step: controlledStep,
onStepChange,
@@ -186,11 +509,13 @@ export function CcForecastFormOrder({
finishLabel = "确认导入",
portal = "admin",
}: CcForecastFormOrderProps) {
+ const { message } = App.useApp();
const readonly = mode === "readonly";
const isAdmin = portal === "admin";
const [innerStep, setInnerStep] = useState(0);
const [queryDraft, setQueryDraft] = useState("");
const [query, setQuery] = useState("");
+ const [editRowIndex, setEditRowIndex] = useState(null);
const step = controlledStep ?? innerStep;
const setStep = (s: number) => {
onStepChange?.(s);
@@ -230,6 +555,96 @@ export function CcForecastFormOrder({
});
}, [shipments, query]);
+ const canEditShipments = !readonly && Boolean(onShipmentsChange);
+ const shipmentsRef = useRef(shipments);
+ shipmentsRef.current = shipments;
+
+ const patchShipment = useCallback(
+ (rowIndex: number, patch: Partial) => {
+ if (!onShipmentsChange) return;
+ onShipmentsChange(
+ shipmentsRef.current.map((row) =>
+ row.row_index === rowIndex
+ ? revalidateShipmentRow({ ...row, ...patch, row_index: rowIndex })
+ : row,
+ ),
+ );
+ },
+ [onShipmentsChange],
+ );
+
+ const openEditRow = useCallback((rowIndex: number) => {
+ setEditRowIndex(rowIndex);
+ }, []);
+
+ const shipmentColumns = useMemo(
+ () =>
+ canEditShipments
+ ? buildEditableCcShipmentColumns(patchShipment, openEditRow)
+ : CC_SHIPMENT_COLUMNS,
+ [canEditShipments, patchShipment, openEditRow],
+ );
+
+ const onAddShipment = () => {
+ if (!onShipmentsChange) return;
+ const cur = shipmentsRef.current;
+ const nextIndex = cur.reduce((m, r) => Math.max(m, r.row_index), -1) + 1;
+ const row = revalidateShipmentRow({ row_index: nextIndex });
+ const next = [...cur, row];
+ shipmentsRef.current = next;
+ onShipmentsChange(next);
+ onSelectedChange?.(new Set(selectedSet).add(nextIndex));
+ message.success("已新增一行,请填写派送方式、件数、体积、毛重(可点「编辑」弹窗填写)");
+ openEditRow(nextIndex);
+ };
+
+ const onSaveEditRow = (values: ShipmentEditFormValues) => {
+ if (editRowIndex == null) return;
+ const transporter = (values.F_Transporter || "").trim();
+ patchShipment(editRowIndex, {
+ channel_raw: transporter,
+ F_Transporter: transporter,
+ F_FBACode: (values.F_FBACode || "").trim() || undefined,
+ F_ShipmentID: (values.F_ShipmentID || "").trim() || null,
+ F_FBAID: (values.F_FBAID || "").trim() || null,
+ F_PO: (values.F_PO || "").trim() || null,
+ F_Address: (values.F_Address || "").trim() || null,
+ F_CTNS: values.F_CTNS ?? 0,
+ F_CBM: values.F_CBM,
+ F_Weight: values.F_Weight,
+ F_Pcs: values.F_Pcs,
+ F_City: (values.F_City || "").trim() || null,
+ F_State: (values.F_State || "").trim() || null,
+ F_ZipCode: (values.F_ZipCode || "").trim() || null,
+ F_Contact: (values.F_Contact || "").trim() || null,
+ F_Mobile: (values.F_Mobile || "").trim() || null,
+ F_Email: (values.F_Email || "").trim() || null,
+ F_EnglishName: (values.F_EnglishName || "").trim() || null,
+ F_Remark: (values.F_Remark || "").trim() || null,
+ });
+ setEditRowIndex(null);
+ message.success("货件已更新");
+ };
+
+ const editRow =
+ editRowIndex == null
+ ? null
+ : shipments.find((r) => r.row_index === editRowIndex) ?? null;
+
+ const onDeleteSelected = () => {
+ if (!onShipmentsChange) return;
+ const keep = shipmentsRef.current.filter(
+ (r) => !selectedSet.has(r.row_index),
+ );
+ const next = reindexShipments(keep);
+ onShipmentsChange(next);
+ onSelectedChange?.(
+ new Set(
+ next.filter((r) => r.row_status === "VALID").map((r) => r.row_index),
+ ),
+ );
+ };
+
const selectedRows = useMemo(
() => shipments.filter((s) => selectedSet.has(s.row_index)),
[shipments, selectedSet],
@@ -254,7 +669,6 @@ export function CcForecastFormOrder({
}, [selectedRows, shipments]);
const ph = "==请选择==";
- const opt = (v: unknown) => showOptional(readonly, v);
const transModeValue =
value.F_TransMode === 0 || value.F_TransMode === 1 || value.F_TransMode === 3
? value.F_TransMode
@@ -291,9 +705,9 @@ export function CcForecastFormOrder({
{isAdmin ? (
修改 · 基础信息
) : (
- 基础信息(客户端字段序)
+ 基础信息
)}
- {/* 客户端顺序:运输方式、业务类型、柜号…;管理端带客户行 */}
+ {/* 对齐 CC「修改·基础信息」:运输方式/业务类型/柜号/柜型* + 柜号Id/提单/ETD/ETA/港/船司/客服/自带车架/Instruction/备注 */}
{isAdmin ? (
@@ -306,294 +720,188 @@ export function CcForecastFormOrder({
}
/>
-
-
-
- ) : (
-
-
-
-
-
+
- )}
+ ) : null}
- {isAdmin ? (
-
-
-
-
-
- patch({ F_ContainerNo: e.target.value.toUpperCase() })
- }
- />
-
- {opt(value.F_CabinetType) ? (
-
-
- ) : null}
-
- ) : (
-
- {isAdmin ? (
- <>
- {opt(value.F_ETD) || opt(value.F_ETA) || opt(value.F_LoadPort) ? (
-
- {opt(value.F_ETD) ? (
-
- patch({ F_ETD: e.target.value })}
- />
-
- ) : null}
- {opt(value.F_ETA) ? (
-
- patch({ F_ETA: e.target.value })}
- />
-
- ) : null}
- {opt(value.F_LoadPort) ? (
-
- patch({ F_LoadPort: e.target.value })}
- />
-
- ) : null}
-
- ) : null}
-
- {opt(value.F_Dock) ||
- opt(value.F_BLCopyCode) ||
- opt(value.F_ContainerId) ? (
-
- {opt(value.F_Dock) ? (
-
- patch({ F_Dock: e.target.value })}
- />
-
- ) : null}
- {opt(value.F_BLCopyCode) ? (
-
-
- patch({ F_BLCopyCode: e.target.value.toUpperCase() })
- }
- />
-
- ) : null}
- {opt(value.F_ContainerId) ? (
-
-
-
- ) : null}
-
- ) : null}
-
- {opt(value.F_ShippingLineId) ||
- opt(value.F_CustomerService) ||
- opt(value.F_Classis) ? (
-
- {opt(value.F_ShippingLineId) ? (
-
-
- ) : null}
- {opt(value.F_CustomerService) ? (
-
-
- patch({ F_CustomerService: e.target.value })
- }
- />
-
- ) : null}
- {opt(value.F_Classis) ? (
-
-
- patch({ F_Classis: e.target.checked ? 1 : 0 })
- }
- >
- 自带车架
-
-
- ) : null}
-
- ) : null}
- >
- ) : (
-
-
-
- patch({ F_CustomerService: e.target.value })
- }
- />
-
-
- )}
+
+
+ patch({ F_Classis: e.target.checked ? 1 : 0 })
+ }
+ >
+ 自带车架
+
+
- {isAdmin ? (
- opt(value.F_Instruction) || opt(value.F_MemoRemark) ? (
-
- {opt(value.F_Instruction) ? (
-
-
- patch({ F_Instruction: e.target.value })
- }
- />
-
- ) : null}
- {opt(value.F_MemoRemark) ? (
-
-
- patch({ F_MemoRemark: e.target.value })
- }
- />
-
- ) : null}
-
- ) : null
- ) : (
-
-
- patch({ F_Instruction: e.target.value })}
- />
-
-
- patch({ F_MemoRemark: e.target.value })}
- />
-
-
- )}
+
+
+ patch({ F_Instruction: e.target.value })}
+ />
+
+
+ patch({ F_MemoRemark: e.target.value })}
+ />
+
+
) : null}
@@ -602,6 +910,16 @@ export function CcForecastFormOrder({
{isAdmin ? (
货件列表
) : null}
+ {canEditShipments ? (
+
+ 表格单元格可直接填写;也可点行末「编辑」弹窗修改。有效行需至少填写:派送方式、件数、体积、毛重。
+
+ ) : null}
+ {errors.rows ? (
+
+ {errors.rows}
+
+ ) : null}
查询
+ {canEditShipments ? (
+ <>
+
+
+ {onImportPackingFile ? (
+ {
+ void onImportPackingFile(file);
+ return false;
+ }}
+ >
+
+
+ ) : null}
+ >
+ ) : null}
-
+
{
if (readonly || !onSelectedChange) return;
const next = new Set(selectedSet);
@@ -635,8 +986,9 @@ export function CcForecastFormOrder({
/>
{shipments.length === 0 ? (
- 货件列表来自卡派清单等表格附件;当前无解析出行。请检查是否附带
- xlsx/xls,或重新解析。
+ {canEditShipments
+ ? "暂无货件行。可点击「新增货件」手工录入,或「导入装箱表」上传卡派清单(xlsx)。"
+ : "货件列表来自卡派清单等表格附件;当前无解析出行。请检查是否附带 xlsx/xls,或重新解析。"}
) : null}
@@ -732,6 +1084,16 @@ export function CcForecastFormOrder({
) : null}
+
+ {editRowIndex != null && editRow ? (
+
setEditRowIndex(null)}
+ onSave={onSaveEditRow}
+ />
+ ) : null}
);
}
@@ -765,6 +1127,7 @@ export function buildCcForecastFormValue(input: {
vessel_voyage?: string;
service?: string;
classis?: 0 | 1;
+ ops_hints?: string[];
} | null;
}): CcForecastFormValue {
const h = input.header || {};
@@ -779,14 +1142,22 @@ export function buildCcForecastFormValue(input: {
const memoRaw = h.F_MemoRemark || "";
// 备注若残留「回复:柜号:…」等脏客户前缀,剥掉已占用柜号包装
const memo = sanitizeMemoRemark(memoRaw, { containerNo, blNo, customer });
- const transMode = h.F_TransMode;
- const operationType = h.F_OperationType;
+ const fromHints = applyServiceFromModules({
+ ops_hints: m.ops_hints,
+ service: m.service,
+ });
+ const transMode =
+ h.F_TransMode ??
+ (m.container_no || m.bl_no || m.cabinet_type || m.vessel_voyage
+ ? (0 as const)
+ : undefined);
+ const operationType = h.F_OperationType ?? fromHints.F_OperationType;
const classis =
h.F_Classis === 0 || h.F_Classis === 1
? h.F_Classis
: m.classis === 0 || m.classis === 1
? m.classis
- : undefined;
+ : fromHints.F_Classis;
return {
customerName: customer,
F_TransMode:
@@ -798,7 +1169,9 @@ export function buildCcForecastFormValue(input: {
? operationType
: undefined,
F_ContainerNo: containerNo,
- F_CabinetType: h.F_CabinetType || m.cabinet_type || "",
+ F_CabinetType: normalizeCabinetType(
+ h.F_CabinetType || m.cabinet_type || "",
+ ),
F_ETD: h.F_ETD || m.etd || "",
F_ETA: h.F_ETA || m.eta || "",
F_LoadPort: h.F_LoadPort || "",
@@ -811,7 +1184,7 @@ export function buildCcForecastFormValue(input: {
F_OverWeight: false,
F_Classis: classis,
F_Instruction: stripMobileMailChrome(
- h.F_Instruction || vesselInst || "",
+ h.F_Instruction || vesselInst || fromHints.F_Instruction || "",
),
F_MemoRemark: memo,
};
diff --git a/src/components/CcMessageTypeDictCard.tsx b/src/components/CcMessageTypeDictCard.tsx
new file mode 100644
index 0000000..4141a23
--- /dev/null
+++ b/src/components/CcMessageTypeDictCard.tsx
@@ -0,0 +1,242 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ App,
+ Button,
+ Card,
+ Form,
+ Input,
+ InputNumber,
+ Popconfirm,
+ Space,
+ Spin,
+ Switch,
+ Table,
+ Typography,
+} from "antd";
+import { DownOutlined, UpOutlined } from "@ant-design/icons";
+import {
+ createCcMessageTypeApi,
+ deleteCcMessageTypeApi,
+ listCcMessageTypesApi,
+ updateCcMessageTypeApi,
+} from "@/lib/client-api";
+import type { CcMessageTypeDictItem } from "@/types";
+
+export function CcMessageTypeDictCard() {
+ const { message } = App.useApp();
+ const [loading, setLoading] = useState(true);
+ const [expanded, setExpanded] = useState(false);
+ const [items, setItems] = useState
([]);
+ const [adding, setAdding] = useState(false);
+ const [form] = Form.useForm<{ name: string; sort_order?: number }>();
+
+ const enabledCount = useMemo(
+ () => items.filter((i) => i.enabled).length,
+ [items],
+ );
+
+ const reload = useCallback(async () => {
+ setLoading(true);
+ const res = await listCcMessageTypesApi();
+ setLoading(false);
+ if (!res.ok) {
+ message.error(res.error.message);
+ return;
+ }
+ setItems(res.data.items);
+ }, [message]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ const onAdd = async () => {
+ const values = await form.validateFields().catch(() => null);
+ if (!values) return;
+ setAdding(true);
+ const res = await createCcMessageTypeApi({
+ name: values.name,
+ sort_order: values.sort_order,
+ enabled: true,
+ });
+ setAdding(false);
+ if (!res.ok) {
+ message.error(res.error.message);
+ return;
+ }
+ message.success("已添加留言类型");
+ form.resetFields();
+ await reload();
+ };
+
+ const onToggle = async (row: CcMessageTypeDictItem, enabled: boolean) => {
+ const res = await updateCcMessageTypeApi(row.id, { enabled });
+ if (!res.ok) {
+ message.error(res.error.message);
+ return;
+ }
+ setItems((prev) =>
+ prev.map((x) => (x.id === row.id ? { ...x, enabled } : x)),
+ );
+ };
+
+ const onRename = async (row: CcMessageTypeDictItem, name: string) => {
+ const next = name.trim();
+ if (!next || next === row.name) return;
+ const res = await updateCcMessageTypeApi(row.id, { name: next });
+ if (!res.ok) {
+ message.error(res.error.message);
+ return;
+ }
+ message.success("已更新名称");
+ await reload();
+ };
+
+ const onSort = async (row: CcMessageTypeDictItem, sort_order: number | null) => {
+ if (sort_order == null || sort_order === row.sort_order) return;
+ const res = await updateCcMessageTypeApi(row.id, { sort_order });
+ if (!res.ok) {
+ message.error(res.error.message);
+ return;
+ }
+ await reload();
+ };
+
+ const onDelete = async (row: CcMessageTypeDictItem) => {
+ const res = await deleteCcMessageTypeApi(row.id);
+ if (!res.ok) {
+ message.error(res.error.message);
+ return;
+ }
+ message.success("已删除");
+ await reload();
+ };
+
+ return (
+
+
+ {loading
+ ? "加载中…"
+ : `共 ${items.length} 项 · 启用 ${enabledCount}`}
+
+ : }
+ onClick={() => setExpanded((v) => !v)}
+ >
+ {expanded ? "收起" : "展开"}
+
+
+ }
+ >
+ {!expanded ? (
+
+ 折叠中。展开后可维护工单 / 客户留言表单的留言类型下拉选项。
+
+ ) : (
+ <>
+
+ 在此维护写入 CarrierCentral 的「留言类型」名称。启用项会出现在确认页工单表单中;首次空库会自动写入默认种子。
+
+
+
+ 8 ? { y: 320 } : undefined}
+ columns={[
+ {
+ title: "名称",
+ dataIndex: "name",
+ render: (name: string, row) => (
+ void onRename(row, e.target.value)}
+ onPressEnter={(e) => {
+ (e.target as HTMLInputElement).blur();
+ }}
+ />
+ ),
+ },
+ {
+ title: "排序",
+ dataIndex: "sort_order",
+ width: 100,
+ render: (v: number, row) => (
+ void onSort(row, n)}
+ />
+ ),
+ },
+ {
+ title: "启用",
+ dataIndex: "enabled",
+ width: 80,
+ render: (enabled: boolean, row) => (
+ void onToggle(row, checked)}
+ />
+ ),
+ },
+ {
+ title: "操作",
+ width: 80,
+ render: (_, row) => (
+ void onDelete(row)}
+ >
+
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/CcSettingsCard.tsx b/src/components/CcSettingsCard.tsx
index 6db6816..091c8fc 100644
--- a/src/components/CcSettingsCard.tsx
+++ b/src/components/CcSettingsCard.tsx
@@ -9,20 +9,15 @@ import {
Input,
Space,
Spin,
- Tag,
} from "antd";
import {
getCcSettingsApi,
testCcSettingsApi,
updateCcSettingsApi,
} from "@/lib/client-api";
-import type { CcSettings } from "@/types";
type FormValues = {
- mock: boolean;
api_base: string;
- saas_header: string;
- login_mark: string;
username: string;
password?: string;
};
@@ -32,11 +27,9 @@ export function CcSettingsCard() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
- const [meta, setMeta] = useState | null>(null);
- const [mockMode, setMockMode] = useState(false);
+ const [hasPassword, setHasPassword] = useState(false);
+ const [saasHeader, setSaasHeader] = useState("TEST");
+ const [loginMark, setLoginMark] = useState("");
const [form] = Form.useForm();
const reload = useCallback(async () => {
@@ -47,17 +40,11 @@ export function CcSettingsCard() {
message.error(res.error.message);
return;
}
- setMeta({
- source: res.data.source,
- has_password: res.data.has_password,
- updated_at: res.data.updated_at,
- });
- setMockMode(res.data.mock);
+ setHasPassword(res.data.has_password);
+ setSaasHeader(res.data.saas_header || "TEST");
+ setLoginMark(res.data.login_mark || crypto.randomUUID());
form.setFieldsValue({
- mock: res.data.mock,
api_base: res.data.api_base,
- saas_header: res.data.saas_header,
- login_mark: res.data.login_mark,
username: res.data.username,
password: undefined,
});
@@ -69,13 +56,13 @@ export function CcSettingsCard() {
const onSave = async () => {
const values = await form.validateFields();
- // mock 开关已从设置页移除;保存时沿用当前运行时配置,不在此页改 mock
- const mock = mockMode;
- if (!mock && !values.username.trim()) {
+ // 工单等业务需真写 CC;保存时强制关闭全局 mock
+ const mock = false;
+ if (!values.username.trim()) {
message.error("请填写用户名");
return;
}
- if (!mock && !meta?.has_password && !values.password?.trim()) {
+ if (!hasPassword && !values.password?.trim()) {
message.error("请填写密码(首次或尚未配置)");
return;
}
@@ -84,8 +71,8 @@ export function CcSettingsCard() {
const res = await updateCcSettingsApi({
mock,
api_base: values.api_base,
- saas_header: values.saas_header,
- login_mark: values.login_mark,
+ saas_header: saasHeader || "TEST",
+ login_mark: loginMark || crypto.randomUUID(),
username: values.username,
...(values.password?.trim()
? { password: values.password.trim() }
@@ -120,21 +107,7 @@ export function CcSettingsCard() {
};
return (
-
- {meta ? (
-
- 来源 {meta.source === "db" ? "数据库" : ".env"}
-
- ) : null}
-
- {mockMode ? "mock" : "live"}
-
-
- }
- >
+
-
-
-
-
-
-
-
-
diff --git a/src/components/CcWorkOrderForm.tsx b/src/components/CcWorkOrderForm.tsx
index b096967..fc297f9 100644
--- a/src/components/CcWorkOrderForm.tsx
+++ b/src/components/CcWorkOrderForm.tsx
@@ -1,12 +1,11 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Button, Input, Select, Space, Tag, Typography } from "antd";
-import {
- CC_CUSTOMER_MESSAGE_TYPE_OPTIONS,
- suggestCcMessageType,
-} from "@/constants/cc-message-types";
+import { suggestCcMessageType } from "@/constants/cc-message-types";
import type { CcUiMode } from "@/constants/cc-ui-mode";
+import { listCcMessageTypesApi } from "@/lib/client-api";
import {
collectSoftOpsHints,
collectWorkOrderActions,
@@ -14,6 +13,12 @@ import {
extractWorkOrderMessageBody,
matchWorkOrderAttachments,
} from "@/services/parse/work-order-record";
+import { extractWorkOrderFormFields } from "@/services/parse/work-order-fields";
+import type {
+ WorkOrderFieldMark,
+ WorkOrderFieldMarks,
+ WorkOrderSnapshotField,
+} from "@/services/parse/work-order-snapshot";
const fieldRow: CSSProperties = {
display: "grid",
@@ -29,25 +34,46 @@ const fieldRow3: CSSProperties = {
marginBottom: 10,
};
+const MARK_TAG: Record = {
+ supplemented: { color: "blue", text: "补充" },
+ modified: { color: "orange", text: "已改" },
+};
+
function Field({
label,
children,
required,
full,
+ mark,
}: {
label: ReactNode;
children: ReactNode;
required?: boolean;
full?: boolean;
+ mark?: WorkOrderFieldMark;
}) {
+ const markMeta = mark ? MARK_TAG[mark] : null;
return (
{label}
{required ? * : null}
+ {markMeta ? (
+
+ {markMeta.text}
+
+ ) : null}
{children}
@@ -74,6 +100,8 @@ export type CcWorkOrderFormProps = {
onChange?: (patch: Partial) => void;
/** 管理端 Form.cshtml vs 客户端 SaveForm 布局完全不同 */
portal?: CcUiMode;
+ /** 相对邮件基线的字段标记(只读回显人工补充/修改) */
+ fieldMarks?: WorkOrderFieldMarks;
};
const URGENCY_OPTIONS = [
@@ -90,6 +118,13 @@ function uniq(list: string[]): string[] {
return out;
}
+function splitAttachFiles(raw: string): string[] {
+ return String(raw || "")
+ .split(/[;;\n]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
/**
* 客户留言 / 工单表单
* - admin:对齐 ccnew SRCustomerMessage/Form(客户、紧急程度、附件上下载)
@@ -100,14 +135,45 @@ export function CcWorkOrderForm({
value,
onChange,
portal = "admin",
+ fieldMarks,
}: CcWorkOrderFormProps) {
const readonly = mode === "readonly";
+ const markOf = (key: WorkOrderSnapshotField): WorkOrderFieldMark | undefined =>
+ fieldMarks?.[key];
const patch = (p: Partial) => {
if (readonly || !onChange) return;
onChange(p);
};
const actions = value.mailActions || [];
const isAdmin = portal === "admin";
+ const attachFiles = splitAttachFiles(value.F_AttachFile);
+ const [messageTypeOptions, setMessageTypeOptions] = useState<
+ Array<{ value: string; label: string }>
+ >([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const res = await listCcMessageTypesApi({ enabled_only: true });
+ if (cancelled || !res.ok) return;
+ setMessageTypeOptions(
+ res.data.items.map((i) => ({ value: i.name, label: i.name })),
+ );
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ /** 字典未加载完或历史值不在启用列表时,仍要显示已提交值 */
+ const typeSelectOptions = useMemo(() => {
+ const cur = (value.F_MessageType || "").trim();
+ if (!cur) return messageTypeOptions;
+ if (messageTypeOptions.some((o) => o.value === cur)) {
+ return messageTypeOptions;
+ }
+ return [{ value: cur, label: cur }, ...messageTypeOptions];
+ }, [messageTypeOptions, value.F_MessageType]);
return (
-
+
patch({ F_CustomerName: e.target.value })}
/>
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
patch({ F_ReferCode: e.target.value })}
/>
-
+
-
+
-
+
void;
+ onOk: () => void;
+}) {
+ return (
+
+
+ 是否为【{customerName || "未命名客户"}】进行【
+ {operationLabel}】?
+
+
+ );
+}
diff --git a/src/components/ConfirmImportModal.tsx b/src/components/ConfirmImportModal.tsx
index c829531..6184fa9 100644
--- a/src/components/ConfirmImportModal.tsx
+++ b/src/components/ConfirmImportModal.tsx
@@ -1,6 +1,7 @@
"use client";
-import { Modal } from "antd";
+import { Alert, Modal, Typography } from "antd";
+import { ccFailureKindLabel } from "@/constants/error-copy";
import { UI_COPY } from "@/constants/ui-copy";
export function ConfirmImportModal({
@@ -34,27 +35,49 @@ export function ConfirmImportModal({
);
}
+const FAILURE_MODAL_TITLE: Record = {
+ CONFLICT_CHECK_FAILED: "柜号查重失败",
+ CONFLICT_CONTAINER: "柜号冲突",
+ IMPORT_FAILED: "导入失败",
+};
+
export function ConflictModal({
open,
reason,
+ message: messageText,
+ failureKind,
+ detailError,
canForceSkip,
loading,
onClose,
onForceSkip,
}: {
open: boolean;
- reason: "CONFLICT_CONTAINER" | "CONFLICT_CHECK_FAILED" | string;
+ reason: "CONFLICT_CONTAINER" | "CONFLICT_CHECK_FAILED" | "IMPORT_FAILED" | string;
+ /** 服务端返回的可读主文案(优先于固定模板) */
+ message?: string;
+ failureKind?: string;
+ detailError?: string;
canForceSkip: boolean;
loading?: boolean;
onClose: () => void;
onForceSkip?: () => void;
}) {
const isCheckFailed = reason === "CONFLICT_CHECK_FAILED";
+ const isImportFailed = reason === "IMPORT_FAILED";
+ const kindText = ccFailureKindLabel(failureKind);
+ const body =
+ messageText ||
+ (isCheckFailed
+ ? "无法在 CarrierCentral 完成柜号查重(接口超时/限流/登录失败),并非表示该柜已在 CC 存在。"
+ : isImportFailed
+ ? "向 CarrierCentral 保存集装箱失败,请根据下方原因修正后重试。"
+ : UI_COPY.conflictBody);
+ const showDetail = Boolean(detailError?.trim());
+
return (
-
- {isCheckFailed
- ? "无法校验柜号是否已在 CC 存在(列表超时/失败)。默认阻断导入。"
- : UI_COPY.conflictBody}
-
+ {failureKind ? (
+
+ ) : null}
+
+ {body}
+
+ {showDetail ? (
+
+
+ CC 返回详情
+
+ {detailError}
+
+ ) : null}
+ {isCheckFailed ? (
+
+ 说明:查重失败不代表柜号已在 CC 存在;换用可登录的客户档案或等待约 1 分钟后再试。
+
+ ) : null}
{canForceSkip ? (
-
+
Admin 且 ENABLE_FORCE_IMPORT=true:可强制跳过冲突检测。
-
+
) : null}
);
diff --git a/src/components/ImapPullSettingsCard.tsx b/src/components/ImapPullSettingsCard.tsx
index e8a1cf1..6f54535 100644
--- a/src/components/ImapPullSettingsCard.tsx
+++ b/src/components/ImapPullSettingsCard.tsx
@@ -10,6 +10,7 @@ import {
Popconfirm,
Select,
Space,
+ Spin,
Tag,
Typography,
} from "antd";
@@ -68,7 +69,6 @@ export function ImapPullSettingsCard() {
const [savingInterval, setSavingInterval] = useState(false);
const [adding, setAdding] = useState(false);
const [intervalMs, setIntervalMs] = useState(30 * 60_000);
- const [source, setSource] = useState<"db" | "env">("env");
const [filters, setFilters] = useState([]);
const [form] = Form.useForm<{ kind: ImapFilterKind; value: string }>();
@@ -97,7 +97,6 @@ export function ImapPullSettingsCard() {
return;
}
setIntervalMs(res.data.poll_interval_ms);
- setSource(res.data.source);
setFilters(res.data.filters || []);
}, [message]);
@@ -114,7 +113,6 @@ export function ImapPullSettingsCard() {
return;
}
message.success("拉取间隔已保存(worker 下一轮生效)");
- setSource(res.data.source);
setIntervalMs(res.data.poll_interval_ms);
};
@@ -152,138 +150,134 @@ export function ImapPullSettingsCard() {
};
return (
-
- 来源:{source === "db" ? "数据库" : ".env 回退"}
-
- }
- >
-
- 拉取间隔
-
+
+
+
+
+ 拉取间隔
+
-
- 过滤规则
-
-
-
-
-
-
-
-
-
-
-
-
- {filters.length === 0 ? (
-
- 暂无规则(将使用业务相关兜底)
-
- ) : (
-
- {grouped.map((g) => (
-
+
+
+
-
+
+
+
+
+
+
+ {filters.length === 0 ? (
+
+ 暂无规则(将使用业务相关兜底)
+
+ ) : (
+
+ {grouped.map((g) => (
+
- {f.value}
-
+
+ {g.label}
+
+ {g.items.map((f) => (
+ void onDelete(f)}
+ okText="删除"
+ cancelText="取消"
+ >
+ e.stopPropagation()}
+ >
+ ×
+
+
+ }
+ onClose={(e) => e.preventDefault()}
+ onClick={() => void onToggle(f)}
+ style={{
+ cursor: "pointer",
+ marginInlineEnd: 0,
+ opacity: f.enabled ? 1 : 0.45,
+ textDecoration: f.enabled ? "none" : "line-through",
+ userSelect: "none",
+ }}
+ title={f.enabled ? "点击停用" : "点击启用"}
+ >
+ {f.value}
+
+ ))}
+
))}
- ))}
+ )}
- )}
+
);
}
diff --git a/src/components/MailBusinessSummary.tsx b/src/components/MailBusinessSummary.tsx
index b48a06d..a7b50fb 100644
--- a/src/components/MailBusinessSummary.tsx
+++ b/src/components/MailBusinessSummary.tsx
@@ -38,11 +38,15 @@ import {
buildCcWorkOrderFormValue,
type CcWorkOrderFormValue,
} from "@/components/CcWorkOrderForm";
+import {
+ describeWorkOrderFieldMarks,
+ workOrderFormFromSubmitted,
+} from "@/services/parse/work-order-snapshot";
import { type CcUiMode } from "@/constants/cc-ui-mode";
import { listShippingLinesApi } from "@/lib/client-api";
import { transferSegmentContextsFromLineage } from "@/services/parse/match-transfer-segment-ocr";
import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate";
-import { confirmHref } from "@/services/parse/resolve-confirm-instruction";
+import { confirmHref, shouldForceForecastUnit } from "@/services/parse/resolve-confirm-instruction";
import { resolveWorkOrderPanelKind } from "@/services/cc/work-order-panel";
import {
matchShippingLineId,
@@ -97,16 +101,29 @@ function buildWorkOrderDraft(input: {
containerNo: string;
filenames: string[];
customerName?: string | null;
+ /** 已导入时优先用提交快照回填,避免空抽取盖住人工补充值 */
+ submitted?: Record
| null;
}): CcWorkOrderFormValue {
- return buildCcWorkOrderFormValue({
+ const extracted = buildCcWorkOrderFormValue({
subject: input.unit.segmentSubject || input.mail.subject,
body: input.unit.segmentText || input.mail.body_text || "",
filenames: input.filenames,
containerNo: input.containerNo,
actions: input.unit.keywords,
customerName: input.customerName,
- isCurrent: true,
+ // 必须跟本段 isCurrent:历史拦截段不得误挂当前卡转海 PDF/zip
+ isCurrent: input.unit.isCurrent,
});
+ if (!input.submitted || Object.keys(input.submitted).length === 0) {
+ return extracted;
+ }
+ return {
+ ...workOrderFormFromSubmitted(input.submitted),
+ mailActions: input.unit.keywords,
+ F_CustomerName:
+ workOrderFormFromSubmitted(input.submitted).F_CustomerName ||
+ extracted.F_CustomerName,
+ };
}
type EvidenceExt = TypeEvidence & {
@@ -115,13 +132,13 @@ type EvidenceExt = TypeEvidence & {
export function MailBusinessSummary({
mail,
- ccUiMode = "admin",
}: {
mail: MailMessage;
onConfirmed?: () => void;
- /** 默认管理端(ccnew);可切换客户端(cc-client) */
+ /** @deprecated 已固定客户端 UI,忽略传入值 */
ccUiMode?: CcUiMode;
}) {
+ const ccUiMode: CcUiMode = "client";
const header = asHeader(mail);
const record = asMailRecord(mail);
const evidence = (mail.type_evidence || {
@@ -210,14 +227,14 @@ export function MailBusinessSummary({
subject: mail.subject,
body: bodyText,
filenames,
- forceForecast: record?.kind === "BL_FORECAST",
+ forceForecast: shouldForceForecastUnit(mail),
workOrderActions: record?.work_order_actions,
}),
[
- mail.subject,
+ mail,
bodyText,
filenames,
- record?.kind,
+ record?.work_order_actions,
workOrderActionsKey,
],
);
@@ -265,7 +282,8 @@ export function MailBusinessSummary({
const canConfirmOps =
mail.status === "PENDING_CONFIRM" ||
mail.status === "FAILED" ||
- mail.status === "PARTIAL_SUCCESS";
+ mail.status === "PARTIAL_SUCCESS" ||
+ mail.status === "SUCCESS";
/** DO:预报 SUCCESS 后仍可补传 */
const canDoConfirm =
canConfirmOps || mail.status === "SUCCESS";
@@ -377,6 +395,7 @@ export function MailBusinessSummary({
containerNo,
filenames,
customerName: record?.modules.customer_name,
+ submitted: instructionStateMap[unit.id]?.submitted,
}),
]),
),
@@ -439,6 +458,7 @@ export function MailBusinessSummary({
containerNo,
filenames,
customerName: record?.modules.customer_name,
+ submitted: instructionStateMap[unit.id]?.submitted,
}),
]),
),
@@ -489,6 +509,7 @@ export function MailBusinessSummary({
currentWorkOrderUnit?.segmentText,
record?.modules,
shipments,
+ instructionStateMap,
]);
useEffect(() => {
@@ -624,8 +645,11 @@ export function MailBusinessSummary({
);
}
} else if (unit.uiKind === "work_order") {
- const unitImported =
- instructionStateMap[unit.id]?.status === "IMPORTED";
+ const unitState = instructionStateMap[unit.id];
+ const unitImportedLive =
+ unitState?.status === "IMPORTED" && unitState.mode !== "mock";
+ const unitImportedMock =
+ unitState?.status === "IMPORTED" && unitState.mode === "mock";
// 仅用本指令段的关键词 + 段正文分流,禁止整封 body 污染(如主题链里的「拦截-209件」)
const unitActions = Array.from(
new Set([
@@ -640,25 +664,57 @@ export function MailBusinessSummary({
.filter(Boolean)
.join("\n"),
);
+ const extracted = buildWorkOrderDraft({
+ unit,
+ mail,
+ containerNo,
+ filenames,
+ customerName: record?.modules.customer_name,
+ submitted: unitState?.submitted,
+ });
+ // 已导入:一律用提交快照,避免抽取空值盖住「补充」字段
const woForUnit =
- workOrderDrafts[unit.id] ||
- buildWorkOrderDraft({
- unit,
- mail,
- containerNo,
- filenames,
- customerName: record?.modules.customer_name,
- });
+ (unitImportedLive || unitImportedMock) && unitState?.submitted
+ ? {
+ ...workOrderFormFromSubmitted(unitState.submitted),
+ mailActions: unit.keywords,
+ }
+ : workOrderDrafts[unit.id] || extracted;
const editMode = "readonly";
+ const markSummary = describeWorkOrderFieldMarks(
+ unitState?.fieldMarks,
+ unitState?.submitted,
+ );
body = (
<>
- {unitImported ? (
+ {unitImportedLive ? (
0 ? (
+
+ {markSummary.map((line) => (
+
{line}
+ ))}
+
+ ) : undefined
+ }
+ />
+ ) : null}
+ {unitImportedMock ? (
+
) : null}
{panelKind === "hold" ? (
@@ -685,6 +741,11 @@ export function MailBusinessSummary({
portal={ccUiMode}
mode={editMode}
value={woForUnit}
+ fieldMarks={
+ unitImportedLive || unitImportedMock
+ ? unitState?.fieldMarks
+ : undefined
+ }
onChange={(p) =>
setWorkOrderDrafts((v) => ({
...v,
@@ -695,13 +756,15 @@ export function MailBusinessSummary({
)}
>
);
- if (canConfirmOps && !unitImported) {
+ if (canConfirmOps && !unitImportedLive) {
actions = (
);
@@ -753,6 +816,9 @@ export function MailBusinessSummary({
index={index}
total={instructions.length}
imported={instructionStateMap[unit.id]?.status === "IMPORTED"}
+ userSupplemented={Boolean(
+ instructionStateMap[unit.id]?.userSupplemented,
+ )}
>
{body}
{actions ? (
diff --git a/src/components/MailInstructionUnitShell.tsx b/src/components/MailInstructionUnitShell.tsx
index 98ef53f..94f004a 100644
--- a/src/components/MailInstructionUnitShell.tsx
+++ b/src/components/MailInstructionUnitShell.tsx
@@ -10,12 +10,15 @@ export function InstructionUnitShell({
index,
total,
imported = false,
+ userSupplemented = false,
children,
}: {
unit: MailInstructionUnit;
index: number;
total: number;
imported?: boolean;
+ /** 导入时含人工补充/修改字段 */
+ userSupplemented?: boolean;
children: ReactNode;
}) {
const color =
@@ -37,6 +40,11 @@ export function InstructionUnitShell({
{imported ? (
已导入
) : null}
+ {imported && userSupplemented ? (
+
+ 经人工补充
+
+ ) : null}
{unit.isCurrent ? (
当前
) : (
diff --git a/src/components/OauthSettingsCard.tsx b/src/components/OauthSettingsCard.tsx
index 664e4e5..c74a0ce 100644
--- a/src/components/OauthSettingsCard.tsx
+++ b/src/components/OauthSettingsCard.tsx
@@ -74,8 +74,8 @@ export function OauthSettingsButton({ onSaved }: { onSaved?: () => void }) {
}, [form, message]);
const fillForm = (visible: boolean) => {
+ // destroyOnClose: Form is gone when closed — do not resetFields on close
if (!visible) {
- form.resetFields();
setMeta(null);
return;
}
diff --git a/src/components/ShipmentVirtualTable.tsx b/src/components/ShipmentVirtualTable.tsx
index abb6ea8..fc5fd56 100644
--- a/src/components/ShipmentVirtualTable.tsx
+++ b/src/components/ShipmentVirtualTable.tsx
@@ -8,8 +8,11 @@ import { formatShipmentIssue } from "@/constants/error-copy";
import { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code";
const ROW_HEIGHT = 48;
+const ROW_HEIGHT_EDIT = 40;
const MAX_TABLE_HEIGHT = 480;
+const MAX_TABLE_HEIGHT_EDIT = 420;
const EMPTY_TABLE_HEIGHT = 96;
+const MIN_TABLE_HEIGHT_EDIT = 280;
export interface ShipmentColumn {
key: string;
@@ -77,31 +80,46 @@ export function ShipmentVirtualTable({
selected,
onToggle,
readonly = false,
+ /** 编辑模式下展示全部列,避免空列被过滤掉无法录入 */
+ showAllColumns = false,
+ /** 确认页编辑:抬高可视区,行高略压缩以多显示几行 */
+ editLayout = false,
}: {
rows: ShipmentRow[];
columns: ShipmentColumn[];
selected: Set;
onToggle: (rowIndex: number, checked: boolean) => void;
readonly?: boolean;
+ showAllColumns?: boolean;
+ editLayout?: boolean;
}) {
const parentRef = useRef(null);
const headerRef = useRef(null);
+ const rowH = editLayout ? ROW_HEIGHT_EDIT : ROW_HEIGHT;
const visibleColumns = useMemo(
- () => filterShipmentColumns(columns, rows),
- [columns, rows],
+ () =>
+ showAllColumns || !rows.length
+ ? columns
+ : filterShipmentColumns(columns, rows),
+ [columns, rows, showAllColumns],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
- estimateSize: () => ROW_HEIGHT,
+ estimateSize: () => rowH,
overscan: 12,
});
const totalWidth = visibleColumns.reduce((sum, col) => sum + col.width, 0);
- const bodyHeight =
- rows.length === 0
- ? EMPTY_TABLE_HEIGHT
- : Math.min(MAX_TABLE_HEIGHT, rows.length * ROW_HEIGHT);
+ const bodyHeight = (() => {
+ if (rows.length === 0) {
+ return editLayout ? MIN_TABLE_HEIGHT_EDIT : EMPTY_TABLE_HEIGHT;
+ }
+ const content = rows.length * rowH;
+ const maxH = editLayout ? MAX_TABLE_HEIGHT_EDIT : MAX_TABLE_HEIGHT;
+ const minH = editLayout ? MIN_TABLE_HEIGHT_EDIT : 0;
+ return Math.max(minH, Math.min(maxH, content));
+ })();
const syncHeaderScroll = useCallback((e: UIEvent) => {
if (headerRef.current) {
@@ -175,7 +193,7 @@ export function ShipmentVirtualTable({
left: 0,
display: "flex",
width: totalWidth,
- height: ROW_HEIGHT,
+ height: rowH,
transform: `translateY(${vRow.start}px)`,
borderBottom: "1px solid var(--color-border)",
fontSize: 14,
diff --git a/src/components/TypeOverrideModal.tsx b/src/components/TypeOverrideModal.tsx
index 59f8dac..2fbf0d2 100644
--- a/src/components/TypeOverrideModal.tsx
+++ b/src/components/TypeOverrideModal.tsx
@@ -4,6 +4,11 @@ import { Form, Input, Modal, Select } from "antd";
import type { MailType } from "@/types/mail";
import { MAIL_TYPE_LABEL } from "@/constants/ui-copy";
+/** Admin 改类型可选目标:预报由解析/确认链路处理,不在此手工改成新增预报 */
+const OVERRIDE_TYPE_OPTIONS = (
+ Object.keys(MAIL_TYPE_LABEL) as MailType[]
+).filter((k) => k !== "NEW_CONTAINER");
+
export function TypeOverrideModal({
open,
loading,
@@ -28,10 +33,21 @@ export function TypeOverrideModal({
width={480}
confirmLoading={loading}
okButtonProps={{ disabled: loading }}
- onCancel={() => {
- form.resetFields();
- onCancel();
+ destroyOnClose
+ afterOpenChange={(visible) => {
+ if (visible) {
+ const fallback =
+ currentType !== "NEW_CONTAINER" &&
+ OVERRIDE_TYPE_OPTIONS.includes(currentType)
+ ? currentType
+ : "WORK_ORDER";
+ form.setFieldsValue({
+ mail_type: fallback,
+ reason: undefined,
+ });
+ }
}}
+ onCancel={onCancel}
onOk={() => {
form.validateFields().then((values) => onSubmit(values));
}}
@@ -39,18 +55,14 @@ export function TypeOverrideModal({
当前类型:{MAIL_TYPE_LABEL[currentType]} · 证据总分 {evidenceTotal}
-
diff --git a/src/components/confirm/ForecastConfirmView.tsx b/src/components/confirm/ForecastConfirmView.tsx
index afd65b4..86faafc 100644
--- a/src/components/confirm/ForecastConfirmView.tsx
+++ b/src/components/confirm/ForecastConfirmView.tsx
@@ -19,25 +19,34 @@ import {
type CcForecastFormValue,
} from "@/components/CcForecastFormOrder";
import { ConfirmImportModal, ConflictModal } from "@/components/ConfirmImportModal";
-import { CcUiModeSwitch } from "@/components/CcUiModeSwitch";
+import { ConfirmCcCustomerModal } from "@/components/ConfirmCcCustomerModal";
+import {
+ CcCustomerProfileSelect,
+ useCcCustomerProfileSelect,
+} from "@/components/CcCustomerProfileSelect";
import { useAuthSession } from "@/components/providers/AuthSessionProvider";
import { UI_COPY } from "@/constants/ui-copy";
-import { formatMailLastError } from "@/constants/error-copy";
+import { describeCcFailureForOperator } from "@/constants/error-copy";
import { buildImportIdempotencyKey } from "@/utils/idempotency-client";
import { withVersionConflictRetry } from "@/utils/mail-submit-version";
-import { ccUiModeLabel } from "@/constants/cc-ui-mode";
import { useImport } from "@/hooks/useImport";
-import { useCcUiMode } from "@/hooks/useCcUiMode";
-import { healthApi, listShippingLinesApi } from "@/lib/client-api";
+import { healthApi, listShippingLinesApi, parseForecastPackingApi } from "@/lib/client-api";
import { collectForecastImportIssues } from "@/services/cc/forecast-confirm-gate";
import { isInstructionImported } from "@/services/parse/instruction-execution-state";
import {
matchShippingLineId,
shippingLineHintFromBl,
} from "@/services/parse/extract-container-header";
+import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template";
+import { extractOpsSubjectFromMail } from "@/services/parse/ops-subject-template";
+import {
+ normalizeParseShipments,
+ reindexShipments,
+ revalidateShipmentRow,
+} from "@/services/parse/revalidate-shipment-row";
import type { MailInstructionUnit } from "@/services/parse/split-instructions";
import type { MailMessage } from "@/types";
-import type { MailRecordModules } from "@/types/mail";
+import type { MailRecordModules, ShipmentRow } from "@/types/mail";
export function ForecastConfirmView({
mail,
@@ -47,20 +56,23 @@ export function ForecastConfirmView({
unit: MailInstructionUnit;
}) {
const router = useRouter();
- const { message } = App.useApp();
+ const { message, modal } = App.useApp();
const { user } = useAuthSession();
const { submitting, submit } = useImport(mail.id);
- const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode();
+ const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id });
const instructionId = unit.id;
const [submitVersion, setSubmitVersion] = useState(mail.version);
useEffect(() => {
setSubmitVersion(mail.version);
}, [mail.version]);
- const shipments = useMemo(
- () => mail.parse_result?.shipments ?? [],
- [mail.parse_result?.shipments],
+ const [shipments, setShipments] = useState(() =>
+ normalizeParseShipments(mail.parse_result?.shipments),
);
+ useEffect(() => {
+ setShipments(normalizeParseShipments(mail.parse_result?.shipments));
+ }, [mail.id, mail.version, mail.parse_result?.shipments]);
+
const customerName =
mail.parse_result?.mail_record?.modules?.customer_name ||
(
@@ -76,9 +88,13 @@ export function ForecastConfirmView({
const [step, setStep] = useState(0);
const [selected, setSelected] = useState>(new Set());
const [ackUnmapped, setAckUnmapped] = useState(false);
+ const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [conflictOpen, setConflictOpen] = useState(false);
const [conflictReason, setConflictReason] = useState("CONFLICT_CONTAINER");
+ const [conflictMessage, setConflictMessage] = useState("");
+ const [conflictKind, setConflictKind] = useState();
+ const [conflictDetail, setConflictDetail] = useState();
const [containerSource, setContainerSource] = useState<
"body" | "attachment" | null
>(null);
@@ -113,16 +129,126 @@ export function ForecastConfirmView({
});
}, []);
- const loadShippingLines = async () => {
- const res = await listShippingLinesApi();
+ const [shippingFetchLoading, setShippingFetchLoading] = useState(false);
+ const [packingImportLoading, setPackingImportLoading] = useState(false);
+
+ const loadShippingLines = async (opts?: { refresh?: boolean }) => {
+ const res = await listShippingLinesApi(
+ opts?.refresh ? { refresh: true } : undefined,
+ );
if (!res.ok) {
message.error(res.error.message);
- return;
+ return null;
}
setShippingLineRows(res.data.items);
setShippingLines(
res.data.items.map((item) => ({ value: item.id, label: item.name })),
);
+ return res.data.items;
+ };
+
+ const matchShippingLineFromBl = (
+ bl: string,
+ rows: typeof shippingLineRows,
+ ): string | null => {
+ const hint = shippingLineHintFromBl(bl);
+ if (!hint && !bl.trim()) return null;
+ return matchShippingLineId(
+ hint,
+ rows.map((l) => ({
+ F_Id: l.id,
+ F_FullName: l.name,
+ F_EnCode: l.code,
+ F_Prefix: l.prefix,
+ })),
+ { blNo: bl },
+ );
+ };
+
+ const onFetchShippingLine = async () => {
+ setShippingFetchLoading(true);
+ try {
+ const items = await loadShippingLines({ refresh: true });
+ if (!items) return;
+ const bl = form.F_BLCopyCode || "";
+ if (!bl.trim()) {
+ message.warning("请先填写提单号,再按提单前缀匹配船司");
+ return;
+ }
+ const id = matchShippingLineFromBl(bl, items);
+ if (!id) {
+ message.warning(
+ `船司列表已刷新(${items.length} 条),但未能根据提单「${bl}」自动匹配,请手工选择`,
+ );
+ return;
+ }
+ setForm((p) => ({ ...p, F_ShippingLineId: id }));
+ const name = items.find((x) => x.id === id)?.name || id;
+ message.success(`已匹配船司:${name}`);
+ } finally {
+ setShippingFetchLoading(false);
+ }
+ };
+
+ const onImportPackingFile = async (file: File) => {
+ if (pageReadonly) return;
+ setPackingImportLoading(true);
+ try {
+ const res = await parseForecastPackingApi(mail.id, file, {
+ containerNo: form.F_ContainerNo,
+ });
+ if (!res.ok) {
+ modal.error({
+ title: "导入装箱表失败",
+ content:
+ res.error.message ||
+ "无法解析该文件。请上传卡派清单等含「渠道 / 件数 / 仓库」表头的 Excel(xlsx/xls)。",
+ });
+ return;
+ }
+ if (!res.data.shipments.length) {
+ modal.warning({
+ title: "未识别到货件行",
+ content:
+ "文件已上传,但没有解析出货件。请确认表头包含渠道/派送方式、件数、体积、毛重等列。",
+ });
+ return;
+ }
+ const imported = reindexShipments(
+ res.data.shipments.map((s, i) =>
+ revalidateShipmentRow({ ...s, row_index: i }),
+ ),
+ );
+ setShipments(imported);
+ setSelected(
+ new Set(
+ imported
+ .filter((s) => s.row_status === "VALID")
+ .map((s) => s.row_index),
+ ),
+ );
+ if (
+ res.data.container_no &&
+ !form.F_ContainerNo.trim() &&
+ !hasContainerConflict
+ ) {
+ setForm((p) => ({
+ ...p,
+ F_ContainerNo: res.data.container_no!.toUpperCase(),
+ }));
+ }
+ message.success(
+ `已导入 ${res.data.row_count} 行货件(有效 ${res.data.valid_count})`,
+ );
+ if (!isAdmin && step === 0) setStep(1);
+ } catch {
+ modal.error({
+ title: "导入装箱表失败",
+ content: "上传或解析时出错,请确认文件为有效的 Excel(xlsx/xls)后重试。",
+ });
+ } finally {
+ setPackingImportLoading(false);
+ }
};
useEffect(() => {
@@ -134,18 +260,7 @@ export function ForecastConfirmView({
if (!form.F_BLCopyCode || !shippingLineRows.length || form.F_ShippingLineId) {
return;
}
- const hint = shippingLineHintFromBl(form.F_BLCopyCode);
- if (!hint) return;
- const id = matchShippingLineId(
- hint,
- shippingLineRows.map((l) => ({
- F_Id: l.id,
- F_FullName: l.name,
- F_EnCode: l.code,
- F_Prefix: l.prefix,
- })),
- { blNo: form.F_BLCopyCode },
- );
+ const id = matchShippingLineFromBl(form.F_BLCopyCode, shippingLineRows);
if (id) {
setForm((p) =>
p.F_ShippingLineId === id ? p : { ...p, F_ShippingLineId: id },
@@ -161,11 +276,20 @@ export function ForecastConfirmView({
| { mail_record?: { modules?: MailRecordModules } }
| undefined
)?.mail_record;
+ const ops = extractOpsSubjectFromMail(mail.subject);
+ const bl = ops
+ ? null
+ : extractBlPlusFromMail(mail.subject, mail.body_text || "");
+ const modules: MailRecordModules = {
+ ...(bl?.modules || {}),
+ ...(ops?.modules || {}),
+ ...(record?.modules || {}),
+ };
setForm(
buildCcForecastFormValue({
customerName,
header: mail.parse_result?.container_header,
- modules: record?.modules,
+ modules,
}),
);
}, [mail, customerName]);
@@ -240,6 +364,7 @@ export function ForecastConfirmView({
transMode: form.F_TransMode,
operationType: form.F_OperationType,
containerNo: form.F_ContainerNo,
+ cabinetType: form.F_CabinetType,
hasContainerConflict,
containerSource,
validRowCount: validRows.length,
@@ -249,13 +374,14 @@ export function ForecastConfirmView({
hasUnmappedSelected,
ackUnmapped,
softLimitExceeded,
- portal: ccUiMode,
+ portal: "client",
step,
}),
[
form.F_TransMode,
form.F_OperationType,
form.F_ContainerNo,
+ form.F_CabinetType,
hasContainerConflict,
containerSource,
validRows.length,
@@ -264,7 +390,6 @@ export function ForecastConfirmView({
hasUnmappedSelected,
ackUnmapped,
softLimitExceeded,
- ccUiMode,
step,
],
);
@@ -273,6 +398,7 @@ export function ForecastConfirmView({
!errors.transMode &&
!errors.operationType &&
!errors.containerNo &&
+ !errors.cabinetType &&
!errors.containerSource;
const onStepChange = (s: number) => {
@@ -293,6 +419,15 @@ export function ForecastConfirmView({
message.error(importIssues.blockers[0]);
return;
}
+ if (!ccProfile.profileId || !ccProfile.selected) {
+ message.error("请先选择 CC 客户");
+ return;
+ }
+ setCustomerConfirmOpen(true);
+ };
+
+ const onCustomerConfirmOk = () => {
+ setCustomerConfirmOpen(false);
setConfirmOpen(true);
};
@@ -303,6 +438,10 @@ export function ForecastConfirmView({
if (operationType !== 0 && operationType !== 2 && operationType !== 4) {
return;
}
+ if (!ccProfile.profileId) {
+ message.error("请先选择 CC 客户");
+ return;
+ }
const containerNo = form.F_ContainerNo.toUpperCase();
const basePayload = {
instruction_id: instructionId,
@@ -330,6 +469,8 @@ export function ForecastConfirmView({
},
selected_row_indexes: selectedValid,
ack_unmapped_channels: ackUnmapped,
+ cc_profile_id: ccProfile.profileId,
+ shipments,
...(forceSkip ? { force_skip_conflict: true } : {}),
};
@@ -353,7 +494,16 @@ export function ForecastConfirmView({
res.error.code === "CONFLICT_CHECK_FAILED"
) {
setConfirmOpen(false);
+ const details = res.error.details as
+ | { kind?: string; error?: string }
+ | undefined;
+ const rawDetail =
+ details?.error || res.error.message || undefined;
+ const described = describeCcFailureForOperator(rawDetail);
setConflictReason(res.error.code);
+ setConflictMessage(described.message || res.error.message || "");
+ setConflictKind(details?.kind || described.kind);
+ setConflictDetail(described.detail || details?.error);
setConflictOpen(true);
return;
}
@@ -372,12 +522,19 @@ export function ForecastConfirmView({
message.success("本条预报已导入,请继续确认其余指令");
router.push(`/mails/${mail.id}`);
} else {
- const importErr = formatMailLastError(
+ const rawErr =
res.data.imports?.[0]?.last_error ||
- mail.last_error ||
- "导入失败,可修改后重试",
- );
- message.error(importErr);
+ mail.last_error ||
+ "导入失败,可修改后重试";
+ const described = describeCcFailureForOperator(rawErr);
+ setConflictReason("IMPORT_FAILED");
+ setConflictMessage(described.message);
+ setConflictKind(described.kind);
+ setConflictDetail(described.detail ?? undefined);
+ if (/柜型/.test(rawErr) || /柜型/.test(described.message)) {
+ setStep(0);
+ }
+ setConflictOpen(true);
}
} finally {
window.clearTimeout(slowHint);
@@ -426,9 +583,15 @@ export function ForecastConfirmView({
-
+
- {ccUiModeLabel(ccUiMode)} · SaveContainer
+ 客户端 · SaveContainer
@@ -503,7 +666,7 @@ export function ForecastConfirmView({
) : null}
{hasUnmappedSelected ? (
-
+
{errors.ack ? (
-
+
{errors.ack}
) : null}
@@ -527,13 +684,18 @@ export function ForecastConfirmView({
setForm((p) => ({ ...p, ...patch }))}
+ onShipmentsChange={pageReadonly ? undefined : setShipments}
+ packingImportLoading={packingImportLoading}
+ onImportPackingFile={pageReadonly ? undefined : onImportPackingFile}
errors={errors}
step={step}
onStepChange={onStepChange}
@@ -541,7 +703,9 @@ export function ForecastConfirmView({
containerNoLocked={hasContainerConflict}
onFinish={onPrimary}
finishLoading={submitting}
- finishDisabled={importIssues.blockers.length > 0}
+ finishDisabled={
+ importIssues.blockers.length > 0 || !ccProfile.profileId
+ }
finishBlockers={pageReadonly ? [] : importIssues.blockers}
finishNotes={pageReadonly ? [] : importIssues.notes}
/>
@@ -560,6 +724,13 @@ export function ForecastConfirmView({
+ setCustomerConfirmOpen(false)}
+ onOk={onCustomerConfirmOk}
+ />
setConflictOpen(false)}
diff --git a/src/components/confirm/OpsInstructionConfirmView.tsx b/src/components/confirm/OpsInstructionConfirmView.tsx
index c5e4492..6df20c3 100644
--- a/src/components/confirm/OpsInstructionConfirmView.tsx
+++ b/src/components/confirm/OpsInstructionConfirmView.tsx
@@ -23,10 +23,13 @@ import {
buildCcWorkOrderFormValue,
type CcWorkOrderFormValue,
} from "@/components/CcWorkOrderForm";
-import { CcUiModeSwitch } from "@/components/CcUiModeSwitch";
+import { ConfirmCcCustomerModal } from "@/components/ConfirmCcCustomerModal";
+import {
+ CcCustomerProfileSelect,
+ useCcCustomerProfileSelect,
+} from "@/components/CcCustomerProfileSelect";
+import { collectDoImportIssues } from "@/services/cc/do-confirm-gate";
import { UI_COPY } from "@/constants/ui-copy";
-import { ccUiModeLabel } from "@/constants/cc-ui-mode";
-import { useCcUiMode } from "@/hooks/useCcUiMode";
import {
confirmDoUploadApi,
confirmTransferApi,
@@ -35,7 +38,17 @@ import {
} from "@/lib/client-api";
import { toCcCustomerMessageEntity } from "@/services/cc/customer-message-entity";
import { resolveWorkOrderPanelKind } from "@/services/cc/work-order-panel";
-import { isInstructionImported } from "@/services/parse/instruction-execution-state";
+import {
+ isInstructionImported,
+ isInstructionImportedLive,
+ isInstructionImportedMock,
+ getInstructionExecutionState,
+} from "@/services/parse/instruction-execution-state";
+import {
+ describeWorkOrderFieldMarks,
+ toWorkOrderSubmittedSnapshot,
+ workOrderFormFromSubmitted,
+} from "@/services/parse/work-order-snapshot";
import { transferSegmentContextsFromLineage } from "@/services/parse/match-transfer-segment-ocr";
import {
buildMailIntent,
@@ -73,14 +86,12 @@ function asMailRecord(mail: MailMessage): MailRecord | null {
function ConfirmChrome({
mail,
unit,
- ccUiMode,
- setCcUiMode,
+ profileSelect,
children,
}: {
mail: MailMessage;
unit: MailInstructionUnit;
- ccUiMode: ReturnType["mode"];
- setCcUiMode: ReturnType["setMode"];
+ profileSelect: ReactNode;
children: ReactNode;
}) {
return (
@@ -109,9 +120,9 @@ function ConfirmChrome({
{mail.subject}
-
+ {profileSelect}
- {ccUiModeLabel(ccUiMode)} · {instructionUiKindLabel(unit.uiKind)}
+ 客户端 · {instructionUiKindLabel(unit.uiKind)}
@@ -133,7 +144,8 @@ export function TransferConfirmView({
}) {
const router = useRouter();
const { message } = App.useApp();
- const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode();
+ const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id });
+ const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false);
const { version: submitVersion, setVersion: setSubmitVersion } =
useSubmitVersion(mail.version);
const header = asHeader(mail);
@@ -230,7 +242,9 @@ export function TransferConfirmView({
confirmWorkOrderApi(mail.id, {
version,
instruction_id: unit.id,
+ cc_profile_id: ccProfile.profileId || undefined,
entity,
+ baseline: toWorkOrderSubmittedSnapshot(woDraft),
}),
});
if (!res.ok) {
@@ -273,6 +287,7 @@ export function TransferConfirmView({
confirmTransferApi(mail.id, {
version,
instruction_id: unit.id,
+ cc_profile_id: ccProfile.profileId || undefined,
container_no:
form.F_ContainerNo ||
header?.F_ContainerNo ||
@@ -312,8 +327,15 @@ export function TransferConfirmView({
+ }
>
{unitImported ? (
@@ -353,13 +375,30 @@ export function TransferConfirmView({
+
setCustomerConfirmOpen(false)}
+ onOk={() => {
+ setCustomerConfirmOpen(false);
+ void onSubmit();
+ }}
+ />
);
}
@@ -373,7 +412,8 @@ export function DoUploadConfirmView({
}) {
const router = useRouter();
const { message } = App.useApp();
- const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode();
+ const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id });
+ const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false);
const { version: submitVersion, setVersion: setSubmitVersion } =
useSubmitVersion(mail.version);
const header = asHeader(mail);
@@ -404,7 +444,24 @@ export function DoUploadConfirmView({
);
const [submitting, setSubmitting] = useState(false);
+ const importIssues = useMemo(
+ () =>
+ collectDoImportIssues({
+ containerNo: value.F_ContainerNo,
+ filenames: value.filenames,
+ }),
+ [value.F_ContainerNo, value.filenames],
+ );
+ const finishDisabled =
+ !canSubmit ||
+ !ccProfile.profileId ||
+ importIssues.blockers.length > 0;
+
const onSubmit = async () => {
+ if (importIssues.blockers.length) {
+ message.error(importIssues.blockers[0]);
+ return;
+ }
setSubmitting(true);
const res = await withVersionConflictRetry({
version: submitVersion,
@@ -413,6 +470,7 @@ export function DoUploadConfirmView({
confirmDoUploadApi(mail.id, {
version,
instruction_id: unit.id,
+ cc_profile_id: ccProfile.profileId || undefined,
container_no: value.F_ContainerNo,
container_task_id: value.F_ContainerTaskId || undefined,
filenames: value.filenames,
@@ -433,8 +491,15 @@ export function DoUploadConfirmView({
+ }
>
{unitImported ? (
@@ -442,8 +507,19 @@ export function DoUploadConfirmView({
setValue((v) => ({ ...v, ...p }))}
/>
+ {canSubmit && importIssues.blockers.length ? (
+
+
无法确认上传
+
+ {importIssues.blockers.map((item) => (
+ - {item}
+ ))}
+
+
+ ) : null}
@@ -453,13 +529,41 @@ export function DoUploadConfirmView({
+ setCustomerConfirmOpen(false)}
+ onOk={() => {
+ setCustomerConfirmOpen(false);
+ void onSubmit();
+ }}
+ />
);
}
@@ -473,35 +577,71 @@ export function WorkOrderConfirmView({
}) {
const router = useRouter();
const { message } = App.useApp();
- const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode();
+ const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id });
+ const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false);
const { version: submitVersion, setVersion: setSubmitVersion } =
useSubmitVersion(mail.version);
const header = asHeader(mail);
const record = asMailRecord(mail);
const shipments = mail.parse_result?.shipments ?? [];
- const filenames = mail.attachments.map((a) => a.filename);
const containerNo = header?.F_ContainerNo || mail.container_no || "";
- const unitImported = isInstructionImported(
+ const unitImportedLive = isInstructionImportedLive(
+ mail.parse_result?.lineage,
+ unit.id,
+ );
+ const unitImportedMock = isInstructionImportedMock(
+ mail.parse_result?.lineage,
+ unit.id,
+ );
+ const importState = getInstructionExecutionState(
mail.parse_result?.lineage,
unit.id,
);
const canSubmit =
- !unitImported &&
+ !unitImportedLive &&
(mail.status === "PENDING_CONFIRM" ||
mail.status === "FAILED" ||
- mail.status === "PARTIAL_SUCCESS");
+ mail.status === "PARTIAL_SUCCESS" ||
+ (mail.status === "SUCCESS" && unitImportedMock));
- const [draft, setDraft] = useState(() =>
- buildCcWorkOrderFormValue({
- subject: unit.segmentSubject || mail.subject,
- body: unit.segmentText || mail.body_text || "",
+ const filenamesKey = mail.attachments.map((a) => a.filename).join("?");
+ const filenames = useMemo(
+ () => (filenamesKey ? filenamesKey.split("?") : []),
+ [filenamesKey],
+ );
+ const mailBaseline = useMemo(
+ () =>
+ buildCcWorkOrderFormValue({
+ subject: unit.segmentSubject || mail.subject,
+ body: unit.segmentText || mail.body_text || "",
+ filenames,
+ containerNo,
+ actions: unit.keywords,
+ customerName: record?.modules.customer_name,
+ isCurrent: unit.isCurrent,
+ }),
+ [
+ unit.segmentSubject,
+ unit.segmentText,
+ unit.keywords,
+ unit.isCurrent,
+ mail.subject,
+ mail.body_text,
filenames,
containerNo,
- actions: unit.keywords,
- customerName: record?.modules.customer_name,
- isCurrent: true,
- }),
+ record?.modules.customer_name,
+ ],
);
+
+ const [draft, setDraft] = useState(() => {
+ if (importState?.submitted) {
+ return {
+ ...workOrderFormFromSubmitted(importState.submitted),
+ mailActions: unit.keywords,
+ };
+ }
+ return mailBaseline;
+ });
const holdBundle = useMemo(
() =>
buildCcHoldFormValue({
@@ -532,7 +672,7 @@ export function WorkOrderConfirmView({
const [submitting, setSubmitting] = useState(false);
const panelKind = resolveWorkOrderPanelKind(
- ccUiMode,
+ "client",
Array.from(new Set([...(unit.keywords || []), ...(draft.mailActions || [])])),
[unit.segmentSubject, unit.segmentText, unit.summary]
.filter(Boolean)
@@ -558,7 +698,9 @@ export function WorkOrderConfirmView({
confirmWorkOrderApi(mail.id, {
version,
instruction_id: unit.id,
+ cc_profile_id: ccProfile.profileId || undefined,
entity,
+ baseline: toWorkOrderSubmittedSnapshot(mailBaseline),
}),
});
setSubmitting(false);
@@ -567,7 +709,9 @@ export function WorkOrderConfirmView({
return;
}
message.success(
- res.data.mode === "mock" ? "工单已提交(mock)" : "工单已提交",
+ res.data.mode === "mock"
+ ? "工单仅在模拟模式提交(未写入 CarrierCentral)。请将写入能力改为 live 后重新提交"
+ : "工单已提交到 CarrierCentral",
);
afterConfirm(router, mail.id);
};
@@ -576,11 +720,49 @@ export function WorkOrderConfirmView({
+ }
>
- {unitImported ? (
-
+ {unitImportedLive ? (
+ {
+ const lines = describeWorkOrderFieldMarks(
+ importState?.fieldMarks,
+ importState?.submitted,
+ );
+ if (!lines.length) return undefined;
+ return (
+
+ {lines.map((line) => (
+
{line}
+ ))}
+
+ );
+ })()
+ }
+ />
+ ) : null}
+ {unitImportedMock ? (
+
) : null}
{panelKind === "hold" ? (
) : (
setDraft((v) => ({ ...v, ...p }))}
/>
)}
@@ -619,8 +806,14 @@ export function WorkOrderConfirmView({
@@ -631,6 +824,17 @@ export function WorkOrderConfirmView({
)}
+ setCustomerConfirmOpen(false)}
+ onOk={() => {
+ setCustomerConfirmOpen(false);
+ void onSubmit();
+ }}
+ />
);
}
diff --git a/src/constants/cabinet-types.ts b/src/constants/cabinet-types.ts
new file mode 100644
index 0000000..10c7c35
--- /dev/null
+++ b/src/constants/cabinet-types.ts
@@ -0,0 +1,17 @@
+/** 对齐 ClientContainerTemplate.xlsx / V1.72 SaveContainer 柜型枚举 */
+export const CC_CABINET_TYPES = [
+ "20GP",
+ "40GP",
+ "40HQ",
+ "40HR",
+ "40HC",
+ "45HQ",
+ "53HQ",
+] as const;
+
+export type CcCabinetType = (typeof CC_CABINET_TYPES)[number];
+
+export const CC_CABINET_TYPE_OPTIONS = CC_CABINET_TYPES.map((v) => ({
+ value: v,
+ label: v,
+}));
diff --git a/src/constants/cc-api-codes.ts b/src/constants/cc-api-codes.ts
new file mode 100644
index 0000000..665adf1
--- /dev/null
+++ b/src/constants/cc-api-codes.ts
@@ -0,0 +1,157 @@
+/**
+ * CarrierCentral 客户端接口 V1.73:状态枚举与统一返回码说明。
+ * 操作员可见文案以本表为准;与旧 PDF 冲突时以 V1.73 为准。
+ */
+
+/** 统一业务返回码(各接口 code 字段) */
+export const CC_RESPONSE_CODE_LABEL: Record = {
+ 200: "成功",
+ 400: "业务失败",
+ 410: "没有登录信息",
+ 500: "服务异常",
+};
+
+/** GetContainerList 输出 F_Status(V1.73 纠正) */
+export const CC_CONTAINER_STATUS_LABEL: Record = {
+ 0: "新单",
+ 20: "清关结束",
+ 30: "提柜直达",
+ 40: "待提柜",
+ 60: "已到仓",
+ 65: "直送运输中",
+ 70: "已拆柜",
+ 80: "已还柜",
+};
+
+/** GetShipmentInfo 输出 Status(V1.73 增加 22-已装车) */
+export const CC_SHIPMENT_STATUS_LABEL: Record = {
+ 0: "预到达",
+ 10: "在库",
+ 15: "在库",
+ 16: "已排货",
+ 20: "已出仓",
+ 22: "已装车",
+ 26: "运输中",
+ 30: "已送达",
+ 90: "已取消",
+};
+
+/** GetStateTrack TypeCode */
+export const CC_STATE_TRACK_LABEL: Record = {
+ 0: "已到港",
+ 10: "已提柜",
+ 20: "已到仓",
+ 30: "已拆柜",
+ 40: "已还空",
+};
+
+/** 接口路径/名称 → 操作员可读接口名 */
+export const CC_API_DISPLAY_NAME: Record = {
+ customerLogin: "登录",
+ GetShippingLineList: "获取船司列表",
+ SaveContainer: "集装箱数据导入",
+ SaveTruckInfo: "卡派数据导入",
+ GetContainerList: "集装箱列表",
+ GetShipmentList: "集装箱货件列表",
+ GetStateTrack: "集装箱状态变更日志",
+ GetShipmentInfo: "集装箱货件信息",
+ GetDevanReport: "集装箱拆箱报告",
+ SaveFieldValue: "修改集装箱字段值",
+ upload: "上传文件",
+ DownPOD: "下载 POD/ClosePage",
+ DownDevanFile: "下载拆柜图片",
+ SaveStockBatchTransfer: "批量转仓",
+ SaveForm: "创建工单(客户指令)",
+};
+
+export type CcApiKey = keyof typeof CC_API_DISPLAY_NAME | string;
+
+export function ccApiDisplayName(api: string | null | undefined): string {
+ const key = (api || "").trim();
+ if (!key) return "CarrierCentral 接口";
+ if (CC_API_DISPLAY_NAME[key]) return CC_API_DISPLAY_NAME[key];
+ const hit = Object.keys(CC_API_DISPLAY_NAME).find((k) =>
+ key.toLowerCase().includes(k.toLowerCase()),
+ );
+ return hit ? CC_API_DISPLAY_NAME[hit] : key;
+}
+
+export function ccResponseCodeLabel(code: number | null | undefined): string {
+ if (code == null || !Number.isFinite(Number(code))) return "";
+ const n = Number(code);
+ return CC_RESPONSE_CODE_LABEL[n] || `状态码 ${n}`;
+}
+
+export function ccContainerStatusLabel(
+ status: number | string | null | undefined,
+): string {
+ if (status == null || status === "") return "";
+ const n = Number(status);
+ if (!Number.isFinite(n)) return String(status);
+ const label = CC_CONTAINER_STATUS_LABEL[n];
+ return label ? `${n}-${label}` : `状态 ${n}`;
+}
+
+export function ccShipmentStatusLabel(
+ status: number | string | null | undefined,
+): string {
+ if (status == null || status === "") return "";
+ const n = Number(status);
+ if (!Number.isFinite(n)) return String(status);
+ const label = CC_SHIPMENT_STATUS_LABEL[n];
+ return label ? `${n}-${label}` : `货件状态 ${n}`;
+}
+
+/** V1.73:柜已到仓及之后(不可批量转仓) */
+export function isContainerArrivedOrLater(status: number | null): boolean {
+ if (status == null) return false;
+ return status >= 60;
+}
+
+/**
+ * 组装「哪个接口 + 返回码含义 + 原因」给操作员。
+ * info 优先保留 CC 原文(中文业务说明)。
+ */
+export function buildCcApiFailureMessage(input: {
+ api: string;
+ code?: number | null;
+ info?: string | null;
+ fallback?: string;
+}): string {
+ const apiName = ccApiDisplayName(input.api);
+ const info = (input.info || "").trim();
+ const code = input.code != null ? Number(input.code) : null;
+ const codeLabel =
+ code != null && Number.isFinite(code) ? ccResponseCodeLabel(code) : "";
+
+ if (code === 410) {
+ return info
+ ? `${apiName}失败(410 没有登录信息):${info}`
+ : `${apiName}失败:没有登录信息(410),请检查客户档案账号或重新登录`;
+ }
+ if (code === 401) {
+ return info
+ ? `${apiName}失败(登录/鉴权):${info}`
+ : `${apiName}失败:登录鉴权失败,请检查客户档案用户名和密码`;
+ }
+ if (code === 400) {
+ return info
+ ? `${apiName}被拒绝(400 业务失败):${info}`
+ : `${apiName}被拒绝(400 业务失败),请核对必填项后重试`;
+ }
+ if (code != null && code >= 500) {
+ return info
+ ? `${apiName}异常(${code} ${codeLabel || "服务异常"}):${info}`
+ : `${apiName}异常(${code}),请稍后重试`;
+ }
+ if (info) {
+ return code != null && Number.isFinite(code)
+ ? `${apiName}失败(${code}${codeLabel ? ` ${codeLabel}` : ""}):${info}`
+ : `${apiName}失败:${info}`;
+ }
+ if (input.fallback) return `${apiName}失败:${input.fallback}`;
+ if (code != null && Number.isFinite(code)) {
+ return `${apiName}失败(${code}${codeLabel ? ` ${codeLabel}` : ""})`;
+ }
+ return `${apiName}失败,CarrierCentral 未返回具体原因`;
+}
diff --git a/src/constants/cc-message-types.ts b/src/constants/cc-message-types.ts
index 30e99b4..079e6af 100644
--- a/src/constants/cc-message-types.ts
+++ b/src/constants/cc-message-types.ts
@@ -1,34 +1,40 @@
/**
- * CC 客户留言类型(CustomerMessageType 字典 F_ItemName)。
- * 邮件识别词(贴标/拦截/拍照/转仓/快递单号)≠ 留言类型。
+ * CC 客户留言类型字典。
+ * 默认种子仅用于首次空表初始化;运行时选项一律读库。
*/
-export const CC_CUSTOMER_MESSAGE_TYPE_OPTIONS = [
- { value: "操作指令", label: "操作指令" },
- { value: "货件拦截", label: "货件拦截" },
- { value: "拆柜时自动转仓", label: "拆柜时自动转仓" },
- { value: "客户拆分指令", label: "客户拆分指令" },
- { value: "扣货", label: "扣货" },
- { value: "扣货问题", label: "扣货问题" },
- { value: "收费问题", label: "收费问题" },
- { value: "查验柜", label: "查验柜" },
- { value: "PO查验报错", label: "PO查验报错" },
- { value: "天灾", label: "天灾" },
- { value: "客户新增集装箱", label: "客户新增集装箱" },
+export const DEFAULT_CC_MESSAGE_TYPE_NAMES = [
+ "操作指令",
+ "货件拦截",
+ "拆柜时自动转仓",
+ "客户拆分指令",
+ "扣货",
+ "扣货问题",
+ "收费问题",
+ "查验柜",
+ "PO查验报错",
+ "天灾",
+ "客户新增集装箱",
] as const;
-/** 邮件动作关键词 → 最接近的 CC 留言类型;贴标/拍照走「操作指令」以便提交 */
-export function suggestCcMessageType(mailActions: string[]): string {
- if (mailActions.includes("拦截")) return "货件拦截";
- if (mailActions.includes("转仓")) return "拆柜时自动转仓";
- if (mailActions.some((a) => a.includes("拆分") && !a.includes("拆柜"))) {
- return "客户拆分指令";
- }
- if (
+/** 邮件动作关键词 → 建议留言类型名(须存在于已启用字典) */
+export function suggestCcMessageType(
+ mailActions: string[],
+ enabledNames?: string[],
+): string {
+ let suggested = "";
+ if (mailActions.includes("拦截")) suggested = "货件拦截";
+ else if (mailActions.includes("转仓")) suggested = "拆柜时自动转仓";
+ else if (mailActions.some((a) => a.includes("拆分") && !a.includes("拆柜"))) {
+ suggested = "客户拆分指令";
+ } else if (
mailActions.some((a) =>
/贴标|换标|拍照|快递单号|拆柜清单|操作指令/.test(a),
)
) {
- return "操作指令";
+ suggested = "操作指令";
}
- return "";
+
+ if (!suggested) return "";
+ if (!enabledNames || enabledNames.length === 0) return suggested;
+ return enabledNames.includes(suggested) ? suggested : "";
}
diff --git a/src/constants/error-copy.ts b/src/constants/error-copy.ts
index f4e5476..487117b 100644
--- a/src/constants/error-copy.ts
+++ b/src/constants/error-copy.ts
@@ -1,8 +1,15 @@
/**
* 操作员可见报错。禁止把英文短句、机器码、CarrierCentral 原文直接展示。
* 内部仍可存机器码;展示与 API message 必须走这里。
+ * CC 业务返回码 / 柜态 / 货件态说明对齐 V1.73。
*/
+import {
+ buildCcApiFailureMessage,
+ ccContainerStatusLabel,
+ ccShipmentStatusLabel,
+} from "@/constants/cc-api-codes";
+
const CJK = /[\u4e00-\u9fff]/;
const CJK_ALL = /[\u4e00-\u9fff]/g;
@@ -45,6 +52,7 @@ const API_CODE_MESSAGE: Record = {
NO_CONTAINER: "CarrierCentral 中还没有该柜,请先确认导入预报",
NO_DO_FILE: "本地没有可上传的 DO 附件",
CC_WRITE_OFF: "当前未开启向 CarrierCentral 写入,请到设置检查 CC 模式",
+ PARSE_FAILED: "文件解析失败,请确认上传的是有效的 Excel 装箱表(xlsx/xls)",
};
const LAST_ERROR_EXACT: Record = {
@@ -66,6 +74,12 @@ const LAST_ERROR_EXACT: Record = {
AUTO_EXEC_DISABLED: "自动执行已关闭,需人工确认",
AUTO_EXEC_FAILED: "自动执行失败,请打开邮件详情查看并手动确认",
AUTO_EXEC_MOCK_OK: "自动执行已在模拟模式完成(未写入真实 CarrierCentral)",
+ WO_MOCK_OK:
+ "工单仅在模拟模式提交成功,未写入 CarrierCentral。请将「客户留言/工单」写入能力改为 live 后重新提交",
+ XFER_MOCK_OK:
+ "转仓仅在模拟模式提交成功,未写入 CarrierCentral。请将写入能力改为 live 后重新提交",
+ DO_MOCK_OK:
+ "上传 DO 仅在模拟模式完成,未写入 CarrierCentral。请将写入能力改为 live 后重新提交",
DO_FAILED: "上传 DO 到 CarrierCentral 失败",
XFER_FAILED: "批量转仓失败",
WO_FAILED: "工单提交失败",
@@ -88,27 +102,39 @@ const LAST_ERROR_EXACT: Record = {
/** 英文 / 机器短语 → 操作员说明(按长词优先) */
const PHRASE_REPLACEMENTS: Array<[RegExp, string]> = [
- [/CC response missing external id/gi, "CarrierCentral 未返回柜任务编号,请到 CC 核对是否已创建"],
- [/SaveContainer request failed/gi, "保存柜信息时无法连接 CarrierCentral"],
- [/CC save failed with code\s*401/gi, "CarrierCentral 登录失败或账号无权限,请到「设置」检查用户名和密码"],
- [/CC save failed with code\s*400/gi, "CarrierCentral 拒绝保存,请核对柜号、运输方式和货件必填项"],
- [/\bBad Request\b/gi, "CarrierCentral 拒绝了请求,请核对填写内容"],
- [/CC save failed with code\s*(\d+)/gi, "CarrierCentral 保存失败(状态 $1),请稍后重试或到 CC 核对"],
- [/CC invalid JSON:[^\n]*/gi, "CarrierCentral 返回了无法解析的数据"],
- [/CC upload invalid JSON:[^\n]*/gi, "CarrierCentral 上传接口返回异常"],
- [/SaveFieldValue failed:\s*(\d+)/gi, "绑定 DO 文件夹失败(状态 $1)"],
- [/SaveFieldValue failed/gi, "绑定 DO 文件夹失败"],
- [/annexes upload failed:\s*(\d+)/gi, "上传 DO 附件失败(状态 $1)"],
- [/annexes upload failed/gi, "上传 DO 附件失败"],
- [/annexes upload error/gi, "上传 DO 附件时无法连接 CarrierCentral"],
- [/annex list error/gi, "读取 DO 附件列表失败"],
+ [/GetContainerList timeout/gi, "集装箱列表查询超时,请稍后重试"],
+ [/GetContainerList failed:\s*410/gi, "集装箱列表查询失败:没有登录信息(410),请检查客户档案"],
+ [/GetContainerList failed:\s*400/gi, "集装箱列表查询被拒绝(400),请核对查询条件"],
+ [/GetContainerList failed:\s*(\d+)/gi, "集装箱列表查询失败(状态 $1)"],
+ [/GetContainerList failed/gi, "集装箱列表查询失败"],
+ [/GetShipmentInfo failed:\s*(\d+)/gi, "集装箱货件信息查询失败(状态 $1)"],
+ [/GetShipmentInfo failed/gi, "集装箱货件信息查询失败"],
+ [/GetShipmentList failed:\s*(\d+)/gi, "集装箱货件列表查询失败(状态 $1)"],
+ [/GetShipmentList failed/gi, "集装箱货件列表查询失败"],
+ [/GetStateTrack failed:\s*(\d+)/gi, "集装箱状态变更日志查询失败(状态 $1)"],
+ [/GetStateTrack failed/gi, "集装箱状态变更日志查询失败"],
+ [/SaveContainer request failed/gi, "集装箱数据导入时无法连接 CarrierCentral"],
+ [/CC save failed with code\s*410/gi, "集装箱数据导入失败:没有登录信息(410),请检查客户档案或重新登录"],
+ [/CC save failed with code\s*401/gi, "集装箱数据导入失败:登录失败或账号无权限,请到「设置」检查用户名和密码"],
+ [/CC save failed with code\s*400/gi, "集装箱数据导入被拒绝(400),请核对柜号、柜型、运输方式和货件必填项"],
+ [/CC save failed with code\s*(\d+)/gi, "集装箱数据导入失败(状态 $1),请稍后重试或到 CC 核对"],
+ [/SaveFieldValue failed:\s*410/gi, "修改集装箱字段失败:没有登录信息(410)"],
+ [/SaveFieldValue failed:\s*400/gi, "修改集装箱字段被拒绝(400),请核对字段名与取值"],
+ [/SaveFieldValue failed:\s*(\d+)/gi, "修改集装箱字段失败(状态 $1)"],
+ [/SaveFieldValue failed/gi, "修改集装箱字段失败"],
+ [/annexes upload failed:\s*410/gi, "上传文件失败:没有登录信息(410)"],
+ [/annexes upload failed:\s*400/gi, "上传文件被拒绝(400),请核对文件类型与柜任务"],
+ [/annexes upload failed:\s*(\d+)/gi, "上传文件失败(状态 $1)"],
+ [/annexes upload failed/gi, "上传文件失败"],
+ [/annexes upload error/gi, "上传文件时无法连接 CarrierCentral"],
+ [/annex list error/gi, "读取附件列表失败"],
[/annex list invalid JSON/gi, "CarrierCentral 附件列表返回异常"],
[/CC request timeout/gi, "连接 CarrierCentral 超时,请稍后重试"],
[/CC request failed/gi, "无法连接 CarrierCentral,请检查网络或 CC 地址"],
- [/GetContainerList timeout/gi, "查询柜号超时,请稍后重试"],
- [/GetContainerList failed:\s*(\d+)/gi, "查询柜号失败(状态 $1)"],
- [/GetContainerList failed/gi, "查询柜号失败"],
- [/AUTHENTICATIONFAILED[:\s]*Invalid credentials/gi, "邮箱登录失败:账号或授权码不正确"],
+ [/CC invalid JSON:[^\n]*/gi, "CarrierCentral 返回了无法解析的数据"],
+ [/CC upload invalid JSON:[^\n]*/gi, "上传文件接口返回异常"],
+ [/\bBad Request\b/gi, "CarrierCentral 拒绝了请求(400 业务失败),请核对填写内容"],
+ [/CC response missing external id/gi, "CarrierCentral 未返回柜任务编号,请到 CC 核对是否已创建"],
[/AUTHENTICATIONFAILED[^\n]*/gi, "邮箱登录失败,请检查账号或授权码"],
[/Invalid credentials/gi, "账号或密码不正确"],
[/Invalid username or password/gi, "用户名或密码不正确"],
@@ -199,11 +225,11 @@ function looksLikeMachineCode(s: string): boolean {
function leftoverLatin(s: string): boolean {
if (!s) return false;
const allowed =
- /CarrierCentral|OAuth|JSON|PDF|\bDO\b|\bCC\b|ISO|IMAP|FBA|xlsx|xls|csv|GUID/gi;
+ /CarrierCentral|OAuth|JSON|PDF|Excel|\bDO\b|\bCC\b|ISO|IMAP|FBA|Move\s*Type|xlsx|xls|csv|GUID/gi;
const rest = s
.replace(CJK_ALL, "")
.replace(allowed, "")
- .replace(/[0-9\s.,::()「」《》\-_/]/g, "");
+ .replace(/[0-9\s.,::()「」《》\[\]\-_/]/g, "");
return /[A-Za-z]{3,}/.test(rest);
}
@@ -249,15 +275,15 @@ export function formatOperatorError(
const rewritten = applyPhraseReplacements(s);
if (LAST_ERROR_EXACT[rewritten]) return LAST_ERROR_EXACT[rewritten];
+ // 已有可读中文:保留给操作员(可夹带 xlsx / Excel 等术语),勿盖成万能句
if (hasCjk(rewritten)) {
- if (leftoverLatin(rewritten)) {
- const again = applyPhraseReplacements(rewritten);
- if (hasCjk(again) && !leftoverLatin(again)) return again;
- }
if (!leftoverLatin(rewritten)) return rewritten;
+ const again = applyPhraseReplacements(rewritten);
+ if (hasCjk(again) && !leftoverLatin(again)) return again;
+ return again;
}
- if (apiCode && API_CODE_MESSAGE[apiCode] && leftoverLatin(rewritten)) {
+ if (apiCode && API_CODE_MESSAGE[apiCode]) {
return API_CODE_MESSAGE[apiCode];
}
@@ -273,20 +299,212 @@ export function formatMailLastError(code: string | null | undefined): string {
return formatOperatorError(code);
}
+export type CcFailureKind =
+ | "login"
+ | "rate_limit"
+ | "timeout"
+ | "network"
+ | "validation"
+ | "unknown";
+
+export const CC_FAILURE_KIND_LABEL: Record = {
+ login: "登录失败",
+ rate_limit: "访问限流",
+ timeout: "连接超时",
+ network: "网络异常",
+ validation: "业务校验失败",
+ unknown: "未知原因",
+};
+
+export function ccFailureKindLabel(kind: CcFailureKind | string | undefined): string {
+ if (!kind) return CC_FAILURE_KIND_LABEL.unknown;
+ return CC_FAILURE_KIND_LABEL[kind as CcFailureKind] || kind;
+}
+
+/** 操作员弹窗:归类 + 主文案 + 可展示的 CC 原文 */
+export function describeCcFailureForOperator(raw?: string | null): {
+ kind: CcFailureKind;
+ kindLabel: string;
+ message: string;
+ detail: string | null;
+} {
+ const detail = (raw || "").trim() || null;
+ const kind = classifyCcFailure(detail);
+ const kindLabel = ccFailureKindLabel(kind);
+ let message: string;
+ switch (kind) {
+ case "login":
+ case "rate_limit":
+ case "timeout":
+ case "network":
+ message = conflictCheckOperatorMessage(kind, detail);
+ break;
+ case "validation":
+ if (detail && /柜型/.test(detail)) {
+ message = "请选择柜型后再导入(如 40HQ / 40GP)";
+ } else {
+ message = detail
+ ? formatCcCause(detail)
+ : "CarrierCentral 拒绝了本次保存,请核对必填项";
+ }
+ break;
+ default:
+ message = detail ? formatCcCause(detail) : "CarrierCentral 处理失败,请稍后重试";
+ }
+ return {
+ kind,
+ kindLabel,
+ message,
+ detail: detail && detail !== message ? detail : null,
+ };
+}
+
+/** 归类 CC 列表/写入失败,供冲突弹窗与日志使用 */
+export function classifyCcFailure(raw: string | null | undefined): CcFailureKind {
+ const s = (raw || "").trim();
+ if (!s) return "unknown";
+ const lower = s.toLowerCase();
+ if (
+ /cc_login_failed|cc_credentials_missing|unauthorized|\b401\b|\b410\b|invalid credentials|账户或密码|登录失败|鉴权|密码错误|没有登录信息/.test(
+ lower,
+ ) ||
+ /账户或密码|登录失败|鉴权|密码错误|没有登录信息/.test(s)
+ ) {
+ return "login";
+ }
+ if (
+ /访问时长限制|访问时间间隔|访问次数|时间间隔\s*\[\d+\]|过于频繁|达上限|rate.?limit/.test(
+ s,
+ )
+ ) {
+ return "rate_limit";
+ }
+ if (/timeout|超时|etimedout|abort/.test(lower)) return "timeout";
+ if (
+ /econnreset|econnrefused|enotfound|无法连接|cc request failed|fetch failed/.test(
+ lower,
+ )
+ ) {
+ return "network";
+ }
+ if (
+ /code\s*400|拒绝保存|validation|必填|格式不正确|格式错误|invalid|未填写|不能保存/.test(
+ lower,
+ ) ||
+ /必填|格式不正确|格式错误|未填写|不能保存/.test(s)
+ ) {
+ return "validation";
+ }
+ return "unknown";
+}
+
+/** 冲突检测(GetContainerList)失败时对操作员的主文案 */
+export function conflictCheckOperatorMessage(
+ kind: CcFailureKind,
+ raw?: string | null,
+): string {
+ const detail = raw ? formatCcCause(raw) : "";
+ switch (kind) {
+ case "login":
+ return detail.includes("账号") || detail.includes("密码")
+ ? detail
+ : "CarrierCentral 登录失败,无法校验柜号是否已存在。请检查当前客户档案的账号和密码。";
+ case "rate_limit":
+ return "集装箱列表接口访问过于频繁(V1.73:约每分钟 1 次),暂时无法校验柜号是否已存在。请等待约 1 分钟后重试。";
+ case "timeout":
+ return detail
+ ? `连接 CarrierCentral 超时,无法完成柜号查重:${detail}`
+ : "连接 CarrierCentral 超时,无法完成柜号查重。请检查网络后重试。";
+ case "network":
+ return detail
+ ? `无法连接 CarrierCentral,柜号查重未完成:${detail}`
+ : "无法连接 CarrierCentral,柜号查重未完成。请检查接口地址与网络。";
+ case "validation":
+ return detail || "CarrierCentral 拒绝了查柜请求,请核对客户档案配置。";
+ default:
+ return (
+ detail ||
+ "无法校验柜号是否已在 CarrierCentral 存在,请稍后重试或联系管理员。"
+ );
+ }
+}
+
/** CarrierCentral 失败原因(不含「操作失败」万能句,便于拼到具体动作前) */
export function formatCcCause(raw: string | null | undefined): string {
const s = (raw || "").trim();
if (!s) return "CarrierCentral 未返回具体原因,请稍后重试或到 CC 核对";
+ if (s === "CC_CREDENTIALS_MISSING") {
+ return "未配置 CarrierCentral 账号或密码,请到客户档案补全后重试";
+ }
+ if (s.startsWith("CC_LOGIN_FAILED:")) {
+ const rest = s.slice("CC_LOGIN_FAILED:".length).trim();
+ if (/401|unauthorized|410|没有登录/i.test(rest)) {
+ return "CarrierCentral 账号或密码错误,或登录已失效(410),请检查当前客户档案";
+ }
+ return formatCcCause(rest);
+ }
+ // 结构化:API|code|info —— 各接口写入失败时统一格式
+ const structured = s.match(/^CC_API:([^|]+)\|code=([^|]*)\|([\s\S]*)$/);
+ if (structured) {
+ const api = structured[1].trim();
+ const codeRaw = structured[2].trim();
+ const info = structured[3].trim();
+ const code = codeRaw === "" ? null : Number(codeRaw);
+ return polishCcChinese(
+ buildCcApiFailureMessage({
+ api,
+ code: Number.isFinite(code as number) ? (code as number) : null,
+ info,
+ }),
+ );
+ }
if (LAST_ERROR_EXACT[s]) return LAST_ERROR_EXACT[s];
const rewritten = polishCcChinese(applyPhraseReplacements(s));
if (LAST_ERROR_EXACT[rewritten]) return LAST_ERROR_EXACT[rewritten];
- // V1.72:失败看 code + info。info 常是中文并夹着 FBACode 等字段名,必须原文给操作员。
+ // V1.73:失败看 code + info。info 常是中文并夹着 FBACode 等字段名,必须原文给操作员。
if (hasCjk(rewritten)) return rewritten;
if (looksLikeMachineCode(s) && LAST_ERROR_EXACT[s]) return LAST_ERROR_EXACT[s];
- if (leftoverLatin(rewritten) || leftoverLatin(s)) {
+ if (rewritten && rewritten !== s && !leftoverLatin(rewritten)) {
+ return rewritten;
+ }
+ if (rewritten && rewritten.length >= 8 && classifyCcFailure(rewritten) !== "unknown") {
+ return rewritten;
+ }
+ if (
+ (leftoverLatin(rewritten) || leftoverLatin(s)) &&
+ classifyCcFailure(s) === "unknown"
+ ) {
return "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态";
}
- return "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态";
+ return rewritten || "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态";
+}
+
+/** 按接口名 + 返回码 + info 生成操作员可读原因(写入/查询统一入口) */
+export function formatCcApiFailure(input: {
+ api: string;
+ code?: number | null;
+ info?: string | null;
+ fallback?: string;
+}): string {
+ return formatCcCause(
+ `CC_API:${input.api}|code=${input.code ?? ""}|${input.info || input.fallback || ""}`,
+ );
+}
+
+export function formatCcContainerStatusReason(
+ status: number | string | null | undefined,
+ action: string,
+): string {
+ const label = ccContainerStatusLabel(status);
+ if (!label) return `当前柜状态不允许${action}`;
+ return `当前柜状态为「${label}」,不允许${action}`;
+}
+
+export function formatCcShipmentStatusHint(
+ status: number | string | null | undefined,
+): string {
+ const label = ccShipmentStatusLabel(status);
+ return label ? `货件状态:${label}` : "";
}
function polishCcChinese(s: string): string {
@@ -299,6 +517,16 @@ function polishCcChinese(s: string): string {
.replace(/\b(?:F_)?CabinetType\b/gi, "柜型")
.replace(/\b(?:F_)?BLCopyCode\b/gi, "提单号")
.replace(/\b(?:F_)?CTNS\b/gi, "件数")
+ .replace(/\b(?:F_)?Email\b/gi, "邮箱")
+ .replace(/\b(?:F_)?Mobile\b/gi, "手机号")
+ .replace(/\b(?:F_)?Pcs\b/gi, "PCS")
+ .replace(/\b(?:F_)?Weight\b/gi, "毛重")
+ .replace(/\b(?:F_)?CBM\b/gi, "体积")
+ .replace(/\b(?:F_)?Status\b/gi, "状态")
+ .replace(/\bGetContainerList\b/gi, "集装箱列表")
+ .replace(/\bSaveContainer\b/gi, "集装箱数据导入")
+ .replace(/\bGetShipmentInfo\b/gi, "集装箱货件信息")
+ .replace(/\bSaveFieldValue\b/gi, "修改集装箱字段")
.replace(/\s{2,}/g, " ")
.trim();
}
diff --git a/src/constants/ui-copy.ts b/src/constants/ui-copy.ts
index 6452aba..f2f36ba 100644
--- a/src/constants/ui-copy.ts
+++ b/src/constants/ui-copy.ts
@@ -28,7 +28,7 @@ export const UI_COPY = {
navMails: "邮件列表",
navLogs: "日志",
navSettings: "设置",
- navCustomers: "客户账号",
+ navCustomers: "租户账号",
emptyMails: "暂无邮件,等待 IMAP 拉取",
emptyImports: "暂无导入记录",
btnConfirmImport: "确认导入",
diff --git a/src/lib/cc-owner.ts b/src/lib/cc-owner.ts
index f5b4559..5084a77 100644
--- a/src/lib/cc-owner.ts
+++ b/src/lib/cc-owner.ts
@@ -1,26 +1,51 @@
import { AsyncLocalStorage } from "async_hooks";
import { prisma } from "@/services/db";
-const als = new AsyncLocalStorage<{ ownerUserId: bigint | null }>();
+type CcOwnerStore = {
+ ownerUserId: bigint | null;
+ /** 选中的 CC 客户档案;优先于 ownerUserId 对应的 CcSettings */
+ profileId?: bigint | null;
+};
+
+const als = new AsyncLocalStorage();
export function getCcOwnerUserId(): bigint | null | undefined {
return als.getStore()?.ownerUserId;
}
+export function getCcProfileId(): bigint | null | undefined {
+ const store = als.getStore();
+ if (!store) return undefined;
+ return store.profileId ?? null;
+}
+
export function runWithCcOwner(
ownerUserId: bigint | null,
fn: () => T,
): T {
- return als.run({ ownerUserId }, fn);
+ return als.run({ ownerUserId, profileId: null }, fn);
+}
+
+export function runWithCcProfile(
+ profileId: bigint,
+ ownerUserId: bigint | null,
+ fn: () => T,
+): T {
+ return als.run({ ownerUserId, profileId }, fn);
}
export async function runWithMailCcOwner(
mailId: bigint,
fn: () => Promise,
+ opts?: { profileId?: bigint | null },
): Promise {
const mail = await prisma.mailMessage.findUnique({
where: { id: mailId },
select: { mailboxAccount: { select: { ownerUserId: true } } },
});
- return runWithCcOwner(mail?.mailboxAccount?.ownerUserId ?? null, fn);
+ const ownerUserId = mail?.mailboxAccount?.ownerUserId ?? null;
+ if (opts?.profileId != null) {
+ return runWithCcProfile(opts.profileId, ownerUserId, fn);
+ }
+ return runWithCcOwner(ownerUserId, fn);
}
diff --git a/src/lib/client-api.ts b/src/lib/client-api.ts
index 8414924..f705d09 100644
--- a/src/lib/client-api.ts
+++ b/src/lib/client-api.ts
@@ -1,5 +1,7 @@
import type {
ApiResponse,
+ CcCustomerProfile,
+ CcMessageTypeDictItem,
CcSettings,
ContainerImport,
HealthStatus,
@@ -245,11 +247,37 @@ export async function importMailApi(
});
}
+/** 确认页上传卡派清单等表格,解析为货件行(不写库) */
+export async function parseForecastPackingApi(
+ mailId: string,
+ file: File,
+ opts?: { containerNo?: string },
+): Promise<
+ ApiResponse<{
+ shipments: import("@/types/mail").ShipmentRow[];
+ container_no: string | null;
+ row_count: number;
+ valid_count: number;
+ file_name: string;
+ }>
+> {
+ const form = new FormData();
+ form.append("file", file);
+ if (opts?.containerNo) form.append("container_no", opts.containerNo);
+ const res = await fetch(`/api/mails/${mailId}/forecast/parse-packing`, {
+ method: "POST",
+ body: form,
+ credentials: "include",
+ });
+ return parseJson(res);
+}
+
export async function confirmWorkOrderApi(
id: string,
payload: {
version: number;
instruction_id?: string;
+ cc_profile_id?: string;
entity: {
F_ReferCode?: string;
F_MessageType: string;
@@ -260,6 +288,16 @@ export async function confirmWorkOrderApi(
F_CustomerName?: string;
F_UrgencyLevel?: string;
};
+ baseline?: {
+ F_ReferCode?: string;
+ F_MessageType?: string;
+ F_RefNo?: string;
+ F_MessageContent?: string;
+ F_Remark?: string;
+ F_AttachFile?: string;
+ F_CustomerName?: string;
+ F_UrgencyLevel?: string;
+ };
},
): Promise> {
return request(`/api/mails/${id}/work-order/confirm`, {
@@ -276,6 +314,7 @@ export async function confirmDoUploadApi(
container_no: string;
container_task_id?: string;
filenames?: string[];
+ cc_profile_id?: string;
},
): Promise<
ApiResponse<{
@@ -296,6 +335,7 @@ export async function confirmTransferApi(
version: number;
instruction_id?: string;
container_no: string;
+ cc_profile_id?: string;
payload: {
keyValue: string;
strDetialList: Array<{
@@ -343,16 +383,36 @@ export async function listImportsApi(params?: {
mail_id?: string;
q?: string;
page?: number;
-}): Promise> {
+ tenant_id?: string;
+}): Promise<
+ ApiResponse<{
+ items: ContainerImport[];
+ total: number;
+ tenants?: Array<{
+ id: string;
+ username: string;
+ display_name: string;
+ enabled: boolean;
+ }>;
+ }>
+> {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.mail_id) qs.set("mail_id", params.mail_id);
if (params?.q) qs.set("q", params.q);
if (params?.page) qs.set("page", String(params.page));
+ if (params?.tenant_id) qs.set("tenant_id", params.tenant_id);
const suffix = qs.toString() ? `?${qs.toString()}` : "";
- return request<{ items: ContainerImport[]; total: number }>(
- `/api/imports${suffix}`,
- );
+ return request<{
+ items: ContainerImport[];
+ total: number;
+ tenants?: Array<{
+ id: string;
+ username: string;
+ display_name: string;
+ enabled: boolean;
+ }>;
+ }>(`/api/imports${suffix}`);
}
export async function retryImportApi(
@@ -380,11 +440,14 @@ export async function retryCompensationApi(
});
}
-export async function listShippingLinesApi(): Promise<
- ApiResponse<{ items: ShippingLine[] }>
-> {
+export async function listShippingLinesApi(opts?: {
+ /** 跳过客户端缓存并请求服务端刷新 CC 船司列表 */
+ refresh?: boolean;
+}): Promise> {
+ if (opts?.refresh) shippingLinesMemo.current = null;
+ const qs = opts?.refresh ? "?refresh=1" : "";
return memoGet(shippingLinesMemo, 10 * 60 * 1000, () =>
- request<{ items: ShippingLine[] }>("/api/shipping-lines"),
+ request<{ items: ShippingLine[] }>(`/api/shipping-lines${qs}`),
);
}
@@ -454,6 +517,40 @@ export async function deleteImapFilterApi(
return request(`/api/settings/imap/filters/${id}`, { method: "DELETE" });
}
+export async function listCcMessageTypesApi(params?: {
+ enabled_only?: boolean;
+}): Promise> {
+ const qs = params?.enabled_only ? "?enabled_only=1" : "";
+ return request(`/api/settings/cc-message-types${qs}`);
+}
+
+export async function createCcMessageTypeApi(body: {
+ name: string;
+ enabled?: boolean;
+ sort_order?: number;
+}): Promise> {
+ return request("/api/settings/cc-message-types", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+}
+
+export async function updateCcMessageTypeApi(
+ id: string,
+ body: { name?: string; enabled?: boolean; sort_order?: number },
+): Promise> {
+ return request(`/api/settings/cc-message-types/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+}
+
+export async function deleteCcMessageTypeApi(
+ id: string,
+): Promise> {
+ return request(`/api/settings/cc-message-types/${id}`, { method: "DELETE" });
+}
+
export async function listImapPullLogsApi(params?: {
page?: number;
pageSize?: number;
@@ -584,6 +681,70 @@ export async function testCcSettingsApi(body?: {
});
}
+export async function listCcCustomerProfilesApi(opts?: {
+ selectable?: boolean;
+ mailId?: string;
+}): Promise> {
+ const q = new URLSearchParams();
+ if (opts?.selectable) q.set("selectable", "1");
+ if (opts?.mailId) q.set("mail_id", opts.mailId);
+ const qs = q.toString();
+ return request(`/api/settings/cc-profiles${qs ? `?${qs}` : ""}`);
+}
+
+export async function createCcCustomerProfileApi(body: {
+ name: string;
+ mock?: boolean;
+ api_base: string;
+ saas_header: string;
+ login_mark: string;
+ username?: string;
+ password?: string;
+ enabled?: boolean;
+ sort_order?: number;
+}): Promise> {
+ return request("/api/settings/cc-profiles", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+}
+
+export async function updateCcCustomerProfileApi(
+ id: string,
+ body: {
+ name?: string;
+ mock?: boolean;
+ api_base?: string;
+ saas_header?: string;
+ login_mark?: string;
+ username?: string;
+ password?: string;
+ enabled?: boolean;
+ sort_order?: number;
+ },
+): Promise> {
+ return request(`/api/settings/cc-profiles/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(body),
+ });
+}
+
+export async function deleteCcCustomerProfileApi(
+ id: string,
+): Promise> {
+ return request(`/api/settings/cc-profiles/${id}`, { method: "DELETE" });
+}
+
+export async function testCcCustomerProfileApi(
+ id: string,
+ body?: { password?: string },
+): Promise> {
+ return request(`/api/settings/cc-profiles/${id}/test`, {
+ method: "POST",
+ body: JSON.stringify(body ?? {}),
+ });
+}
+
export async function getOAuthSettingsApi(): Promise> {
return request("/api/settings/oauth");
}
@@ -699,6 +860,8 @@ export type CustomerAccount = {
display_name: string;
enabled: boolean;
mailbox_count?: number;
+ /** 租户自建的 CC 客户档案数 */
+ cc_profile_count?: number;
cc_username?: string;
cc_mock?: boolean;
created_at?: string;
diff --git a/src/services/auth/customers.ts b/src/services/auth/customers.ts
index 4abb9e6..e34e9bf 100644
--- a/src/services/auth/customers.ts
+++ b/src/services/auth/customers.ts
@@ -54,7 +54,9 @@ export async function listCustomers() {
where: { role: "customer" },
orderBy: { id: "asc" },
include: {
- _count: { select: { mailboxes: true } },
+ _count: {
+ select: { mailboxes: true, ccCustomerProfiles: true },
+ },
ccSettings: { select: { username: true, mock: true, updatedAt: true } },
},
});
@@ -65,6 +67,7 @@ export async function listCustomers() {
display_name: r.displayName,
enabled: r.enabled,
mailbox_count: r._count.mailboxes,
+ cc_profile_count: r._count.ccCustomerProfiles,
cc_username: r.ccSettings?.username || "",
cc_mock: r.ccSettings?.mock ?? true,
created_at: r.createdAt.toISOString(),
@@ -115,7 +118,7 @@ export async function updateCustomer(
where: { id, role: "customer" },
});
if (!row) {
- throw new CustomerAccountError("NOT_FOUND", "客户不存在", 404);
+ throw new CustomerAccountError("NOT_FOUND", "租户不存在", 404);
}
const data: {
passwordHash?: string;
diff --git a/src/services/cc/auth.ts b/src/services/cc/auth.ts
index da59e45..8a4e956 100644
--- a/src/services/cc/auth.ts
+++ b/src/services/cc/auth.ts
@@ -41,6 +41,7 @@ export function extractLoginToken(data: unknown): string {
type MemoryToken = {
token: string;
loginMark: string;
+ username: string;
expireAt: Date;
};
@@ -49,14 +50,23 @@ export const CC_TOKEN_MEMORY_TRUST_MS = 30_000;
let memoryToken: MemoryToken | null = null;
let mockToken: MemoryToken | null = null;
+/** 与 ccTokenCache 配套:多客户档案可能共用 loginMark,须按 username 隔离 token */
+let cachedLoginUsername = "";
export function shouldReuseCcMemoryToken(
- memory: { loginMark: string; expireAt: Date } | null,
+ memory: { loginMark: string; username: string; expireAt: Date } | null,
loginMark: string,
+ username: string,
nowMs: number,
minRemainingMs = CC_TOKEN_MEMORY_TRUST_MS,
): boolean {
- if (!memory || memory.loginMark !== loginMark) return false;
+ if (
+ !memory ||
+ memory.loginMark !== loginMark ||
+ memory.username !== username
+ ) {
+ return false;
+ }
return memory.expireAt.getTime() - nowMs > minRemainingMs;
}
@@ -72,20 +82,35 @@ export class CcAuth {
}
const loginMark = rt.loginMark;
+ const username = rt.username.trim();
const now = new Date();
+ if (cachedLoginUsername && cachedLoginUsername !== username) {
+ memoryToken = null;
+ await prisma.ccTokenCache
+ .deleteMany({ where: { loginMark } })
+ .catch(() => undefined);
+ }
+
// 内存未近过期则跳过 DB;临近过期仍以 DB expireAt 为准(韧性 §7.1)
- if (shouldReuseCcMemoryToken(memoryToken, loginMark, now.getTime())) {
+ if (
+ shouldReuseCcMemoryToken(memoryToken, loginMark, username, now.getTime())
+ ) {
return { token: memoryToken!.token, loginMark };
}
const cached = await prisma.ccTokenCache.findUnique({
where: { loginMark },
});
- if (cached && cached.expireAt > now) {
+ if (
+ cached &&
+ cached.expireAt > now &&
+ cachedLoginUsername === username
+ ) {
memoryToken = {
token: cached.token,
loginMark: cached.loginMark,
+ username,
expireAt: cached.expireAt,
};
return { token: cached.token, loginMark };
@@ -95,12 +120,13 @@ export class CcAuth {
memoryToken = null;
}
- return this.loginWithLock();
+ return this.loginWithLock(username);
}
async invalidateToken(): Promise {
memoryToken = null;
mockToken = null;
+ cachedLoginUsername = "";
if (isTestMode()) return;
const rt = await resolveCcRuntime();
if (rt.mock) return;
@@ -119,12 +145,13 @@ export class CcAuth {
mockToken = {
token: `mock-cc-token-${Date.now()}`,
loginMark,
+ username: "mock",
expireAt: new Date(now.getTime() + TOKEN_TTL_MS),
};
return { token: mockToken.token, loginMark };
}
- private async loginWithLock(): Promise<{ token: string; loginMark: string }> {
+ private async loginWithLock(username: string): Promise<{ token: string; loginMark: string }> {
const rt = await resolveCcRuntime();
const loginMark = rt.loginMark;
@@ -133,10 +160,15 @@ export class CcAuth {
const cached = await prisma.ccTokenCache.findUnique({
where: { loginMark },
});
- if (cached && cached.expireAt > now) {
+ if (
+ cached &&
+ cached.expireAt > now &&
+ cachedLoginUsername === username
+ ) {
memoryToken = {
token: cached.token,
loginMark: cached.loginMark,
+ username,
expireAt: cached.expireAt,
};
return { token: cached.token, loginMark };
@@ -151,7 +183,8 @@ export class CcAuth {
update: { token, expireAt },
});
- memoryToken = { token, loginMark, expireAt };
+ memoryToken = { token, loginMark, username, expireAt };
+ cachedLoginUsername = username;
return { token, loginMark };
});
diff --git a/src/services/cc/batch-transfer.ts b/src/services/cc/batch-transfer.ts
index 06bd869..23fe103 100644
--- a/src/services/cc/batch-transfer.ts
+++ b/src/services/cc/batch-transfer.ts
@@ -6,7 +6,7 @@ import { ccAuth } from "@/services/cc/auth";
import { CcHttpClient } from "@/services/cc/http";
import { normalizeCcCode } from "@/services/cc/code";
import { getCcWriteCapability, resolveWriteMode } from "@/services/cc/write-capability";
-import { formatCcCause } from "@/constants/error-copy";
+import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy";
const ccHttp = new CcHttpClient(ccAuth);
@@ -73,13 +73,16 @@ export async function saveStockBatchTransfer(
response: res,
error: ok
? undefined
- : formatCcCause(
- String(
+ : formatCcApiFailure({
+ api: "SaveStockBatchTransfer",
+ code,
+ info: String(
(res as { info?: string; message?: string }).info ||
(res as { message?: string }).message ||
- "CC_XFER_FAILED",
+ "",
),
- ),
+ fallback: "CC_XFER_FAILED",
+ }),
};
} catch (err) {
return {
diff --git a/src/services/cc/conflict.ts b/src/services/cc/conflict.ts
index a8cf640..5c0c70a 100644
--- a/src/services/cc/conflict.ts
+++ b/src/services/cc/conflict.ts
@@ -1,4 +1,5 @@
import { getEnv } from "@/lib/env";
+import { getCcOwnerUserId, getCcProfileId } from "@/lib/cc-owner";
import { logger } from "@/lib/logger";
import { prisma } from "@/services/db";
import { ccAuth } from "@/services/cc/auth";
@@ -10,12 +11,18 @@ import {
} from "@/services/cc/mock-state";
import { isCcMock } from "@/services/cc/settings-config";
import { toShanghaiDateString } from "@/utils/dates";
-import { formatCcCause } from "@/constants/error-copy";
+import {
+ classifyCcFailure,
+ conflictCheckOperatorMessage,
+ formatCcApiFailure,
+ formatCcCause,
+} from "@/constants/error-copy";
+import { runWithListHttpGate } from "@/services/cc/list-http-gate";
const LIST_PATH = "/Container/GetContainerList";
const ccHttp = new CcHttpClient(ccAuth);
-/** V1.72:GetContainerList 1 分钟 1 次;成功结果缓存,避免转仓闸门把限流当成失败 */
+/** V1.73:GetContainerList 1 分钟 1 次;成功结果缓存,避免转仓闸门把限流当成失败 */
const LIST_CACHE_TTL_MS = 70_000;
const listCache = new Map<
string,
@@ -26,8 +33,20 @@ const listInflight = new Map<
Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }>
>();
+function listProfileKey(): string {
+ const profileId = getCcProfileId();
+ if (profileId != null) return `profile:${profileId}`;
+ const ownerUserId = getCcOwnerUserId();
+ if (ownerUserId != null) return `owner:${ownerUserId}`;
+ return "global";
+}
+
+function listCacheKey(profileKey: string, containerNo: string): string {
+ return `${profileKey}:${containerNo}`;
+}
+
export function isCcRateLimitedMessage(msg: string): boolean {
- return /访问时长限制|访问时间间隔|时间间隔\s*\[\d+\]\s*秒/.test(msg || "");
+ return classifyCcFailure(msg) === "rate_limit";
}
export type ConflictCheckResult =
@@ -74,6 +93,8 @@ export async function getContainerListFromCc(
containerNo: string,
): Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }> {
const normalized = containerNo.toUpperCase();
+ const profileKey = listProfileKey();
+ const cacheKey = listCacheKey(profileKey, normalized);
if (await isCcMock()) {
if (mockContainerExists(normalized)) {
@@ -85,12 +106,12 @@ export async function getContainerListFromCc(
return { ok: true, items: [] };
}
- const cached = listCache.get(normalized);
+ const cached = listCache.get(cacheKey);
if (cached && Date.now() - cached.at < LIST_CACHE_TTL_MS) {
return cached.result;
}
- const pending = listInflight.get(normalized);
+ const pending = listInflight.get(cacheKey);
if (pending) return pending;
const { startTime, endTime } = listDateRange();
@@ -120,20 +141,27 @@ export async function getContainerListFromCc(
> => {
try {
const env = getEnv();
- const response = await ccHttp.request(LIST_PATH, queryPayload, {
- timeoutMs: env.CC_READ_TIMEOUT_MS,
- });
+ const response = await runWithListHttpGate(profileKey, () =>
+ ccHttp.request(LIST_PATH, queryPayload, {
+ timeoutMs: env.CC_READ_TIMEOUT_MS,
+ }),
+ );
const code = normalizeCcCode(response.code);
if (code !== 200) {
- const error = response.info || `GetContainerList failed: ${code}`;
- if (isCcRateLimitedMessage(error) && cached?.result.ok) {
+ const error = formatCcApiFailure({
+ api: "GetContainerList",
+ code,
+ info: response.info,
+ fallback: `GetContainerList failed: ${code}`,
+ });
+ if (isCcRateLimitedMessage(String(response.info || error)) && cached?.result.ok) {
return cached.result;
}
- return { ok: false, error: formatCcCause(error) };
+ return { ok: false, error };
}
const items = parseContainerListRows(response.data);
const okResult = { ok: true as const, items };
- listCache.set(normalized, { at: Date.now(), result: okResult });
+ listCache.set(cacheKey, { at: Date.now(), result: okResult });
return okResult;
} catch (err) {
const msg =
@@ -151,9 +179,9 @@ export async function getContainerListFromCc(
};
const promise = run().finally(() => {
- listInflight.delete(normalized);
+ listInflight.delete(cacheKey);
});
- listInflight.set(normalized, promise);
+ listInflight.set(cacheKey, promise);
return promise;
}
@@ -255,11 +283,12 @@ export async function checkContainerConflict(
if (env.ENABLE_FORCE_IMPORT && input.forceSkipConflict) {
return { ok: true };
}
+ const kind = classifyCcFailure(listResult.error);
return {
ok: false,
code: "CONFLICT_CHECK_FAILED",
- message: "无法校验柜号是否已存在,请稍后重试",
- details: { error: listResult.error },
+ message: conflictCheckOperatorMessage(kind, listResult.error),
+ details: { kind, error: listResult.error },
};
}
diff --git a/src/services/cc/container-status.ts b/src/services/cc/container-status.ts
index a484ed9..710ff82 100644
--- a/src/services/cc/container-status.ts
+++ b/src/services/cc/container-status.ts
@@ -1,11 +1,18 @@
/**
- * 到仓闸门:决定转仓邮件走批量转仓还是降级工单
+ * 到仓闸门:决定转仓邮件走批量转仓还是降级工单。
+ * 柜态以 V1.73 GetContainerList.F_Status 为准:
+ * 0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜
*/
import {
getContainerListFromCc,
isCcRateLimitedMessage,
} from "@/services/cc/conflict";
import { isCcMock } from "@/services/cc/settings-config";
+import {
+ ccContainerStatusLabel,
+ isContainerArrivedOrLater,
+} from "@/constants/cc-api-codes";
+import { formatCcContainerStatusReason } from "@/constants/error-copy";
export type TransferGateDecision =
| {
@@ -28,6 +35,9 @@ type CcRow = {
F_ContainerNo?: string;
F_Id?: string;
F_ContainerTaskId?: string;
+ /** V1.73 柜头状态 */
+ F_Status?: number | string;
+ /** 兼容旧字段;优先使用 F_Status */
F_ShipStatus?: number | string;
F_ToWhDate?: string | null;
F_OperationType?: number | string;
@@ -42,8 +52,8 @@ function toInt(v: unknown): number | null {
}
/**
- * 未到仓(F_ShipStatus < 10 且无到仓日)且非直送/未拆柜中 → 可批量转仓
- * 已到仓或不可转 → 降级工单
+ * 未到仓(F_Status < 60 且无到仓日)且非直送/未拆柜中 → 可批量转仓
+ * 已到仓(60)及之后 → 降级工单
* 查询失败 → 默认降级工单
*/
export async function evaluateTransferGate(
@@ -60,19 +70,19 @@ export async function evaluateTransferGate(
}
if (await isCcMock()) {
- // mock:柜号含 ARRIVED 模拟已到仓,否则可转
+ // mock:柜号含 ARRIVED 模拟已到仓(60),否则可转
if (/ARRIVED|TOWH/i.test(cn)) {
return {
canTransfer: false,
- reason: "mock:已到仓,转仓须下工单",
- shipStatus: 10,
+ reason: `mock:${formatCcContainerStatusReason(60, "批量转仓")},须下工单`,
+ shipStatus: 60,
fallback: "work_order",
containerTaskId: `mock-task-${cn}`,
};
}
return {
canTransfer: true,
- reason: "mock:未到仓,可批量转仓",
+ reason: `mock:柜状态「${ccContainerStatusLabel(0)}」,可批量转仓`,
shipStatus: 0,
containerTaskId: `mock-task-${cn}`,
};
@@ -85,7 +95,7 @@ export async function evaluateTransferGate(
return {
canTransfer: true,
reason:
- "查柜接口限流(GetContainerList 间隔 60 秒)。未改走工单;确认提交调用转仓接口 SaveStockBatchTransfer。",
+ "集装箱列表接口限流(GetContainerList 间隔约 60 秒,V1.73)。未改走工单;确认提交调用转仓接口 SaveStockBatchTransfer。",
shipStatus: null,
lookupRateLimited: true,
};
@@ -100,25 +110,26 @@ export async function evaluateTransferGate(
if (!list.items.length) {
return {
canTransfer: false,
- reason: "CC 无此柜,无法批量转仓,请下工单",
+ reason: "CarrierCentral 中无此柜,无法批量转仓,请下工单",
shipStatus: null,
fallback: "work_order",
};
}
const row = list.items[0] as CcRow;
- const shipStatus = toInt(row.F_ShipStatus);
+ const status =
+ toInt(row.F_Status) ?? toInt(row.F_ShipStatus);
const toWh = row.F_ToWhDate;
const opType = toInt(row.F_OperationType);
const dismantle = toInt(row.F_DismantleStatus);
const report = toInt(row.F_Report);
const taskId = row.F_ContainerTaskId || row.F_Id || undefined;
- if (shipStatus != null && shipStatus >= 10) {
+ if (isContainerArrivedOrLater(status)) {
return {
canTransfer: false,
- reason: "已到仓,转仓必须下工单",
- shipStatus,
+ reason: `${formatCcContainerStatusReason(status, "批量转仓")},须下工单`,
+ shipStatus: status,
fallback: "work_order",
containerTaskId: taskId,
};
@@ -127,7 +138,7 @@ export async function evaluateTransferGate(
return {
canTransfer: false,
reason: "已有到仓日期,转仓必须下工单",
- shipStatus,
+ shipStatus: status,
fallback: "work_order",
containerTaskId: taskId,
};
@@ -136,7 +147,7 @@ export async function evaluateTransferGate(
return {
canTransfer: false,
reason: "直送柜不可批量转仓,请下工单",
- shipStatus,
+ shipStatus: status,
fallback: "work_order",
containerTaskId: taskId,
};
@@ -145,7 +156,7 @@ export async function evaluateTransferGate(
return {
canTransfer: false,
reason: "正在拆柜,不可转仓",
- shipStatus,
+ shipStatus: status,
fallback: "work_order",
containerTaskId: taskId,
};
@@ -154,7 +165,7 @@ export async function evaluateTransferGate(
return {
canTransfer: false,
reason: "已做拆柜报告,不可转仓",
- shipStatus,
+ shipStatus: status,
fallback: "work_order",
containerTaskId: taskId,
};
@@ -162,8 +173,10 @@ export async function evaluateTransferGate(
return {
canTransfer: true,
- reason: "未到仓,可确认批量转仓",
- shipStatus: shipStatus ?? 0,
+ reason: status != null
+ ? `柜状态「${ccContainerStatusLabel(status)}」,可确认批量转仓`
+ : "未到仓,可确认批量转仓",
+ shipStatus: status ?? 0,
containerTaskId: taskId,
};
}
diff --git a/src/services/cc/customer-message.ts b/src/services/cc/customer-message.ts
index b8286ff..8618e7f 100644
--- a/src/services/cc/customer-message.ts
+++ b/src/services/cc/customer-message.ts
@@ -7,8 +7,9 @@ import { ccAuth } from "@/services/cc/auth";
import { CcHttpClient } from "@/services/cc/http";
import { normalizeCcCode } from "@/services/cc/code";
import { resolveWriteMode } from "@/services/cc/write-capability";
-import { formatCcCause } from "@/constants/error-copy";
+import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy";
import type { CustomerMessageEntity } from "@/services/cc/customer-message-entity";
+import { normalizeExternalId } from "@/services/import/import-log-meta";
export type { CustomerMessageEntity } from "@/services/cc/customer-message-entity";
export { toCcCustomerMessageEntity } from "@/services/cc/customer-message-entity";
@@ -68,18 +69,21 @@ export async function saveCustomerMessage(
return {
ok,
mode: "live",
- externalId: ok ? String(res.data ?? "") : null,
+ externalId: ok ? normalizeExternalId(res.data) : null,
request,
response: res,
error: ok
? undefined
- : formatCcCause(
- String(
+ : formatCcApiFailure({
+ api: "SaveForm",
+ code,
+ info: String(
(res as { info?: string; message?: string }).info ||
(res as { message?: string }).message ||
- "CC_WO_FAILED",
+ "",
),
- ),
+ fallback: "CC_WO_FAILED",
+ }),
};
} catch (err) {
return {
diff --git a/src/services/cc/customer-profiles.ts b/src/services/cc/customer-profiles.ts
new file mode 100644
index 0000000..1c830fb
--- /dev/null
+++ b/src/services/cc/customer-profiles.ts
@@ -0,0 +1,245 @@
+import { getEnv } from "@/lib/env";
+import { decryptSecret, encryptSecret, maskUsername } from "@/lib/secret-crypto";
+import { prisma } from "@/services/db";
+import { md5 } from "@/utils/hash";
+import { invalidateCcSettingsCache } from "@/services/cc/settings-config";
+import type { CcRuntimeConfig } from "@/services/cc/settings-config";
+
+export type CcCustomerProfilePublic = {
+ id: string;
+ name: string;
+ mock: boolean;
+ api_base: string;
+ saas_header: string;
+ login_mark: string;
+ username: string;
+ username_masked: string;
+ has_password: boolean;
+ enabled: boolean;
+ sort_order: number;
+ owner_user_id: string | null;
+ updated_at: string;
+};
+
+function toPublic(row: {
+ id: bigint;
+ name: string;
+ mock: boolean;
+ apiBase: string;
+ saasHeader: string;
+ loginMark: string;
+ username: string;
+ passwordEnc: string | null;
+ enabled: boolean;
+ sortOrder: number;
+ ownerUserId: bigint | null;
+ updatedAt: Date;
+}): CcCustomerProfilePublic {
+ return {
+ id: row.id.toString(),
+ name: row.name,
+ mock: row.mock,
+ api_base: row.apiBase,
+ saas_header: row.saasHeader,
+ login_mark: row.loginMark,
+ username: row.username,
+ username_masked: row.username ? maskUsername(row.username) : "",
+ has_password: Boolean(row.passwordEnc),
+ enabled: row.enabled,
+ sort_order: row.sortOrder,
+ owner_user_id: row.ownerUserId != null ? row.ownerUserId.toString() : null,
+ updated_at: row.updatedAt.toISOString(),
+ };
+}
+
+export async function listCcCustomerProfiles(opts?: {
+ ownerUserId?: bigint | null;
+ includeDisabled?: boolean;
+}): Promise {
+ const where: {
+ ownerUserId?: bigint | null;
+ enabled?: boolean;
+ } = {};
+ if (opts && "ownerUserId" in (opts || {})) {
+ where.ownerUserId = opts!.ownerUserId ?? null;
+ } else {
+ // 默认 staff 共享档案
+ where.ownerUserId = null;
+ }
+ if (!opts?.includeDisabled) where.enabled = true;
+
+ const rows = await prisma.ccCustomerProfile.findMany({
+ where,
+ orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
+ });
+ return rows.map(toPublic);
+}
+
+/** 操作下拉:staff 共享 + 可选邮件归属租户的档案 */
+export async function listSelectableCcCustomerProfiles(opts?: {
+ mailOwnerUserId?: bigint | null;
+}): Promise {
+ const or: Array<{ ownerUserId: bigint | null }> = [{ ownerUserId: null }];
+ if (opts?.mailOwnerUserId != null) {
+ or.push({ ownerUserId: opts.mailOwnerUserId });
+ }
+ const rows = await prisma.ccCustomerProfile.findMany({
+ where: { enabled: true, OR: or },
+ orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
+ });
+ return rows.map(toPublic);
+}
+
+export async function getCcCustomerProfile(
+ id: bigint,
+): Promise {
+ const row = await prisma.ccCustomerProfile.findUnique({ where: { id } });
+ return row ? toPublic(row) : null;
+}
+
+export async function loadCcCustomerProfileRuntime(
+ id: bigint,
+): Promise {
+ const row = await prisma.ccCustomerProfile.findUnique({ where: { id } });
+ if (!row || !row.enabled) return null;
+ const env = getEnv();
+ let passwordMd5 = "";
+ let hasPassword = false;
+ if (row.passwordEnc) {
+ try {
+ passwordMd5 = md5(decryptSecret(row.passwordEnc));
+ hasPassword = true;
+ } catch {
+ hasPassword = false;
+ }
+ }
+ const username = row.username.trim();
+ return {
+ mock: row.mock,
+ apiBase: row.apiBase.trim() || env.CC_API_BASE,
+ saasHeader: row.saasHeader.trim() || env.CC_SAAS_HEADER,
+ loginMark: row.loginMark.trim() || env.CC_LOGIN_MARK,
+ username,
+ passwordMd5,
+ source: "db",
+ hasPassword: Boolean(username && passwordMd5),
+ };
+}
+
+export async function createCcCustomerProfile(input: {
+ name: string;
+ mock: boolean;
+ apiBase: string;
+ saasHeader: string;
+ loginMark: string;
+ username: string;
+ password?: string;
+ enabled?: boolean;
+ sortOrder?: number;
+ ownerUserId?: bigint | null;
+}): Promise {
+ const env = getEnv();
+ const row = await prisma.ccCustomerProfile.create({
+ data: {
+ ownerUserId: input.ownerUserId ?? null,
+ name: input.name.trim(),
+ mock: input.mock,
+ apiBase: input.apiBase.trim() || env.CC_API_BASE,
+ saasHeader: input.saasHeader.trim() || env.CC_SAAS_HEADER,
+ loginMark: input.loginMark.trim() || env.CC_LOGIN_MARK,
+ username: input.username.trim(),
+ passwordEnc: input.password?.trim()
+ ? encryptSecret(input.password.trim())
+ : null,
+ enabled: input.enabled ?? true,
+ sortOrder: input.sortOrder ?? 0,
+ },
+ });
+ invalidateCcSettingsCache();
+ return toPublic(row);
+}
+
+export async function updateCcCustomerProfile(
+ id: bigint,
+ input: {
+ name?: string;
+ mock?: boolean;
+ apiBase?: string;
+ saasHeader?: string;
+ loginMark?: string;
+ username?: string;
+ password?: string;
+ enabled?: boolean;
+ sortOrder?: number;
+ },
+): Promise {
+ const existing = await prisma.ccCustomerProfile.findUnique({ where: { id } });
+ if (!existing) return null;
+ const env = getEnv();
+ const row = await prisma.ccCustomerProfile.update({
+ where: { id },
+ data: {
+ ...(input.name !== undefined ? { name: input.name.trim() } : {}),
+ ...(input.mock !== undefined ? { mock: input.mock } : {}),
+ ...(input.apiBase !== undefined
+ ? { apiBase: input.apiBase.trim() || env.CC_API_BASE }
+ : {}),
+ ...(input.saasHeader !== undefined
+ ? { saasHeader: input.saasHeader.trim() || env.CC_SAAS_HEADER }
+ : {}),
+ ...(input.loginMark !== undefined
+ ? { loginMark: input.loginMark.trim() || env.CC_LOGIN_MARK }
+ : {}),
+ ...(input.username !== undefined
+ ? { username: input.username.trim() }
+ : {}),
+ ...(input.password !== undefined
+ ? {
+ passwordEnc: input.password.trim()
+ ? encryptSecret(input.password.trim())
+ : existing.passwordEnc,
+ }
+ : {}),
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
+ ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
+ },
+ });
+ invalidateCcSettingsCache();
+ return toPublic(row);
+}
+
+export async function deleteCcCustomerProfile(id: bigint): Promise {
+ const existing = await prisma.ccCustomerProfile.findUnique({ where: { id } });
+ if (!existing) return false;
+ await prisma.ccCustomerProfile.delete({ where: { id } });
+ invalidateCcSettingsCache();
+ return true;
+}
+
+/** 把全局 CcSettings 迁成第一条共享档案(幂等) */
+export async function ensureCcProfilesMigratedFromGlobalSettings(): Promise {
+ const count = await prisma.ccCustomerProfile.count({
+ where: { ownerUserId: null },
+ });
+ if (count > 0) return;
+
+ const global =
+ (await prisma.ccSettings.findFirst({ where: { userId: null } })) ??
+ (await prisma.ccSettings.findUnique({ where: { id: 1 } }));
+ if (!global) return;
+
+ await prisma.ccCustomerProfile.create({
+ data: {
+ ownerUserId: null,
+ name: global.username.trim() || "默认客户",
+ mock: global.mock,
+ apiBase: global.apiBase,
+ saasHeader: global.saasHeader,
+ loginMark: global.loginMark,
+ username: global.username,
+ passwordEnc: global.passwordEnc,
+ enabled: true,
+ sortOrder: 0,
+ },
+ });
+}
diff --git a/src/services/cc/do-confirm-gate.ts b/src/services/cc/do-confirm-gate.ts
new file mode 100644
index 0000000..0e3cb52
--- /dev/null
+++ b/src/services/cc/do-confirm-gate.ts
@@ -0,0 +1,40 @@
+import { isValidContainerNo } from "@/utils/iso6346";
+
+export type DoImportIssues = {
+ errors: Record;
+ blockers: string[];
+ notes: string[];
+};
+
+/**
+ * DO 上传确认页缺项:柜号必填且 ISO;至少一份 DO 附件。
+ * 对齐 assistant:柜须可定位,口语「DO请查收」不算已有文件。
+ */
+export function collectDoImportIssues(input: {
+ containerNo: string;
+ filenames: string[];
+}): DoImportIssues {
+ const errors: Record = {};
+ const blockers: string[] = [];
+ const notes: string[] = [];
+
+ const containerNo = (input.containerNo || "").trim();
+ if (!containerNo) {
+ const msg = "请填写柜号(4 位字母 + 7 位数字)";
+ errors.containerNo = msg;
+ blockers.push(msg);
+ } else if (!isValidContainerNo(containerNo)) {
+ const msg = `柜号「${containerNo.toUpperCase()}」不符合 ISO 6346 校验位,请核对后改正`;
+ errors.containerNo = msg;
+ blockers.push(msg);
+ }
+
+ if (!input.filenames.length) {
+ const msg =
+ "未识别到 DO 附件(如 WHSU8127240-DO.pdf)。请确认邮件含 DO 文件后再上传";
+ errors.filenames = msg;
+ blockers.push(msg);
+ }
+
+ return { errors, blockers, notes };
+}
diff --git a/src/services/cc/do-upload.ts b/src/services/cc/do-upload.ts
index 75047ba..dfc69b6 100644
--- a/src/services/cc/do-upload.ts
+++ b/src/services/cc/do-upload.ts
@@ -21,7 +21,7 @@ import { resolveWriteMode } from "@/services/cc/write-capability";
import { getContainerListFromCc } from "@/services/cc/conflict";
import { getMockTargetAPI } from "@/services/cc/mock-target-api";
import { resolveCcRuntime } from "@/services/cc/settings-config";
-import { formatCcCause } from "@/constants/error-copy";
+import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy";
import type { CcApiResponse } from "@/types/cc";
const SAVE_FIELD_PATH = "/Container/SaveFieldValue";
@@ -188,7 +188,12 @@ async function uploadAnnexFile(
error:
code === 200
? undefined
- : formatCcCause(body.info || `annexes upload failed: ${code}`),
+ : formatCcApiFailure({
+ api: "upload",
+ code,
+ info: body.info,
+ fallback: `annexes upload failed: ${code}`,
+ }),
};
} catch (err) {
return {
@@ -264,7 +269,12 @@ async function uploadAnnexFile(
error:
code === 200
? undefined
- : formatCcCause(body.info || `annexes upload failed: ${code}`),
+ : formatCcApiFailure({
+ api: "upload",
+ code,
+ info: body.info,
+ fallback: `annexes upload failed: ${code}`,
+ }),
};
} catch (err) {
return {
@@ -384,7 +394,12 @@ export async function saveContainerDoFile(input: {
folderId: null,
request: { ...requestBase, fieldValue: folderId },
response: bind,
- error: formatCcCause(bind.info || `SaveFieldValue failed: ${bindCode}`),
+ error: formatCcApiFailure({
+ api: "SaveFieldValue",
+ code: bindCode,
+ info: bind.info,
+ fallback: `SaveFieldValue failed: ${bindCode}`,
+ }),
};
}
diff --git a/src/services/cc/forecast-confirm-gate.ts b/src/services/cc/forecast-confirm-gate.ts
index 7dd905e..eddf810 100644
--- a/src/services/cc/forecast-confirm-gate.ts
+++ b/src/services/cc/forecast-confirm-gate.ts
@@ -60,17 +60,20 @@ function summarizeInvalidRows(
const parts = [...counts.entries()].map(([reason, n]) =>
n > 1 ? `${reason}(${n}行)` : reason,
);
- return `货件有 ${rows.length} 行未通过校验:${parts.join("、")}。请在附件中补全后重新解析,或仅导入柜头。`;
+ return `货件有 ${rows.length} 行未通过校验:${parts.join("、")}。请在本页补全必填项或导入装箱表。`;
}
/**
* 实时收集确认导入页的缺项 / 错项,供灰按钮旁提示与字段红字共用。
- * 无有效货件但柜号可用时允许仅柜头导入,不把「未选货件」当作阻断。
+ * 必填以 SaveContainer 为准:柜头 F_TransMode / F_OperationType / F_ContainerNo / F_CabinetType;
+ * 货件行 F_FBACode / F_Transporter / F_CTNS(由 VALID 行保证)。无有效货件不可导入。
*/
export function collectForecastImportIssues(input: {
transMode: number | null | undefined;
operationType: number | null | undefined;
containerNo: string;
+ /** 柜型(SaveContainer 必填) */
+ cabinetType?: string | null;
hasContainerConflict: boolean;
containerSource: "body" | "attachment" | null;
validRowCount: number;
@@ -115,13 +118,26 @@ export function collectForecastImportIssues(input: {
blockers.push(msg);
}
+ if (!(input.cabinetType || "").trim()) {
+ const msg = "请选择柜型(标准:20GP / 40GP / 40HQ / 40HR / 45HQ / 53HQ 等)";
+ errors.cabinetType = msg;
+ blockers.push(msg);
+ }
+
if (input.hasContainerConflict && !input.containerSource) {
const msg = "正文与附件柜号不一致,请先选定使用哪一个";
errors.containerSource = msg;
blockers.push(msg);
}
- if (input.validRowCount > 0 && input.selectedValidCount === 0) {
+ if (input.validRowCount <= 0) {
+ const msg =
+ shipmentCount > 0 || invalidRows.length > 0
+ ? "没有可导入的有效货件行(必填:派送方式、件数、体积、毛重)。请在本页修改或导入装箱表"
+ : "未解析到货件信息。请在本页新增货件,或导入卡派清单(xlsx)后再确认导入";
+ errors.rows = msg;
+ blockers.push(msg);
+ } else if (input.selectedValidCount === 0) {
const msg = input.softLimitExceeded
? "货件超过软限,默认未全选;请筛选后至少勾选一行有效货件"
: "请至少勾选一行有效货件";
@@ -138,8 +154,6 @@ export function collectForecastImportIssues(input: {
const invalidSummary = summarizeInvalidRows(invalidRows);
if (input.validRowCount === 0 && invalidSummary) {
notes.push(invalidSummary);
- } else if (input.validRowCount === 0 && shipmentCount === 0) {
- notes.push("未解析到货件行,确认导入将只提交柜头信息。");
}
return { errors, blockers, notes };
diff --git a/src/services/cc/list-http-gate.ts b/src/services/cc/list-http-gate.ts
new file mode 100644
index 0000000..d25b8dd
--- /dev/null
+++ b/src/services/cc/list-http-gate.ts
@@ -0,0 +1,37 @@
+/** V1.73:GetContainerList 真网调用串行 + 60 秒间隔 */
+
+const LIST_HTTP_MIN_INTERVAL_MS = 60_000;
+const listHttpLastAt = new Map();
+const listHttpChains = new Map>();
+
+export function resetListHttpGateForTests(): void {
+ listHttpLastAt.clear();
+ listHttpChains.clear();
+}
+
+export async function runWithListHttpGate(
+ profileKey: string,
+ fn: () => Promise,
+): Promise {
+ const prev = listHttpChains.get(profileKey) ?? Promise.resolve();
+ const run = prev.then(async () => {
+ const last = listHttpLastAt.get(profileKey) ?? 0;
+ const waitMs = LIST_HTTP_MIN_INTERVAL_MS - (Date.now() - last);
+ if (waitMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
+ }
+ try {
+ return await fn();
+ } finally {
+ listHttpLastAt.set(profileKey, Date.now());
+ }
+ });
+ listHttpChains.set(
+ profileKey,
+ run.then(
+ () => undefined,
+ () => undefined,
+ ),
+ );
+ return run;
+}
diff --git a/src/services/cc/message-type-dict.ts b/src/services/cc/message-type-dict.ts
new file mode 100644
index 0000000..351690e
--- /dev/null
+++ b/src/services/cc/message-type-dict.ts
@@ -0,0 +1,101 @@
+import { prisma } from "@/services/db";
+import { DEFAULT_CC_MESSAGE_TYPE_NAMES } from "@/constants/cc-message-types";
+
+export type CcMessageTypeDictPublic = {
+ id: string;
+ name: string;
+ enabled: boolean;
+ sort_order: number;
+ updated_at: string;
+};
+
+function toPublic(row: {
+ id: bigint;
+ name: string;
+ enabled: boolean;
+ sortOrder: number;
+ updatedAt: Date;
+}): CcMessageTypeDictPublic {
+ return {
+ id: row.id.toString(),
+ name: row.name,
+ enabled: row.enabled,
+ sort_order: row.sortOrder,
+ updated_at: row.updatedAt.toISOString(),
+ };
+}
+
+/** 空表时写入默认种子(幂等) */
+export async function ensureCcMessageTypeDictSeeded(): Promise {
+ const count = await prisma.ccMessageTypeDict.count();
+ if (count > 0) return;
+ await prisma.ccMessageTypeDict.createMany({
+ data: DEFAULT_CC_MESSAGE_TYPE_NAMES.map((name, i) => ({
+ name,
+ enabled: true,
+ sortOrder: i,
+ })),
+ });
+}
+
+export async function listCcMessageTypeDict(opts?: {
+ enabledOnly?: boolean;
+}): Promise {
+ await ensureCcMessageTypeDictSeeded();
+ const rows = await prisma.ccMessageTypeDict.findMany({
+ where: opts?.enabledOnly ? { enabled: true } : undefined,
+ orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
+ });
+ return rows.map(toPublic);
+}
+
+export async function listEnabledCcMessageTypeOptions(): Promise<
+ Array<{ value: string; label: string }>
+> {
+ const items = await listCcMessageTypeDict({ enabledOnly: true });
+ return items.map((i) => ({ value: i.name, label: i.name }));
+}
+
+export async function createCcMessageTypeDict(input: {
+ name: string;
+ enabled?: boolean;
+ sortOrder?: number;
+}): Promise {
+ await ensureCcMessageTypeDictSeeded();
+ const name = input.name.trim();
+ const maxSort = await prisma.ccMessageTypeDict.aggregate({
+ _max: { sortOrder: true },
+ });
+ const row = await prisma.ccMessageTypeDict.create({
+ data: {
+ name,
+ enabled: input.enabled ?? true,
+ sortOrder: input.sortOrder ?? (maxSort._max.sortOrder ?? 0) + 1,
+ },
+ });
+ return toPublic(row);
+}
+
+export async function updateCcMessageTypeDict(
+ id: bigint,
+ input: { name?: string; enabled?: boolean; sortOrder?: number },
+): Promise {
+ const existing = await prisma.ccMessageTypeDict.findUnique({ where: { id } });
+ if (!existing) return null;
+ const row = await prisma.ccMessageTypeDict.update({
+ where: { id },
+ data: {
+ ...(input.name !== undefined ? { name: input.name.trim() } : {}),
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
+ ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
+ },
+ });
+ return toPublic(row);
+}
+
+export async function deleteCcMessageTypeDict(id: bigint): Promise {
+ const existing = await prisma.ccMessageTypeDict.findUnique({ where: { id } });
+ if (!existing) return false;
+ await prisma.ccMessageTypeDict.delete({ where: { id } });
+ return true;
+}
diff --git a/src/services/cc/save-container.ts b/src/services/cc/save-container.ts
index ab0e128..fdef05d 100644
--- a/src/services/cc/save-container.ts
+++ b/src/services/cc/save-container.ts
@@ -13,7 +13,7 @@ import type {
CcShipment,
} from "@/types/cc";
import type { ContainerHeader, ShipmentRow } from "@/types/mail";
-import { formatCcCause } from "@/constants/error-copy";
+import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy";
import { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code";
export { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code";
@@ -58,7 +58,7 @@ export function buildSaveContainerEntity(
F_TransMode: merged.F_TransMode ?? overrides.F_TransMode,
F_OperationType: merged.F_OperationType ?? overrides.F_OperationType,
F_ContainerNo: merged.F_ContainerNo.toUpperCase(),
- F_CabinetType: cabinet || undefined,
+ F_CabinetType: cabinet ? cabinet : null,
F_BLCopyCode: bl,
F_ETD: merged.F_ETD ?? null,
F_ETA: merged.F_ETA ?? null,
@@ -190,7 +190,7 @@ export async function saveContainer(
return {
ok: false,
status: "FAILED",
- error: formatCcCause(ccApiErrorText(response, code)),
+ error: ccApiErrorText(response, code),
requestBody,
responseBody,
response,
@@ -214,15 +214,19 @@ function ccApiErrorText(
code = normalizeCcCode(response.code),
): string {
const info = String(response.info || "").trim();
- if (info) return info;
const message = String(
(response as { message?: string }).message || "",
).trim();
- if (message) return message;
- if (typeof response.data === "string" && response.data.trim()) {
- return response.data.trim();
- }
- return `CC save failed with code ${code}`;
+ const dataHint =
+ typeof response.data === "string" && response.data.trim()
+ ? response.data.trim()
+ : "";
+ return formatCcApiFailure({
+ api: "SaveContainer",
+ code,
+ info: info || message || dataHint || null,
+ fallback: `CC save failed with code ${code}`,
+ });
}
function extractExternalId(data: unknown): string | null {
diff --git a/src/services/cc/settings-config.ts b/src/services/cc/settings-config.ts
index 688b2a4..14e48e4 100644
--- a/src/services/cc/settings-config.ts
+++ b/src/services/cc/settings-config.ts
@@ -1,5 +1,5 @@
import { getEnv } from "@/lib/env";
-import { getCcOwnerUserId } from "@/lib/cc-owner";
+import { getCcOwnerUserId, getCcProfileId } from "@/lib/cc-owner";
import { isTestMode } from "@/lib/test-mode";
import { decryptSecret, encryptSecret, maskUsername } from "@/lib/secret-crypto";
import { prisma } from "@/services/db";
@@ -8,6 +8,7 @@ import { normalizeCcCode } from "@/services/cc/code";
import type { CcApiResponse } from "@/types/cc";
import { fetch } from "undici";
import { getMockTargetAPI } from "@/services/cc/mock-target-api";
+import { loadCcCustomerProfileRuntime } from "@/services/cc/customer-profiles";
const SINGLETON_ID = 1;
const LOGIN_PATH = "/learun/adms/user/customerLogin";
@@ -41,7 +42,11 @@ type CacheEntry = { at: number; value: CcRuntimeConfig };
const cacheByOwner = new Map();
-function cacheKey(ownerUserId: bigint | null | undefined): string {
+function cacheKey(
+ ownerUserId: bigint | null | undefined,
+ profileId?: bigint | null,
+): string {
+ if (profileId != null) return `profile:${profileId}`;
return ownerUserId != null ? String(ownerUserId) : "global";
}
@@ -153,12 +158,20 @@ async function loadRuntime(
}
export async function resolveCcRuntime(): Promise {
+ const profileId = getCcProfileId();
const ownerUserId = getCcOwnerUserId();
- const key = cacheKey(ownerUserId);
+ const key = cacheKey(ownerUserId, profileId);
const now = Date.now();
const hit = cacheByOwner.get(key);
if (hit && now - hit.at < CACHE_TTL_MS) return hit.value;
- const value = await loadRuntime(ownerUserId);
+
+ let value: CcRuntimeConfig;
+ if (profileId != null) {
+ const fromProfile = await loadCcCustomerProfileRuntime(profileId);
+ value = fromProfile ?? (await loadRuntime(ownerUserId));
+ } else {
+ value = await loadRuntime(ownerUserId);
+ }
cacheByOwner.set(key, { at: now, value });
return value;
}
diff --git a/src/services/cc/shipping-lines.ts b/src/services/cc/shipping-lines.ts
index eb41e0f..8448242 100644
--- a/src/services/cc/shipping-lines.ts
+++ b/src/services/cc/shipping-lines.ts
@@ -1,7 +1,18 @@
export type { ShippingLineItem } from "./shipping-line";
-export { getShippingLineList } from "./shipping-line";
+export {
+ getShippingLineList,
+ invalidateShippingLineCache,
+} from "./shipping-line";
-import { getShippingLineList } from "./shipping-line";
+import {
+ getShippingLineList,
+ invalidateShippingLineCache,
+} from "./shipping-line";
+
+/** API 强制刷新船司列表时清服务端缓存 */
+export function invalidateShippingLineListCache(): void {
+ invalidateShippingLineCache();
+}
export async function listShippingLines(): Promise<
Array<{
diff --git a/src/services/cc/write-capability.ts b/src/services/cc/write-capability.ts
index 0736faf..6014fb7 100644
--- a/src/services/cc/write-capability.ts
+++ b/src/services/cc/write-capability.ts
@@ -34,7 +34,7 @@ const DEFAULTS: Record(["customer_message"]);
+
export async function ensureCcWriteCapabilities(): Promise {
for (const [id, def] of Object.entries(DEFAULTS) as Array<
[CcWriteCapabilityId, (typeof DEFAULTS)[CcWriteCapabilityId]]
>) {
+ const forceLive = FORCE_LIVE_IDS.has(id);
await prisma.ccWriteCapability.upsert({
where: { id },
create: { id, mode: def.mode, endpoint: def.endpoint },
- update: {},
+ update: forceLive
+ ? { mode: "live", endpoint: def.endpoint }
+ : {},
});
}
}
@@ -82,6 +88,7 @@ export async function resolveWriteMode(
): Promise {
if (isTestMode()) return "live";
if (await isCcMock()) return "mock";
+ if (FORCE_LIVE_IDS.has(id)) return "live";
const cap = await getCcWriteCapability(id);
return cap.mode;
}
diff --git a/src/services/imap/mailbox-config.ts b/src/services/imap/mailbox-config.ts
index b023050..5656e68 100644
--- a/src/services/imap/mailbox-config.ts
+++ b/src/services/imap/mailbox-config.ts
@@ -142,9 +142,16 @@ function rowToConfig(
};
}
-export async function listMailboxes(ownerUserId?: bigint | null): Promise {
+/**
+ * 列出邮箱。
+ * - bigint:该租户名下邮箱
+ * - null:仅管理端公共邮箱(owner_user_id IS NULL)
+ */
+export async function listMailboxes(
+ ownerUserId: bigint | null,
+): Promise {
const rows = await prisma.mailboxAccount.findMany({
- where: ownerUserId != null ? { ownerUserId } : undefined,
+ where: { ownerUserId },
orderBy: [{ enabled: "desc" }, { id: "asc" }],
});
return rows.map(toPublic);
diff --git a/src/services/import/auto-exec.ts b/src/services/import/auto-exec.ts
index 470514c..0b92f53 100644
--- a/src/services/import/auto-exec.ts
+++ b/src/services/import/auto-exec.ts
@@ -131,6 +131,7 @@ async function runAutoExecInner(mailId: bigint): Promise<{
responseBody: JSON.stringify(result.response),
status: "SUCCESS",
shipmentsHash: "auto-exec",
+ importKind: "forecast",
},
}).catch(() => undefined);
}
diff --git a/src/services/import/confirm-batch-transfer.ts b/src/services/import/confirm-batch-transfer.ts
index fb04563..36dac52 100644
--- a/src/services/import/confirm-batch-transfer.ts
+++ b/src/services/import/confirm-batch-transfer.ts
@@ -13,6 +13,7 @@ import {
markInstructionImportedAndResolveMail,
validateInstructionImportable,
} from "@/services/import/instruction-import-state";
+import { recordInstructionImportLog } from "@/services/import/instruction-import-log";
export class TransferConfirmError extends Error {
constructor(
@@ -33,9 +34,12 @@ export async function confirmBatchTransfer(input: {
instructionId?: string;
containerNo: string;
payload: BatchTransferPayload;
+ ccProfileId?: bigint;
}): Promise<{ externalId: string | null; mode: string }> {
- return runWithMailCcOwner(input.mailId, () =>
- runWithMailTrace(input.mailId, "submit", async () => {
+ return runWithMailCcOwner(
+ input.mailId,
+ () =>
+ runWithMailTrace(input.mailId, "submit", async () => {
const checked = await validateInstructionImportable({
mailId: input.mailId,
instructionId: input.instructionId,
@@ -113,9 +117,31 @@ export async function confirmBatchTransfer(input: {
externalId: result.externalId || null,
successLastError: result.mode === "mock" ? "XFER_MOCK_OK" : null,
});
+ await recordInstructionImportLog({
+ mailId: input.mailId,
+ instructionId: checked.unit.id,
+ kind: "transfer",
+ display: input.containerNo,
+ status: "SUCCESS",
+ externalId: result.externalId || null,
+ lastError: result.mode === "mock" ? "XFER_MOCK_OK" : null,
+ requestBody: result.request ? JSON.stringify(result.request) : null,
+ responseBody: result.response ? JSON.stringify(result.response) : null,
+ });
return { externalId: result.externalId || null, mode: result.mode };
}
+ await recordInstructionImportLog({
+ mailId: input.mailId,
+ instructionId: checked.unit.id,
+ kind: "transfer",
+ display: input.containerNo,
+ status: "FAILED",
+ lastError: result.error || "XFER_FAILED",
+ requestBody: result.request ? JSON.stringify(result.request) : null,
+ responseBody: result.response ? JSON.stringify(result.response) : null,
+ });
+
assertTransition("IMPORTING", "FAILED");
const failed = await prisma.mailMessage.update({
where: { id: input.mailId },
@@ -133,5 +159,6 @@ export async function confirmBatchTransfer(input: {
{ server_version: failed.version },
);
}),
+ { profileId: input.ccProfileId },
);
}
diff --git a/src/services/import/confirm-do-upload.ts b/src/services/import/confirm-do-upload.ts
index d5d45a7..68c461a 100644
--- a/src/services/import/confirm-do-upload.ts
+++ b/src/services/import/confirm-do-upload.ts
@@ -16,6 +16,7 @@ import {
markInstructionImportedAndResolveMail,
validateInstructionImportable,
} from "@/services/import/instruction-import-state";
+import { recordInstructionImportLog } from "@/services/import/instruction-import-log";
export class DoConfirmError extends Error {
constructor(
@@ -136,14 +137,17 @@ export async function confirmDoUpload(input: {
containerNo: string;
containerTaskId?: string;
filenames?: string[];
+ ccProfileId?: bigint;
}): Promise<{
folderId: string | null;
mode: string;
containerTaskId: string;
uploadedFileIds?: string[];
}> {
- return runWithMailCcOwner(input.mailId, () =>
- runWithMailTrace(input.mailId, "submit", async () => {
+ return runWithMailCcOwner(
+ input.mailId,
+ () =>
+ runWithMailTrace(input.mailId, "submit", async () => {
const checked = await validateInstructionImportable({
mailId: input.mailId,
instructionId: input.instructionId,
@@ -272,6 +276,20 @@ export async function confirmDoUpload(input: {
? "DO_MOCK_OK"
: `DO 已上传: ${(result.uploadedFileIds || []).join(",") || result.folderId || "ok"}`,
});
+ await recordInstructionImportLog({
+ mailId: input.mailId,
+ instructionId: checked.unit.id,
+ kind: "do_upload",
+ display: cn,
+ status: "SUCCESS",
+ externalId: taskId,
+ lastError:
+ result.mode === "mock"
+ ? "DO_MOCK_OK"
+ : `DO 已上传: ${(result.uploadedFileIds || []).join(",") || result.folderId || "ok"}`,
+ requestBody: result.request ? JSON.stringify(result.request) : null,
+ responseBody: result.response ? JSON.stringify(result.response) : null,
+ });
return {
folderId: result.folderId || null,
mode: result.mode,
@@ -280,6 +298,17 @@ export async function confirmDoUpload(input: {
};
}
+ await recordInstructionImportLog({
+ mailId: input.mailId,
+ instructionId: checked.unit.id,
+ kind: "do_upload",
+ display: cn,
+ status: "FAILED",
+ lastError: result.error || "DO_FAILED",
+ requestBody: result.request ? JSON.stringify(result.request) : null,
+ responseBody: result.response ? JSON.stringify(result.response) : null,
+ });
+
if (wasSuccess) {
const failed = await prisma.mailMessage.update({
where: { id: input.mailId },
@@ -308,5 +337,6 @@ export async function confirmDoUpload(input: {
server_version: failed.version,
});
}),
+ { profileId: input.ccProfileId },
);
}
diff --git a/src/services/import/confirm-work-order.ts b/src/services/import/confirm-work-order.ts
index 50184c7..7ef9e79 100644
--- a/src/services/import/confirm-work-order.ts
+++ b/src/services/import/confirm-work-order.ts
@@ -12,6 +12,12 @@ import {
markInstructionImportedAndResolveMail,
validateInstructionImportable,
} from "@/services/import/instruction-import-state";
+import { recordInstructionImportLog } from "@/services/import/instruction-import-log";
+import {
+ diffWorkOrderSupplementedFields,
+ toWorkOrderSubmittedSnapshot,
+ type WorkOrderSnapshotValue,
+} from "@/services/parse/work-order-snapshot";
export class WorkOrderConfirmError extends Error {
constructor(
@@ -31,14 +37,19 @@ export async function confirmWorkOrder(input: {
actor: string;
instructionId?: string;
entity: CustomerMessageEntity;
+ /** 邮件抽取基线,用于标记人工补充/修改字段 */
+ baseline?: WorkOrderSnapshotValue | null;
+ ccProfileId?: bigint;
}): Promise<{ externalId: string | null; mode: string }> {
- return runWithMailCcOwner(input.mailId, () =>
- runWithMailTrace(input.mailId, "submit", async () => {
+ return runWithMailCcOwner(
+ input.mailId,
+ () =>
+ runWithMailTrace(input.mailId, "submit", async () => {
const checked = await validateInstructionImportable({
mailId: input.mailId,
instructionId: input.instructionId,
allowedKinds: ["work_order", "transfer"],
- allowedStatuses: ["PENDING_CONFIRM", "FAILED", "PARTIAL_SUCCESS"],
+ allowedStatuses: ["PENDING_CONFIRM", "FAILED", "PARTIAL_SUCCESS", "SUCCESS"],
});
if (checked.error === "NOT_FOUND") {
throw new WorkOrderConfirmError("NOT_FOUND", "邮件不存在", 404);
@@ -99,7 +110,20 @@ export async function confirmWorkOrder(input: {
},
});
+ const logDisplay =
+ input.entity.F_ReferCode ||
+ input.entity.F_RefNo ||
+ checked.unit.label ||
+ checked.unit.id;
+
if (result.ok) {
+ const submitted = toWorkOrderSubmittedSnapshot(
+ input.entity as unknown as Record,
+ );
+ const diff = diffWorkOrderSupplementedFields(
+ input.baseline || null,
+ submitted,
+ );
await markInstructionImportedAndResolveMail({
mailId: input.mailId,
instructionId: checked.unit.id,
@@ -107,10 +131,37 @@ export async function confirmWorkOrder(input: {
mode: result.mode,
externalId: result.externalId || null,
successLastError: result.mode === "mock" ? "WO_MOCK_OK" : null,
+ submitted,
+ supplementedFields: diff.supplementedFields,
+ fieldMarks: diff.fieldMarks,
+ userSupplemented: diff.userSupplemented,
+ });
+ // 按指令落导入日志(不等整封邮件全部指令完成)
+ 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,
});
return { externalId: result.externalId || null, mode: result.mode };
}
+ await recordInstructionImportLog({
+ mailId: input.mailId,
+ instructionId: checked.unit.id,
+ kind: "work_order",
+ display: logDisplay,
+ status: "FAILED",
+ lastError: result.error || "WO_FAILED",
+ requestBody: result.request ? JSON.stringify(result.request) : null,
+ responseBody: result.response ? JSON.stringify(result.response) : null,
+ });
+
assertTransition("IMPORTING", "FAILED");
const failed = await prisma.mailMessage.update({
where: { id: input.mailId },
@@ -128,5 +179,6 @@ export async function confirmWorkOrder(input: {
{ server_version: failed.version },
);
}),
+ { profileId: input.ccProfileId },
);
}
diff --git a/src/services/import/confirm.ts b/src/services/import/confirm.ts
index f21ff25..4ab28d6 100644
--- a/src/services/import/confirm.ts
+++ b/src/services/import/confirm.ts
@@ -33,7 +33,12 @@ import {
markInstructionImportedAndResolveMail,
validateInstructionImportable,
} from "@/services/import/instruction-import-state";
+import {
+ normalizeExternalId,
+ resolveImportCcCustomerMeta,
+} from "@/services/import/import-log-meta";
import { fillShipmentFbaCodes } from "@/services/parse/normalize-fba-code";
+import { sanitizeClientShipments } from "@/services/parse/revalidate-shipment-row";
/** 仅允许白名单柜头字段从客户端覆盖,防止 mass assignment 窜改 CC payload */
function pickContainerHeaderPatch(
@@ -125,6 +130,13 @@ export interface ConfirmImportInput {
selectedRowIndexes: number[];
ackUnmappedChannels?: boolean;
forceSkipConflict?: boolean;
+ /** 选中的 CC 客户档案,写 CC 时使用该账密 */
+ ccProfileId?: bigint;
+ /**
+ * 确认页手工增改 / 导入装箱表后的货件覆盖。
+ * 传入时(含空数组)以客户端货件为准,不再只用库内 parseResult。
+ */
+ shipments?: ShipmentRow[];
}
export interface ConfirmImportResult {
@@ -169,10 +181,13 @@ async function createCompensation(
export class ConfirmImport {
static async execute(input: ConfirmImportInput): Promise {
- return runWithMailCcOwner(input.mailId, () =>
- runWithMailTrace(input.mailId, "submit", () =>
- ConfirmImport.executeUntraced(input),
- ),
+ return runWithMailCcOwner(
+ input.mailId,
+ () =>
+ runWithMailTrace(input.mailId, "submit", () =>
+ ConfirmImport.executeUntraced(input),
+ ),
+ { profileId: input.ccProfileId },
);
}
@@ -232,9 +247,18 @@ export class ConfirmImport {
mail.parseResult.shipments,
) as unknown as ShipmentRow[])
: [];
- const validShipmentCount = shipmentRows.filter(
+ let validShipmentCount = shipmentRows.filter(
(s) => s.row_status === "VALID",
).length;
+ if (input.shipments !== undefined) {
+ try {
+ validShipmentCount = sanitizeClientShipments(input.shipments).filter(
+ (s) => s.row_status === "VALID",
+ ).length;
+ } catch {
+ validShipmentCount = 0;
+ }
+ }
const checked = await validateInstructionImportable({
mailId: input.mailId,
@@ -258,9 +282,13 @@ export class ConfirmImport {
);
}
- const containerNoForGate = (
- mail.parseResult?.containerHeader as { F_ContainerNo?: string } | null
- )?.F_ContainerNo || "";
+ const containerNoForGate =
+ (typeof input.containerHeader?.F_ContainerNo === "string" &&
+ input.containerHeader.F_ContainerNo.trim()) ||
+ (
+ mail.parseResult?.containerHeader as { F_ContainerNo?: string } | null
+ )?.F_ContainerNo ||
+ "";
if (validShipmentCount < 1 && !containerNoForGate.trim()) {
throw new ConfirmImportError(
@@ -300,21 +328,43 @@ export class ConfirmImport {
);
}
+ let clientShipments: ShipmentRow[] | undefined;
+ if (input.shipments !== undefined) {
+ try {
+ clientShipments = sanitizeClientShipments(input.shipments);
+ } catch (err) {
+ throw new ConfirmImportError(
+ "INVALID_ROWS",
+ err instanceof Error ? err.message : "货件数据无效",
+ );
+ }
+ }
+
const parseResult = mail.parseResult
? {
container_header: mail.parseResult
.containerHeader as unknown as ContainerHeader,
shipments: fillShipmentFbaCodes(
- mail.parseResult.shipments,
- ) as unknown as ShipmentRow[],
+ clientShipments ??
+ (mail.parseResult.shipments as unknown as ShipmentRow[]),
+ ),
}
- : null;
+ : clientShipments
+ ? {
+ container_header: {
+ F_ContainerNo: "",
+ container_no_valid: false,
+ } as ContainerHeader,
+ shipments: fillShipmentFbaCodes(clientShipments),
+ }
+ : null;
if (!parseResult) {
throw new ConfirmImportError("INVALID_ROWS", "缺少解析结果");
}
- const selected = parseResult.shipments.filter((s) =>
+ const allShipments = parseResult.shipments as ShipmentRow[];
+ const selected = allShipments.filter((s) =>
input.selectedRowIndexes.includes(s.row_index),
);
@@ -493,6 +543,10 @@ export class ConfirmImport {
F_ContainerNo: containerNo,
});
+ const customer = await resolveImportCcCustomerMeta({
+ ccProfileId: input.ccProfileId ?? null,
+ });
+
let importRecord = await prisma.containerImport.upsert({
where: {
mailId_containerNo_shipmentsHash: {
@@ -506,10 +560,16 @@ export class ConfirmImport {
containerNo,
shipmentsHash,
status: "IMPORTING",
+ importKind: "forecast",
+ ccCustomerName: customer.ccCustomerName,
+ ccProfileId: customer.ccProfileId,
},
update: {
status: "IMPORTING",
lastError: null,
+ importKind: "forecast",
+ ccCustomerName: customer.ccCustomerName ?? undefined,
+ ccProfileId: customer.ccProfileId ?? undefined,
},
});
@@ -529,10 +589,13 @@ export class ConfirmImport {
where: { id: importRecord.id },
data: {
status: "SUCCESS",
- externalId: saveResult.externalId,
+ externalId: normalizeExternalId(saveResult.externalId),
requestBody: saveResult.requestBody,
responseBody: saveResult.responseBody,
lastError: null,
+ importKind: "forecast",
+ ccCustomerName: customer.ccCustomerName ?? undefined,
+ ccProfileId: customer.ccProfileId ?? undefined,
},
});
diff --git a/src/services/import/import-log-meta.ts b/src/services/import/import-log-meta.ts
new file mode 100644
index 0000000..b1820fe
--- /dev/null
+++ b/src/services/import/import-log-meta.ts
@@ -0,0 +1,80 @@
+/**
+ * 导入日志展示用:类型标签、外部 ID 规范化、当前 CC 客户名解析。
+ */
+import { getCcProfileId } from "@/lib/cc-owner";
+import { getCcCustomerProfile } from "@/services/cc/customer-profiles";
+
+export const IMPORT_KIND_LABEL: Record = {
+ forecast: "新增预报",
+ work_order: "工单",
+ do_upload: "上传DO",
+ transfer: "批量转仓",
+};
+
+export function importKindLabel(kind: string | null | undefined): string {
+ const k = (kind || "").trim();
+ if (!k) return "其他";
+ return IMPORT_KIND_LABEL[k] || k;
+}
+
+/** 历史记录无 import_kind 时,从对象列前缀推断 */
+export function inferImportKindFromObjectKey(
+ containerNo: string | null | undefined,
+): string {
+ const s = String(containerNo || "");
+ if (s.startsWith("WO/")) return "work_order";
+ if (s.startsWith("DO/")) return "do_upload";
+ if (s.startsWith("XF/")) return "transfer";
+ return "forecast";
+}
+
+/** 避免 res.data 为对象时写入 "[object Object]" */
+export function normalizeExternalId(value: unknown): string | null {
+ if (value == null || value === "") return null;
+ if (typeof value === "string") {
+ const t = value.trim();
+ if (!t || t === "[object Object]") return null;
+ return t.slice(0, 64);
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value).slice(0, 64);
+ }
+ if (typeof value === "object") {
+ const o = value as Record;
+ for (const key of ["F_Id", "id", "Id", "data", "externalId", "taskId"]) {
+ const inner = o[key];
+ if (typeof inner === "string" && inner.trim()) {
+ return inner.trim().slice(0, 64);
+ }
+ if (typeof inner === "number") return String(inner).slice(0, 64);
+ }
+ try {
+ return JSON.stringify(value).slice(0, 64);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+export async function resolveImportCcCustomerMeta(explicit?: {
+ ccProfileId?: bigint | null;
+ ccCustomerName?: string | null;
+}): Promise<{ ccProfileId: bigint | null; ccCustomerName: string | null }> {
+ if (explicit?.ccCustomerName?.trim()) {
+ return {
+ ccProfileId: explicit.ccProfileId ?? null,
+ ccCustomerName: explicit.ccCustomerName.trim().slice(0, 128),
+ };
+ }
+ const pid =
+ explicit?.ccProfileId != null ? explicit.ccProfileId : getCcProfileId();
+ if (pid == null) {
+ return { ccProfileId: null, ccCustomerName: null };
+ }
+ const profile = await getCcCustomerProfile(pid);
+ return {
+ ccProfileId: pid,
+ ccCustomerName: (profile?.name || "").trim().slice(0, 128) || null,
+ };
+}
diff --git a/src/services/import/instruction-import-log.ts b/src/services/import/instruction-import-log.ts
new file mode 100644
index 0000000..aa7dd7b
--- /dev/null
+++ b/src/services/import/instruction-import-log.ts
@@ -0,0 +1,227 @@
+/**
+ * 指令级导入日志:一封邮件可有多条指令,每条成功/失败各写一条 container_import,
+ * 供 /logs「导入日志」按指令展示(非等整封邮件全部成功才落库)。
+ *
+ * container_no 带 WO/DO/XF 前缀,避免与真实柜号 SUCCESS 冲突检测撞车。
+ */
+import { prisma } from "@/services/db";
+import {
+ getInstructionExecutionStateMap,
+ listActionableInstructionUnits,
+} from "@/services/parse/instruction-execution-state";
+import {
+ normalizeExternalId,
+ resolveImportCcCustomerMeta,
+} from "@/services/import/import-log-meta";
+import { sha256 } from "@/utils/hash";
+
+export type InstructionImportLogKind =
+ | "work_order"
+ | "do_upload"
+ | "transfer"
+ | string;
+
+const KIND_PREFIX: Record = {
+ work_order: "WO",
+ do_upload: "DO",
+ transfer: "XF",
+};
+
+/** 回填时只补这些;预报本身已有柜级 container_import */
+const BACKFILL_KINDS = new Set(["work_order", "do_upload", "transfer"]);
+
+export function instructionImportShipmentsHash(instructionId: string): string {
+ return sha256(`instruction:${instructionId}`);
+}
+
+/** 列表「对象」列:最长 50(与 schema VarChar(50) 对齐) */
+export function instructionImportObjectKey(input: {
+ kind: InstructionImportLogKind;
+ instructionId: string;
+ display?: string | null;
+}): string {
+ const prefix = KIND_PREFIX[input.kind] || "IN";
+ const disp = String(input.display || "")
+ .trim()
+ .toUpperCase()
+ .replace(/\s+/g, "");
+ const core = (disp || input.instructionId.replace(/[^A-Za-z0-9_-]/g, "")).slice(
+ 0,
+ 40,
+ );
+ return `${prefix}/${core || "ITEM"}`.slice(0, 50);
+}
+
+export async function recordInstructionImportLog(input: {
+ mailId: bigint;
+ instructionId: string;
+ kind: InstructionImportLogKind;
+ /** 柜号 / 参考号 / 指令摘要,用于对象列 */
+ display?: string | null;
+ status: "SUCCESS" | "FAILED" | "IMPORTING";
+ externalId?: unknown;
+ lastError?: string | null;
+ requestBody?: string | null;
+ responseBody?: string | null;
+ /** 历史回填:用指令导入时间作为日志时间 */
+ importedAt?: string | Date | null;
+ ccProfileId?: bigint | null;
+ ccCustomerName?: string | null;
+}): Promise<"created" | "updated"> {
+ const containerNo = instructionImportObjectKey({
+ kind: input.kind,
+ instructionId: input.instructionId,
+ display: input.display,
+ });
+ const shipmentsHash = instructionImportShipmentsHash(input.instructionId);
+ const lastError = input.lastError
+ ? String(input.lastError).slice(0, 1000)
+ : null;
+ const at = input.importedAt ? new Date(input.importedAt) : null;
+ const stamp = at && !Number.isNaN(at.getTime()) ? at : undefined;
+ const externalId = normalizeExternalId(input.externalId);
+ const customer = await resolveImportCcCustomerMeta({
+ ccProfileId: input.ccProfileId,
+ ccCustomerName: input.ccCustomerName,
+ });
+ const importKind = String(input.kind || "work_order").slice(0, 32);
+
+ const existing = await prisma.containerImport.findUnique({
+ where: {
+ mailId_containerNo_shipmentsHash: {
+ mailId: input.mailId,
+ containerNo,
+ shipmentsHash,
+ },
+ },
+ select: { id: true },
+ });
+
+ if (!existing) {
+ await prisma.containerImport.create({
+ data: {
+ mailId: input.mailId,
+ containerNo,
+ shipmentsHash,
+ status: input.status,
+ externalId,
+ lastError,
+ requestBody: input.requestBody ?? null,
+ responseBody: input.responseBody ?? null,
+ importKind,
+ ccCustomerName: customer.ccCustomerName,
+ ccProfileId: customer.ccProfileId,
+ ...(stamp ? { createdAt: stamp, updatedAt: stamp } : {}),
+ },
+ });
+ return "created";
+ }
+
+ await prisma.containerImport.update({
+ where: { id: existing.id },
+ data: {
+ status: input.status,
+ externalId,
+ lastError,
+ requestBody: input.requestBody ?? undefined,
+ responseBody: input.responseBody ?? undefined,
+ importKind,
+ ccCustomerName: customer.ccCustomerName ?? undefined,
+ ccProfileId: customer.ccProfileId ?? undefined,
+ },
+ });
+ return "updated";
+}
+
+/**
+ * 扫描 lineage.instruction_state 中已 IMPORTED 的工单/DO/转仓,补写导入日志。
+ * 幂等:已有同 mail+instruction 记录则跳过创建。
+ */
+export async function backfillInstructionImportLogs(options?: {
+ dryRun?: boolean;
+}): Promise<{ scannedMails: number; created: number; skipped: number }> {
+ const dryRun = !!options?.dryRun;
+ const rows = await prisma.parseResult.findMany({
+ select: {
+ mailId: true,
+ lineage: true,
+ mail: {
+ select: {
+ subject: true,
+ bodyText: true,
+ attachments: { select: { filename: true } },
+ },
+ },
+ },
+ });
+
+ let created = 0;
+ let skipped = 0;
+
+ for (const row of rows) {
+ const stateMap = getInstructionExecutionStateMap(row.lineage);
+ const ids = Object.keys(stateMap);
+ if (!ids.length) continue;
+
+ const units = listActionableInstructionUnits({
+ subject: row.mail.subject,
+ body: row.mail.bodyText,
+ filenames: row.mail.attachments.map((a) => a.filename),
+ lineage: row.lineage,
+ });
+ const byId = new Map(units.map((u) => [u.id, u]));
+
+ for (const instructionId of ids) {
+ const entry = stateMap[instructionId];
+ if (!entry || entry.status !== "IMPORTED") {
+ skipped += 1;
+ continue;
+ }
+ const unit = byId.get(instructionId);
+ const kind = unit?.uiKind || "work_order";
+ if (!BACKFILL_KINDS.has(kind)) {
+ skipped += 1;
+ continue;
+ }
+
+ const hash = instructionImportShipmentsHash(instructionId);
+ const already = await prisma.containerImport.findFirst({
+ where: { mailId: row.mailId, shipmentsHash: hash },
+ select: { id: true },
+ });
+ if (already) {
+ skipped += 1;
+ continue;
+ }
+
+ const submitted = entry.submitted || {};
+ const display =
+ submitted.F_ReferCode ||
+ submitted.F_RefNo ||
+ unit?.summary ||
+ unit?.label ||
+ instructionId;
+
+ if (dryRun) {
+ created += 1;
+ continue;
+ }
+
+ const action = await recordInstructionImportLog({
+ mailId: row.mailId,
+ instructionId,
+ kind,
+ display,
+ status: "SUCCESS",
+ externalId: entry.externalId || null,
+ lastError:
+ entry.mode === "mock" ? `${String(kind).toUpperCase()}_MOCK_OK` : null,
+ importedAt: entry.importedAt,
+ });
+ if (action === "created") created += 1;
+ else skipped += 1;
+ }
+ }
+
+ return { scannedMails: rows.length, created, skipped };
+}
diff --git a/src/services/import/instruction-import-state.ts b/src/services/import/instruction-import-state.ts
index 4e108f2..b6fb504 100644
--- a/src/services/import/instruction-import-state.ts
+++ b/src/services/import/instruction-import-state.ts
@@ -5,8 +5,13 @@ import {
listActionableInstructionUnits,
markInstructionImported,
resolveMailStatusAfterInstructionImport,
+ type InstructionExecutionStateEntry,
} from "@/services/parse/instruction-execution-state";
import type { MailStatus } from "@/types/mail";
+import type {
+ WorkOrderFieldMarks,
+ WorkOrderSnapshotField,
+} from "@/services/parse/work-order-snapshot";
export async function validateInstructionImportable(input: {
mailId: bigint;
@@ -19,10 +24,21 @@ export async function validateInstructionImportable(input: {
include: { attachments: true, parseResult: true },
});
if (!mail) return { error: "NOT_FOUND" as const };
+ const lineage = mail.parseResult?.lineage || {};
if (!input.allowedStatuses.includes(mail.status as MailStatus)) {
- return { error: "BAD_STATUS" as const, mail };
+ // 历史:mock 误标 SUCCESS 时仍允许对该 mock 指令重提
+ const mockRetry =
+ mail.status === "SUCCESS" &&
+ !!input.instructionId &&
+ (
+ lineage as {
+ instruction_state?: Record;
+ }
+ )?.instruction_state?.[input.instructionId]?.mode === "mock";
+ if (!mockRetry) {
+ return { error: "BAD_STATUS" as const, mail };
+ }
}
- const lineage = mail.parseResult?.lineage || {};
const units = listActionableInstructionUnits({
subject: mail.subject,
body: mail.bodyText,
@@ -39,10 +55,11 @@ export async function validateInstructionImportable(input: {
return { error: "FORBIDDEN_KIND" as const, mail, unit };
}
const state = (
- (lineage as { instruction_state?: Record })
+ (lineage as { instruction_state?: Record })
?.instruction_state || {}
)[unit.id];
- if (state?.status === "IMPORTED") {
+ // mock 成功未写真实 CC,允许改 live 后再次提交
+ if (state?.status === "IMPORTED" && state.mode !== "mock") {
return { error: "ALREADY_IMPORTED" as const, mail, unit };
}
return { mail, unit, lineage };
@@ -55,6 +72,10 @@ export async function markInstructionImportedAndResolveMail(input: {
mode: string;
externalId?: string | null;
successLastError?: string | null;
+ submitted?: Record;
+ supplementedFields?: WorkOrderSnapshotField[];
+ fieldMarks?: WorkOrderFieldMarks;
+ userSupplemented?: boolean;
}): Promise {
const mail = await prisma.mailMessage.findUnique({
where: { id: input.mailId },
@@ -67,7 +88,11 @@ export async function markInstructionImportedAndResolveMail(input: {
importedBy: input.actor,
mode: input.mode,
externalId: input.externalId || null,
- });
+ submitted: input.submitted,
+ supplementedFields: input.supplementedFields,
+ fieldMarks: input.fieldMarks,
+ userSupplemented: input.userSupplemented,
+ } satisfies InstructionExecutionStateEntry);
const status = resolveMailStatusAfterInstructionImport({
currentStatus: mail.status as MailStatus,
subject: mail.subject,
diff --git a/src/services/parse/address-block.ts b/src/services/parse/address-block.ts
index f26b22d..a3c0114 100644
--- a/src/services/parse/address-block.ts
+++ b/src/services/parse/address-block.ts
@@ -30,30 +30,86 @@ export type LabeledField =
| "F_Expected_DeliveryDateB"
| "F_Expected_DeliveryDateE";
-const LABELS: Array<{ keys: string[]; field: LabeledField }> = [
- { keys: ["仓库代码", "仓库ID", "仓库id", "目的代码", "FBACode", "FBA Code"], field: "F_FBACode" },
+type BlockField = LabeledField | "_RegionCombo" | "_CompanyName";
+
+const LABELS: Array<{ keys: string[]; field: BlockField }> = [
+ {
+ keys: [
+ "仓库代码",
+ "仓库编码",
+ "仓库ID",
+ "仓库id",
+ "目的代码",
+ "FBACode",
+ "FBA Code",
+ "Warehouse Code",
+ "Warehouse",
+ "Mark",
+ ],
+ field: "F_FBACode",
+ },
{ keys: ["渠道", "Move Type", "Transporter", "运输方式"], field: "F_Transporter" },
{ keys: ["件数", "箱数", "Qty.(CTNS)", "CTNS"], field: "F_CTNS" },
{ keys: ["总体积", "体积(m³)", "Vol(CBM)", "CBM"], field: "F_CBM" },
{ keys: ["毛重", "G.W.(KGS)", "Weight"], field: "F_Weight" },
{ keys: ["件数(PCS)", "PCS", "Pcs"], field: "F_Pcs" },
- { keys: ["FBA ID", "FBAID", "FBAId(Amazon)", "Shipment ID(FBA)"], field: "F_FBAID" },
+ {
+ keys: ["FBA ID", "FBAID", "FBAId(Amazon)", "Shipment ID(FBA)"],
+ field: "F_FBAID",
+ },
{ keys: ["PO(Amazon)", "Amazon PO", "FBA PO"], field: "F_PO" },
- { keys: ["Amazon reference ID", "ReferenceId", "Reference ID"], field: "F_ReferenceId" },
{
- keys: ["分货标识", "箱唛", "Customer Ref No.", "Customer Ref No", "Custmer Ref No.", "ShipmentID"],
+ keys: ["Amazon reference ID", "ReferenceId", "Reference ID"],
+ field: "F_ReferenceId",
+ },
+ {
+ keys: [
+ "分货标识",
+ "箱唛",
+ "Customer Ref No.",
+ "Customer Ref No",
+ "Custmer Ref No.",
+ "ShipmentID",
+ ],
field: "F_ShipmentID",
},
- { keys: ["收货地址", "派送地址", "详细地址", "Address"], field: "F_Address" },
+ {
+ keys: [
+ "收货地址",
+ "详细地址",
+ "派送地址",
+ "地址",
+ "街道",
+ "Address",
+ "ADDR",
+ "Street",
+ ],
+ field: "F_Address",
+ },
{ keys: ["城市", "City"], field: "F_City" },
- { keys: ["州省", "State"], field: "F_State" },
- { keys: ["省/市/区", "省市/区", "省市区"], field: "F_State" },
- { keys: ["邮政编码", "邮编", "ZipCode", "Zip Code", "ZIP"], field: "F_ZipCode" },
- { keys: ["收件人", "收货人", "联系人", "Contact"], field: "F_Contact" },
- { keys: ["联系电话", "手机号", "电话", "Mobile"], field: "F_Mobile" },
- { keys: ["邮箱", "Email"], field: "F_Email" },
- { keys: ["公司名称", "公司名", "Company Name", "Company"], field: "F_Company" },
- { keys: ["行备注", "备注", "Remark"], field: "F_Remark" },
+ { keys: ["州省", "State", "省州"], field: "F_State" },
+ { keys: ["省/市/区", "省市/区", "省市区", "州/城市", "State/City"], field: "_RegionCombo" },
+ {
+ keys: ["邮政编码", "邮编", "ZipCode", "Zip Code", "ZIP", "Postcode"],
+ field: "F_ZipCode",
+ },
+ {
+ keys: ["收件人", "收货人", "联系人", "Contact", "Consignee", "Receiver", "Attn"],
+ field: "F_Contact",
+ },
+ {
+ keys: ["联系电话", "手机号", "电话", "手机", "Tel", "Phone", "Mobile", "Telephone"],
+ field: "F_Mobile",
+ },
+ { keys: ["邮箱", "Email", "E-mail"], field: "F_Email" },
+ {
+ keys: ["公司名称", "公司名", "Company Name", "Company"],
+ field: "_CompanyName",
+ },
+ {
+ keys: ["行备注", "备注", "Remark", "Note", "客户拆柜备注", "客户预约备注"],
+ field: "F_Remark",
+ },
{ keys: ["SKU"], field: "F_SKU" },
{ keys: ["品名", "货物描述", "Description"], field: "F_Description" },
{ keys: ["英文名称", "English Name", "EnglishName"], field: "F_EnglishName" },
@@ -72,20 +128,79 @@ function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+function normHeader(h: string): string {
+ return (h || "").replace(/\s+/g, "").toLowerCase();
+}
+
const LABEL_ALT = [...new Set(LABELS.flatMap((x) => x.keys))]
.sort((a, b) => b.length - a.length)
.map(escapeRe)
.join("|");
-const LABEL_RE = new RegExp(`(${LABEL_ALT})\\s*[::]`, "gi");
+/** 标签 + 多种分隔符(对齐 AI 助手 packing_parse._split_kv_segments) */
+const LABEL_SPLIT_RE = new RegExp(
+ `(${LABEL_ALT})\\s*(?:[::]|[-–—→]|=>|[·・])\\s*`,
+ "gi",
+);
-function fieldForLabel(label: string): LabeledField | null {
- const n = label.replace(/\s+/g, "").toLowerCase();
+function fieldForLabel(label: string): BlockField | null {
+ const n = normHeader(label);
for (const row of LABELS) {
- if (row.keys.some((k) => k.replace(/\s+/g, "").toLowerCase() === n)) {
- return row.field;
+ if (row.keys.some((k) => normHeader(k) === n)) return row.field;
+ }
+ return mapBlockLabelFuzzy(label);
+}
+
+function mapBlockLabelFuzzy(label: string): BlockField | null {
+ const lab = (label || "").trim();
+ if (!lab) return null;
+ const key = normHeader(lab);
+ const low = lab.toLowerCase().replace(/\s+/g, "");
+ if (/省\s*\/\s*市\s*\/\s*区|省市区/.test(lab)) return "_RegionCombo";
+
+ const rules: Array<[BlockField, string[]]> = [
+ ["F_Mobile", ["联系电话", "手机号", "电话", "手机", "tel", "phone", "mobile", "telephone"]],
+ ["F_ZipCode", ["邮政编码", "邮编", "zipcode", "zip", "postcode", "postal"]],
+ [
+ "F_Address",
+ ["收货地址", "详细地址", "派送地址", "街道地址", "街道", "address", "addr", "street"],
+ ],
+ ["F_Contact", ["收件人", "联系人", "consignee", "receiver", "contact", "attn"]],
+ ["_CompanyName", ["公司名称", "公司名", "company"]],
+ [
+ "F_FBACode",
+ ["仓库代码", "仓库编码", "仓库id", "目的代码", "warehouse", "fbacode", "mark"],
+ ],
+ ["_RegionCombo", ["州/城市", "state/city", "州省市区"]],
+ ["F_City", ["城市", "city"]],
+ ["F_State", ["州", "state", "省州"]],
+ ["F_Remark", ["行备注", "备注", "remark", "note", "客户拆柜备注", "客户预约备注"]],
+ ["F_Email", ["邮箱", "email", "e-mail"]],
+ ];
+
+ for (const [field, kws] of rules) {
+ for (const kw of kws) {
+ const nk = /[\u4e00-\u9fff]/.test(kw)
+ ? normHeader(kw)
+ : kw.toLowerCase().replace(/\s+/g, "");
+ if (!nk) continue;
+ if (nk.includes(key) || key.includes(nk) || low.includes(nk)) {
+ if (field === "F_State" && key.length > 6 && !lab.includes("州") && !low.includes("state")) {
+ continue;
+ }
+ if (
+ field === "F_Address" &&
+ (lab.includes("仓库") || low.includes("warehouse")) &&
+ !lab.includes("收货")
+ ) {
+ continue;
+ }
+ return field;
+ }
}
}
+ if (key === "地址" || low === "addr" || low === "address") return "F_Address";
+ if (key === "电话" || low === "tel" || low === "phone") return "F_Mobile";
return null;
}
@@ -93,6 +208,34 @@ function placeholderEmpty(t: string): boolean {
return !t || t === "/" || t === "/" || t === "-" || t === "—";
}
+function cleanBlockFieldValue(field: BlockField, val: string): string {
+ const text = (val || "").trim();
+ if (!text) return "";
+ if (field === "F_Mobile") {
+ const base = text.replace(/[((][^))]*[))]/g, " ");
+ const digits = base.replace(/\D/g, "");
+ return digits.length >= 7 ? digits : base.replace(/\s+/g, " ").trim();
+ }
+ if (field === "F_ZipCode") {
+ const base = text.replace(/[((][^))]*[))]/g, " ");
+ const us = base.match(/\b(\d{5}(?:-\d{4})?)\b/);
+ if (us) return us[1];
+ const ca = base.match(/\b([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)\b/);
+ if (ca) return ca[0].replace(/\s+/g, "").toUpperCase();
+ return base.split(/\s+(?:ext\.?|extension)\b/i)[0]?.trim() || base.trim();
+ }
+ if (
+ field === "F_Address" ||
+ field === "F_Contact" ||
+ field === "F_Email" ||
+ field === "F_Remark" ||
+ field === "_CompanyName"
+ ) {
+ return text.replace(/[ \t]+\n/g, "\n").trim();
+ }
+ return text.replace(/\s+/g, " ").trim();
+}
+
/** 「NV / HENDERSON /」或「NV/Henderson」→ 州 + 城市 */
export function parseRegionComposite(raw: unknown): {
state: string | null;
@@ -124,7 +267,7 @@ function normalizeCellText(text: string): string {
function labelHitCount(src: string): number {
let n = 0;
- const re = new RegExp(LABEL_RE.source, "gi");
+ const re = new RegExp(LABEL_SPLIT_RE.source, "gi");
while (re.exec(src)) n += 1;
return n;
}
@@ -133,19 +276,38 @@ export function looksLikeLabeledBlock(text: string): boolean {
return labelHitCount(normalizeCellText(text)) >= 1;
}
-function emptyLabeled(): LabeledShipmentFields {
- return {};
+function assignBlockField(
+ out: LabeledShipmentFields,
+ field: BlockField,
+ value: string,
+): void {
+ const cleaned = cleanBlockFieldValue(field, value);
+ if (placeholderEmpty(cleaned)) return;
+ if (field === "_RegionCombo") {
+ const region = parseRegionComposite(cleaned);
+ if (region.state && !out.F_State) out.F_State = region.state;
+ if (region.city && !out.F_City) out.F_City = region.city;
+ return;
+ }
+ if (field === "_CompanyName") {
+ if (!out.F_Company) out.F_Company = cleaned;
+ if (!out.F_EnglishName) out.F_EnglishName = cleaned;
+ return;
+ }
+ const key = field as LabeledField;
+ if (!out[key]) out[key] = cleaned;
}
/**
- * 任意单元格:「标签:值」可多项。一项归本列,其余归对应货件字段。
+ * 任意单元格:「标签:值」可多项,顺序/个数/文案可不标准。
+ * 一项归本列,其余归对应货件字段。
*/
export function parseLabeledCell(text: string): LabeledShipmentFields | null {
const src = normalizeCellText(text);
if (!src || labelHitCount(src) < 1) return null;
- const hits: Array<{ field: LabeledField; start: number; valueStart: number }> = [];
- const re = new RegExp(LABEL_RE.source, "gi");
+ const hits: Array<{ field: BlockField; start: number; valueStart: number }> = [];
+ const re = new RegExp(LABEL_SPLIT_RE.source, "gi");
let m: RegExpExecArray | null;
while ((m = re.exec(src))) {
const field = fieldForLabel(m[1]);
@@ -154,19 +316,20 @@ export function parseLabeledCell(text: string): LabeledShipmentFields | null {
}
if (!hits.length) return null;
- const out = emptyLabeled();
+ if (hits.length === 1) {
+ const hasStrongSep =
+ /[::]/.test(src) ||
+ src.includes("\n") ||
+ /\s[-–—→=>·・]\s/.test(src);
+ if (!hasStrongSep) return null;
+ }
+
+ const out: LabeledShipmentFields = {};
for (let i = 0; i < hits.length; i++) {
const cur = hits[i];
const end = i + 1 < hits.length ? hits[i + 1].start : src.length;
const value = src.slice(cur.valueStart, end).replace(/^[\s;;]+|[\s;;]+$/g, "");
- if (placeholderEmpty(value)) continue;
- if (cur.field === "F_State" && /[//]/.test(value)) {
- const region = parseRegionComposite(value);
- if (region.state && !out.F_State) out.F_State = region.state;
- if (region.city && !out.F_City) out.F_City = region.city;
- continue;
- }
- if (!out[cur.field]) out[cur.field] = value;
+ assignBlockField(out, cur.field, value);
}
return Object.keys(out).length ? out : null;
}
@@ -188,12 +351,112 @@ export function mergeLabeledHarvest(
block: LabeledShipmentFields,
homeField?: string,
): void {
- for (const [key, value] of Object.entries(block) as Array<[LabeledField, string | undefined]>) {
+ for (const [key, value] of Object.entries(block) as Array<
+ [LabeledField, string | undefined]
+ >) {
if (!value) continue;
if (!target[key] || key === homeField) target[key] = value;
}
}
+const SHIPMENT_LABELED_SCAN_KEYS = [
+ "F_FBACode",
+ "F_Transporter",
+ "F_CTNS",
+ "F_CBM",
+ "F_Weight",
+ "F_Pcs",
+ "F_FBAID",
+ "F_PO",
+ "F_ReferenceId",
+ "F_ShipmentID",
+ "F_Address",
+ "F_City",
+ "F_State",
+ "F_ZipCode",
+ "F_Contact",
+ "F_Mobile",
+ "F_Email",
+ "F_Company",
+ "F_EnglishName",
+ "F_Remark",
+ "F_SKU",
+ "F_Description",
+ "F_ContainerNo",
+ "F_Expected_DeliveryDateB",
+ "F_Expected_DeliveryDateE",
+ "channel_raw",
+] as const;
+
+function strEmpty(v: unknown): boolean {
+ const t = String(v ?? "").trim();
+ return !t || t === "/" || t === "—";
+}
+
+/**
+ * 对齐 AI 助手 apply_labeled_blocks_to_row:
+ * 任意字段若含「标签:值」多段文本,拆到对应货件字段;本列只保留归属值。
+ */
+export function applyLabeledBlocksToShipmentRow<
+ T extends Record,
+>(row: T): T {
+ const out: Record = { ...row };
+ let changed = false;
+
+ for (const hostField of SHIPMENT_LABELED_SCAN_KEYS) {
+ const val = out[hostField];
+ if (typeof val !== "string" || !looksLikeLabeledBlock(val)) continue;
+ const block = parseLabeledCell(val);
+ if (!block || !Object.keys(block).length) continue;
+
+ changed = true;
+ for (const [key, piece] of Object.entries(block) as Array<
+ [LabeledField, string | undefined]
+ >) {
+ if (!piece) continue;
+ if (key === hostField) {
+ out[hostField] = piece;
+ continue;
+ }
+ if (strEmpty(out[key])) out[key] = piece;
+ }
+ if (hostField === "F_Address") {
+ if (block.F_Address) out.F_Address = block.F_Address;
+ else if (
+ typeof out.F_Address === "string" &&
+ looksLikeLabeledBlock(out.F_Address)
+ ) {
+ const home = columnValueFromCell("F_Address", out.F_Address);
+ out.F_Address = home || out.F_Address;
+ }
+ } else if (!block[hostField as LabeledField]) {
+ const home = columnValueFromCell(hostField, val);
+ if (home) out[hostField] = home;
+ }
+ }
+
+ // Address 列仍含标签时强制再拆(防换行/粘连漏拆)
+ const addr = out.F_Address;
+ if (typeof addr === "string" && looksLikeLabeledBlock(addr)) {
+ const block = parseLabeledCell(addr);
+ if (block && Object.keys(block).length) {
+ changed = true;
+ for (const [key, piece] of Object.entries(block) as Array<
+ [LabeledField, string | undefined]
+ >) {
+ if (!piece) continue;
+ if (key === "F_Address") out.F_Address = piece;
+ else if (strEmpty(out[key])) out[key] = piece;
+ }
+ if (!block.F_Address && typeof out.F_Address === "string") {
+ out.F_Address = columnValueFromCell("F_Address", addr) || out.F_Address;
+ }
+ }
+ }
+
+ return (changed ? out : row) as T;
+}
+
export function firstFilled(...vals: Array): string | null {
for (const v of vals) {
const t = (v || "").trim();
diff --git a/src/services/parse/bl-plus-template.ts b/src/services/parse/bl-plus-template.ts
index d393bb4..4d24a4b 100644
--- a/src/services/parse/bl-plus-template.ts
+++ b/src/services/parse/bl-plus-template.ts
@@ -7,6 +7,10 @@ import {
isKnownUsDestinationPort,
matchUsDestinationPort,
} from "@/services/parse/us-ports";
+import {
+ applyServiceFromModules,
+ normalizeCabinetType,
+} from "@/services/parse/header-service-hints";
const ISO_CN = /\b([A-Z]{4}\d{7})\b/;
const ISO_CN_EXACT = /^[A-Z]{4}\d{7}$/;
@@ -840,14 +844,20 @@ export function mapBlModulesToHeader(
opts?: { enableIsoCheck?: boolean },
): ContainerHeader {
const enableIso = opts?.enableIsoCheck ?? true;
- const opFromService =
- modules.ops_hints?.includes("直送")
- ? (2 as const)
- : modules.ops_hints?.includes("拆柜") ||
- modules.ops_hints?.includes("提柜") ||
- modules.ops_hints?.includes("派送")
- ? (0 as const)
- : undefined;
+ const fromHints = applyServiceFromModules({
+ ops_hints: modules.ops_hints,
+ service: modules.service,
+ });
+ const classis =
+ header.F_Classis ??
+ (modules.classis !== undefined ? modules.classis : undefined) ??
+ fromHints.F_Classis;
+ const opType = header.F_OperationType ?? fromHints.F_OperationType;
+ const instruction =
+ header.F_Instruction ||
+ (modules.vessel_voyage
+ ? `船名航次 ${modules.vessel_voyage}`
+ : fromHints.F_Instruction);
return {
...header,
F_ContainerNo: header.F_ContainerNo || modules.container_no || "",
@@ -856,7 +866,9 @@ export function mapBlModulesToHeader(
(modules.container_no
? isValidContainerNo(modules.container_no, enableIso)
: header.container_no_valid),
- F_CabinetType: header.F_CabinetType || modules.cabinet_type,
+ F_CabinetType: normalizeCabinetType(
+ header.F_CabinetType || modules.cabinet_type || "",
+ ),
F_ETA: header.F_ETA || modules.eta || null,
F_ETD: header.F_ETD || modules.etd || null,
F_BLCopyCode: header.F_BLCopyCode || modules.bl_no || "",
@@ -864,20 +876,14 @@ export function mapBlModulesToHeader(
normalizeDestinationPort(header.F_Dock) ||
normalizeDestinationPort(modules.port) ||
"",
- F_Classis:
- header.F_Classis ??
- (modules.classis !== undefined ? modules.classis : undefined),
- F_OperationType: header.F_OperationType ?? opFromService,
+ F_Classis: classis as 0 | 1 | undefined,
+ F_OperationType: opType,
F_TransMode:
header.F_TransMode ??
(modules.container_no || modules.bl_no || modules.cabinet_type
? 0
: undefined),
- F_Instruction:
- header.F_Instruction ||
- (modules.vessel_voyage
- ? `船名航次 ${modules.vessel_voyage}`
- : undefined),
+ F_Instruction: instruction,
F_MemoRemark:
header.F_MemoRemark ||
(modules.memo_bits?.length ? modules.memo_bits.join(" / ") : undefined),
diff --git a/src/services/parse/classify.ts b/src/services/parse/classify.ts
index f1c3d6e..f6a1d21 100644
--- a/src/services/parse/classify.ts
+++ b/src/services/parse/classify.ts
@@ -132,6 +132,36 @@ export function classifyMail(input: {
scores.NEW_CONTAINER += 50;
}
+ // 主题仅「预报」+ 表格附件(信息全在 ClientContainerTemplate / 测试AI建单 等)
+ const subjectForecastOnly = /^预报$/.test(subject.trim());
+ const hasSpreadsheet = filenames.some((f) => SPREADSHEET_RE.test(f));
+ if (subjectForecastOnly && hasSpreadsheet) {
+ signals.push({
+ signal: "主题预报+表格",
+ score: 45,
+ matched: "预报",
+ source: "subject",
+ });
+ scores.NEW_CONTAINER += 45;
+ }
+
+ const aiBuildSheet = filenames.some((f) =>
+ /测试AI建单|ClientContainer|AI建单|集装箱导入/i.test(f),
+ );
+ if (aiBuildSheet) {
+ const add = scores.NEW_CONTAINER < 40 ? 45 : 15;
+ signals.push({
+ signal: "整表建单附件",
+ score: add,
+ matched:
+ filenames.find((f) =>
+ /测试AI建单|ClientContainer|AI建单|集装箱导入/i.test(f),
+ ) || "xlsx",
+ source: "attachment",
+ });
+ scores.NEW_CONTAINER += add;
+ }
+
// 金样:数据模版 / 明确预报清单附件 → NEW_CONTAINER(不把单纯「卡派资料」当预报)
const forecastPacking = filenames.some(
(f) =>
diff --git a/src/services/parse/container-header-from-sheet.ts b/src/services/parse/container-header-from-sheet.ts
new file mode 100644
index 0000000..998f130
--- /dev/null
+++ b/src/services/parse/container-header-from-sheet.ts
@@ -0,0 +1,165 @@
+import { normalizeCabinetType } from "./header-service-hints";
+import type { ContainerHeader, OperationType, TransMode } from "@/types/mail";
+import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346";
+import { parseFlexibleDate, toShanghaiDateString } from "@/utils/dates";
+
+/** 官方 ClientContainerTemplate 柜头列(与货件列同表头行左侧);仅精确匹配 */
+export const CONTAINER_HEADER_ALIASES: Record = {
+ F_ContainerNo: ["柜号", "集装箱号", "ContainerNo", "Container No", "SORTBY", "SortBy"],
+ F_CabinetType: ["柜型", "CabinetType", "Cabinet Type"],
+ F_TransModeText: ["运输方式"],
+ F_OperationTypeText: ["业务类型", "操作类型"],
+ F_BLCopyCode: ["提单号", "提单", "BL", "B/L"],
+ F_ETD: ["ETD"],
+ F_ETA: ["ETA"],
+ F_LoadPort: ["起始港", "起运港", "装货港"],
+ F_Dock: ["目的港", "卸货港"],
+ shipping_line_hint: ["船司", "船公司"],
+ F_Instruction: ["instruction", "Instruction"],
+ F_MemoRemark: ["备注"],
+};
+
+function normHeader(h: string): string {
+ return (h || "").replace(/\s+/g, "").toLowerCase();
+}
+
+function cleanCellText(raw: unknown): string {
+ const t = String(raw ?? "").trim();
+ if (!t || t === "/" || t === "/" || t === "-" || t === "—") return "";
+ return t;
+}
+
+function fmtSheetCell(raw: unknown): string {
+ if (raw == null || raw === "") return "";
+ if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
+ return toShanghaiDateString(raw) || "";
+ }
+ const text = cleanCellText(raw);
+ const m = text.match(/^(\d{4}-\d{1,2}-\d{1,2})\b/);
+ if (m) return m[1];
+ const parsed = parseFlexibleDate(text);
+ return parsed ? toShanghaiDateString(parsed) || text : text;
+}
+
+export function matchContainerHeaderColumn(header: string): string | null {
+ const n = normHeader(header);
+ if (!n) return null;
+ for (const [field, aliases] of Object.entries(CONTAINER_HEADER_ALIASES)) {
+ if (aliases.some((a) => normHeader(a) === n)) return field;
+ }
+ return null;
+}
+
+export function mapTransModeText(raw: string): TransMode | null {
+ const t = cleanCellText(raw);
+ if (!t) return null;
+ if (/^全部|运输方式)|运输方式\)|集装箱状态/.test(t)) return null;
+ if (/空运|\bAIR\b/i.test(t)) return 1;
+ if (/自送/.test(t)) return 3;
+ if (/海运|\bSEA\b|\bOCEAN\b/i.test(t)) return 0;
+ return null;
+}
+
+export function mapOperationTypeText(raw: string): OperationType | null {
+ const t = cleanCellText(raw);
+ if (!t) return null;
+ if (/^全部/.test(t)) return null;
+ if (/只清关|仅清关/.test(t)) return 4;
+ if (/直送/.test(t) && !/拆柜|提拆派/.test(t)) return 2;
+ if (/拆柜|提拆派|派拆/.test(t)) return 0;
+ return null;
+}
+
+export function inferTransModeFromCabinet(meta: Record): void {
+ if (meta.F_TransMode != null) return;
+ const cab = String(meta.F_CabinetType || "");
+ if (
+ /(?:20|40|45)\s*(?:HQ|HC|GP)|40HC|40HQ|45HC|45HQ|20GP|53HQ/i.test(cab)
+ ) {
+ meta.F_TransMode = 0;
+ }
+}
+
+/** 从 ClientContainerTemplate 类表格首行数据提取柜头 */
+export function containerHeaderFromSheet(
+ grid: string[][],
+ headerIdx: number,
+ extractedContainerNo: string,
+ opts: { enableIsoCheck?: boolean } = {},
+): ContainerHeader {
+ const meta: Record = {};
+ const hdr = grid[headerIdx] || [];
+ const ccol: Record = {};
+ hdr.forEach((cellText, idx) => {
+ const field = matchContainerHeaderColumn(cellText);
+ if (field) ccol[idx] = field;
+ });
+
+ if (Object.keys(ccol).length) {
+ for (const row of grid.slice(headerIdx + 1)) {
+ if (!row.some((c) => cleanCellText(c))) continue;
+ for (const [idxStr, field] of Object.entries(ccol)) {
+ const idx = Number(idxStr);
+ const raw = row[idx] ?? "";
+ if (field === "F_TransModeText") {
+ const tm = mapTransModeText(String(raw));
+ if (tm != null) meta.F_TransMode = tm;
+ } else if (field === "F_OperationTypeText") {
+ const ot = mapOperationTypeText(String(raw));
+ if (ot != null) {
+ meta.F_OperationType = ot;
+ const tip = cleanCellText(raw);
+ if (tip && !meta.F_Instruction) meta.F_Instruction = tip;
+ }
+ } else if (field === "F_ETD" || field === "F_ETA") {
+ const val = fmtSheetCell(raw);
+ if (val) meta[field] = val;
+ } else if (field === "F_ContainerNo") {
+ const cn = normalizeContainerNo(String(raw));
+ if (cn) meta.F_ContainerNo = cn;
+ } else if (field === "F_CabinetType") {
+ const cab = normalizeCabinetType(String(raw));
+ if (cab) meta.F_CabinetType = cab;
+ } else {
+ const val = cleanCellText(raw);
+ if (val && !meta[field]) meta[field] = val;
+ }
+ }
+ break;
+ }
+ }
+
+ inferTransModeFromCabinet(meta);
+
+ const containerNo = normalizeContainerNo(
+ extractedContainerNo || String(meta.F_ContainerNo || ""),
+ );
+ const out: ContainerHeader = {
+ F_ContainerNo: containerNo,
+ container_no_valid:
+ !!containerNo &&
+ isValidContainerNo(containerNo, opts.enableIsoCheck !== false),
+ };
+
+ for (const key of [
+ "F_ETA",
+ "F_ETD",
+ "F_BLCopyCode",
+ "F_CabinetType",
+ "F_Dock",
+ "F_LoadPort",
+ "shipping_line_hint",
+ "F_OperationType",
+ "F_TransMode",
+ "F_Classis",
+ "F_MemoRemark",
+ "F_Instruction",
+ ] as const) {
+ const val = meta[key];
+ if (val != null && val !== "") {
+ (out as unknown as Record)[key] = val;
+ }
+ }
+
+ return out;
+}
diff --git a/src/services/parse/do-upload-extract.ts b/src/services/parse/do-upload-extract.ts
index 19d0305..91bb4e5 100644
--- a/src/services/parse/do-upload-extract.ts
+++ b/src/services/parse/do-upload-extract.ts
@@ -8,7 +8,7 @@ const ISO_CN = /\b([A-Z]{4}\d{7})\b/i;
export function isDoAttachmentFilename(filename: string): boolean {
const f = filename.trim();
if (!f) return false;
- if (/DO请查收|提货单|delivery\s*order/i.test(f)) return true;
+ if (/DO请查收|提货单|派车单|delivery\s*order/i.test(f)) return true;
// 独立 DO 词:xxx-DO.pdf / DO.pdf / 柜号_DO.pdf
if (/\.pdf$/i.test(f) && /(^|[^A-Za-z])DO([^A-Za-z]|$)/i.test(f)) {
return true;
diff --git a/src/services/parse/excel-cell-text.ts b/src/services/parse/excel-cell-text.ts
index 8aece03..27e7085 100644
--- a/src/services/parse/excel-cell-text.ts
+++ b/src/services/parse/excel-cell-text.ts
@@ -20,3 +20,17 @@ export function flattenExcelCellValue(raw: unknown): string {
if ("result" in o) return flattenExcelCellValue(o.result);
return "";
}
+
+/** 安全读取 ExcelJS 单元格展示文本(合并单元格 master 为空时 cell.text 可能抛错) */
+export function readExcelCellText(cell: {
+ value?: unknown;
+ text?: string;
+}): string {
+ const fromValue = flattenExcelCellValue(cell.value);
+ if (fromValue) return fromValue;
+ try {
+ return cell.text || "";
+ } catch {
+ return "";
+ }
+}
diff --git a/src/services/parse/excel-sheet-grid.ts b/src/services/parse/excel-sheet-grid.ts
new file mode 100644
index 0000000..c0d8fde
--- /dev/null
+++ b/src/services/parse/excel-sheet-grid.ts
@@ -0,0 +1,136 @@
+import type ExcelJS from "exceljs";
+import { readExcelCellText } from "./excel-cell-text";
+
+/** 合并单元格的从属格视为空,只保留左上角 master 的值 */
+export function isMergedSlaveCell(cell: ExcelJS.Cell): boolean {
+ if (!cell.isMerged) return false;
+ const master = cell.master as ExcelJS.Cell | undefined;
+ if (!master) return false;
+ return master.address !== cell.address;
+}
+
+/** 按值读取整张表为二维网格(合并从属格为空,避免重复/崩溃) */
+export function readWorksheetGrid(sheet: ExcelJS.Worksheet): string[][] {
+ const rowCount = sheet.rowCount || 0;
+ const colCount = sheet.columnCount || 0;
+ if (rowCount <= 0 || colCount <= 0) return [];
+
+ const grid: string[][] = [];
+ for (let r = 1; r <= rowCount; r++) {
+ const row: string[] = [];
+ for (let c = 1; c <= colCount; c++) {
+ const cell = sheet.getCell(r, c);
+ if (isMergedSlaveCell(cell)) {
+ row.push("");
+ continue;
+ }
+ row.push(readExcelCellText(cell));
+ }
+ grid.push(row);
+ }
+ return grid;
+}
+
+/**
+ * 在顶部若干行中找「字段名最完整」的表头行(中英双表头时优先中文行)。
+ * 对齐 AI 助手 packing_parse 评分逻辑。
+ */
+export function findPackingHeaderRowIndex(
+ grid: string[][],
+ maxScan = 15,
+): number {
+ let bestIdx = 0;
+ let bestScore = -1;
+ const limit = Math.min(grid.length, maxScan);
+ for (let i = 0; i < limit; i++) {
+ const joined = grid[i].join("|");
+ let score = 0;
+ if (/渠道/.test(joined)) score += 3;
+ if (/件数/.test(joined)) score += 3;
+ if (/仓库/.test(joined)) score += 3;
+ if (/FBACode/i.test(joined)) score += 2;
+ if (/Qty\.\(CTNS\)|QTY\(CTNS\)|\bCTNS\b/i.test(joined)) score += 2;
+ if (/Address/i.test(joined) || /派送地址|收货地址/.test(joined)) score += 2;
+ if (/Move Type|Transporter/i.test(joined)) score += 3;
+ if (/QTY\(CTNS\)|GW\(KGS\)|Vol\(CBM\)/i.test(joined)) score += 2;
+ if (/柜型/.test(joined) && /柜号/.test(joined)) score += 3;
+ if (/仓库ID|派送地址|分货标识/.test(joined)) score += 2;
+ if (/集装箱信息基础|货件详情/.test(joined)) score -= 10;
+ if (score > bestScore && score >= 5) {
+ bestScore = score;
+ bestIdx = i;
+ }
+ }
+ return bestIdx;
+}
+
+/** 顶部合并标题行(如「卡派操作数据表格」) */
+export function isPackingTitleNoiseRow(cells: string[]): boolean {
+ const parts = cells.map((v) => v.trim()).filter(Boolean);
+ if (!parts.length) return false;
+ const unique = new Set(parts);
+ if (unique.size === 1 && /^卡派操作数据表格$/i.test([...unique][0])) return true;
+ if (parts.every((p) => p === parts[0]) && /操作数据表格/.test(parts[0])) return true;
+ return false;
+}
+
+/** 表尾合计/汇总行 */
+export function isPackingSummaryRow(raw: Record): boolean {
+ const blob = Object.values(raw)
+ .map((v) => String(v ?? "").trim())
+ .filter(Boolean)
+ .join(" ");
+ if (/合计|总计|小计|汇总|合计行|grand\s*total|\btotal\b|\bsum\b/i.test(blob)) {
+ return true;
+ }
+ const channel = String(raw.F_Transporter ?? "").trim();
+ const warehouse = String(raw.F_FBACode ?? "").trim();
+ const shipment = String(raw.F_ShipmentID ?? "").trim();
+ const fbaId = String(raw.F_FBAID ?? "").trim();
+ const po = String(raw.F_PO ?? "").trim();
+ if (channel) return false;
+ if (warehouse && !/^(UPS|FEDEX|USPS|DHL)$/i.test(warehouse)) return false;
+ if (shipment && shipment !== "/") return false;
+ if (fbaId || po) return false;
+ const ctns = Number(raw.F_CTNS);
+ const cbm = raw.F_CBM;
+ const weight = raw.F_Weight;
+ const hasMetric =
+ (Number.isFinite(ctns) && ctns > 0) ||
+ (cbm != null && cbm !== "") ||
+ (weight != null && weight !== "");
+ return hasMetric;
+}
+
+/** 表尾合并备注 / 填写说明行 */
+export function isPackingFooterNoiseRow(
+ raw: Record,
+ cells: string[],
+): boolean {
+ const parts = cells.map((v) => v.trim()).filter(Boolean);
+ const blob = parts.join(" ");
+ if (!blob) return false;
+ if (/^注意\s*[::]/.test(blob)) return true;
+ if (parts.some((p) => /^注意\s*[::]/.test(p))) return true;
+ if (/必须填写|单元格格式|请勿修改表头|填写规范/.test(blob) && blob.length > 40) {
+ return true;
+ }
+ const nonEmpty = parts.filter(Boolean);
+ if (
+ nonEmpty.length >= 3 &&
+ new Set(nonEmpty).size === 1 &&
+ /^注意/.test(nonEmpty[0])
+ ) {
+ return true;
+ }
+ const channel = String(raw.F_Transporter ?? "").trim();
+ const fba = String(raw.F_FBACode ?? "").trim();
+ const addr = String(raw.F_Address ?? "").trim();
+ if (
+ (channel.length > 40 || fba.length > 40 || addr.length > 80) &&
+ /注意|填写|单元格|必须填写/.test(blob)
+ ) {
+ return true;
+ }
+ return false;
+}
diff --git a/src/services/parse/extract-container-header.ts b/src/services/parse/extract-container-header.ts
index 4a52fad..ce24b17 100644
--- a/src/services/parse/extract-container-header.ts
+++ b/src/services/parse/extract-container-header.ts
@@ -6,12 +6,20 @@ import {
isSeaBlPrefix,
} from "@/services/parse/carrier-aliases";
import { normalizeDestinationPort } from "@/services/parse/bl-plus-template";
-import { matchUsDestinationPort } from "@/services/parse/us-ports";
+import {
+ applyServiceFromText,
+ normalizeCabinetType,
+ stripOrChoiceNoise,
+} from "@/services/parse/header-service-hints";
+import {
+ matchUsDestinationPort,
+ scanUsDestinationPortInText,
+} from "@/services/parse/us-ports";
const CABINET_RE = /\b(45HC|45HQ|40HC|40HQ|40GP|20GP|20HQ|45GP)\b/i;
const LOOSE_DATE = String.raw`(?:\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2})`;
const ETA_RE = new RegExp(
- String.raw`(?:ETA|预计抵港|抵港日|到港)\s*[::\s]*(${LOOSE_DATE})`,
+ String.raw`(?:ETA|预计抵港|预计到达(?:时间)?|抵港日|到港(?:日|时间)?)\s*[::\s]*(${LOOSE_DATE})`,
"i",
);
const ETD_RE = new RegExp(
@@ -20,16 +28,14 @@ const ETD_RE = new RegExp(
);
const BL_RE =
/(?:提单(?:号)?|B\/?L(?:\s*(?:No\.?|号))?|BLCopy)\s*[::#\s]*([A-Z0-9][A-Z0-9\-_/]{3,31})/i;
-/** 无标签提单号(常见船司前缀+数字,如 WHLC027G597465) */
-const BARE_BL_RE = /\b([A-Z]{4}[A-Z0-9]{6,28})\b/;
+/** 无标签提单号(WHLC… / WHL063… / EGLV…);须含数字且非 ISO 柜号 */
+const BARE_BL_RE = /\b([A-Z]{2,4}[A-Z0-9]{6,28})\b/;
const LOAD_PORT_RE = /(?:装货港|起运港|POL)\s*[::\s]*([^\n\r,,;;]{1,40})/i;
const DOCK_RE =
/(?:目的港|卸货港|码头|卸货码头|POD(?:\s*码头)?)\s*[::\s]*([^\n\r,,;;+]{1,40})/i;
const SHIP_LINE_RE =
/(?:船司|船公司|Shipping\s*Line|Carrier)\s*[::\s]*([^\n\r,,;;]{1,60})/i;
-const VESSEL_RE = /船名航次\s*([^\n\r+]{2,60})/i;
-const CLASSIS_RE = /(?:带车架|带托架)/;
-const NO_CLASSIS_RE = /(?:不带车架|不带托架|无车架)/;
+const VESSEL_RE = /船名航次\s*[::]?\s*([^\n\r+]{2,80})/i;
const SEAL_NO_RE =
/(?:封号|铅封(?:号)?|封条号?)\s*[::]\s*([A-Z0-9][A-Z0-9\-_/]{3,31})/gi;
const ISO_CN_RE = /^[A-Z]{4}\d{7}$/;
@@ -92,51 +98,54 @@ function findShippingLineAliasInText(text: string): string | null {
/**
* 从主题+正文抽取柜头字段(卡派表通常无这些列)。
- * 不覆盖已有非空字段(由 mergeContainerHeader 控制)。
+ * 对齐 AI 助手 header_extract._extract_labeled + _apply_service + _infer_trans_mode。
*/
export function extractContainerHeaderFromText(
text: string,
): MailHeaderExtract {
+ const cleaned = stripOrChoiceNoise(text || "");
const out: MailHeaderExtract = {};
- if (!text.trim()) return out;
+ if (!cleaned.trim()) return out;
- const cab = text.match(CABINET_RE);
- if (cab) out.F_CabinetType = cab[1].toUpperCase();
+ const cab = cleaned.match(CABINET_RE);
+ if (cab) out.F_CabinetType = normalizeCabinetType(cab[1]);
- const eta = text.match(ETA_RE);
+ const eta = cleaned.match(ETA_RE);
const etaDate = normDate(eta?.[1]);
if (etaDate) out.F_ETA = etaDate;
- const etd = text.match(ETD_RE);
+ const etd = cleaned.match(ETD_RE);
const etdDate = normDate(etd?.[1]);
if (etdDate) out.F_ETD = etdDate;
- const bl = text.match(BL_RE);
+ const bl = cleaned.match(BL_RE);
if (bl?.[1]) {
out.F_BLCopyCode = bl[1].trim().toUpperCase();
} else {
- // 跳过 ISO 柜号,取首个像提单的裸串
- for (const m of text.matchAll(new RegExp(BARE_BL_RE.source, "g"))) {
+ for (const m of cleaned.matchAll(new RegExp(BARE_BL_RE.source, "gi"))) {
const cand = m[1].toUpperCase();
if (ISO_CN_RE.test(cand)) continue;
if (CABINET_RE.test(cand)) continue;
+ if (!/\d/.test(cand)) continue;
out.F_BLCopyCode = cand;
break;
}
}
- const port = text.match(LOAD_PORT_RE);
+ const port = cleaned.match(LOAD_PORT_RE);
if (port?.[1]) out.F_LoadPort = port[1].trim();
- const dock = text.match(DOCK_RE);
+ const dock = cleaned.match(DOCK_RE);
const dockMatched = dock?.[1] ? matchUsDestinationPort(dock[1]) : null;
- // 仅「目的港/卸货港/POD」显式标签 + 白名单;不扫全文乱截
if (dockMatched) {
out.F_Dock = dockMatched;
+ } else {
+ const scanned = scanUsDestinationPortInText(cleaned);
+ if (scanned) out.F_Dock = scanned;
}
- // 船司线索优先级:显式「船司」> 提单前缀 > 正文已知船司简称 > 船名首词
- const line = text.match(SHIP_LINE_RE);
+ // 船司:显式「船司」> 提单前缀 > 正文别名;禁止船名航次首词(WAN HAI→WAN)
+ const line = cleaned.match(SHIP_LINE_RE);
if (line?.[1]) {
out.shipping_line_hint = line[1].trim();
} else {
@@ -144,53 +153,72 @@ export function extractContainerHeaderFromText(
if (blHint) {
out.shipping_line_hint = blHint;
} else {
- const aliasHint = findShippingLineAliasInText(text);
- if (aliasHint) {
- out.shipping_line_hint = aliasHint;
- } else {
- const vessel = text.match(VESSEL_RE);
- if (vessel?.[1]) {
- out.shipping_line_hint = vessel[1].trim().split(/\s+/)[0];
- }
- }
+ const aliasHint = findShippingLineAliasInText(cleaned);
+ if (aliasHint) out.shipping_line_hint = aliasHint;
+ }
+ }
+ if (!out.shipping_line_hint) {
+ const vessel = cleaned.match(VESSEL_RE);
+ if (vessel?.[1]) {
+ const fromVessel = findShippingLineAliasInText(vessel[1]);
+ if (fromVessel) out.shipping_line_hint = fromVessel;
}
}
- if (NO_CLASSIS_RE.test(text)) out.F_Classis = 0;
- else if (CLASSIS_RE.test(text)) out.F_Classis = 1;
+ const service = applyServiceFromText(cleaned);
+ if (service.F_Classis === 0 || service.F_Classis === 1) {
+ out.F_Classis = service.F_Classis;
+ }
+ if (
+ service.F_OperationType === 0 ||
+ service.F_OperationType === 2 ||
+ service.F_OperationType === 4
+ ) {
+ out.F_OperationType = service.F_OperationType;
+ }
+ const vesselInst = cleaned.match(VESSEL_RE);
+ if (vesselInst?.[1]) {
+ out.F_Instruction = `船名航次 ${vesselInst[1].trim().replace(/\s+/g, " ")}`;
+ } else if (service.F_Instruction) {
+ out.F_Instruction = service.F_Instruction;
+ }
const sealBits: string[] = [];
- for (const m of text.matchAll(SEAL_NO_RE)) {
- const label = (m[0].match(/^(封号|铅封(?:号)?|封条号?)/i)?.[1] || "封号").trim();
+ for (const m of cleaned.matchAll(SEAL_NO_RE)) {
+ const label =
+ (m[0].match(/^(封号|铅封(?:号)?|封条号?)/i)?.[1] || "封号").trim();
const value = (m[1] || "").trim().toUpperCase();
if (value) sealBits.push(`${label}:${value}`);
}
+ if (service.F_Classis === 0) sealBits.push("不带车架");
+ else if (service.F_Classis === 1) sealBits.push("带车架");
if (sealBits.length) {
out.F_MemoRemark = [...new Set(sealBits)].join(" / ");
}
- // 提拆派优先于直送(二者可同时出现在说明文案中)
- if (/提拆派/.test(text)) {
+ if (out.F_OperationType === undefined && /拆柜清单|拆柜表/.test(cleaned)) {
out.F_OperationType = 0 as OperationType;
- } else if (/直送/.test(text)) {
- out.F_OperationType = 2 as OperationType;
+ out.F_Instruction = out.F_Instruction || "拆柜清单";
}
- const hasAirHint = containsAirlineHint(text);
+ const hasAirHint =
+ containsAirlineHint(cleaned) && !/海运|柜型|船名|航次/.test(cleaned);
const blPrefix = shippingLineHintFromBl(out.F_BLCopyCode);
- const seaFromBlPrefix = isSeaBlPrefix(blPrefix) || isSeaBlPrefix(out.shipping_line_hint);
+ const seaFromBlPrefix =
+ isSeaBlPrefix(blPrefix) || isSeaBlPrefix(out.shipping_line_hint);
+ const seaFromText =
+ /海运|船名航次/.test(cleaned) ||
+ Boolean(findShippingLineAliasInText(cleaned));
- // 有柜型/船名航次/已知海运提单前缀(EGLV/COSU…)→ 海运
- // 仅裸号时不要压过已识别出的空运航司线索
if (
out.F_CabinetType ||
- VESSEL_RE.test(text) ||
- /船名航次/.test(text) ||
+ VESSEL_RE.test(cleaned) ||
seaFromBlPrefix ||
+ seaFromText ||
(out.F_BLCopyCode && !hasAirHint)
) {
out.F_TransMode = 0 as TransMode;
- } else if (hasAirHint) {
+ } else if (hasAirHint || /空运|\bAIR\b/i.test(cleaned)) {
out.F_TransMode = 1 as TransMode;
}
@@ -213,7 +241,9 @@ export function mergeContainerHeader(
return {
...base,
- F_CabinetType: pick("F_CabinetType", fromMail.F_CabinetType),
+ F_CabinetType: normalizeCabinetType(
+ pick("F_CabinetType", fromMail.F_CabinetType) || "",
+ ),
F_ETA: pick("F_ETA", fromMail.F_ETA ?? null) ?? null,
F_ETD: pick("F_ETD", fromMail.F_ETD ?? null) ?? null,
F_BLCopyCode: pick("F_BLCopyCode", fromMail.F_BLCopyCode) ?? "",
@@ -257,13 +287,17 @@ function normalizeShippingLineHints(hint: string | undefined | null): string[] {
const raw = (hint || "").trim();
if (!raw) return [];
const upper = raw.toUpperCase();
- const out = new Set([raw, raw.toLowerCase(), upper, upper.toLowerCase()]);
+ const out = new Set([
+ raw,
+ raw.toLowerCase(),
+ upper,
+ upper.toLowerCase(),
+ ]);
const seed = [upper, upper.slice(0, 4)].filter((x) => x.length >= 2);
for (const key of seed) {
for (const alias of SHIPPING_LINE_ALIASES[key] || []) {
out.add(alias);
out.add(alias.toLowerCase());
- // 二级展开:EGLV → EMC → 长荣海运
const aliasKey = alias.toUpperCase();
for (const nested of SHIPPING_LINE_ALIASES[aliasKey] || []) {
out.add(nested);
@@ -288,7 +322,6 @@ export function matchShippingLineId(
const bl = (options?.blNo || "").trim().toUpperCase();
if ((!q && !bl) || lines.length === 0) return null;
- // 1) F_Prefix:BL 以 prefix 开头,或 hint 等于 prefix
const byPrefix = lines
.map((l) => {
const tokens = prefixTokens(l.F_Prefix);
@@ -307,29 +340,24 @@ export function matchShippingLineId(
if (!q) return null;
- const exact = lines.find(
- (l) =>
- hints.some(
- (h) =>
- (l.F_FullName || "").toLowerCase() === h.toLowerCase() ||
- (l.F_EnCode || "").toLowerCase() === h.toLowerCase(),
- ),
+ const exact = lines.find((l) =>
+ hints.some(
+ (h) =>
+ (l.F_FullName || "").toLowerCase() === h.toLowerCase() ||
+ (l.F_EnCode || "").toLowerCase() === h.toLowerCase(),
+ ),
);
if (exact) return exact.F_Id;
- // 2) 代码:WHLC 精确或前缀;较长 code 优先
const byCode = lines
.filter((l) => {
const code = (l.F_EnCode || "").toLowerCase();
if (!code) return false;
return q === code || q.startsWith(code) || code.startsWith(q);
})
- .sort(
- (a, b) => (b.F_EnCode || "").length - (a.F_EnCode || "").length,
- );
+ .sort((a, b) => (b.F_EnCode || "").length - (a.F_EnCode || "").length);
if (byCode[0]) return byCode[0].F_Id;
- // 3) 名称/编码包含
const partial = lines.find((l) => {
const name = (l.F_FullName || "").toLowerCase();
const code = (l.F_EnCode || "").toLowerCase();
diff --git a/src/services/parse/header-service-hints.ts b/src/services/parse/header-service-hints.ts
new file mode 100644
index 0000000..23b66c3
--- /dev/null
+++ b/src/services/parse/header-service-hints.ts
@@ -0,0 +1,95 @@
+/**
+ * 对齐 carriercentral-ai-assistant header_extract._apply_service /
+ * _strip_or_choice_noise / 柜型规范化。
+ * 供 extract-container-header、mapBlModules、预报表单初值共用。
+ */
+import type { OperationType } from "@/types/mail";
+
+/** 技能卡/话术里的「A或B」未决选项,不得当成已选值 */
+const OR_CHOICE_NOISE_RE =
+ /(?:运输方式|操作类型|业务类型)?\s*[((]?\s*(?:海运\s*[或/、]\s*空运|空运\s*[或/、]\s*海运|提拆派\s*[或/、]\s*直送|直送\s*[或/、]\s*提拆派|拆柜\s*[或/、]\s*直送|直送\s*[或/、]\s*拆柜|拆柜\s*[或/、]\s*直送\s*[或/、]\s*只清关)\s*[))]?/g;
+
+const DEVANNING_RE =
+ /提拆派|拆柜清单|拆柜表|派拆柜派送|派拆柜|派拆|组合柜|拖合拖|拆柜派送/;
+
+export function stripOrChoiceNoise(text: string): string {
+ return (text || "").replace(OR_CHOICE_NOISE_RE, " ");
+}
+
+/** 助手惯例:40HC→40HQ(CC 侧柜型统一) */
+export function normalizeCabinetType(raw: string | null | undefined): string {
+ const t = (raw || "").trim().toUpperCase();
+ if (!t) return "";
+ return t.replace(/HC\b/g, "HQ");
+}
+
+export type ServiceHintResult = {
+ F_OperationType?: OperationType;
+ F_Classis?: 0 | 1;
+ /** 业务短指引;有船名航次 Instruction 时勿覆盖 */
+ F_Instruction?: string;
+};
+
+/**
+ * 对照助手 _apply_service:车架 + 操作类型(拆柜/直送/只清关)。
+ * 直送不得压过拆柜类词;拆柜清单 → 0。
+ */
+export function applyServiceFromText(raw: string): ServiceHintResult {
+ const text = stripOrChoiceNoise(raw || "").trim();
+ if (!text) return {};
+
+ const out: ServiceHintResult = {};
+
+ if (/不带车架|不带托架|无车架/.test(text)) out.F_Classis = 0;
+ else if (/带车架|带托架/.test(text)) out.F_Classis = 1;
+
+ if (
+ /直送/.test(text) &&
+ !DEVANNING_RE.test(text) &&
+ !/拆柜/.test(text)
+ ) {
+ out.F_OperationType = 2;
+ out.F_Instruction = "直送";
+ return out;
+ }
+ if (/只清关|仅清关/.test(text)) {
+ out.F_OperationType = 4;
+ out.F_Instruction = "只清关";
+ return out;
+ }
+ if (
+ DEVANNING_RE.test(text) ||
+ /拆柜/.test(text) ||
+ (/提柜/.test(text) && /拆柜|派送/.test(text))
+ ) {
+ out.F_OperationType = 0;
+ out.F_Instruction = /提拆派/.test(text)
+ ? "提拆派"
+ : /拆柜清单/.test(text)
+ ? "拆柜清单"
+ : /拆柜/.test(text)
+ ? "拆柜"
+ : text.slice(0, 40);
+ return out;
+ }
+ return out;
+}
+
+/** 从 ops_hints / service 片段汇总业务类型与车架(空缺才填) */
+export function applyServiceFromModules(input: {
+ ops_hints?: string[] | null;
+ service?: string | null;
+}): ServiceHintResult {
+ const blob = [
+ ...(input.ops_hints || []),
+ input.service || "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ if (!blob.trim()) return {};
+ // 直送 hint 单独优先于泛「派送」
+ if ((input.ops_hints || []).includes("直送") && !/拆柜|提拆派/.test(blob)) {
+ return { F_OperationType: 2, F_Instruction: "直送" };
+ }
+ return applyServiceFromText(blob);
+}
diff --git a/src/services/parse/instruction-execution-state.ts b/src/services/parse/instruction-execution-state.ts
index a6e6908..5384f12 100644
--- a/src/services/parse/instruction-execution-state.ts
+++ b/src/services/parse/instruction-execution-state.ts
@@ -1,5 +1,9 @@
import { extractMailInstructions, type MailInstructionUnit } from "@/services/parse/split-instructions";
import type { MailStatus } from "@/types/mail";
+import type {
+ WorkOrderFieldMarks,
+ WorkOrderSnapshotField,
+} from "@/services/parse/work-order-snapshot";
export type InstructionExecutionStateEntry = {
status: "IMPORTED";
@@ -7,6 +11,14 @@ export type InstructionExecutionStateEntry = {
importedBy?: string;
mode?: string;
externalId?: string | null;
+ /** 最终提交到 CC 的工单表单快照 */
+ submitted?: Record;
+ /** 相对邮件抽取基线被补充或修改的字段名 */
+ supplementedFields?: WorkOrderSnapshotField[];
+ /** 字段级标记:补充 / 已改 */
+ fieldMarks?: WorkOrderFieldMarks;
+ /** 是否含人工补充或修改 */
+ userSupplemented?: boolean;
};
export type InstructionExecutionStateMap = Record<
@@ -42,6 +54,23 @@ export function isInstructionImported(
return getInstructionExecutionState(lineage, unitId)?.status === "IMPORTED";
}
+/** 已写入真实 CC(mock 不算锁定,允许改 live 后重提) */
+export function isInstructionImportedLive(
+ lineage: unknown,
+ unitId: string,
+): boolean {
+ const e = getInstructionExecutionState(lineage, unitId);
+ return e?.status === "IMPORTED" && e.mode !== "mock";
+}
+
+export function isInstructionImportedMock(
+ lineage: unknown,
+ unitId: string,
+): boolean {
+ const e = getInstructionExecutionState(lineage, unitId);
+ return e?.status === "IMPORTED" && e.mode === "mock";
+}
+
export function markInstructionImported(
lineage: unknown,
unitId: string,
@@ -89,10 +118,16 @@ export function resolveMailStatusAfterInstructionImport(input: {
}): MailStatus {
const units = listActionableInstructionUnits(input);
if (!units.length) return input.currentStatus;
- const importedCount = units.filter((u) =>
+ const imported = units.filter((u) =>
isInstructionImported(input.lineage, u.id),
- ).length;
- if (importedCount <= 0) return "PENDING_CONFIRM";
- if (importedCount >= units.length) return "SUCCESS";
+ );
+ if (imported.length <= 0) return "PENDING_CONFIRM";
+ if (imported.length >= units.length) {
+ // 全部只是 mock 成功 → PARTIAL,允许再进 IMPORTING 写真实 CC
+ const allMock = imported.every((u) =>
+ isInstructionImportedMock(input.lineage, u.id),
+ );
+ return allMock ? "PARTIAL_SUCCESS" : "SUCCESS";
+ }
return "PARTIAL_SUCCESS";
}
diff --git a/src/services/parse/instruction-lexicon.ts b/src/services/parse/instruction-lexicon.ts
index 8057c5f..949ca09 100644
--- a/src/services/parse/instruction-lexicon.ts
+++ b/src/services/parse/instruction-lexicon.ts
@@ -91,6 +91,18 @@ export const KEYWORD_RULES: KeywordRule[] = [
re: /新增预报|请查收新增预报/i,
where: ["subject", "body"],
},
+ {
+ uiKind: "forecast",
+ keyword: "主题预报",
+ re: /^预报$/,
+ where: ["subject"],
+ },
+ {
+ uiKind: "forecast",
+ keyword: "整表建单",
+ re: /测试AI建单|ClientContainer|AI建单|集装箱导入/i,
+ where: ["attachment"],
+ },
{
uiKind: "forecast",
keyword: "派送单请查收",
diff --git a/src/services/parse/match-segment-attachments.ts b/src/services/parse/match-segment-attachments.ts
index f36476c..0d5c5ba 100644
--- a/src/services/parse/match-segment-attachments.ts
+++ b/src/services/parse/match-segment-attachments.ts
@@ -55,7 +55,9 @@ export type MatchSegmentAttachmentsInput = {
/**
* 本段应挂的业务附件(工单 F_AttachFile / 出库 F_LabelFile)。
- * 1) 主键交叉 2) 正文点名文件名 3) 当前段角色兜底(卡转海/换标)
+ * 优先按文件名与指令正文的主键对应(WL 唛头 / FBA / YT / FNSKU),
+ * 例如指令里的「WL103276 SBD1」对应附件「WL103276-SBD1(1).pdf」。
+ * 其次正文点名文件名;再才是当前段角色兜底(卡转海.zip / 换标指令)。
* 跳过截图、DO;卡派 xlsx 仅当正文点名才进工单。
*/
export function matchSegmentAttachments(
diff --git a/src/services/parse/packing-list.ts b/src/services/parse/packing-list.ts
index b0740ec..08a2556 100644
--- a/src/services/parse/packing-list.ts
+++ b/src/services/parse/packing-list.ts
@@ -11,10 +11,20 @@ import {
type LabeledShipmentFields,
} from "./address-block";
import { flattenExcelCellValue } from "./excel-cell-text";
+import { containerHeaderFromSheet } from "./container-header-from-sheet";
+import {
+ findPackingHeaderRowIndex,
+ isPackingFooterNoiseRow,
+ isPackingSummaryRow,
+ isPackingTitleNoiseRow,
+ readWorksheetGrid,
+} from "./excel-sheet-grid";
import type { ContainerHeader, ShipmentRow } from "@/types/mail";
-import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346";
+import { normalizeContainerNo } from "@/utils/iso6346";
import { parseFlexibleDate, round4, toShanghaiDateString } from "@/utils/dates";
+const NO_FUZZY_FIELDS = new Set(["F_ContainerNo"]);
+
const HEADER_ALIASES: Record = {
F_FBACode: [
"仓库代码",
@@ -23,13 +33,24 @@ const HEADER_ALIASES: Record = {
"目的代码",
"FBACode",
"FBA Code",
- "SORTBY",
"FBA_CODE",
+ "贴标目的地",
],
- F_Transporter: ["渠道", "Transporter", "运输方式", "Move Type"],
+ // 货件渠道 = Move Type;禁止把柜头「运输方式」(海运/空运) 映射到本字段
+ F_Transporter: ["渠道", "Transporter", "Move Type", "MoveType"],
F_CTNS: ["件数", "CTNS", "箱数", "Qty.(CTNS)", "Qty.(CTN)", "QTY(CTNS)"],
- F_CBM: ["总体积", "体积(m³)", "体积m3", "Val(CBM)", "Vol(CBM)", "CBM"],
- F_Weight: ["毛重", "毛重(kg)", "G.W.(KGS)", "G.W.OKGS)", "Weight"],
+ F_CBM: ["总体积", "体积", "体积(m³)", "体积m3", "Val(CBM)", "Vol(CBM)", "CBM"],
+ F_Weight: [
+ "毛重",
+ "毛重(kg)",
+ "G.W.(KGS)",
+ "G.W.OKGS)",
+ "GW(KGS)",
+ "GW(KG)",
+ "G.W.(KG)",
+ "Weight",
+ "重量",
+ ],
F_Pcs: ["PCS", "件数(PCS)", "Pcs"],
F_FBAID: [
"FBA ID",
@@ -58,13 +79,13 @@ const HEADER_ALIASES: Record = {
"Customer Ref No",
"MarkingNo",
],
- F_Address: ["收货地址", "派送地址", "详细地址", "Address"],
- F_City: ["City", "城市"],
- F_State: ["State", "州省", "州"],
- F_ZipCode: ["邮政编码", "ZipCode", "Zip Code", "邮编", "ZIP"],
- F_Contact: ["收件人", "收货人", "Contact", "联系人"],
- F_Mobile: ["联系电话", "Mobile", "电话", "手机"],
- F_Email: ["Email", "邮箱"],
+ F_Address: ["收货地址", "派送地址", "详细地址", "Address", "客户地址"],
+ F_City: ["City", "城市", "City/城市"],
+ F_State: ["State", "州省", "州", "State/州"],
+ F_ZipCode: ["邮政编码", "ZipCode", "Zip Code", "邮编", "ZIP", "ZipCode/邮编"],
+ F_Contact: ["收件人", "收货人", "Contact", "联系人", "Contact/联系人"],
+ F_Mobile: ["联系电话", "Mobile", "电话", "手机", "手机号", "Mobile/手机"],
+ F_Email: ["Email", "邮箱", "Email/邮箱"],
/** 内部列:拆成 State/City,不直接提交 CC */
F_Region: ["省/市/区", "省市/区", "省市区", "省市"],
F_Company: ["公司名称", "公司名", "Company Name", "Company"],
@@ -87,17 +108,28 @@ const HEADER_ALIASES: Record = {
"Instruction",
"配送时段",
"配送时段(亚马逊仓库必填)",
+ "客户拆柜备注",
+ "客户预约备注",
],
F_SKU: ["SKU"],
- F_Description: ["品名", "Description", "货物描述"],
+ F_Description: ["品名", "Description", "货物描述", "中文名称"],
F_EnglishName: ["英文名称", "English Name", "EnglishName"],
- F_ContainerNo: ["柜号", "集装箱号", "ContainerNo", "Container No"],
+ F_ContainerNo: [
+ "柜号",
+ "集装箱号",
+ "ContainerNo",
+ "Container No",
+ "SORTBY",
+ "SortBy",
+ ],
};
-/** 模板占位符「/」「-」视为未填 */
+/** 模板占位符「/」「-」、CC 列表筛选文案视为未填 */
function cleanCellText(raw: unknown): string {
const t = flattenExcelCellValue(raw).trim();
if (!t || t === "/" || t === "/" || t === "-" || t === "—") return "";
+ if (/^全部[((]/.test(t) || /运输方式[))]/.test(t)) return "";
+ if (/^全部\(集装箱状态\)/.test(t)) return "";
return t;
}
@@ -117,6 +149,7 @@ function matchColumn(header: string): string | null {
// 2) 较长别名的包含匹配(别名长度 ≥ 2,且按长度降序)
const ranked: Array<{ field: string; alias: string }> = [];
for (const [field, aliases] of Object.entries(HEADER_ALIASES)) {
+ if (NO_FUZZY_FIELDS.has(field)) continue;
for (const a of aliases) {
const na = normHeader(a);
if (na.length >= 2) ranked.push({ field, alias: na });
@@ -129,7 +162,31 @@ function matchColumn(header: string): string | null {
return null;
}
-export { parseRegionComposite } from "./address-block";
+const COLUMN_LABEL: Record = {
+ F_FBACode: "仓库代码/FBACode",
+ F_Transporter: "渠道/派送方式",
+ F_CTNS: "件数",
+ sheet: "工作表",
+ header: "表头",
+};
+
+export function formatPackingParseLineageMessage(
+ lineage: Record,
+): string {
+ const missing = lineage.missing_columns;
+ if (Array.isArray(missing) && missing.length) {
+ if (missing.includes("sheet")) {
+ return "Excel 中没有可读取的工作表,请确认文件未损坏";
+ }
+ if (missing.includes("header")) {
+ return "表格行数过少或未找到表头,请使用数据模版或卡派清单标准格式";
+ }
+ const labels = missing.map((m) => COLUMN_LABEL[String(m)] || String(m));
+ return `表格缺少必要列:${labels.join("、")}。请对照数据模版补全表头后重试`;
+ }
+ return "未能从表格解析出货件行,请确认是含「渠道/件数/仓库」表头的卡派清单或数据模版";
+}
+
function parseNumber(raw: unknown): number | null {
if (raw == null || raw === "") return null;
@@ -147,7 +204,12 @@ export interface PackingListParseResult {
}
/** Template footer instruction row — not a shipment */
-export function isInstructionNoiseRow(raw: Record): boolean {
+export function isInstructionNoiseRow(
+ raw: Record,
+ cells?: string[],
+): boolean {
+ if (cells && isPackingTitleNoiseRow(cells)) return true;
+ if (cells && isPackingFooterNoiseRow(raw, cells)) return true;
const parts = Object.values(raw).map((v) => String(v ?? "").trim());
const blob = parts.join(" ");
if (!blob) return false;
@@ -165,6 +227,18 @@ export function isInstructionNoiseRow(raw: Record): boolean {
return false;
}
+function assignRawField(
+ raw: Record,
+ field: string,
+ value: string,
+): void {
+ const next = value.trim();
+ if (!next) return;
+ const cur = raw[field];
+ if (cur != null && String(cur).trim() !== "") return;
+ raw[field] = next;
+}
+
export async function parsePackingListBuffer(
buffer: Buffer,
opts: {
@@ -181,9 +255,16 @@ export async function parsePackingListBuffer(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await wb.xlsx.load(buffer as any);
- let sheet = wb.worksheets.find((s) => /卡派/.test(s.name));
- const sheetFallback = !sheet;
- if (!sheet) sheet = wb.worksheets[0];
+ let sheet = wb.worksheets.find((s) => /集装箱导入/.test(s.name));
+ let sheetFallback = !sheet;
+ if (!sheet) {
+ sheet = wb.worksheets.find((s) => /卡派/.test(s.name));
+ sheetFallback = !sheet;
+ }
+ if (!sheet) {
+ sheet = wb.worksheets[0];
+ sheetFallback = true;
+ }
if (!sheet) {
return {
container_header: {
@@ -196,9 +277,8 @@ export async function parsePackingListBuffer(
};
}
- const rows: ExcelJS.Row[] = [];
- sheet.eachRow((row) => rows.push(row));
- if (rows.length < 2) {
+ const grid = readWorksheetGrid(sheet);
+ if (grid.length < 2) {
return {
container_header: {
F_ContainerNo: opts.hintContainerNo || "",
@@ -210,29 +290,12 @@ export async function parsePackingListBuffer(
};
}
- let headerIdx = 0;
- for (let i = 0; i < Math.min(rows.length, 15); i++) {
- const texts: string[] = [];
- rows[i].eachCell((cell) =>
- texts.push(flattenExcelCellValue(cell.value) || cell.text || ""),
- );
- const joined = texts.join("|");
- if (
- /渠道/.test(joined) &&
- (/件数/.test(joined) || /CTNS/i.test(joined)) &&
- (/仓库/.test(joined) || /FBACode/i.test(joined))
- ) {
- headerIdx = i;
- break;
- }
- }
+ const headerIdx = findPackingHeaderRowIndex(grid);
- const headerRow = rows[headerIdx];
const colMap: Record = {};
- headerRow.eachCell((cell, col) => {
- const field = matchColumn(
- flattenExcelCellValue(cell.value) || cell.text || "",
- );
+ const headerCells = grid[headerIdx] || [];
+ headerCells.forEach((cellText, col) => {
+ const field = matchColumn(cellText);
if (field) colMap[col] = field;
});
@@ -257,7 +320,7 @@ export async function parsePackingListBuffer(
};
}
- const dataRows = rows.slice(headerIdx + 1);
+ const dataRows = grid.slice(headerIdx + 1);
if (dataRows.length > hardLimit) {
return {
container_header: {
@@ -274,23 +337,24 @@ export async function parsePackingListBuffer(
let extractedContainer = opts.hintContainerNo || "";
for (let i = 0; i < dataRows.length; i++) {
- const row = dataRows[i];
+ const rowCells = dataRows[i];
const raw: Record = {};
const harvest: LabeledShipmentFields = {};
- row.eachCell((cell, col) => {
- const text = flattenExcelCellValue(cell.value) || cell.text || "";
+ for (let col = 0; col < rowCells.length; col++) {
+ const text = rowCells[col] ?? "";
const field = colMap[col];
const block = parseLabeledCell(text);
if (block) mergeLabeledHarvest(harvest, block, field);
- if (field) raw[field] = columnValueFromCell(field, text);
- });
+ if (field) assignRawField(raw, field, columnValueFromCell(field, text));
+ }
- // 跳过空行
- const hasAny = Object.values(raw).some(
- (v) => v != null && String(v).trim() !== "",
- );
+ const hasAny =
+ rowCells.some((v) => v.trim() !== "") ||
+ Object.values(raw).some((v) => v != null && String(v).trim() !== "");
if (!hasAny) continue;
- if (isInstructionNoiseRow(raw)) continue;
+ if (isPackingTitleNoiseRow(rowCells)) continue;
+ if (isInstructionNoiseRow(raw, rowCells)) continue;
+ if (isPackingSummaryRow(raw)) continue;
if (raw.F_ContainerNo && !extractedContainer) {
extractedContainer = normalizeContainerNo(String(raw.F_ContainerNo));
@@ -418,22 +482,50 @@ export async function parsePackingListBuffer(
}
const containerNo = normalizeContainerNo(extractedContainer);
- const valid = isValidContainerNo(containerNo, opts.enableIsoCheck !== false);
+ const headerFromSheet = containerHeaderFromSheet(
+ grid,
+ headerIdx,
+ containerNo,
+ { enableIsoCheck: opts.enableIsoCheck },
+ );
+ if (headerFromSheet.F_TransMode == null) {
+ headerFromSheet.F_TransMode = 0;
+ }
+ if (headerFromSheet.F_OperationType == null) {
+ headerFromSheet.F_OperationType = 0;
+ }
+ if (headerFromSheet.F_Classis == null) {
+ headerFromSheet.F_Classis = 0;
+ }
+
+ const headerAttachmentKeys = [
+ "F_CabinetType",
+ "F_TransMode",
+ "F_OperationType",
+ "F_ETA",
+ "F_ETD",
+ "F_BLCopyCode",
+ "F_LoadPort",
+ "F_Dock",
+ "F_Instruction",
+ "F_MemoRemark",
+ "shipping_line_hint",
+ ] as const;
+ const headerFromAttachment = headerAttachmentKeys.some((k) => {
+ const v = headerFromSheet[k as keyof ContainerHeader];
+ return v != null && v !== "";
+ });
return {
- container_header: {
- F_ContainerNo: containerNo,
- container_no_valid: valid && !!containerNo,
- F_TransMode: 0,
- F_OperationType: 0,
- F_Classis: 0,
- },
+ container_header: headerFromSheet,
shipments,
lineage: {
sheet: sheet.name,
sheetFallback,
+ header_row: headerIdx + 1,
row_count: shipments.length,
soft_limit_exceeded: shipments.length > softLimit,
+ header_from_attachment: headerFromAttachment,
},
};
}
diff --git a/src/services/parse/pipeline.ts b/src/services/parse/pipeline.ts
index 8796fd6..ea985e1 100644
--- a/src/services/parse/pipeline.ts
+++ b/src/services/parse/pipeline.ts
@@ -783,17 +783,42 @@ export class ParsePipeline {
});
}
}
+
+ let mailType = evidence.mail_type;
+ let typeEvidence = evidence;
+ if (
+ mailType === "UNKNOWN" &&
+ validCount >= 1 &&
+ (parsed.container_header.F_ContainerNo || "").trim()
+ ) {
+ mailType = "NEW_CONTAINER";
+ typeEvidence = {
+ ...evidence,
+ mail_type: "NEW_CONTAINER",
+ signals: [
+ ...evidence.signals,
+ {
+ signal: "装箱表推断预报",
+ score: 45,
+ matched: String(parsed.lineage.usedFilename || usedFilename),
+ source: "attachment" as const,
+ },
+ ],
+ scores: { ...evidence.scores, NEW_CONTAINER: 45, UNKNOWN: 0 },
+ total: Math.max(evidence.total, 45),
+ };
+ }
+
const status = resolveAfterParse({
- mailType: evidence.mail_type,
+ mailType,
validCount,
autoExecEligible:
- evidence.mail_type !== "WORK_ORDER" &&
- isAutoExecEligible(evidence.mail_type),
+ mailType !== "WORK_ORDER" && isAutoExecEligible(mailType),
});
await this.finish(mailId, {
- mailType: evidence.mail_type,
- evidence,
+ mailType,
+ evidence: typeEvidence,
status,
lastError: validCount === 0 ? "NO_SHIPMENT_ROWS" : null,
parse: this.withMailRecord(
@@ -803,7 +828,7 @@ export class ParsePipeline {
lineage: { ...parsed.lineage, usedFilename },
},
{
- mailType: evidence.mail_type,
+ mailType,
subject,
body,
filenames,
diff --git a/src/services/parse/resolve-confirm-instruction.ts b/src/services/parse/resolve-confirm-instruction.ts
index dd9f528..6410347 100644
--- a/src/services/parse/resolve-confirm-instruction.ts
+++ b/src/services/parse/resolve-confirm-instruction.ts
@@ -1,104 +1,301 @@
import {
+
isInstructionImported,
+
} from "@/services/parse/instruction-execution-state";
+
import {
+
STANDARD_INSTRUCTION_KINDS,
+
extractMailInstructions,
+
type MailInstructionUnit,
+
} from "@/services/parse/split-instructions";
+
+
export type ConfirmInstructionReason =
+
| "ok"
+
| "missing_id"
+
| "not_found"
+
| "not_confirmable"
+
| "already_imported";
+
+
export type ConfirmInstructionResolution = {
+
unit: MailInstructionUnit | null;
+
candidates: MailInstructionUnit[];
+
reason: ConfirmInstructionReason;
+
};
+
+
type MailLike = {
+
subject: string;
+
body_text?: string | null;
+
+ mail_type?: string;
+
attachments?: Array<{ filename: string }>;
+
parse_result?: {
+
lineage?: unknown;
- mail_record?: { work_order_actions?: string[] } | null;
+
+ container_header?: { F_ContainerNo?: string | null } | null;
+
+ mail_record?: {
+
+ kind?: string;
+
+ work_order_actions?: string[];
+
+ } | null;
+
} | null;
+
};
+
+
export function listConfirmableInstructionUnits(
+
units: MailInstructionUnit[],
+
): MailInstructionUnit[] {
+
return units.filter((u) =>
+
(STANDARD_INSTRUCTION_KINDS as string[]).includes(u.uiKind),
+
+ );
+
+}
+
+
+
+/**
+
+ * 是否强制补一张「预报」指令卡。
+
+ * - 提单预报 / 新增预报 / DO+柜头同封:需要
+
+ * - 纯工单(预约码、贴标等)主题里碰巧有柜号:不需要
+
+ */
+
+export function shouldForceForecastUnit(mail: MailLike): boolean {
+
+ const lineage = mail.parse_result?.lineage as
+
+ | { mail_record?: { kind?: string } }
+
+ | undefined;
+
+ const kind =
+
+ mail.parse_result?.mail_record?.kind ||
+
+ lineage?.mail_record?.kind ||
+
+ mail.mail_type ||
+
+ "";
+
+ if (kind === "BL_FORECAST" || kind === "NEW_CONTAINER") return true;
+
+
+
+ const cn = (mail.parse_result?.container_header?.F_ContainerNo || "").trim();
+
+ // DO 与预报同封时,主类型常是 DO_UPLOAD,需靠柜头补预报卡
+
+ if (kind === "DO_UPLOAD" && cn) return true;
+
+
+
+ const sub = mail.subject || "";
+
+ // 柜号+提单 / 整柜直送 类主题(邮件1),即使主类型是 DO_UPLOAD
+
+ if (
+
+ /整柜直送|不带车架|带车架/.test(sub) &&
+
+ /柜号|提单/.test(sub)
+
+ ) {
+
+ return true;
+
+ }
+
+ if (/柜号\s*[::]/.test(sub) && /提单/.test(sub)) return true;
+
+ if (/请查收新增预报|新增预报\d*/.test(sub)) return true;
+
+ const hasSpreadsheet = (mail.attachments || []).some((a) =>
+ /\.(xlsx|xls|csv)$/i.test(a.filename),
);
+ if (/^预报$/.test(sub.trim()) && hasSpreadsheet) return true;
+ if (
+ (mail.attachments || []).some((a) =>
+ /测试AI建单|ClientContainer|AI建单|集装箱导入/i.test(a.filename),
+ )
+ ) {
+ return true;
+ }
+
+ return false;
+
}
+
+
export function extractInstructionsFromMail(
+
mail: MailLike,
+
): MailInstructionUnit[] {
+
const lineage = mail.parse_result?.lineage as
+
| { mail_record?: { work_order_actions?: string[] } }
+
| undefined;
+
return extractMailInstructions({
+
subject: mail.subject,
+
body: mail.body_text,
+
filenames: (mail.attachments || []).map((a) => a.filename),
+
+ forceForecast: shouldForceForecastUnit(mail),
+
workOrderActions:
+
mail.parse_result?.mail_record?.work_order_actions ||
+
lineage?.mail_record?.work_order_actions ||
+
[],
+
});
+
}
+
+
+
+
/**
+
* 确认页只针对「这一条指令」,不看整封邮件主类型。
+
* 无 instruction_id 且仅一条可确认指令时直接选中,多条则交给页面列出选择。
+
*/
+
export function resolveConfirmInstruction(input: {
+
units: MailInstructionUnit[];
+
instructionId?: string | null;
+
lineage?: unknown;
+
}): ConfirmInstructionResolution {
+
const candidates = listConfirmableInstructionUnits(input.units);
+
const instructionId = input.instructionId?.trim() || "";
+
+
if (instructionId) {
+
const unit = input.units.find((u) => u.id === instructionId) || null;
+
if (!unit) {
+
return { unit: null, candidates, reason: "not_found" };
+
}
+
if (!(STANDARD_INSTRUCTION_KINDS as string[]).includes(unit.uiKind)) {
+
return { unit, candidates, reason: "not_confirmable" };
+
}
+
if (isInstructionImported(input.lineage, unit.id)) {
+
return { unit, candidates, reason: "already_imported" };
+
}
+
return { unit, candidates, reason: "ok" };
+
}
+
+
const pending = candidates.filter(
+
(u) => !isInstructionImported(input.lineage, u.id),
+
);
+
if (pending.length === 1) {
+
return { unit: pending[0], candidates, reason: "ok" };
+
}
+
if (pending.length === 0 && candidates.length === 1) {
+
return {
+
unit: candidates[0],
+
candidates,
+
reason: isInstructionImported(input.lineage, candidates[0].id)
+
? "already_imported"
+
: "ok",
+
};
+
}
+
return { unit: null, candidates, reason: "missing_id" };
+
}
+
+
export function confirmHref(mailId: string, instructionId: string): string {
+
return `/mails/${mailId}/confirm?instruction_id=${encodeURIComponent(instructionId)}`;
+
}
+
+
diff --git a/src/services/parse/revalidate-shipment-row.ts b/src/services/parse/revalidate-shipment-row.ts
new file mode 100644
index 0000000..657b6cb
--- /dev/null
+++ b/src/services/parse/revalidate-shipment-row.ts
@@ -0,0 +1,158 @@
+import { mapChannel } from "./channel-map";
+import { validatePackingFill } from "./packing-fill-rules";
+import { normalizeFbaCodeForCc } from "./normalize-fba-code";
+import { applyLabeledBlocksToShipmentRow } from "./address-block";
+import type { ShipmentRow } from "@/types/mail";
+
+function toNum(v: unknown): number | null {
+ if (v == null || v === "") return null;
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
+ const n = Number(String(v).replace(/,/g, "").trim());
+ return Number.isFinite(n) ? n : null;
+}
+
+function strOrNull(v: unknown, max = 200): string | null {
+ const t = String(v ?? "").replace(/\s+/g, " ").trim();
+ if (!t || t === "/" || t === "/" || t === "-" || t === "—") return null;
+ return t.slice(0, max);
+}
+
+/**
+ * 确认页手工增改货件后,按与装箱表解析相同的规则重算 VALID/INVALID。
+ */
+export function revalidateShipmentRow(
+ input: Partial & { row_index: number },
+): ShipmentRow {
+ const normalized = applyLabeledBlocksToShipmentRow(input);
+ const channelRaw =
+ (normalized.channel_raw || normalized.F_Transporter || "").trim() || "";
+ const mapped = mapChannel(channelRaw);
+ const ctnsNum = toNum(normalized.F_CTNS);
+ const ctns = ctnsNum == null ? 0 : Math.trunc(ctnsNum);
+ const cbm = toNum(normalized.F_CBM);
+ const weight = toNum(normalized.F_Weight);
+ const addr = strOrNull(normalized.F_Address, 500) || "";
+ const fbaIdRaw = strOrNull(normalized.F_FBAID, 80) || "";
+ const refRaw = strOrNull(normalized.F_ReferenceId, 80) || "";
+ const shipmentIdRaw = strOrNull(normalized.F_ShipmentID, 120) || "";
+
+ const fill = validatePackingFill({
+ channelRaw,
+ transporter: mapped.transporter,
+ warehouseId: strOrNull(normalized.F_FBACode, 40) || "",
+ ctns,
+ cbm,
+ weight,
+ address: addr,
+ shipmentId: shipmentIdRaw,
+ fbaId: fbaIdRaw,
+ referenceId: refRaw,
+ });
+
+ let fba = fill.warehouseId;
+ const warnings = [...fill.warnings];
+ const invalid_reasons = [...fill.invalid_reasons];
+ if (mapped.unmapped && mapped.transporter) {
+ warnings.push("CHANNEL_UNMAPPED");
+ }
+ const filledFba = normalizeFbaCodeForCc(fba, mapped.transporter);
+ if (!fba && filledFba === "HOLD") {
+ warnings.push("存仓/留仓等无仓库ID,已用 HOLD 作为目的代码");
+ }
+ fba = filledFba;
+
+ const pcs = toNum(normalized.F_Pcs);
+
+ return {
+ row_index: normalized.row_index,
+ row_status: invalid_reasons.length ? "INVALID" : "VALID",
+ invalid_reasons: invalid_reasons.length ? invalid_reasons : undefined,
+ warnings: warnings.length ? warnings : undefined,
+ channel_raw: channelRaw || undefined,
+ F_Transporter: mapped.transporter || undefined,
+ F_FBACode: fba || undefined,
+ F_CTNS: ctns,
+ F_CBM: cbm,
+ F_Weight: weight,
+ F_Pcs: pcs,
+ F_FBAID: fbaIdRaw || null,
+ F_PO: strOrNull(normalized.F_PO, 80),
+ F_ReferenceId: refRaw || null,
+ F_ShipmentID: fill.shipmentId || null,
+ F_Address: addr || null,
+ F_City: strOrNull(normalized.F_City, 80),
+ F_State: strOrNull(normalized.F_State, 80),
+ F_ZipCode: strOrNull(normalized.F_ZipCode, 40),
+ F_Contact: strOrNull(normalized.F_Contact, 80),
+ F_Mobile: strOrNull(normalized.F_Mobile, 40),
+ F_Email: strOrNull(normalized.F_Email, 120),
+ F_Expected_DeliveryDateB: strOrNull(normalized.F_Expected_DeliveryDateB, 40),
+ F_Expected_DeliveryDateE: strOrNull(normalized.F_Expected_DeliveryDateE, 40),
+ F_Remark: strOrNull(normalized.F_Remark, 500),
+ F_SKU: strOrNull(normalized.F_SKU, 80),
+ F_Description: strOrNull(normalized.F_Description, 200),
+ F_EnglishName: strOrNull(normalized.F_EnglishName, 200),
+ };
+}
+
+export function reindexShipments(rows: ShipmentRow[]): ShipmentRow[] {
+ return rows.map((row, i) =>
+ row.row_index === i ? row : { ...row, row_index: i },
+ );
+}
+
+/** 从 parse_result 载入时重拆 Address 等键值块并校验行状态 */
+export function normalizeParseShipments(
+ rows: ShipmentRow[] | undefined | null,
+): ShipmentRow[] {
+ if (!rows?.length) return [];
+ return reindexShipments(
+ rows.map((s, i) => revalidateShipmentRow({ ...s, row_index: i })),
+ );
+}
+
+/** 从确认页提交的货件数组做白名单清洗并重校验 */
+export function sanitizeClientShipments(
+ raw: unknown,
+ opts?: { maxRows?: number },
+): ShipmentRow[] {
+ if (!Array.isArray(raw)) {
+ throw new Error("shipments 须为数组");
+ }
+ const max = opts?.maxRows ?? 2000;
+ if (raw.length > max) {
+ throw new Error(`货件最多 ${max} 行`);
+ }
+ return reindexShipments(
+ raw.map((item, i) => {
+ const r = (item && typeof item === "object" ? item : {}) as Partial;
+ return revalidateShipmentRow({
+ row_index: typeof r.row_index === "number" ? r.row_index : i,
+ channel_raw: r.channel_raw,
+ F_Transporter: r.F_Transporter,
+ F_FBACode: r.F_FBACode,
+ F_CTNS: r.F_CTNS,
+ F_CBM: r.F_CBM,
+ F_Weight: r.F_Weight,
+ F_Pcs: r.F_Pcs,
+ F_FBAID: r.F_FBAID,
+ F_PO: r.F_PO,
+ F_ReferenceId: r.F_ReferenceId,
+ F_ShipmentID: r.F_ShipmentID,
+ F_Address: r.F_Address,
+ F_City: r.F_City,
+ F_State: r.F_State,
+ F_ZipCode: r.F_ZipCode,
+ F_Contact: r.F_Contact,
+ F_Mobile: r.F_Mobile,
+ F_Email: r.F_Email,
+ F_Expected_DeliveryDateB: r.F_Expected_DeliveryDateB,
+ F_Expected_DeliveryDateE: r.F_Expected_DeliveryDateE,
+ F_Remark: r.F_Remark,
+ F_SKU: r.F_SKU,
+ F_Description: r.F_Description,
+ F_EnglishName: r.F_EnglishName,
+ });
+ }),
+ );
+}
diff --git a/src/services/parse/split-instructions.ts b/src/services/parse/split-instructions.ts
index 72b7f6c..9415e82 100644
--- a/src/services/parse/split-instructions.ts
+++ b/src/services/parse/split-instructions.ts
@@ -754,6 +754,27 @@ export function extractMailInstructions(input: {
);
}
+ // 主题写明「新增预报」且尚无预报卡:即使正文已是贴标工单,也保留预报卡(当前轮为工单)
+ if (
+ !units.some((u) => u.uiKind === "forecast") &&
+ (/新增预报/.test(subject) || /^预报$/.test(subject.trim()))
+ ) {
+ const currentIsOps =
+ bodyHasHardAction || kindsInCurrentBody.has("work_order");
+ push(
+ makeUnit({
+ uiKind: "forecast",
+ source: "subject",
+ segmentIndex: subjectAnchor.segmentIndex,
+ isCurrent: !currentIsOps && subjectAnchor.isCurrent,
+ text: subject,
+ segmentSubject: subject,
+ roundAt: subjectAnchor.roundAt,
+ extraKeywords: ["主题预报"],
+ }),
+ );
+ }
+
// 3) 附件:仅补充正文/主题未覆盖的 kind
for (const f of filenames) {
for (const kind of detectUiKindsFromText("", "", [f])) {
@@ -923,25 +944,38 @@ function dedupeSamePayloadUnits(
return out;
}
-/** 预报 Instruction:只用预报段,排除贴标段与移动端转发壳 */
+/**
+ * 预报 Instruction:只取预报自有内容,禁止混入工单/贴标/DO/拆柜清单等其他指令段。
+ * 优先级:modules(船名航次/装箱单 Instruction)→ 纯预报正文段 → 主题船名航次短句。
+ */
+const FORECAST_INSTR_POSITIVE = /新增预报|请查收新增预报/;
+const FORECAST_INSTR_FOREIGN =
+ /换标|覆盖贴|贴标|贴好拍照|拆柜清单|派送要求|DO请查收|卡转海|拦截|改自提|开箱拍|操作指令/;
+
export function extractForecastInstructionText(input: {
subject: string;
body?: string | null;
modulesInstruction?: string | null;
}): string {
+ const mod = stripMobileMailChrome(input.modulesInstruction || "").trim();
+ if (mod) return mod.slice(0, 500);
+
const body = stripMobileMailChrome(input.body || "");
const segments = splitBodySegments(body);
const forecastSegs = segments.filter(
- (s) =>
- /新增预报|提单|柜型|ETA|ETD|船名/.test(s) &&
- !/换标|覆盖贴|贴标|贴好拍照/.test(s),
+ (s) => FORECAST_INSTR_POSITIVE.test(s) && !FORECAST_INSTR_FOREIGN.test(s),
);
- let raw = "";
- if (forecastSegs.length) raw = forecastSegs[0];
- else if (input.modulesInstruction) raw = input.modulesInstruction;
- else {
- const m = /船名航次\s*([^\s+]+(?:\s+[^\s+]+)?)/i.exec(input.subject);
- if (m) raw = `\u8239\u540d\u822a\u6b21 ${m[1]}`;
+ if (forecastSegs.length) {
+ return stripMobileMailChrome(forecastSegs[0]).slice(0, 500);
+ }
+
+ const vessel =
+ /船名航次\s*[::]?\s*([^\n+]+?)(?=\s*\+|ETA|ETD|$)/i.exec(input.subject) ||
+ /船名航次\s*([^\s+]+(?:\s+[^\s+/]+)?(?:\s*\/\s*[^\s+]+)?)/i.exec(
+ input.subject,
+ );
+ if (vessel?.[1]) {
+ return `船名航次 ${vessel[1].trim()}`.slice(0, 500);
}
- return stripMobileMailChrome(raw).slice(0, 500);
+ return "";
}
diff --git a/src/services/parse/us-ports.ts b/src/services/parse/us-ports.ts
index 6104b40..5a05443 100644
--- a/src/services/parse/us-ports.ts
+++ b/src/services/parse/us-ports.ts
@@ -152,3 +152,28 @@ export function isKnownUsDestinationPort(
): boolean {
return matchUsDestinationPort(raw) != null;
}
+
+/**
+ * 长正文扫美国目的港(对齐助手 header_extract US_PORTS 全文包含)。
+ * 短码(LA/NY)不扫,避免正文误伤。
+ */
+export function scanUsDestinationPortInText(
+ raw: string | null | undefined,
+): string | null {
+ const text = (raw || "").trim();
+ if (!text) return null;
+ if (text.length <= 60) return matchUsDestinationPort(text);
+ const upper = normalizePortKey(text);
+ for (const row of PORT_LOOKUP) {
+ if (row.len <= 3) continue;
+ const safe = row.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp(
+ `(?:^|[^A-Z0-9\u4e00-\u9fff])${safe}(?:[^A-Z0-9\u4e00-\u9fff]|$)`,
+ "i",
+ );
+ if (re.test(upper) || text.includes(row.canonical)) {
+ return row.canonical;
+ }
+ }
+ return null;
+}
diff --git a/src/services/parse/work-order-fields.ts b/src/services/parse/work-order-fields.ts
new file mode 100644
index 0000000..6f8a065
--- /dev/null
+++ b/src/services/parse/work-order-fields.ts
@@ -0,0 +1,510 @@
+/**
+ * 新建工单字段提取(对齐 carriercentral-ai-assistant work_order_ops)。
+ *
+ * 留言内容 / 备注分界:
+ * - 货件清单、操作/派送步骤进留言内容;备注选填可空
+ * - 「注/注意/备注/特别说明:」冒号后进备注
+ * - 「注意:」后接 1./2. 编号或货件清单 → 仍为正文;空「注意:」后的说明散文进备注
+ * - 货件清单之后的独立催促/时限/拍照叮嘱行进备注(非「安排贴标/卡转海/派送要求」操作句)
+ * - 编号分节内的开箱拍照等操作说明留在留言内容
+ * - 剥离邮箱、QQ 附件栏、转发壳、落款等噪声
+ */
+import { stripMobileMailChrome } from "@/services/parse/instruction-lexicon";
+import {
+ isValidContainerNoFormat,
+ normalizeContainerNo,
+} from "@/utils/iso6346";
+
+const REMARK_LINE =
+ /^\s*[【\[]?\s*(?:注|注意|备注|特别说明|补充说明|温馨提示)\s*[】\]]?\s*[::]\s*(.*)\s*$/;
+
+const MANIFEST_ID =
+ /(?:\bFBA[A-Z0-9]+\b|\b[A-Z]{4}\d{7}\b|\bWL\d+\b|\bYT\d+\b|\bMJYT\d+\b|\bLX\d+\b|\bTS\d+\b)/i;
+const MANIFEST_ID_G = new RegExp(MANIFEST_ID.source, "gi");
+
+const OPS_STEP =
+ /(?:覆盖贴|换标后|贴\s*FBA|贴\s*SKU|一箱贴|UPS\s*默认|面单|^\s*\d+\s*[::.、.))]\s*\S|(?:安排|请安排|帮忙安排|麻烦安排|麻烦).{0,16}(?:贴标|换标|卡派|卡转海|拆柜|转仓|操作|打板|打托|交付)|操作指令|卡转海|贴标交付|开箱|回传照片|回传|派送要求|送货要求|操作要求|需要拦截|改自提|以此为准|拆柜清单|打板|打托|分货|分拣|按票|混打|修补|预约时间|一面朝外|箱子有字)/i;
+
+/** 货件/操作之后的催促·时限·拍照叮嘱 → 备注(对齐助手 _REQUEST_TONE) */
+const REQUEST_TONE =
+ /(?:记得|切记|千万|务必|必须|一定要|一定|特别|重点|严禁|不得|尽量|尽早|早点|早些|提早|提前|赶在|赶早|越早越好|能早则早|优先|加急|催|拜托|烦请|恳请|希望|请务必|请尽快|尽快|马上|立刻|辛苦|麻烦(?!安排(?:贴标|换标|卡派|拆柜|转仓|操作))|拜托了|客户(?:需要|要求|希望|说|强调|叮嘱|交代)|叮嘱|交代|关照|喊|叫(?:仓库|仓|操作|同事|司机)|拍(?:一下|张|个)?(?:照|照片|图片)|照片过来|发我|发过来|今晚|今天|明天|后天|辛苦操作|操作一下|(?:需要|要求|希望|叮嘱).{0,24}(?:前|之前|以内|之内|截止)|(?:\d{1,2}\s*[月/.\-]\s*\d{1,2}|\d{1,2}\s*点|\d{1,2}:\d{2}).{0,16}(?:前|之前|以内|截止)|美时|当地时间|北京时间|(?:送达|交付|送到|提货|入仓|上架).{0,8}(?:前|之前|以内|截止)|这(?:两|几|三|四|五|批|些)?(?:票|柜|单|货).{0,20}(?:需要|要求|务必|客户|尽量|记得|总共|一共)|别晚|不能晚|不要晚|赶不及|来不及|就行|即可|为要|为准)/i;
+
+const NUMBERED_LINE = /^\s*\d+\s*[.、.::))]/
+const NUMBERED_ONLY = /^\s*\d+\s*[.、.::))]?\s*$/;
+const SENT_SPLIT = /(?<=[!?;])\s*|(??\s*[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\s*)$/;
+
+/** QQ/Webmail 附件栏噪声 */
+const ATTACH_CHROME_LINE =
+ /^(?:\d+\s*个附件|普通附件(?:\s*[((][\d.]+\s*[KMGTB]?B?[))])?|超大附件|附件(?:列表|栏)?\s*[::]?\s*$)/i;
+
+/** 单独文件名行:xxx.zip (1M) / WHSU… 卡转海.zip */
+const ATTACH_FILENAME_LINE =
+ /^(?:[A-Za-z0-9._\-一-龥]+\.(?:zip|rar|7z|pdf|xlsx?|xls|csv|png|jpe?g|docx?|eml))\s*(?:[((][\d.]+\s*[KMGTB]?B?[))])?\s*$/i;
+
+function clip(s: string, n: number): string {
+ const t = (s || "").trim();
+ return t.length <= n ? t : t.slice(0, n);
+}
+
+function cjkRatio(text: string): number {
+ const t = (text || "").replace(/\s+/g, "");
+ if (!t) return 0;
+ return (t.match(CJK) || []).length / t.length;
+}
+
+/** 整行是否为工单正文不应保留的噪声 */
+export function isWorkOrderNoiseLine(text: string): boolean {
+ const t = (text || "").trim();
+ if (!t) return true;
+ if (EMAIL_ONLY_LINE.test(t)) return true;
+ if (/^\s*[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\s*>?$/.test(t)) {
+ return true;
+ }
+ if (ATTACH_CHROME_LINE.test(t)) return true;
+ if (ATTACH_FILENAME_LINE.test(t)) return true;
+ if (
+ /^[A-Z]{4}\d{7}\s+.+\.(?:zip|rar|pdf|xlsx?)\b/i.test(t) &&
+ !OPS_STEP.test(t) &&
+ !/拦截|改自提|贴标|换标|注意/.test(t)
+ ) {
+ return true;
+ }
+ if (
+ /^(?:发件人|收件人|抄送|寄件人|From|To|Cc)\s*[::]/i.test(t) ||
+ (/^(?:发件人|收件人)/.test(t) && /@/.test(t))
+ ) {
+ return true;
+ }
+ if (/^发自我的|^Sent from my|^Get Outlook/i.test(t)) return true;
+ // 网易灵犀 / Outlook 等邮件客户端签名栏
+ if (/签名由/.test(t) || /网易灵犀/.test(t) || /灵犀办公/.test(t)) {
+ return true;
+ }
+ // 落款短名(km16 / luo),勿误伤柜号/唛头/FBA
+ if (
+ /^[A-Za-z][A-Za-z0-9._\-]{1,20}$/.test(t) &&
+ !MANIFEST_ID.test(t) &&
+ !/^(?:Dear|Thanks|Thankyou|Regards|UPS|FedEx|DHL|COSCO|OOCL|ETA|ETD|FBA|DO)$/i.test(
+ t,
+ )
+ ) {
+ return true;
+ }
+ if (
+ /^(?:[\u4e00-\u9fffA-Za-z0-9]{2,20}(?:供应链|物流|货运|国际))\s*$/.test(t) ||
+ /^[\u4e00-\u9fff]{2,8}\s*(?:港后)?客服\s*$/.test(t) ||
+ /^(?:Best\s*regards|Thanks|Thank you)[,!.]?\s*$/i.test(t)
+ ) {
+ return true;
+ }
+ return false;
+}
+
+function stripTrailingEmailToken(line: string): string {
+ return line
+ .replace(
+ /(?:^|\s)[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\s*$/g,
+ "",
+ )
+ .trim();
+}
+
+/**
+ * 剥离转发壳、邮箱签名、QQ 附件栏等,避免污染留言内容/备注。
+ */
+export function stripWorkOrderBodyNoise(text: string): string {
+ const decoded = (text || "")
+ .replace(/\r\n/g, "\n")
+ .replace(/ /gi, " ")
+ .replace(/>/gi, ">")
+ .replace(/</gi, "<")
+ .replace(/&/gi, "&")
+ .replace(/"/gi, '"');
+ const chrome = stripMobileMailChrome(decoded);
+ const lines = chrome.split("\n");
+ const kept: string[] = [];
+ let inAttachBlock = false;
+
+ for (const line of lines) {
+ const t = line.trim();
+ if (!t) {
+ if (kept.length && kept[kept.length - 1] !== "") kept.push("");
+ continue;
+ }
+
+ if (
+ /在\s*\d{4}[-/.年][^\n]{0,160}写道[::]?/.test(t) ||
+ /^Forwarded message\s*:/i.test(t) ||
+ /^Original Message\s*:/i.test(t)
+ ) {
+ continue;
+ }
+
+ if (ATTACH_CHROME_LINE.test(t) || /^\d+\s*个附件/.test(t)) {
+ inAttachBlock = true;
+ continue;
+ }
+ if (inAttachBlock) {
+ if (
+ isWorkOrderNoiseLine(t) ||
+ ATTACH_FILENAME_LINE.test(t) ||
+ EMAIL_ONLY_LINE.test(t) ||
+ /\.(zip|rar|pdf|xlsx?)\b/i.test(t)
+ ) {
+ continue;
+ }
+ inAttachBlock = false;
+ }
+
+ if (isWorkOrderNoiseLine(t)) continue;
+
+ const cleaned = stripTrailingEmailToken(t);
+ if (!cleaned || isWorkOrderNoiseLine(cleaned)) continue;
+ kept.push(cleaned);
+ }
+
+ while (kept.length) {
+ const last = kept[kept.length - 1].trim();
+ if (!last || isWorkOrderNoiseLine(last)) {
+ kept.pop();
+ continue;
+ }
+ break;
+ }
+
+ return kept
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+function isNumberedOpsLine(text: string): boolean {
+ const t = (text || "").trim();
+ if (!t) return false;
+ return NUMBERED_LINE.test(t) || NUMBERED_ONLY.test(t);
+}
+
+/** 硬操作句:贴标/卡转海/派送要求/开箱回传等 → 留言内容 */
+function isHardOpsLine(text: string): boolean {
+ const t = (text || "").trim();
+ if (!t) return false;
+ if (REMARK_LINE.test(t)) return false;
+ if (isNumberedOpsLine(t)) return true;
+ return OPS_STEP.test(t);
+}
+
+function isManifestLine(text: string): boolean {
+ const t = (text || "").trim();
+ if (!t) return false;
+ if (!MANIFEST_ID.test(t)) return false;
+ const soft = REQUEST_TONE.exec(t);
+ // 整行几乎都是叮嘱(「这两票客户需要美时…」)→ 不算纯清单
+ if (soft && soft.index <= 4 && cjkRatio(t) >= 0.45) return false;
+ return true;
+}
+
+/**
+ * 纯叮嘱/时限/拍照要求行 → 备注。
+ * 硬操作句(安排贴标、卡转海、派送要求、编号内开箱回传)不进备注。
+ */
+function isRequestToneRemarkLine(text: string): boolean {
+ const t = (text || "").trim();
+ if (!t) return false;
+ if (isHardOpsLine(t)) return false;
+ if (isNumberedOpsLine(t)) return false;
+ if (/^(?:派送要求|操作要求|送货要求|操作指令)\s*[::]/.test(t)) return false;
+ if (!REQUEST_TONE.test(t)) return false;
+ // 须有足够中文,避免误伤纯代码行
+ return cjkRatio(t) >= 0.35;
+}
+
+/**
+ * 备注段内遇「新操作段」才跳出;勿因说明里夹带「分货/打托」误把备注打回正文。
+ */
+function isRemarkSectionExit(text: string): boolean {
+ const t = (text || "").trim();
+ if (!t) return false;
+ if (isNumberedOpsLine(t)) return true;
+ if (isManifestLine(t) && !isRequestToneRemarkLine(t)) return true;
+ if (
+ /^(?:派送要求|操作要求|送货要求|操作指令|安排|请安排|麻烦安排|麻烦|Dear)\b/i.test(
+ t,
+ )
+ ) {
+ return true;
+ }
+ if (/^(?:以上|这\d*票).{0,16}(?:需要拦截|改自提|卡转海)/.test(t)) {
+ return true;
+ }
+ return false;
+}
+
+function peelInlineEmphasis(line: string): { head: string; tail: string } {
+ const t = (line || "").trim();
+ if (!t) return { head: "", tail: "" };
+ const m = REMARK_LINE.exec(t);
+ if (m) {
+ const piece = (m[1] || "").trim();
+ if (piece && isNumberedOpsLine(piece)) return { head: t, tail: "" };
+ return { head: "", tail: piece };
+ }
+ // 同行:前半货件清单 + 后半叮嘱 → 拆开
+ const soft = REQUEST_TONE.exec(t);
+ if (
+ soft &&
+ soft.index > max(8, Math.floor(t.length / 5)) &&
+ MANIFEST_ID.test(t.slice(0, soft.index)) &&
+ !isHardOpsLine(t.slice(soft.index)) &&
+ cjkRatio(t.slice(soft.index)) >= 0.4
+ ) {
+ return {
+ head: t.slice(0, soft.index).trim(),
+ tail: t.slice(soft.index).trim(),
+ };
+ }
+ return { head: t, tail: "" };
+}
+
+function max(a: number, b: number): number {
+ return a > b ? a : b;
+}
+
+function iterUnits(raw: string): string[] {
+ const units: string[] = [];
+ for (const line of (raw || "").split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ // 仅当行内可能含「注意:」或叮嘱时才按句切开,避免把操作说明拆碎
+ if (
+ !/(?:注|注意|备注|特别说明|补充说明|温馨提示)\s*[::]/.test(trimmed) &&
+ !REQUEST_TONE.test(trimmed)
+ ) {
+ units.push(trimmed);
+ continue;
+ }
+ const parts = trimmed
+ .split(SENT_SPLIT)
+ .map((p) => p.trim())
+ .filter(Boolean);
+ if (parts.length <= 1) units.push(trimmed);
+ else units.push(...parts);
+ }
+ return units;
+}
+
+export type WorkOrderContentSplit = {
+ content: string;
+ remark: string;
+ /** 备注里仍含货件标识时,建议人工再核对一次 */
+ needRemarkReview: boolean;
+};
+
+/**
+ * 货件/操作进留言内容;「注意:」与货件后的纯叮嘱进备注。
+ */
+export function splitWorkOrderContentAndRemark(
+ text: string,
+): WorkOrderContentSplit {
+ let raw = stripWorkOrderBodyNoise(text || "");
+ if (!raw) return { content: "", remark: "", needRemarkReview: false };
+
+ const bodyLabeled = /(?:留言内容|工单内容)[::]\s*([\s\S]*)\s*$/.exec(raw);
+ if (bodyLabeled?.[1]?.trim()) {
+ raw = bodyLabeled[1].trim();
+ }
+
+ raw = raw
+ .split("\n")
+ .map((line) => {
+ const m = /^\s*参考号\s*[::]\s*(.*)$/.exec(line);
+ if (m) {
+ const val = (m[1] || "").trim();
+ return val || null;
+ }
+ return line;
+ })
+ .filter((l): l is string => l != null)
+ .join("\n");
+
+ const bodyParts: string[] = [];
+ const remarks: string[] = [];
+ let inRemarkProse = false;
+ let usedExplicitNote = false;
+ let seenBody = false;
+
+ for (const unit of iterUnits(raw)) {
+ const stripped = unit.trim();
+ if (!stripped || isWorkOrderNoiseLine(stripped)) continue;
+
+ if (isNumberedOpsLine(stripped)) {
+ inRemarkProse = false;
+ bodyParts.push(stripped);
+ seenBody = true;
+ continue;
+ }
+
+ const note = REMARK_LINE.exec(stripped);
+ if (note) {
+ const piece = stripTrailingEmailToken((note[1] || "").trim());
+ usedExplicitNote = true;
+ if (!piece || isWorkOrderNoiseLine(piece)) {
+ inRemarkProse = true;
+ continue;
+ }
+ if (isNumberedOpsLine(piece)) {
+ bodyParts.push(piece);
+ inRemarkProse = false;
+ seenBody = true;
+ continue;
+ }
+ remarks.push(piece);
+ inRemarkProse = true;
+ continue;
+ }
+
+ if (inRemarkProse && !isRemarkSectionExit(stripped)) {
+ if (!isWorkOrderNoiseLine(stripped)) {
+ const cleaned = stripTrailingEmailToken(stripped);
+ if (cleaned && !isWorkOrderNoiseLine(cleaned)) remarks.push(cleaned);
+ }
+ continue;
+ }
+ if (inRemarkProse && isRemarkSectionExit(stripped)) {
+ inRemarkProse = false;
+ }
+
+ const peeled = peelInlineEmphasis(stripped);
+ if (peeled.tail && !peeled.head) {
+ const tail = stripTrailingEmailToken(peeled.tail);
+ if (tail && !isWorkOrderNoiseLine(tail)) {
+ remarks.push(tail);
+ usedExplicitNote = true;
+ inRemarkProse = true;
+ }
+ continue;
+ }
+ if (peeled.head && peeled.tail) {
+ bodyParts.push(stripTrailingEmailToken(peeled.head) || peeled.head);
+ seenBody = true;
+ const tail = stripTrailingEmailToken(peeled.tail);
+ if (tail && !isWorkOrderNoiseLine(tail)) {
+ remarks.push(tail);
+ inRemarkProse = true;
+ }
+ continue;
+ }
+
+ // 货件/操作之后的独立叮嘱行 → 备注
+ if (
+ seenBody &&
+ isRequestToneRemarkLine(stripped) &&
+ !isManifestLine(stripped)
+ ) {
+ remarks.push(stripTrailingEmailToken(stripped) || stripped);
+ inRemarkProse = true;
+ continue;
+ }
+
+ bodyParts.push(stripTrailingEmailToken(stripped) || stripped);
+ seenBody = true;
+ }
+
+ const content = clip(
+ stripWorkOrderBodyNoise(bodyParts.join("\n").trim()),
+ 800,
+ );
+ const remark = clip(
+ stripWorkOrderBodyNoise(remarks.filter(Boolean).join("\n")),
+ 800,
+ );
+ const needRemarkReview = Boolean(
+ (usedExplicitNote || Boolean(remark)) && remark && MANIFEST_ID.test(remark),
+ );
+ return { content, remark, needRemarkReview };
+}
+
+/** 参考号:显式「参考号/柜号/原箱号」或正文中的 ISO 柜号 */
+export function parseWorkOrderReferCode(text: string): string {
+ const raw = text || "";
+ const labeled = /(?:参考号|柜号)\s*[::]\s*([A-Za-z0-9\-]+)/i.exec(raw);
+ if (labeled?.[1]) {
+ const cand = labeled[1].trim().replace(/[。..]+$/, "");
+ const up = normalizeContainerNo(cand);
+ if (isValidContainerNoFormat(up)) return clip(up, 100);
+ return clip(cand, 100);
+ }
+ const box = /原箱号\s*[::]?\s*([A-Za-z]{2,4}\d{6,14})/i.exec(raw);
+ if (box?.[1]) return clip(box[1].toUpperCase(), 100);
+ for (const m of raw.matchAll(/\b([A-Za-z]{4}\d{7})\b/g)) {
+ const up = normalizeContainerNo(m[1]);
+ if (isValidContainerNoFormat(up)) return up;
+ }
+ return "";
+}
+
+/** Ref No:显式 Ref No/唛头,或首个非柜号货件标识(FBA/WL 等) */
+export function parseWorkOrderRefNo(text: string, referCode = ""): string {
+ const raw = text || "";
+ const labeled = /(?:Ref\s*No|RefNo|唛头)\s*[::]\s*([^\n]+)/i.exec(raw);
+ if (labeled?.[1]) {
+ return clip(labeled[1].trim().replace(/[。..]+$/, ""), 100);
+ }
+ const referU = (referCode || "").replace(/[\s\-]/g, "").toUpperCase();
+ for (const m of raw.matchAll(MANIFEST_ID_G)) {
+ const tok = (m[0] || "").trim();
+ if (!tok) continue;
+ const tokU = tok.replace(/[\s\-]/g, "").toUpperCase();
+ if (referU && tokU === referU) continue;
+ if (isValidContainerNoFormat(tokU)) continue;
+ return clip(tok, 100);
+ }
+ return "";
+}
+
+/** 从邮件正文抽出工单弹窗字段(留言内容/备注已分界) */
+export function extractWorkOrderFormFields(input: {
+ body: string;
+ subject?: string;
+ /** 已剥壳的操作正文;缺省时用 body */
+ messageBody?: string;
+ containerHint?: string | null;
+}): {
+ F_ReferCode: string;
+ F_RefNo: string;
+ F_MessageContent: string;
+ F_Remark: string;
+ needRemarkReview: boolean;
+} {
+ const bodyClean = stripWorkOrderBodyNoise(input.body || "");
+ const msgClean = stripWorkOrderBodyNoise(input.messageBody || "");
+ // 段正文若被 messageBody 截掉货件行,优先用更完整的 body
+ const source =
+ bodyClean.length >= (msgClean?.length || 0)
+ ? bodyClean || msgClean
+ : msgClean || bodyClean;
+ const split = splitWorkOrderContentAndRemark(source);
+ const referPool = [input.body, input.messageBody, input.subject]
+ .filter(Boolean)
+ .join("\n");
+ const refer =
+ parseWorkOrderReferCode(referPool) ||
+ parseWorkOrderReferCode(source) ||
+ (input.containerHint
+ ? normalizeContainerNo(input.containerHint)
+ : "");
+ const refNo =
+ parseWorkOrderRefNo(referPool, refer) ||
+ parseWorkOrderRefNo(source, refer);
+ return {
+ F_ReferCode: refer,
+ F_RefNo: refNo,
+ F_MessageContent: split.content,
+ F_Remark: split.remark,
+ needRemarkReview: split.needRemarkReview,
+ };
+}
diff --git a/src/services/parse/work-order-record.ts b/src/services/parse/work-order-record.ts
index fe118de..42dc9d3 100644
--- a/src/services/parse/work-order-record.ts
+++ b/src/services/parse/work-order-record.ts
@@ -1,4 +1,6 @@
import { extractTransferPairs } from "./mail-intent";
+import { stripMobileMailChrome } from "@/services/parse/instruction-lexicon";
+import { stripWorkOrderBodyNoise } from "@/services/parse/work-order-fields";
import {
PACKING_TEMPLATE_COLUMNS,
WORK_ORDER_TABLE_COLUMNS,
@@ -20,8 +22,30 @@ export const WORK_ORDER_SOFT_KEYWORDS = [
"拆柜清单",
"卡转海",
"派送要求",
+ "改地址派送",
+ "改地址",
+ "更新派送单",
+ "改自提",
] as const;
+/** 正文里的「变更指令」优先于主题模板的拆柜/派送提示 */
+export function collectBodyOverrideActions(body: string): string[] {
+ const text = (body || "").replace(/ /gi, " ");
+ const out: string[] = [];
+ if (/改地址派送/.test(text) || (/改地址/.test(text) && /派送/.test(text))) {
+ out.push("改地址派送");
+ } else if (/改地址/.test(text)) {
+ out.push("改地址");
+ }
+ if (/更新派送单/.test(text) && !out.includes("更新派送单")) {
+ out.push("更新派送单");
+ }
+ if (/改自提|仓库自提/.test(text) && !out.includes("改自提")) {
+ out.push("改自提");
+ }
+ return out;
+}
+
/** 贴标类同义词 → 工单 */
export const LABEL_WORK_ORDER_KEYWORDS = [
"换标",
@@ -70,7 +94,9 @@ export function extractWorkOrderMessageBody(
body: string,
subject = "",
): string {
- const raw = (body || "").replace(/\r\n/g, "\n").trim();
+ const raw = stripWorkOrderBodyNoise(
+ stripMobileMailChrome((body || "").replace(/\r\n/g, "\n")),
+ ).trim();
if (!raw) return (subject || "").trim();
if (
@@ -82,21 +108,49 @@ export function extractWorkOrderMessageBody(
}
const dear = /(?:^|\n)(Dear[,,][\s\S]*)/i.exec(raw);
- if (dear?.[1]) return dear[1].trim();
+ if (dear?.[1]) return stripWorkOrderBodyNoise(dear[1]);
const box = /(?:^|\n)((?:原箱号|换标后单号|覆盖贴|操作指令)[\s\S]*)/.exec(
raw,
);
- if (box?.[1]) return box[1].trim();
+ if (box?.[1]) return stripWorkOrderBodyNoise(box[1]);
const notice = /(?:^|\n)(拆柜清单更新\s*以此为准[\s\S]*)/.exec(raw);
- if (notice?.[1]) return notice[1].trim();
+ if (notice?.[1]) return stripWorkOrderBodyNoise(notice[1]);
const lines = raw.split("\n");
const startIdx = lines.findIndex((l) =>
- /Dear[,,]|原箱号|换标|覆盖贴|操作指令|^\d+\s*[::.]/.test(l.trim()),
+ /Dear[,,]|原箱号|换标|覆盖贴|操作指令|^\d+\s*[::.]|需要拦截|改自提|卡转海|贴标/.test(
+ l.trim(),
+ ),
);
- if (startIdx >= 0) return lines.slice(startIdx).join("\n").trim();
+ if (startIdx >= 0) {
+ // 操作句之前的货件清单行必须保留(验收:FBA/WL 行 + 安排卡转海)
+ let from = startIdx;
+ while (from > 0) {
+ const prev = lines[from - 1].trim();
+ if (!prev) {
+ from -= 1;
+ continue;
+ }
+ if (
+ /写道|原始邮件|Forwarded|发件人|收件人|Subject\s*[::]/i.test(prev)
+ ) {
+ break;
+ }
+ if (
+ /(?:\bFBA[A-Z0-9]+\b|\b[A-Z]{4}\d{7}\b|\bWL\d+\b|\bYT\d+\b)/i.test(
+ prev,
+ ) ||
+ /^\d+\s*[.、.]/.test(prev)
+ ) {
+ from -= 1;
+ continue;
+ }
+ break;
+ }
+ return stripWorkOrderBodyNoise(lines.slice(from).join("\n"));
+ }
const stripped = lines
.filter(
@@ -109,7 +163,7 @@ export function extractWorkOrderMessageBody(
)
.join("\n")
.trim();
- return stripped || raw;
+ return stripWorkOrderBodyNoise(stripped || raw);
}
export function collectSoftOpsHints(
@@ -237,6 +291,9 @@ export function buildWorkOrderRecord(input: {
subjectOnly ? "" : input.body,
subjectOnly ? [] : filenames,
);
+ const bodyOverrides = subjectOnly
+ ? []
+ : collectBodyOverrideActions(input.body);
const softHints = [
...(input.modules?.ops_hints || []),
...collectSoftOpsHints(
@@ -245,8 +302,12 @@ export function buildWorkOrderRecord(input: {
subjectOnly ? [] : filenames,
),
].filter((h, i, arr) => arr.indexOf(h) === i);
- // 展示用:硬动作优先;否则用拆柜清单等软词(渠道「拦截-N件」绝不当成整票动作)
- const displayActions = hardActions.length ? hardActions : softHints;
+ // 正文变更指令(改地址等)优先;否则硬动作;再否则主题软词
+ const displayActions = bodyOverrides.length
+ ? bodyOverrides
+ : hardActions.length
+ ? hardActions
+ : softHints;
// 主题优先:modules / subject 柜号
const heuristicCn = subjectOnly
? extractContainerFromFirstLines(input.subject, 3)
@@ -321,7 +382,7 @@ export function buildWorkOrderRecord(input: {
input.modules?.eta ? `ETA ${input.modules.eta}` : null,
].filter(Boolean);
- const summary = `工单邮件(主题):${displayActions.join("、") || "待识别动作"}${
+ const summary = `工单邮件${subjectOnly ? "(主题)" : ""}:${displayActions.join("、") || "待识别动作"}${
bits.length ? `;${bits.join(" · ")}` : ""
}${
input.channelSummary?.length
@@ -332,8 +393,8 @@ export function buildWorkOrderRecord(input: {
return {
kind: "WORK_ORDER",
summary,
- // 仅硬动作进入 work_order_actions(避免「拆柜清单」被当成工单留言类型/注入工单卡)
- work_order_actions: hardActions,
+ // 正文变更指令写入 actions;否则仅硬动作
+ work_order_actions: bodyOverrides.length ? bodyOverrides : hardActions,
modules: {
...(input.modules || {}),
container_no: containerNo || input.modules?.container_no,
@@ -427,7 +488,11 @@ export function buildMailRecord(input: {
}): MailRecord {
const shipments = input.shipments || [];
if (input.mailType === "WORK_ORDER") {
- const subjectOnly = Boolean(input.opsSubject);
+ // 主题可解析柜头时仍带 opsSubject modules;正文有改地址等变更指令时不得 subjectOnly 丢正文
+ // 注意:勿用泛化「拍照」打断 subjectOnly(邮件3 正文「开箱拍照」应忽略)
+ const bodyOverrides = collectBodyOverrideActions(input.body || "");
+ const subjectOnly =
+ Boolean(input.opsSubject) && bodyOverrides.length === 0;
return buildWorkOrderRecord({
subject: input.subject,
body: subjectOnly ? "" : input.body,
diff --git a/src/services/parse/work-order-snapshot.ts b/src/services/parse/work-order-snapshot.ts
new file mode 100644
index 0000000..f31a0a5
--- /dev/null
+++ b/src/services/parse/work-order-snapshot.ts
@@ -0,0 +1,139 @@
+/**
+ * 工单提交快照:相对邮件抽取基线的「补充 / 已改」字段标记。
+ */
+export const WORK_ORDER_SNAPSHOT_FIELDS = [
+ "F_ReferCode",
+ "F_MessageType",
+ "F_RefNo",
+ "F_MessageContent",
+ "F_Remark",
+ "F_AttachFile",
+ "F_CustomerName",
+ "F_UrgencyLevel",
+] as const;
+
+export type WorkOrderSnapshotField = (typeof WORK_ORDER_SNAPSHOT_FIELDS)[number];
+
+export type WorkOrderFieldMark = "supplemented" | "modified";
+
+export type WorkOrderFieldMarks = Partial<
+ Record
+>;
+
+export type WorkOrderSnapshotValue = Partial<
+ Record
+>;
+
+function norm(v: unknown): string {
+ return String(v ?? "").trim();
+}
+
+/** 将表单/entity 压成可持久化的字符串快照 */
+export function toWorkOrderSubmittedSnapshot(
+ value: WorkOrderSnapshotValue | Record | null | undefined,
+): Record {
+ const out: Record = {};
+ if (!value) return out;
+ for (const key of WORK_ORDER_SNAPSHOT_FIELDS) {
+ const s = norm((value as Record)[key]);
+ if (s) out[key] = s;
+ }
+ return out;
+}
+
+/**
+ * 对比邮件基线与最终提交:
+ * - 基线空、提交有值 → supplemented(补充)
+ * - 基线有值且不同 → modified(已改)
+ */
+export function diffWorkOrderFieldMarks(
+ baseline: WorkOrderSnapshotValue | null | undefined,
+ submitted: WorkOrderSnapshotValue | null | undefined,
+): WorkOrderFieldMarks {
+ const marks: WorkOrderFieldMarks = {};
+ for (const key of WORK_ORDER_SNAPSHOT_FIELDS) {
+ const a = norm(baseline?.[key]);
+ const b = norm(submitted?.[key]);
+ if (a === b) continue;
+ if (!a && b) marks[key] = "supplemented";
+ else if (a && b !== a) marks[key] = "modified";
+ // 基线有值、提交清空:也算已改(人工删掉)
+ else if (a && !b) marks[key] = "modified";
+ }
+ return marks;
+}
+
+export function diffWorkOrderSupplementedFields(
+ baseline: WorkOrderSnapshotValue | null | undefined,
+ submitted: WorkOrderSnapshotValue | null | undefined,
+): {
+ supplementedFields: WorkOrderSnapshotField[];
+ fieldMarks: WorkOrderFieldMarks;
+ userSupplemented: boolean;
+} {
+ const fieldMarks = diffWorkOrderFieldMarks(baseline, submitted);
+ const supplementedFields = Object.keys(fieldMarks) as WorkOrderSnapshotField[];
+ return {
+ supplementedFields,
+ fieldMarks,
+ userSupplemented: supplementedFields.length > 0,
+ };
+}
+
+export function workOrderFormFromSubmitted(
+ submitted: Record | null | undefined,
+): {
+ F_ReferCode: string;
+ F_MessageType: string;
+ F_RefNo: string;
+ F_MessageContent: string;
+ F_Remark: string;
+ F_AttachFile: string;
+ F_CustomerName?: string;
+ F_UrgencyLevel: string;
+} {
+ const s = submitted || {};
+ return {
+ F_ReferCode: s.F_ReferCode || "",
+ F_MessageType: s.F_MessageType || "",
+ F_RefNo: s.F_RefNo || "",
+ F_MessageContent: s.F_MessageContent || "",
+ F_Remark: s.F_Remark || "",
+ F_AttachFile: s.F_AttachFile || "",
+ F_CustomerName: s.F_CustomerName || undefined,
+ F_UrgencyLevel: s.F_UrgencyLevel || "0",
+ };
+}
+
+export const WORK_ORDER_FIELD_LABELS: Record = {
+ F_ReferCode: "参考号",
+ F_MessageType: "留言类型",
+ F_RefNo: "Ref No.",
+ F_MessageContent: "留言内容",
+ F_Remark: "备注",
+ F_AttachFile: "附件",
+ F_CustomerName: "客户",
+ F_UrgencyLevel: "紧急程度",
+};
+
+/** 只读回显:列出人工补充/修改了哪些字段及提交值 */
+export function describeWorkOrderFieldMarks(
+ fieldMarks: WorkOrderFieldMarks | null | undefined,
+ submitted: Record | null | undefined,
+): string[] {
+ if (!fieldMarks) return [];
+ const lines: string[] = [];
+ for (const key of WORK_ORDER_SNAPSHOT_FIELDS) {
+ const mark = fieldMarks[key];
+ if (!mark) continue;
+ const label = WORK_ORDER_FIELD_LABELS[key];
+ const val = norm(submitted?.[key]);
+ const shown = val || "(空)";
+ lines.push(
+ mark === "supplemented"
+ ? `${label}:${shown}`
+ : `${label}:已改为 ${shown}`,
+ );
+ }
+ return lines;
+}
diff --git a/src/services/state-machine.ts b/src/services/state-machine.ts
index 69b7326..4aa650b 100644
--- a/src/services/state-machine.ts
+++ b/src/services/state-machine.ts
@@ -26,7 +26,7 @@ const ALLOWED: Record = {
PENDING_CONFIRM: ["IMPORTING", "PARSING", "IGNORED", "PENDING_CONFIRM"],
IMPORTING: ["SUCCESS", "PARTIAL_SUCCESS", "FAILED"],
AUTO_EXECUTING: ["SUCCESS", "FAILED", "PARSED"],
- SUCCESS: [],
+ SUCCESS: ["IMPORTING"],
PARTIAL_SUCCESS: ["IMPORTING"],
FAILED: [
"IMPORTING",
diff --git a/src/types/cc.ts b/src/types/cc.ts
index cdc3aef..92cecff 100644
--- a/src/types/cc.ts
+++ b/src/types/cc.ts
@@ -10,7 +10,7 @@ export interface CcSaveContainerEntity {
F_TransMode: number;
F_OperationType: number;
F_ContainerNo: string;
- F_CabinetType?: string;
+ F_CabinetType?: string | null;
F_BLCopyCode?: string;
F_ETD?: string | null;
F_ETA?: string | null;
diff --git a/src/types/index.ts b/src/types/index.ts
index d4860df..5a7bb34 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -43,6 +43,8 @@ export interface MailMessage {
message_id: string;
subject: string;
from_addr: string;
+ /** 本系统接入邮箱(IMAP 收件账号) */
+ mailbox_addr?: string | null;
ingest_source?: "IMAP";
ingest_source_label?: string;
received_at: string;
@@ -120,6 +122,15 @@ export interface ContainerImport {
updated_at: string;
shipments_count?: number;
compensations?: ImportCompensationBrief[];
+ /** 管理端:归属租户;null=公共邮箱 */
+ tenant_id?: string | null;
+ tenant_username?: string | null;
+ tenant_display_name?: string | null;
+ /** forecast | work_order | do_upload | transfer */
+ import_kind?: string | null;
+ import_kind_label?: string | null;
+ /** 写入时选中的 CC 客户档案名称 */
+ cc_customer_name?: string | null;
}
export interface SessionUser {
@@ -138,6 +149,9 @@ export interface ImportPayload {
selected_row_indexes: number[];
ack_unmapped_channels?: boolean;
force_skip_conflict?: boolean;
+ cc_profile_id?: string;
+ /** 确认页手工增改 / 导入装箱表后的货件 */
+ shipments?: import("./mail").ShipmentRow[];
}
export interface ShippingLine {
@@ -200,6 +214,22 @@ export interface CcSettings {
updated_at: string | null;
}
+export interface CcCustomerProfile {
+ id: string;
+ name: string;
+ mock: boolean;
+ api_base: string;
+ saas_header: string;
+ login_mark: string;
+ username: string;
+ username_masked: string;
+ has_password: boolean;
+ enabled: boolean;
+ sort_order: number;
+ owner_user_id: string | null;
+ updated_at: string;
+}
+
export interface OAuthSettings {
public_base_url: string;
google_client_id: string;
@@ -229,6 +259,14 @@ export interface ImapFilterRuleItem {
created_at: string;
}
+export interface CcMessageTypeDictItem {
+ id: string;
+ name: string;
+ enabled: boolean;
+ sort_order: number;
+ updated_at: string;
+}
+
export interface ImapPullSettings {
poll_interval_ms: number;
poll_interval_ms_min?: number;
diff --git a/src/utils/mail-body-text.ts b/src/utils/mail-body-text.ts
index d464a25..b89ca3b 100644
--- a/src/utils/mail-body-text.ts
+++ b/src/utils/mail-body-text.ts
@@ -20,6 +20,8 @@ function decodeBasicEntities(s: string): string {
});
}
+export { decodeBasicEntities };
+
/** 粗抽 HTML 为纯文本(不解脚本;保留换行) */
export function htmlToPlainText(html: string): string {
if (!html) return "";
@@ -41,12 +43,12 @@ export function htmlToPlainText(html: string): string {
.trim();
}
-/** 若像 HTML 则转纯文本,否则原样 */
+/** 若像 HTML 则转纯文本;否则仍解码 等实体(转发链常见) */
export function plainTextFromMaybeHtml(raw: string | null | undefined): string {
const t = (raw || "").trim();
if (!t) return "";
if (/<[a-z][\s\S]*>/i.test(t)) return htmlToPlainText(t);
- return t;
+ return decodeBasicEntities(t);
}
/**
diff --git a/tests/unit/address-block.test.ts b/tests/unit/address-block.test.ts
index 81d7847..75b557e 100644
--- a/tests/unit/address-block.test.ts
+++ b/tests/unit/address-block.test.ts
@@ -44,4 +44,20 @@ describe("parseLabeledCell", () => {
it("ignores a plain street with no labels", () => {
expect(parseLabeledAddressBlock("12924 Pierce St")).toBeNull();
});
+
+ it("maps non-standard labels Tel / ADDR / Consignee", () => {
+ const p = parseLabeledCell(
+ "Consignee: Alice\nTel: 12025550123\nZIP: 10001\nStreet: 100 Main Street",
+ );
+ expect(p?.F_Contact).toBe("Alice");
+ expect(p?.F_Mobile).toBe("12025550123");
+ expect(p?.F_ZipCode).toBe("10001");
+ expect(p?.F_Address).toBe("100 Main Street");
+ });
+
+ it("supports subset and random order", () => {
+ const p = parseLabeledCell("ADDR: 9 Ocean Ave\n电话: 555-0100");
+ expect(p?.F_Address).toBe("9 Ocean Ave");
+ expect(p?.F_Mobile).toBe("5550100");
+ });
});
diff --git a/tests/unit/cc-api-v173-errors.test.ts b/tests/unit/cc-api-v173-errors.test.ts
new file mode 100644
index 0000000..6d8f1a9
--- /dev/null
+++ b/tests/unit/cc-api-v173-errors.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildCcApiFailureMessage,
+ ccContainerStatusLabel,
+ ccShipmentStatusLabel,
+ isContainerArrivedOrLater,
+} from "@/constants/cc-api-codes";
+import {
+ classifyCcFailure,
+ formatCcApiFailure,
+ formatCcCause,
+ formatCcContainerStatusReason,
+} from "@/constants/error-copy";
+
+describe("V1.73 CC status labels", () => {
+ it("maps container F_Status per V1.73", () => {
+ expect(ccContainerStatusLabel(0)).toBe("0-新单");
+ expect(ccContainerStatusLabel(20)).toBe("20-清关结束");
+ expect(ccContainerStatusLabel(60)).toBe("60-已到仓");
+ expect(ccContainerStatusLabel(65)).toBe("65-直送运输中");
+ expect(ccContainerStatusLabel(70)).toBe("70-已拆柜");
+ expect(ccContainerStatusLabel(80)).toBe("80-已还柜");
+ expect(isContainerArrivedOrLater(40)).toBe(false);
+ expect(isContainerArrivedOrLater(60)).toBe(true);
+ });
+
+ it("maps shipment Status including 22-已装车", () => {
+ expect(ccShipmentStatusLabel(20)).toBe("20-已出仓");
+ expect(ccShipmentStatusLabel(22)).toBe("22-已装车");
+ expect(ccShipmentStatusLabel(26)).toBe("26-运输中");
+ });
+});
+
+describe("formatCcApiFailure per interface", () => {
+ it("explains GetContainerList 400/410", () => {
+ expect(
+ formatCcApiFailure({
+ api: "GetContainerList",
+ code: 410,
+ info: "没有登录信息",
+ }),
+ ).toMatch(/集装箱列表|没有登录信息|410/);
+ expect(
+ formatCcApiFailure({
+ api: "GetContainerList",
+ code: 400,
+ info: "查询条件无效",
+ }),
+ ).toMatch(/集装箱列表|业务失败|查询条件/);
+ });
+
+ it("explains SaveContainer 400 with field reason", () => {
+ const msg = formatCcApiFailure({
+ api: "SaveContainer",
+ code: 400,
+ info: "以下字段未填写,不能保存:柜型",
+ });
+ expect(msg).toMatch(/集装箱数据导入|柜型/);
+ expect(classifyCcFailure(msg)).toBe("validation");
+ });
+
+ it("buildCcApiFailureMessage covers 500", () => {
+ expect(
+ buildCcApiFailureMessage({ api: "GetShipmentInfo", code: 500 }),
+ ).toMatch(/集装箱货件信息|异常|500/);
+ });
+
+ it("formats gate reason with container status", () => {
+ expect(formatCcContainerStatusReason(60, "批量转仓")).toMatch(
+ /60-已到仓.*批量转仓/,
+ );
+ });
+
+ it("still rewrites legacy GetContainerList failed strings", () => {
+ expect(formatCcCause("GetContainerList failed: 410")).toMatch(
+ /没有登录|集装箱列表|410/,
+ );
+ });
+});
diff --git a/tests/unit/cc-message-types.test.ts b/tests/unit/cc-message-types.test.ts
index 66c9cee..3598e1c 100644
--- a/tests/unit/cc-message-types.test.ts
+++ b/tests/unit/cc-message-types.test.ts
@@ -9,4 +9,11 @@ describe("suggestCcMessageType", () => {
"操作指令",
);
});
+
+ it("filters by enabled dictionary names", () => {
+ expect(suggestCcMessageType(["拦截"], ["操作指令"])).toBe("");
+ expect(suggestCcMessageType(["拦截"], ["货件拦截", "操作指令"])).toBe(
+ "货件拦截",
+ );
+ });
});
diff --git a/tests/unit/cc-response-speed.test.ts b/tests/unit/cc-response-speed.test.ts
index b7c1212..7fc960c 100644
--- a/tests/unit/cc-response-speed.test.ts
+++ b/tests/unit/cc-response-speed.test.ts
@@ -4,13 +4,15 @@ import { summarizeBody, deriveMailContentKind } from "@/utils/mailContent";
describe("CC token memory reuse", () => {
const loginMark = "mark-1";
+ const username = "fj";
it("reuses memory token when plenty of TTL remains", () => {
const expireAt = new Date(Date.now() + 2 * 60 * 60 * 1000);
expect(
shouldReuseCcMemoryToken(
- { loginMark, expireAt },
+ { loginMark, username, expireAt },
loginMark,
+ username,
Date.now(),
),
).toBe(true);
@@ -19,8 +21,28 @@ describe("CC token memory reuse", () => {
it("does not reuse when loginMark differs", () => {
expect(
shouldReuseCcMemoryToken(
- { loginMark: "other", expireAt: new Date(Date.now() + 3_600_000) },
+ {
+ loginMark: "other",
+ username,
+ expireAt: new Date(Date.now() + 3_600_000),
+ },
loginMark,
+ username,
+ Date.now(),
+ ),
+ ).toBe(false);
+ });
+
+ it("does not reuse when username differs (multi profile same loginMark)", () => {
+ expect(
+ shouldReuseCcMemoryToken(
+ {
+ loginMark,
+ username: "cs",
+ expireAt: new Date(Date.now() + 3_600_000),
+ },
+ loginMark,
+ username,
Date.now(),
),
).toBe(false);
@@ -29,15 +51,18 @@ describe("CC token memory reuse", () => {
it("falls back to DB when remaining TTL is under 30s", () => {
expect(
shouldReuseCcMemoryToken(
- { loginMark, expireAt: new Date(Date.now() + 10_000) },
+ { loginMark, username, expireAt: new Date(Date.now() + 10_000) },
loginMark,
+ username,
Date.now(),
),
).toBe(false);
});
it("does not reuse null memory", () => {
- expect(shouldReuseCcMemoryToken(null, loginMark, Date.now())).toBe(false);
+ expect(
+ shouldReuseCcMemoryToken(null, loginMark, username, Date.now()),
+ ).toBe(false);
});
});
diff --git a/tests/unit/classify-forecast-subject.test.ts b/tests/unit/classify-forecast-subject.test.ts
new file mode 100644
index 0000000..e1a6294
--- /dev/null
+++ b/tests/unit/classify-forecast-subject.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { classifyMail } from "@/services/parse/classify";
+import { shouldForceForecastUnit } from "@/services/parse/resolve-confirm-instruction";
+
+describe("classify — 主题预报+整表附件", () => {
+ it("classifies subject 预报 + 测试AI建单.xlsx as NEW_CONTAINER", () => {
+ const ev = classifyMail({
+ subject: "预报",
+ body: "",
+ filenames: ["测试AI建单.xlsx"],
+ });
+ expect(ev.mail_type).toBe("NEW_CONTAINER");
+ expect(ev.scores.NEW_CONTAINER).toBeGreaterThanOrEqual(40);
+ });
+
+ it("forces forecast unit for attachment-only 预报 mail", () => {
+ expect(
+ shouldForceForecastUnit({
+ subject: "预报",
+ attachments: [{ filename: "测试AI建单.xlsx" }],
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/tests/unit/container-header-from-sheet.test.ts b/tests/unit/container-header-from-sheet.test.ts
new file mode 100644
index 0000000..cf0195d
--- /dev/null
+++ b/tests/unit/container-header-from-sheet.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+import {
+ containerHeaderFromSheet,
+ matchContainerHeaderColumn,
+} from "@/services/parse/container-header-from-sheet";
+
+describe("container-header-from-sheet", () => {
+ it("matches ClientContainerTemplate header columns exactly", () => {
+ expect(matchContainerHeaderColumn("柜号Id")).toBeNull();
+ expect(matchContainerHeaderColumn("柜号")).toBe("F_ContainerNo");
+ expect(matchContainerHeaderColumn("柜型")).toBe("F_CabinetType");
+ expect(matchContainerHeaderColumn("运输方式")).toBe("F_TransModeText");
+ expect(matchContainerHeaderColumn("业务类型")).toBe("F_OperationTypeText");
+ });
+
+ it("extracts header from template-like grid", () => {
+ const grid = [
+ ["集装箱信息基础", "", "", "", "", "", "", "", "", "", "", "", "", "", "货件详情"],
+ [
+ "柜号",
+ "柜型",
+ "运输方式",
+ "业务类型",
+ "柜号Id",
+ "提单号",
+ "ETD",
+ "ETA",
+ "起始港",
+ "目的港",
+ "船司",
+ "客服",
+ "instruction",
+ "备注",
+ "Move Type",
+ "Custmer Ref No",
+ "Shipment ID(FBA)",
+ "PO",
+ "ReferenceId",
+ "FBACode",
+ "Address",
+ "QTY(CTNS)",
+ "Vol(CBM)",
+ "GW(KGS)",
+ ],
+ [
+ "TEST1234567",
+ "40HQ",
+ "海运",
+ "拆柜",
+ "TEST1234",
+ "TEST1234",
+ "2026-06-08",
+ "2026-06-20",
+ "深圳",
+ "纽约",
+ "",
+ "TEST",
+ "",
+ "",
+ "TRUCK",
+ "TEST123456789",
+ "FBA123456789",
+ "",
+ "",
+ "TEST",
+ "",
+ "123",
+ "2",
+ "2",
+ ],
+ ];
+ const header = containerHeaderFromSheet(grid, 1, "", {
+ enableIsoCheck: false,
+ });
+ expect(header.F_ContainerNo).toBe("TEST1234567");
+ expect(header.F_CabinetType).toBe("40HQ");
+ expect(header.F_TransMode).toBe(0);
+ expect(header.F_OperationType).toBe(0);
+ expect(header.F_ETD).toBe("2026-06-08");
+ expect(header.F_ETA).toBe("2026-06-20");
+ expect(header.F_LoadPort).toBe("深圳");
+ expect(header.F_Dock).toBe("纽约");
+ });
+});
diff --git a/tests/unit/do-confirm-gate.test.ts b/tests/unit/do-confirm-gate.test.ts
new file mode 100644
index 0000000..68a6247
--- /dev/null
+++ b/tests/unit/do-confirm-gate.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { collectDoImportIssues } from "@/services/cc/do-confirm-gate";
+
+describe("collectDoImportIssues", () => {
+ it("blocks empty or invalid container no", () => {
+ expect(collectDoImportIssues({ containerNo: "", filenames: ["A-DO.pdf"] }).blockers[0]).toMatch(
+ /柜号/,
+ );
+ expect(
+ collectDoImportIssues({
+ containerNo: "ABCD1234567",
+ filenames: ["A-DO.pdf"],
+ }).errors.containerNo,
+ ).toMatch(/ISO 6346/);
+ });
+
+ it("blocks when no DO attachment", () => {
+ const issues = collectDoImportIssues({
+ containerNo: "TRHU8524770",
+ filenames: [],
+ });
+ expect(issues.errors.filenames).toBeTruthy();
+ expect(issues.blockers[0]).toMatch(/DO 附件/);
+ });
+
+ it("allows valid container + DO file", () => {
+ const issues = collectDoImportIssues({
+ containerNo: "TRHU8524770",
+ filenames: ["TRHU8524770-DO.pdf"],
+ });
+ expect(issues.blockers).toEqual([]);
+ expect(issues.errors).toEqual({});
+ });
+});
diff --git a/tests/unit/excel-sheet-grid.test.ts b/tests/unit/excel-sheet-grid.test.ts
new file mode 100644
index 0000000..75d84de
--- /dev/null
+++ b/tests/unit/excel-sheet-grid.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+import fs from "fs";
+import path from "path";
+import ExcelJS from "exceljs";
+import {
+ findPackingHeaderRowIndex,
+ isMergedSlaveCell,
+ isPackingFooterNoiseRow,
+ isPackingTitleNoiseRow,
+ readWorksheetGrid,
+} from "@/services/parse/excel-sheet-grid";
+import { parsePackingListBuffer } from "@/services/parse/packing-list";
+
+describe("excel-sheet-grid", () => {
+ it("finds Chinese header row on merged 数据模版", async () => {
+ const file = path.join(
+ process.cwd(),
+ "docx",
+ "邮件",
+ "模板",
+ "数据模版.xlsx",
+ );
+ const wb = new ExcelJS.Workbook();
+ await wb.xlsx.readFile(file);
+ const grid = readWorksheetGrid(wb.worksheets[0]);
+ expect(isPackingTitleNoiseRow(grid[0])).toBe(true);
+ expect(findPackingHeaderRowIndex(grid)).toBe(2);
+ expect(grid[2].join("|")).toMatch(/件数.*渠道.*仓库/);
+ });
+
+ it("treats merged slaves as empty", async () => {
+ const file = path.join(
+ process.cwd(),
+ "docx",
+ "邮件",
+ "模板",
+ "数据模版.xlsx",
+ );
+ const wb = new ExcelJS.Workbook();
+ await wb.xlsx.readFile(file);
+ const ws = wb.worksheets[0];
+ const master = ws.getCell("B4");
+ const slave = ws.getCell("B5");
+ expect(isMergedSlaveCell(master)).toBe(false);
+ expect(isMergedSlaveCell(slave)).toBe(true);
+ const grid = readWorksheetGrid(ws);
+ expect(grid[3][1]).toBe("可不填");
+ expect(grid[4][1]).toBe("");
+ });
+
+ it("detects footer 注意 merged note row", () => {
+ const cells = [
+ "注意:1.ups和fedex的件请直接在渠道栏对应写上ups和fedex",
+ "注意:1.ups和fedex的件请直接在渠道栏对应写上ups和fedex",
+ ];
+ expect(
+ isPackingFooterNoiseRow({ F_Transporter: cells[0] }, cells),
+ ).toBe(true);
+ });
+});
+
+describe("数据模版.xlsx merged template", () => {
+ it("parses structured region and skips footer note", async () => {
+ const file = path.join(
+ process.cwd(),
+ "docx",
+ "邮件",
+ "模板",
+ "数据模版.xlsx",
+ );
+ const buf = fs.readFileSync(file);
+ const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
+ expect(parsed.errorCode).toBeUndefined();
+ expect(parsed.lineage.header_row).toBe(3);
+ expect(parsed.shipments.length).toBe(33);
+ expect(
+ parsed.shipments.every(
+ (s) => !String(s.F_Address || "").startsWith("注意"),
+ ),
+ ).toBe(true);
+ expect(
+ parsed.shipments.every(
+ (s) => !String(s.F_Transporter || "").startsWith("注意"),
+ ),
+ ).toBe(true);
+ const las1 = parsed.shipments.find((s) => s.F_FBAID === "FBA16SQQ7PTF");
+ expect(las1?.F_Contact).toBe("LAS1");
+ expect(las1?.F_Address).toBe("12300 Bermuda Road");
+ });
+});
diff --git a/tests/unit/extract-header.test.ts b/tests/unit/extract-header.test.ts
index 40f094f..355ee95 100644
--- a/tests/unit/extract-header.test.ts
+++ b/tests/unit/extract-header.test.ts
@@ -15,7 +15,7 @@ ETA:2026-08-15
ETD:2026-07-20
船司:MSC
装货港:上海
-码头:洋山
+目的港:洛杉矶
提单:BLMATU2745683
`;
const h = extractContainerHeaderFromText(text);
@@ -24,7 +24,7 @@ ETD:2026-07-20
expect(h.F_ETD).toBe("2026-07-20");
expect(h.shipping_line_hint).toBe("MSC");
expect(h.F_LoadPort).toBe("上海");
- expect(h.F_Dock).toBe("洋山");
+ expect(h.F_Dock).toBe("洛杉矶");
expect(h.F_BLCopyCode).toBe("BLMATU2745683");
});
@@ -39,6 +39,13 @@ ETD:2026-07-20
expect(h.F_Classis).toBe(0);
});
+ it("拆柜清单 → OperationType 0(对齐 AI 助手 header_extract)", () => {
+ const h = extractContainerHeaderFromText(
+ "WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+UPS-102件+亚马逊卡派-289件+私人地址-166件+拦截-209件+拆柜清单",
+ );
+ expect(h.F_OperationType).toBe(0);
+ });
+
it("shippingLineHintFromBl extracts WHLC", () => {
expect(shippingLineHintFromBl("WHLC027G597465")).toBe("WHLC");
});
diff --git a/tests/unit/forecast-confirm-gate.test.ts b/tests/unit/forecast-confirm-gate.test.ts
index 9a91b0e..32d7857 100644
--- a/tests/unit/forecast-confirm-gate.test.ts
+++ b/tests/unit/forecast-confirm-gate.test.ts
@@ -48,6 +48,7 @@ describe("collectForecastImportIssues", () => {
transMode: 0 as const,
operationType: 0 as const,
containerNo: "TRHU8524770",
+ cabinetType: "40HQ",
hasContainerConflict: false,
containerSource: null as "body" | "attachment" | null,
validRowCount: 1,
@@ -56,15 +57,15 @@ describe("collectForecastImportIssues", () => {
ackUnmapped: false,
};
- it("does not block header-only import when no valid rows", () => {
+ it("blocks when no shipment info (API requires shipment fields)", () => {
const issues = collectForecastImportIssues({
...base,
validRowCount: 0,
selectedValidCount: 0,
shipmentCount: 0,
});
- expect(issues.blockers).toEqual([]);
- expect(issues.notes.some((n) => n.includes("只提交柜头"))).toBe(true);
+ expect(issues.blockers.some((b) => /货件/.test(b))).toBe(true);
+ expect(issues.errors.rows).toBeTruthy();
});
it("blocks when valid rows exist but none selected", () => {
@@ -86,7 +87,7 @@ describe("collectForecastImportIssues", () => {
).toContain("ISO 6346");
});
- it("summarizes invalid packing rows without blocking header-only", () => {
+ it("blocks invalid packing rows with no valid shipment", () => {
const issues = collectForecastImportIssues({
...base,
validRowCount: 0,
@@ -96,17 +97,42 @@ describe("collectForecastImportIssues", () => {
{ row_index: 2, invalid_reasons: ["渠道缺失"] },
],
});
- expect(issues.blockers).toEqual([]);
+ expect(issues.blockers.some((b) => /有效货件/.test(b))).toBe(true);
expect(issues.notes[0]).toContain("渠道缺失(2行)");
expect(issues.notes[0]).toContain("件数无效");
});
- it("does not block client portal on the first tab", () => {
+ it("still blocks missing required fields on client first tab", () => {
const issues = collectForecastImportIssues({
...base,
portal: "client",
step: 0,
+ operationType: undefined,
+ validRowCount: 0,
+ selectedValidCount: 0,
+ shipmentCount: 0,
+ });
+ expect(issues.errors.operationType).toMatch(/业务类型/);
+ expect(issues.blockers.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("blocks missing transport / operation type with field errors", () => {
+ const issues = collectForecastImportIssues({
+ ...base,
+ transMode: undefined,
+ operationType: undefined,
+ });
+ expect(issues.errors.transMode).toMatch(/运输方式/);
+ expect(issues.errors.operationType).toMatch(/业务类型/);
+ expect(issues.blockers.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("blocks empty cabinet type", () => {
+ const issues = collectForecastImportIssues({
+ ...base,
+ cabinetType: "",
});
- expect(issues.blockers).toEqual([]);
+ expect(issues.errors.cabinetType).toMatch(/柜型/);
+ expect(issues.blockers.some((b) => /柜型/.test(b))).toBe(true);
});
});
diff --git a/tests/unit/four-business.test.ts b/tests/unit/four-business.test.ts
index eabeb16..d5607ec 100644
--- a/tests/unit/four-business.test.ts
+++ b/tests/unit/four-business.test.ts
@@ -55,7 +55,22 @@ describe("extractForecastInstructionText P4", () => {
body: MAIL4_BODY,
});
expect(text).not.toMatch(/覆盖贴/);
- expect(text).toMatch(/船名航次|HMM|EMERALD|/);
+ expect(text).toMatch(/船名航次|HMM|EMERALD/);
+ });
+
+ it("keeps Instruction independent of 拆柜清单 body even if forecast card exists", () => {
+ const subject =
+ "Fw: 转发:派送要求更新: 拆柜清单更新: DO请查收+拆柜清单更新: WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+拆柜清单";
+ const body = "拆柜清单更新 以此为准\n注意:需要拦截\n麻烦贴标交付";
+ const units = extractMailInstructions({ subject, body, filenames: [] });
+ expect(units.some((u) => u.uiKind === "forecast")).toBe(true);
+ const instr = extractForecastInstructionText({
+ subject,
+ body,
+ modulesInstruction: "船名航次 OOCL SINGAPORE / 065W",
+ });
+ expect(instr).toBe("船名航次 OOCL SINGAPORE / 065W");
+ expect(instr).not.toMatch(/拦截|贴标|拆柜清单更新/);
});
});
@@ -179,12 +194,13 @@ describe("evaluateTransferGate mock", () => {
it("ARRIVED container → work_order fallback", async () => {
const g = await evaluateTransferGate("ARRIVED1234567");
expect(g.canTransfer).toBe(false);
- if (!g.canTransfer) expect(g.fallback).toBe("work_order");
- });
-
- it("normal container → can transfer in mock", async () => {
- const g = await evaluateTransferGate("WHSU8127240");
- // depends on CC_MOCK; if live empty list may fallback
- expect(typeof g.canTransfer).toBe("boolean");
- });
+ if (!g.canTransfer) {
+ expect(g.fallback).toBe("work_order");
+ // mock 模式:柜态 60-已到仓;live 空列表也会降级工单
+ if (g.shipStatus != null) {
+ expect(g.shipStatus).toBe(60);
+ expect(g.reason).toMatch(/已到仓/);
+ }
+ }
+ }, 15_000);
});
diff --git a/tests/unit/header-service-hints.test.ts b/tests/unit/header-service-hints.test.ts
new file mode 100644
index 0000000..4c4d07e
--- /dev/null
+++ b/tests/unit/header-service-hints.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyServiceFromModules,
+ applyServiceFromText,
+ normalizeCabinetType,
+ stripOrChoiceNoise,
+} from "@/services/parse/header-service-hints";
+import { extractContainerHeaderFromText } from "@/services/parse/extract-container-header";
+import { mapBlModulesToHeader } from "@/services/parse/bl-plus-template";
+import { extractOpsSubjectFromMail } from "@/services/parse/ops-subject-template";
+import { buildCcForecastFormValue } from "@/components/CcForecastFormOrder";
+
+describe("header-service-hints (AI assistant parity)", () => {
+ it("strips 海运或空运 / 提拆派或直送 option noise", () => {
+ const t = stripOrChoiceNoise("运输方式(海运或空运)操作类型(提拆派或直送)");
+ expect(t).not.toMatch(/海运或空运|提拆派或直送/);
+ });
+
+ it("normalizeCabinetType 40HC → 40HQ", () => {
+ expect(normalizeCabinetType("40HC")).toBe("40HQ");
+ expect(normalizeCabinetType("40HQ")).toBe("40HQ");
+ });
+
+ it("拆柜清单 / 提拆派 / 直送 / 只清关", () => {
+ expect(applyServiceFromText("拆柜清单").F_OperationType).toBe(0);
+ expect(applyServiceFromText("提拆派组合柜-不带托架")).toMatchObject({
+ F_OperationType: 0,
+ F_Classis: 0,
+ F_Instruction: "提拆派",
+ });
+ expect(applyServiceFromText("本票直送").F_OperationType).toBe(2);
+ expect(applyServiceFromText("只清关").F_OperationType).toBe(4);
+ expect(
+ applyServiceFromText("提拆派,说明另有直送类型").F_OperationType,
+ ).toBe(0);
+ });
+
+ it("ops_hints 拆柜清单 → modules path", () => {
+ expect(
+ applyServiceFromModules({ ops_hints: ["拆柜清单", "派送要求"] })
+ .F_OperationType,
+ ).toBe(0);
+ expect(
+ applyServiceFromModules({ ops_hints: ["直送"] }).F_OperationType,
+ ).toBe(2);
+ });
+});
+
+describe("extractContainerHeaderFromText assistant parity", () => {
+ it("mail3 channel summary → 拆柜 + 海运 + OOCL 船司别名", () => {
+ const h = extractContainerHeaderFromText(
+ "WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+UPS-102件+亚马逊卡派-289件+私人地址-166件+拦截-209件+拆柜清单",
+ );
+ expect(h.F_OperationType).toBe(0);
+ expect(h.F_TransMode).toBe(0);
+ expect(h.F_ETA).toBe("2026-06-28");
+ expect(h.F_BLCopyCode).toBe("WHL063G550810");
+ expect(h.shipping_line_hint).toMatch(/WHL/i);
+ expect(h.F_Instruction).toMatch(/船名航次.*OOCL SINGAPORE/);
+ // 禁止 WAN/OOCL 船名首词误当船司时:有提单前缀则用 WHL
+ });
+
+ it("does not take vessel first token as shipping line (WAN HAI)", () => {
+ const h = extractContainerHeaderFromText(
+ "船名航次:WAN HAI 511-V.E110 柜号 TCNU4412465",
+ );
+ expect(h.shipping_line_hint?.toUpperCase()).not.toBe("WAN");
+ expect(h.shipping_line_hint?.toUpperCase()).toMatch(/WAN\s*HAI|WHL|WAN HAI/i);
+ });
+
+ it("ignores 提拆派或直送 as selected operation", () => {
+ const h = extractContainerHeaderFromText(
+ "请选择操作类型(提拆派或直送)柜号 WHSU8127240",
+ );
+ expect(h.F_OperationType).toBeUndefined();
+ });
+});
+
+describe("ops subject → form full chain", () => {
+ it("mail3 subject fills OperationType via map + form", () => {
+ const subject =
+ "Fw: 转发:派送要求更新: 拆柜清单更新: DO请查收+拆柜清单更新: WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+UPS-102件+亚马逊卡派-289件+私人地址-166件+拦截-209件+拆柜清单";
+ const ops = extractOpsSubjectFromMail(subject, { defaultYear: 2026 })!;
+ const header = mapBlModulesToHeader(
+ {
+ F_ContainerNo: ops.modules.container_no || "",
+ container_no_valid: true,
+ },
+ ops.modules,
+ );
+ expect(header.F_OperationType).toBe(0);
+ expect(header.F_TransMode).toBe(0);
+ expect(header.F_ETA).toBe("2026-06-28");
+ const form = buildCcForecastFormValue({
+ header: {},
+ modules: ops.modules,
+ });
+ expect(form.F_OperationType).toBe(0);
+ expect(form.F_TransMode).toBe(0);
+ expect(form.F_ETA).toBe("2026-06-28");
+ expect(form.F_Instruction).toMatch(/船名航次/);
+ });
+});
diff --git a/tests/unit/import-log-meta.test.ts b/tests/unit/import-log-meta.test.ts
new file mode 100644
index 0000000..09cd3bc
--- /dev/null
+++ b/tests/unit/import-log-meta.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import {
+ importKindLabel,
+ inferImportKindFromObjectKey,
+ normalizeExternalId,
+} from "@/services/import/import-log-meta";
+
+describe("import-log-meta", () => {
+ it("infers kind from object key prefix", () => {
+ expect(inferImportKindFromObjectKey("WO/YT1")).toBe("work_order");
+ expect(inferImportKindFromObjectKey("DO/TCNU")).toBe("do_upload");
+ expect(inferImportKindFromObjectKey("XF/ABCD")).toBe("transfer");
+ expect(inferImportKindFromObjectKey("TCNU4412465")).toBe("forecast");
+ });
+
+ it("labels kinds in Chinese", () => {
+ expect(importKindLabel("work_order")).toBe("工单");
+ expect(importKindLabel("forecast")).toBe("新增预报");
+ });
+
+ it("normalizes external ids and drops [object Object]", () => {
+ expect(normalizeExternalId("[object Object]")).toBeNull();
+ expect(normalizeExternalId({ F_Id: "abc-1" })).toBe("abc-1");
+ expect(normalizeExternalId("uuid-ok")).toBe("uuid-ok");
+ });
+});
diff --git a/tests/unit/instruction-import-log.test.ts b/tests/unit/instruction-import-log.test.ts
new file mode 100644
index 0000000..5a9cc4e
--- /dev/null
+++ b/tests/unit/instruction-import-log.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import {
+ instructionImportObjectKey,
+ instructionImportShipmentsHash,
+} from "@/services/import/instruction-import-log";
+
+describe("instruction-import-log", () => {
+ it("builds unique object keys with kind prefix (no raw ISO collide)", () => {
+ expect(
+ instructionImportObjectKey({
+ kind: "work_order",
+ instructionId: "wo-1",
+ display: "YT2604021091",
+ }),
+ ).toBe("WO/YT2604021091");
+ expect(
+ instructionImportObjectKey({
+ kind: "do_upload",
+ instructionId: "do-1",
+ display: "TCNU4412465",
+ }),
+ ).toBe("DO/TCNU4412465");
+ expect(
+ instructionImportObjectKey({
+ kind: "transfer",
+ instructionId: "xf-1",
+ display: "ABCD1234567",
+ }),
+ ).toBe("XF/ABCD1234567");
+ });
+
+ it("same instruction id shares shipments hash; different ids differ", () => {
+ const a = instructionImportShipmentsHash("unit-a");
+ const b = instructionImportShipmentsHash("unit-b");
+ expect(a).toHaveLength(64);
+ expect(a).not.toBe(b);
+ expect(instructionImportShipmentsHash("unit-a")).toBe(a);
+ });
+
+ it("falls back to instruction id when display empty", () => {
+ expect(
+ instructionImportObjectKey({
+ kind: "work_order",
+ instructionId: "seg-body-2",
+ display: "",
+ }),
+ ).toBe("WO/seg-body-2");
+ });
+
+ it("stable object key length within 50", () => {
+ const key = instructionImportObjectKey({
+ kind: "work_order",
+ instructionId: "x".repeat(80),
+ display: "Y".repeat(80),
+ });
+ expect(key.length).toBeLessThanOrEqual(50);
+ expect(key.startsWith("WO/")).toBe(true);
+ });
+});
diff --git a/tests/unit/list-http-gate.test.ts b/tests/unit/list-http-gate.test.ts
new file mode 100644
index 0000000..63b1aca
--- /dev/null
+++ b/tests/unit/list-http-gate.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ resetListHttpGateForTests,
+ runWithListHttpGate,
+} from "@/services/cc/list-http-gate";
+
+describe("runWithListHttpGate", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ resetListHttpGateForTests();
+ });
+
+ it("waits 60s between HTTP slots for the same profile", async () => {
+ vi.useFakeTimers();
+ const calls: number[] = [];
+ const first = runWithListHttpGate("profile:1", async () => {
+ calls.push(Date.now());
+ return "a";
+ });
+ await vi.runOnlyPendingTimersAsync();
+ await first;
+
+ const second = runWithListHttpGate("profile:1", async () => {
+ calls.push(Date.now());
+ return "b";
+ });
+ await vi.advanceTimersByTimeAsync(59_000);
+ let settled = false;
+ void second.then(() => {
+ settled = true;
+ });
+ await Promise.resolve();
+ expect(settled).toBe(false);
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(await second).toBe("b");
+ expect(calls).toHaveLength(2);
+ });
+});
diff --git a/tests/unit/match-segment-attachments.test.ts b/tests/unit/match-segment-attachments.test.ts
index f60c67f..9c95eca 100644
--- a/tests/unit/match-segment-attachments.test.ts
+++ b/tests/unit/match-segment-attachments.test.ts
@@ -164,4 +164,17 @@ describe("buildCcWorkOrderFormValue uses the same rule", () => {
expect(v.F_AttachFile).toContain("WL99583-LAX9.pdf");
expect(v.F_AttachFile).toContain("卡转海.zip");
});
+
+ it("does not show empty attach as code; matches zip via 卡转海 action tags", () => {
+ const v = buildCcWorkOrderFormValue({
+ subject: "拆柜清单更新:拦截-209件+拆柜清单",
+ body: "以上几票需要拦截 清单中已备注",
+ filenames: ["WHSU5574991 卡转海.zip", "3邮箱.pdf"],
+ containerNo: "WHSU5574991",
+ actions: ["拦截", "拆柜清单", "卡转海"],
+ isCurrent: true,
+ });
+ expect(v.F_AttachFile).toContain("卡转海.zip");
+ expect(v.F_AttachFile).not.toMatch(/F_AttachFile/);
+ });
});
diff --git a/tests/unit/operator-error-copy.test.ts b/tests/unit/operator-error-copy.test.ts
index d928346..c1be1ca 100644
--- a/tests/unit/operator-error-copy.test.ts
+++ b/tests/unit/operator-error-copy.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ classifyCcFailure,
+ conflictCheckOperatorMessage,
+ describeCcFailureForOperator,
formatCcCause,
formatImapSkipReason,
formatMailLastError,
@@ -32,9 +35,17 @@ describe("formatOperatorError", () => {
it("translates CC transport failures", () => {
expect(formatCcCause("CC request timeout")).toMatch(/超时/);
expect(formatCcCause("CC request failed")).toMatch(/无法连接/);
- expect(formatCcCause("CC save failed with code 400")).toMatch(/拒绝保存/);
- expect(formatCcCause("GetContainerList timeout")).toMatch(/查询柜号/);
+ expect(formatCcCause("CC save failed with code 400")).toMatch(
+ /集装箱数据导入|拒绝|400/,
+ );
+ expect(formatCcCause("GetContainerList timeout")).toMatch(
+ /集装箱列表|超时/,
+ );
expect(formatMailLastError("CC_WRITE_OFF")).toMatch(/未开启/);
+ expect(formatMailLastError("WO_MOCK_OK")).toMatch(/模拟|未写入/);
+ expect(formatMailLastError("WO_MOCK_OK")).not.toMatch(/操作失败/);
+ expect(formatMailLastError("XFER_MOCK_OK")).toMatch(/模拟|未写入/);
+ expect(formatMailLastError("DO_MOCK_OK")).toMatch(/模拟|未写入/);
expect(formatCcCause("Unauthorized")).not.toMatch(/Unauthorized/i);
});
@@ -55,7 +66,22 @@ describe("formatOperatorError", () => {
it("rewrites English embedded in Chinese", () => {
expect(
formatOperatorError("查柜失败:GetContainerList timeout,默认降级工单"),
- ).toMatch(/查询柜号超时/);
+ ).toMatch(/集装箱列表|超时/);
+ });
+
+ it("keeps Chinese packing-list errors that mention Excel/xlsx", () => {
+ expect(
+ formatOperatorError(
+ "解析装箱表失败,请确认文件为有效的 Excel(xlsx)",
+ "PARSE_FAILED",
+ ),
+ ).toMatch(/装箱表|Excel|xlsx/);
+ expect(
+ formatOperatorError(
+ "未能从表格解析出货件行,请确认是卡派清单等含「渠道/件数/仓库」表头的 xlsx",
+ "PARSE_FAILED",
+ ),
+ ).toMatch(/货件行|卡派清单/);
});
it("maps OAuth and IMAP skip reasons", () => {
@@ -63,4 +89,32 @@ describe("formatOperatorError", () => {
expect(formatImapSkipReason("no_credentials")).toMatch(/邮箱/);
expect(formatImapSkipReason("SKIP_BLACKLIST")).toMatch(/黑名单/);
});
+
+ it("classifies conflict check failures for operators", () => {
+ expect(classifyCcFailure("CC_LOGIN_FAILED:账户或密码错误")).toBe("login");
+ expect(classifyCcFailure("访问次数限制,请稍后再试")).toBe("rate_limit");
+ expect(classifyCcFailure("GetContainerList timeout")).toBe("timeout");
+ expect(
+ conflictCheckOperatorMessage("login", "CC_LOGIN_FAILED:401"),
+ ).toMatch(/账号|密码/);
+ expect(conflictCheckOperatorMessage("rate_limit")).toMatch(/每分钟|60/);
+ });
+
+ it("maps CC login credential errors", () => {
+ expect(formatCcCause("CC_CREDENTIALS_MISSING")).toMatch(/未配置/);
+ expect(formatCcCause("CC_LOGIN_FAILED:账户或密码错误")).toMatch(/密码/);
+ });
+
+ it("describeCcFailureForOperator exposes kind and message", () => {
+ const login = describeCcFailureForOperator("CC_LOGIN_FAILED:账户或密码错误");
+ expect(login.kind).toBe("login");
+ expect(login.kindLabel).toMatch(/登录/);
+ expect(login.message).toMatch(/账号|密码/);
+
+ const validation = describeCcFailureForOperator(
+ "以下字段未填写,不能保存:柜型",
+ );
+ expect(validation.kind).toBe("validation");
+ expect(validation.message).toMatch(/柜型|40HQ/);
+ });
});
diff --git a/tests/unit/ops-subject-mail3.test.ts b/tests/unit/ops-subject-mail3.test.ts
index 4247ca7..e5ed6f7 100644
--- a/tests/unit/ops-subject-mail3.test.ts
+++ b/tests/unit/ops-subject-mail3.test.ts
@@ -46,6 +46,24 @@ describe("ops-subject-template mail3", () => {
expect(r!.modules.eta).toBe("2026-06-28");
});
+ it("拆柜清单 ops_hints → mapBlModulesToHeader OperationType 0", async () => {
+ const { mapBlModulesToHeader } = await import(
+ "@/services/parse/bl-plus-template"
+ );
+ const r = extractOpsSubjectFromMail(MAIL3_SUBJECT, { defaultYear: 2026 })!;
+ const header = mapBlModulesToHeader(
+ {
+ F_ContainerNo: r.modules.container_no || "",
+ container_no_valid: true,
+ },
+ r.modules,
+ );
+ expect(r.modules.ops_hints).toEqual(
+ expect.arrayContaining(["拆柜清单"]),
+ );
+ expect(header.F_OperationType).toBe(0);
+ });
+
it("classifies as WORK_ORDER and builds subject-only mail_record", () => {
const evidence = classifyMail({
subject: MAIL3_SUBJECT,
diff --git a/tests/unit/packing-list.test.ts b/tests/unit/packing-list.test.ts
index 47166c0..83f48d4 100644
--- a/tests/unit/packing-list.test.ts
+++ b/tests/unit/packing-list.test.ts
@@ -54,6 +54,23 @@ describe("packing-list", () => {
expect(parsed.lineage.missing_columns).toBeTruthy();
});
+ it("parses 数据模版.xlsx(含合并单元格占位行)", async () => {
+ const fs = await import("fs/promises");
+ const path = await import("path");
+ const file = path.join(
+ process.cwd(),
+ "docx",
+ "邮件",
+ "模板",
+ "数据模版.xlsx",
+ );
+ const buf = await fs.readFile(file);
+ const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
+ expect(parsed.errorCode).toBeUndefined();
+ expect(parsed.shipments.length).toBeGreaterThan(0);
+ expect(parsed.shipments.some((s) => s.row_status === "VALID")).toBe(true);
+ });
+
it("parses 新辰泽官方模板(多行表头 + UPS空仓库ID)", async () => {
const fs = await import("fs/promises");
const path = await import("path");
@@ -77,6 +94,14 @@ describe("packing-list", () => {
expect(fedex.every((s) => s.F_FBACode === "FEDEX")).toBe(true);
const amazon = parsed.shipments.find((s) => s.F_FBAID === "FBA16SQQ7PTF");
expect(amazon?.F_FBACode).toBe("LAS1");
+ expect(amazon?.F_Contact).toBe("LAS1");
+ expect(amazon?.F_Mobile).toBe("0123456789");
+ expect(amazon?.F_State).toBe("NV");
+ expect(amazon?.F_City).toBe("HENDERSON");
+ expect(amazon?.F_ZipCode).toBe("89044-8746");
+ expect(amazon?.F_Address).toBe("12300 Bermuda Road");
+ expect(amazon?.F_EnglishName).toBe("AMAZON COM SERVICES INC");
+ expect(String(amazon?.F_Address || "")).not.toMatch(/仓库代码|收件人/);
const priv = parsed.shipments.find((s) =>
(s.F_Address || "").includes("12924 Pierce"),
);
@@ -283,6 +308,24 @@ describe("packing-list", () => {
expect(s.F_Remark).toBe("三票分开打托");
});
+ it("splits non-standard ADDR/Tel labels in Address column", async () => {
+ const wb = new ExcelJS.Workbook();
+ const ws = wb.addWorksheet("Sheet1");
+ ws.addRow(["仓库ID", "渠道", "件数", "派送地址"]);
+ ws.addRow([
+ "LAS1",
+ "卡派",
+ 2,
+ "ADDR: 9 Ocean Ave\n电话: 555-0100",
+ ]);
+ const buf = Buffer.from(await wb.xlsx.writeBuffer());
+ const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
+ const s = parsed.shipments[0];
+ expect(s.F_Address).toBe("9 Ocean Ave");
+ expect(s.F_Mobile).toBe("5550100");
+ expect(String(s.F_Address || "")).not.toMatch(/电话/);
+ });
+
it("keeps dedicated Contact column over Address 块里的收件人", async () => {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Sheet1");
@@ -301,4 +344,39 @@ describe("packing-list", () => {
expect(s.F_Contact).toBe("Tom");
expect(s.F_Address).toBe("1 Main St");
});
+
+ it("parses official ClientContainerTemplate.xlsx", async () => {
+ const fs = await import("fs/promises");
+ const path = await import("path");
+ const file = path.join(
+ "x:",
+ "work",
+ "carriercentral-ai-assistant",
+ "docs",
+ "附件",
+ "ClientContainerTemplate.xlsx",
+ );
+ try {
+ await fs.access(file);
+ } catch {
+ return;
+ }
+ const buf = await fs.readFile(file);
+ const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
+ expect(parsed.errorCode).toBeUndefined();
+ expect(parsed.container_header.F_ContainerNo).toBe("TEST1234567");
+ expect(parsed.container_header.F_CabinetType).toBe("40HQ");
+ expect(parsed.container_header.F_TransMode).toBe(0);
+ expect(parsed.container_header.F_OperationType).toBe(0);
+ expect(parsed.container_header.F_ETD).toBe("2026-06-08");
+ expect(parsed.container_header.F_ETA).toBe("2026-06-20");
+ expect(parsed.shipments.length).toBeGreaterThanOrEqual(1);
+ const row = parsed.shipments[0];
+ expect(row.F_Transporter).toBe("TRUCK");
+ expect(row.F_ShipmentID).toBe("TEST123456789");
+ expect(row.F_FBAID).toBe("FBA123456789");
+ expect(row.F_CTNS).toBe(123);
+ expect(row.F_Weight).toBe(2);
+ expect(parsed.lineage.header_from_attachment).toBe(true);
+ });
});
diff --git a/tests/unit/resolve-confirm-instruction.test.ts b/tests/unit/resolve-confirm-instruction.test.ts
index 0c2c31f..283c703 100644
--- a/tests/unit/resolve-confirm-instruction.test.ts
+++ b/tests/unit/resolve-confirm-instruction.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ extractInstructionsFromMail,
resolveConfirmInstruction,
+ shouldForceForecastUnit,
} from "@/services/parse/resolve-confirm-instruction";
import type { MailInstructionUnit } from "@/services/parse/split-instructions";
@@ -78,3 +80,58 @@ describe("resolveConfirmInstruction", () => {
expect(resolved.unit?.id).toBe(forecast.id);
});
});
+
+describe("extractInstructionsFromMail forceForecast parity", () => {
+ it("mail100-style DO + 整柜直送 subject still yields forecast unit", () => {
+ const mail = {
+ subject:
+ "Fw: Fw:回复:柜号:TRHU8524770+提单号:EGLV143661466958+封号:EMCEJJ0635+YWZD+不带车架+整柜直送",
+ body_text: "TRHU8524770 DO请查收",
+ attachments: [{ filename: "TRHU8524770-DO-1.pdf" }],
+ parse_result: {
+ container_header: { F_ContainerNo: "TRHU8524770" },
+ mail_record: { kind: "BL_FORECAST" },
+ },
+ };
+ expect(shouldForceForecastUnit(mail)).toBe(true);
+ const units = extractInstructionsFromMail(mail);
+ expect(units.some((u) => u.uiKind === "do_upload")).toBe(true);
+ const forecast = units.find((u) => u.uiKind === "forecast");
+ expect(forecast).toBeTruthy();
+ expect(forecast!.id).toMatch(/^forecast-record-/);
+ const resolved = resolveConfirmInstruction({
+ units,
+ instructionId: forecast!.id,
+ });
+ expect(resolved.reason).toBe("ok");
+ expect(resolved.unit?.uiKind).toBe("forecast");
+ });
+
+ it("forces forecast from 整柜直送 subject even without mail_record", () => {
+ const units = extractInstructionsFromMail({
+ subject:
+ "回复:柜号:TRHU8524770+提单号:EGLV143661466958+YWZD+不带车架+整柜直送",
+ body_text: "DO请查收",
+ attachments: [{ filename: "TRHU8524770-DO-1.pdf" }],
+ });
+ expect(units.some((u) => u.uiKind === "forecast")).toBe(true);
+ expect(units.some((u) => u.uiKind === "do_upload")).toBe(true);
+ });
+
+ it("does not force forecast for work-order mail that only has container in subject", () => {
+ const mail = {
+ subject: "Fw: 转发:TIIU8073522-90022",
+ body_text:
+ "主题 TIIU8073522-90022\nBAZUS001787430-90022 4PX 预约码: ==CNF26070717",
+ mail_type: "WORK_ORDER",
+ attachments: [] as Array<{ filename: string }>,
+ parse_result: {
+ container_header: { F_ContainerNo: "TIIU8073522" },
+ mail_record: { kind: "WORK_ORDER" },
+ },
+ };
+ expect(shouldForceForecastUnit(mail)).toBe(false);
+ const units = extractInstructionsFromMail(mail);
+ expect(units.some((u) => u.uiKind === "forecast")).toBe(false);
+ });
+});
diff --git a/tests/unit/revalidate-shipment-row.test.ts b/tests/unit/revalidate-shipment-row.test.ts
new file mode 100644
index 0000000..81f40fa
--- /dev/null
+++ b/tests/unit/revalidate-shipment-row.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import {
+ revalidateShipmentRow,
+ sanitizeClientShipments,
+} from "@/services/parse/revalidate-shipment-row";
+
+describe("revalidateShipmentRow", () => {
+ it("marks incomplete row INVALID", () => {
+ const row = revalidateShipmentRow({ row_index: 0 });
+ expect(row.row_status).toBe("INVALID");
+ expect(row.invalid_reasons?.length).toBeGreaterThan(0);
+ });
+
+ it("accepts truck row with qty/cbm/weight", () => {
+ const row = revalidateShipmentRow({
+ row_index: 0,
+ channel_raw: "TRUCK",
+ F_FBACode: "ONT8",
+ F_CTNS: 10,
+ F_CBM: 1.2,
+ F_Weight: 100,
+ F_ShipmentID: "REF-1",
+ });
+ expect(row.row_status).toBe("VALID");
+ expect(row.F_Transporter).toBe("TRUCK");
+ expect(row.F_ShipmentID).toBe("REF-1");
+ });
+
+ it("sanitizeClientShipments reindexes", () => {
+ const rows = sanitizeClientShipments([
+ {
+ row_index: 9,
+ channel_raw: "UPS",
+ F_CTNS: 1,
+ F_CBM: 0.1,
+ F_Weight: 2,
+ },
+ ]);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].row_index).toBe(0);
+ expect(rows[0].row_status).toBe("VALID");
+ });
+
+ it("splits labeled Address blob into city/state/contact/mobile", () => {
+ const blob =
+ "仓库代码: LAS1 收件人: LAS1 公司名称: AMAZON COM SERVICES INC 联系电话: 0123456789 省/市/区: NV / HENDERSON / 邮政编码: 89044-8746 收货地址: 12300 Bermuda Road";
+ const row = revalidateShipmentRow({
+ row_index: 0,
+ channel_raw: "TRUCK",
+ F_CTNS: 44,
+ F_CBM: 5.14,
+ F_Weight: 567,
+ F_Address: blob,
+ });
+ expect(row.F_Address).toBe("12300 Bermuda Road");
+ expect(row.F_City).toBe("HENDERSON");
+ expect(row.F_State).toBe("NV");
+ expect(row.F_ZipCode).toBe("89044-8746");
+ expect(row.F_Contact).toBe("LAS1");
+ expect(row.F_Mobile).toBe("0123456789");
+ expect(row.F_EnglishName).toBe("AMAZON COM SERVICES INC");
+ expect(row.F_FBACode).toBe("LAS1");
+ });
+});
diff --git a/tests/unit/split-instructions.test.ts b/tests/unit/split-instructions.test.ts
index 4a9fee0..ac2e256 100644
--- a/tests/unit/split-instructions.test.ts
+++ b/tests/unit/split-instructions.test.ts
@@ -327,6 +327,32 @@ describe("extractForecastInstructionText", () => {
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, 请处理。",
diff --git a/tests/unit/transfer-gate-rate-limit.test.ts b/tests/unit/transfer-gate-rate-limit.test.ts
index 360c73b..659d2b2 100644
--- a/tests/unit/transfer-gate-rate-limit.test.ts
+++ b/tests/unit/transfer-gate-rate-limit.test.ts
@@ -8,6 +8,7 @@ describe("transfer gate vs GetContainerList rate limit", () => {
"访问时长限制,当前接口访问时间间隔[60]秒!",
),
).toBe(true);
+ expect(isCcRateLimitedMessage("访问次数限制,请稍后再试")).toBe(true);
expect(isCcRateLimitedMessage("GetContainerList timeout")).toBe(false);
});
});
diff --git a/tests/unit/work-order-fields.test.ts b/tests/unit/work-order-fields.test.ts
new file mode 100644
index 0000000..b3cf492
--- /dev/null
+++ b/tests/unit/work-order-fields.test.ts
@@ -0,0 +1,239 @@
+import { describe, expect, it } from "vitest";
+import {
+ extractWorkOrderFormFields,
+ parseWorkOrderReferCode,
+ parseWorkOrderRefNo,
+ splitWorkOrderContentAndRemark,
+ stripWorkOrderBodyNoise,
+} from "@/services/parse/work-order-fields";
+import { buildCcWorkOrderFormValue } from "@/components/CcWorkOrderForm";
+
+describe("splitWorkOrderContentAndRemark(对齐 AI 助手)", () => {
+ const cases: Array<[string, string, string]> = [
+ [
+ "安排卡转海,麻烦安排贴标操作。\n注意:ups默认一件贴2张面单",
+ "安排卡转海",
+ "ups默认",
+ ],
+ ["特别说明:拍照要清晰", "", "拍照要清晰"],
+ [
+ "WL1 2件\n注意:加急处理,必须今天下午前交付",
+ "WL1",
+ "加急处理",
+ ],
+ [
+ "WL103262-6件\n派送要求:分别按票打板 千万不要混打\n注意:柜子里的亚马逊卡派货物,是我们在国内换过标签",
+ "派送要求",
+ "换过标签",
+ ],
+ [
+ "1.WL104834-2 SCK8 2件\n这票2件每箱都需要开箱拍下里面的产品并回传照片\n2.\n以上货物需要卡转海 麻烦贴标交付\n注意:FBA箱号需要对应快递子单号",
+ "开箱拍下",
+ "FBA箱号需要对应",
+ ],
+ [
+ "拆柜清单更新 以此为准\n注意:\n1.WL103516\n以上几票需要拦截\n2.\n这2票改自提",
+ "改自提",
+ "",
+ ],
+ [
+ "TCNU4412465+拆柜清单\n注意:\nWL103262-6件\nWL104381-24件\n派送要求:分别按票打板 千万不要混打\n注意:\n柜子里的亚马逊卡派货物,是我们在国内换过标签,WL唛头上的仓点和FBA标签可能不一样,需要注意下区分。需要根据wl唛头、拆柜清单还有FBA来分货,不能只根据WL唛头来打托。",
+ "派送要求",
+ "换过标签",
+ ],
+ [
+ "TCNU4412465\nWL103262 6件\n这两票客户需要美时7/10 下午2点前送达 尽量早些哈",
+ "WL103262",
+ "尽量早些",
+ ],
+ [
+ "TCNU4412465\nWL103262 6件\nWL104381 25件\n这两票总共31件记得叫仓库拍一下照片过来哈。今晚辛苦操作一下",
+ "WL104381",
+ "拍一下照片",
+ ],
+ ["请安排贴标", "请安排贴标", ""],
+ ];
+
+ for (const [text, needContent, needRemark] of cases) {
+ it(`splits: ${text.slice(0, 28).replace(/\n/g, " ")}…`, () => {
+ const { content, remark } = splitWorkOrderContentAndRemark(text);
+ if (needContent) expect(content).toContain(needContent);
+ if (needRemark) expect(remark).toContain(needRemark);
+ else expect(remark.trim()).toBe("");
+ if (needContent.includes("开箱拍下")) {
+ expect(remark).not.toContain("开箱拍下");
+ }
+ if (needRemark.includes("换过标签") && text.includes("派送要求")) {
+ expect(content).toContain("派送要求");
+ expect(content).not.toContain("柜子里的亚马逊");
+ expect(remark).not.toContain("派送要求");
+ }
+ if (needRemark.includes("尽量早些")) {
+ expect(content).not.toContain("尽量早些");
+ expect(content).toContain("WL103262");
+ }
+ if (needRemark.includes("拍一下照片")) {
+ expect(content).not.toContain("拍一下照片");
+ expect(content).toContain("WL103262");
+ }
+ });
+ }
+});
+
+describe("work-order refer / ref no", () => {
+ it("parses ISO container as refer code", () => {
+ expect(parseWorkOrderReferCode("参考号:TCNU4412465\n请安排换标")).toBe(
+ "TCNU4412465",
+ );
+ expect(parseWorkOrderReferCode("请处理 TCNU4412465 贴标")).toBe(
+ "TCNU4412465",
+ );
+ });
+
+ it("parses Ref No / FBA as ref no, not container", () => {
+ expect(parseWorkOrderRefNo("Ref No:WL003\n请安排贴标")).toBe("WL003");
+ expect(
+ parseWorkOrderRefNo(
+ "FBA19DYHCDLS-XPB2-37件\n安排卡转海",
+ "",
+ ),
+ ).toMatch(/FBA19DYHCDLS/);
+ });
+});
+
+describe("stripWorkOrderBodyNoise 邮箱/附件栏", () => {
+ it("drops standalone email and QQ attachment chrome from content", () => {
+ const raw = [
+ "2.",
+ "WL101167",
+ "WL92334",
+ "这2票改自提",
+ "clx@cnwally.com.cn",
+ ].join("\n");
+ const { content, remark } = splitWorkOrderContentAndRemark(raw);
+ expect(content).toContain("改自提");
+ expect(content).toContain("WL101167");
+ expect(content).not.toMatch(/clx@cnwally\.com\.cn/i);
+ expect(remark).not.toMatch(/@/);
+ });
+
+ it("drops attachment footer after intercept instruction", () => {
+ const raw = [
+ "以上单号需要拦截 清单中已备注",
+ "1个附件",
+ "普通附件 (1MB)",
+ "WHSU5574991 卡转海.zip (1M)",
+ "clx@cnwally.com.cn",
+ ].join("\n");
+ const cleaned = stripWorkOrderBodyNoise(raw);
+ expect(cleaned).toContain("需要拦截");
+ expect(cleaned).not.toMatch(/个附件|普通附件|\.zip|clx@/i);
+ });
+
+ it("drops 网易灵犀签名与落款短名", () => {
+ const raw = [
+ "FCIU7089474 MR03002964 改地址派送到 7400 E. Slauson Ave commerce. CA 90040",
+ "km16",
+ "km16@kmfba.com",
+ "签名由 网易灵犀办公 定制",
+ ].join("\n");
+ const cleaned = stripWorkOrderBodyNoise(raw);
+ expect(cleaned).toContain("改地址派送到");
+ expect(cleaned).toContain("FCIU7089474");
+ expect(cleaned).not.toMatch(/km16|网易灵犀|签名由/i);
+ });
+
+ it("keeps 注意 remark but strips trailing signature email", () => {
+ const { content, remark } = splitWorkOrderContentAndRemark(
+ [
+ "WL99583 LAX9 2",
+ "以上货物需要卡转海 麻烦贴标交付",
+ "注意:FBA箱号需要对应快递子单号,例如:FBA0001对应第一张快递面单",
+ "clx@cnwally.com.cn",
+ ].join("\n"),
+ );
+ expect(content).toContain("卡转海");
+ expect(remark).toContain("FBA箱号需要对应");
+ expect(remark).not.toMatch(/clx@cnwally\.com\.cn/i);
+ expect(content).not.toMatch(/clx@cnwally\.com\.cn/i);
+ });
+
+ it("strips company/signature lines from remark after 注意", () => {
+ const { content, remark } = splitWorkOrderContentAndRemark(
+ [
+ "派送要求:分别按票打板",
+ "注意:",
+ "柜子里的亚马逊卡派货物,是我们在国内换过标签",
+ "沃利供应链",
+ "朱甜甜 港后客服",
+ ].join("\n"),
+ );
+ expect(content).toContain("派送要求");
+ expect(remark).toContain("换过标签");
+ expect(remark).not.toMatch(/沃利供应链|港后客服/);
+ });
+});
+
+describe("buildCcWorkOrderFormValue 备注分界", () => {
+ it("does not put 邮件动作 into F_Remark; only 注意: block", () => {
+ const v = buildCcWorkOrderFormValue({
+ subject: "贴标操作",
+ body: [
+ "Dear,",
+ "FBA19DYHCDLS-XPB2-37件",
+ "安排卡转海,麻烦安排贴标操作。",
+ "注意:ups默认一件贴2张面单",
+ ].join("\n"),
+ filenames: [],
+ });
+ expect(v.F_MessageContent).toContain("安排卡转海");
+ expect(v.F_MessageContent).toContain("FBA19DYHCDLS");
+ expect(v.F_MessageContent).not.toMatch(/注意:/);
+ expect(v.F_Remark).toContain("ups默认");
+ expect(v.F_Remark).not.toMatch(/邮件动作/);
+ expect(v.F_RefNo).toMatch(/FBA19DYHCDLS/);
+ });
+
+ it("keeps numbered ops after 注意: in content", () => {
+ const v = buildCcWorkOrderFormValue({
+ subject: "拆柜清单更新",
+ body: [
+ "Dear,",
+ "拆柜清单更新 以此为准",
+ "注意:",
+ "1.WL103516",
+ "以上几票需要拦截",
+ "2.",
+ "这2票改自提",
+ ].join("\n"),
+ });
+ expect(v.F_MessageContent).toContain("改自提");
+ expect(v.F_MessageContent).toContain("WL103516");
+ expect(v.F_Remark.trim()).toBe("");
+ });
+
+ it("mail98 unit1 keeps FBA line in content and ups in remark", () => {
+ const v = buildCcWorkOrderFormValue({
+ subject: "贴标操作",
+ body: [
+ "TCNU4412465 WL003 FBA19DYHCDLS-XPB2-37件",
+ "安排卡转海,麻烦安排贴标操作。",
+ "注意:ups默认一件贴2张面单",
+ ].join("\n"),
+ filenames: [],
+ containerNo: "TCNU4412465",
+ });
+ expect(v.F_MessageContent).toContain("FBA19DYHCDLS");
+ expect(v.F_MessageContent).toContain("安排卡转海");
+ expect(v.F_Remark).toContain("ups默认");
+ expect(v.F_RefNo).toMatch(/WL003|FBA19DYHCDLS/);
+ });
+
+ it("extractWorkOrderFormFields preserves remark boundary", () => {
+ const f = extractWorkOrderFormFields({
+ body: "请安排贴标\n注意:照片要清晰",
+ });
+ expect(f.F_MessageContent).toBe("请安排贴标");
+ expect(f.F_Remark).toContain("照片要清晰");
+ });
+});
diff --git a/tests/unit/work-order-record.test.ts b/tests/unit/work-order-record.test.ts
index c91b88e..3090b1b 100644
--- a/tests/unit/work-order-record.test.ts
+++ b/tests/unit/work-order-record.test.ts
@@ -46,6 +46,31 @@ describe("work-order-record", () => {
expect(r.table.rows[0]["目标仓"]).toBe("POC2");
});
+ it("prefers body 改地址派送 over subject 派拆柜 template", () => {
+ const r = buildMailRecord({
+ mailType: "WORK_ORDER",
+ subject: "Fw: ZJYC COSU6504130784 FCIU7089474 7.10 派拆柜派送",
+ body:
+ "FCIU7089474 MR03002964 改地址派送到 7400 E. Slauson Ave commerce. CA 90040",
+ opsSubject: {
+ modules: {
+ customer_name: "ZJYC",
+ bl_no: "COSU6504130784",
+ container_no: "FCIU7089474",
+ eta: "2026-07-10",
+ service: "派拆柜派送",
+ ops_hints: ["拆柜", "派送"],
+ },
+ channels: [],
+ },
+ });
+ expect(r.kind).toBe("WORK_ORDER");
+ expect(r.work_order_actions).toContain("改地址派送");
+ expect(r.summary).toMatch(/改地址派送/);
+ expect(r.summary).not.toMatch(/拆柜、派送/);
+ expect(r.lineage.subject_only).toBe(false);
+ });
+
it("builds BL_FORECAST from modules", () => {
const r = buildMailRecord({
mailType: "NEW_CONTAINER",
diff --git a/tests/unit/work-order-snapshot.test.ts b/tests/unit/work-order-snapshot.test.ts
new file mode 100644
index 0000000..47ee5e8
--- /dev/null
+++ b/tests/unit/work-order-snapshot.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import {
+ describeWorkOrderFieldMarks,
+ diffWorkOrderSupplementedFields,
+ toWorkOrderSubmittedSnapshot,
+ workOrderFormFromSubmitted,
+} from "@/services/parse/work-order-snapshot";
+import { markInstructionImported } from "@/services/parse/instruction-execution-state";
+
+describe("diffWorkOrderSupplementedFields", () => {
+ it("marks empty→filled as supplemented", () => {
+ const { fieldMarks, userSupplemented, supplementedFields } =
+ diffWorkOrderSupplementedFields(
+ { F_Remark: "", F_MessageContent: "安排贴标" },
+ { F_Remark: "ups默认两张", F_MessageContent: "安排贴标" },
+ );
+ expect(fieldMarks.F_Remark).toBe("supplemented");
+ expect(fieldMarks.F_MessageContent).toBeUndefined();
+ expect(userSupplemented).toBe(true);
+ expect(supplementedFields).toContain("F_Remark");
+ });
+
+ it("marks changed value as modified", () => {
+ const { fieldMarks } = diffWorkOrderSupplementedFields(
+ { F_RefNo: "WL001", F_MessageType: "操作指令" },
+ { F_RefNo: "WL002", F_MessageType: "操作指令" },
+ );
+ expect(fieldMarks.F_RefNo).toBe("modified");
+ expect(fieldMarks.F_MessageType).toBeUndefined();
+ });
+
+ it("marks cleared value as modified", () => {
+ const { fieldMarks } = diffWorkOrderSupplementedFields(
+ { F_AttachFile: "a.pdf" },
+ { F_AttachFile: "" },
+ );
+ expect(fieldMarks.F_AttachFile).toBe("modified");
+ });
+
+ it("returns no marks when identical", () => {
+ const snap = {
+ F_ReferCode: "TCNU4412465",
+ F_MessageContent: "安排卡转海",
+ F_Remark: "ups默认",
+ };
+ const { userSupplemented, supplementedFields } =
+ diffWorkOrderSupplementedFields(snap, snap);
+ expect(userSupplemented).toBe(false);
+ expect(supplementedFields).toEqual([]);
+ });
+});
+
+describe("toWorkOrderSubmittedSnapshot / workOrderFormFromSubmitted", () => {
+ it("round-trips non-empty fields", () => {
+ const snap = toWorkOrderSubmittedSnapshot({
+ F_ReferCode: "TCNU4412465",
+ F_MessageType: "操作指令",
+ F_RefNo: "WL003",
+ F_MessageContent: "安排卡转海",
+ F_Remark: " ",
+ F_AttachFile: "",
+ F_UrgencyLevel: "0",
+ });
+ expect(snap.F_Remark).toBeUndefined();
+ expect(snap.F_ReferCode).toBe("TCNU4412465");
+ const form = workOrderFormFromSubmitted(snap);
+ expect(form.F_MessageContent).toBe("安排卡转海");
+ expect(form.F_Remark).toBe("");
+ expect(form.F_UrgencyLevel).toBe("0");
+ });
+
+ it("describeWorkOrderFieldMarks lists supplemented values", () => {
+ const lines = describeWorkOrderFieldMarks(
+ { F_MessageType: "supplemented" },
+ { F_MessageType: "操作指令" },
+ );
+ expect(lines).toEqual(["留言类型:操作指令"]);
+ });
+});
+
+describe("markInstructionImported snapshot", () => {
+ it("persists submitted and userSupplemented flags", () => {
+ const lineage = markInstructionImported({}, "wo-1", {
+ status: "IMPORTED",
+ importedAt: "2026-08-28T00:00:00.000Z",
+ mode: "live",
+ submitted: { F_MessageContent: "补全后的正文", F_Remark: "人工备注" },
+ supplementedFields: ["F_Remark"],
+ fieldMarks: { F_Remark: "supplemented" },
+ userSupplemented: true,
+ });
+ const entry = (
+ lineage as {
+ instruction_state: Record<
+ string,
+ {
+ submitted?: Record;
+ userSupplemented?: boolean;
+ fieldMarks?: Record;
+ }
+ >;
+ }
+ ).instruction_state["wo-1"];
+ expect(entry.submitted?.F_Remark).toBe("人工备注");
+ expect(entry.userSupplemented).toBe(true);
+ expect(entry.fieldMarks?.F_Remark).toBe("supplemented");
+ });
+});