You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

756 lines
23 KiB

This file contains ambiguous Unicode characters!

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

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Alert,
App,
Button,
Checkbox,
Radio,
Space,
Typography,
} from "antd";
import { LeftOutlined } from "@ant-design/icons";
import {
CcForecastFormOrder,
buildCcForecastFormValue,
type CcForecastFormValue,
} from "@/components/CcForecastFormOrder";
import { ConfirmImportModal, ConflictModal } from "@/components/ConfirmImportModal";
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 { describeCcFailureForOperator } from "@/constants/error-copy";
import { buildImportIdempotencyKey } from "@/utils/idempotency-client";
import { withVersionConflictRetry } from "@/utils/mail-submit-version";
import { useImport } from "@/hooks/useImport";
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, ShipmentRow } from "@/types/mail";
export function ForecastConfirmView({
mail,
unit,
}: {
mail: MailMessage;
unit: MailInstructionUnit;
}) {
const router = useRouter();
const { message, modal } = App.useApp();
const { user } = useAuthSession();
const { submitting, submit } = useImport(mail.id);
const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id });
const instructionId = unit.id;
const [submitVersion, setSubmitVersion] = useState(mail.version);
useEffect(() => {
setSubmitVersion(mail.version);
}, [mail.version]);
const [shipments, setShipments] = useState<ShipmentRow[]>(() =>
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 ||
(
mail.parse_result?.lineage?.mail_record as
| { modules?: { customer_name?: string } }
| undefined
)?.modules?.customer_name ||
"";
const [form, setForm] = useState<CcForecastFormValue>(() =>
buildCcForecastFormValue({}),
);
const [step, setStep] = useState(0);
const [selected, setSelected] = useState<Set<number>>(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<string | undefined>();
const [conflictDetail, setConflictDetail] = useState<string | undefined>();
const [containerSource, setContainerSource] = useState<
"body" | "attachment" | null
>(null);
const [shippingLines, setShippingLines] = useState<
{ value: string; label: string }[]
>([]);
const [shippingLineRows, setShippingLineRows] = useState<
Array<{ id: string; name: string; code?: string; prefix?: string }>
>([]);
const isAdmin = user.role === "admin";
const [forceImportEnabled, setForceImportEnabled] = useState(false);
const softLimitExceeded = Boolean(
mail.parse_result?.lineage?.soft_limit_exceeded,
);
const containerConflict = mail.parse_result?.lineage?.CONTAINER_NO_CONFLICT as
| { body?: string; attachment?: string }
| undefined;
const hasContainerConflict = Boolean(
containerConflict?.body && containerConflict?.attachment,
);
const unitImported = isInstructionImported(
mail.parse_result?.lineage,
instructionId,
);
useEffect(() => {
void healthApi().then((healthRes) => {
if (healthRes.ok) {
setForceImportEnabled(Boolean(healthRes.data.enable_force_import));
}
});
}, []);
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 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(() => {
void loadShippingLines();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!form.F_BLCopyCode || !shippingLineRows.length || form.F_ShippingLineId) {
return;
}
const id = matchShippingLineFromBl(form.F_BLCopyCode, shippingLineRows);
if (id) {
setForm((p) =>
p.F_ShippingLineId === id ? p : { ...p, F_ShippingLineId: id },
);
}
}, [form.F_BLCopyCode, form.F_ShippingLineId, shippingLineRows]);
useEffect(() => {
const record =
mail.parse_result?.mail_record ||
(
mail.parse_result?.lineage as
| { 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,
}),
);
}, [mail, customerName]);
const validRows = useMemo(
() => shipments.filter((s) => s.row_status === "VALID"),
[shipments],
);
const validRowKey = useMemo(
() => validRows.map((r) => r.row_index).join(","),
[validRows],
);
useEffect(() => {
if (softLimitExceeded) {
setSelected((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
setSelected((prev) => {
const nextIds = validRowKey
? validRowKey.split(",").map((x) => Number(x))
: [];
if (
prev.size === nextIds.length &&
nextIds.every((rowId) => prev.has(rowId))
) {
return prev;
}
return new Set(nextIds);
});
}, [mail.id, validRowKey, softLimitExceeded]);
useEffect(() => {
setContainerSource(null);
setStep(0);
}, [mail.id]);
useEffect(() => {
if (!hasContainerConflict || !containerConflict || !containerSource) return;
const next =
containerSource === "body"
? containerConflict.body || ""
: containerConflict.attachment || "";
const upper = next.toUpperCase();
setForm((p) =>
p.F_ContainerNo === upper ? p : { ...p, F_ContainerNo: upper },
);
}, [
containerSource,
hasContainerConflict,
containerConflict?.body,
containerConflict?.attachment,
]);
const selectedValid = Array.from(selected).filter((idx) =>
validRows.some((r) => r.row_index === idx),
);
const hasUnmappedSelected = selectedValid.some((idx) => {
const row = shipments.find((s) => s.row_index === idx);
return row?.warnings?.includes("CHANNEL_UNMAPPED");
});
const pageReadonly =
unitImported ||
mail.status === "IMPORTING" ||
mail.status === "SUCCESS";
const importIssues = useMemo(
() =>
collectForecastImportIssues({
transMode: form.F_TransMode,
operationType: form.F_OperationType,
containerNo: form.F_ContainerNo,
cabinetType: form.F_CabinetType,
hasContainerConflict,
containerSource,
validRowCount: validRows.length,
selectedValidCount: selectedValid.length,
invalidRows: shipments.filter((s) => s.row_status === "INVALID"),
shipmentCount: shipments.length,
hasUnmappedSelected,
ackUnmapped,
softLimitExceeded,
portal: "client",
step,
}),
[
form.F_TransMode,
form.F_OperationType,
form.F_ContainerNo,
form.F_CabinetType,
hasContainerConflict,
containerSource,
validRows.length,
selectedValid.length,
shipments,
hasUnmappedSelected,
ackUnmapped,
softLimitExceeded,
step,
],
);
const errors = importIssues.errors;
const headerReady =
!errors.transMode &&
!errors.operationType &&
!errors.containerNo &&
!errors.cabinetType &&
!errors.containerSource;
const onStepChange = (s: number) => {
if (pageReadonly) {
setStep(s);
return;
}
if (s === 1 && !headerReady) {
message.error("请先修正基础信息");
return;
}
setStep(s);
};
const onPrimary = () => {
if (pageReadonly) return;
if (importIssues.blockers.length) {
message.error(importIssues.blockers[0]);
return;
}
if (!ccProfile.profileId || !ccProfile.selected) {
message.error("请先选择 CC 客户");
return;
}
setCustomerConfirmOpen(true);
};
const onCustomerConfirmOk = () => {
setCustomerConfirmOpen(false);
setConfirmOpen(true);
};
const onConfirmSubmit = async (forceSkip = false) => {
const transMode = form.F_TransMode;
const operationType = form.F_OperationType;
if (transMode !== 0 && transMode !== 1 && transMode !== 3) return;
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,
idempotency_key: buildImportIdempotencyKey({
mailId: mail.id,
instructionId,
containerNo,
rowIndexes: selectedValid,
forceSkip,
}),
F_TransMode: transMode,
F_OperationType: operationType,
container_header: {
F_ContainerNo: containerNo,
F_CabinetType: form.F_CabinetType,
F_Classis: form.F_Classis,
F_ETA: form.F_ETA || null,
F_ETD: form.F_ETD || null,
F_LoadPort: form.F_LoadPort,
F_Dock: form.F_Dock,
F_BLCopyCode: form.F_BLCopyCode,
F_MemoRemark: form.F_MemoRemark,
F_Instruction: form.F_Instruction,
F_ShippingLineId: form.F_ShippingLineId,
},
selected_row_indexes: selectedValid,
ack_unmapped_channels: ackUnmapped,
cc_profile_id: ccProfile.profileId,
shipments,
...(forceSkip ? { force_skip_conflict: true } : {}),
};
const slowHint = window.setTimeout(() => {
message.loading({
content: "正在提交到 CarrierCentral,请稍候…",
key: "cc-import",
duration: 0,
});
}, 600);
try {
const res = await withVersionConflictRetry({
version: submitVersion,
onVersion: setSubmitVersion,
submit: (version) => submit({ ...basePayload, version }),
});
if (!res.ok) {
if (
res.error.code === "CONFLICT_CONTAINER" ||
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;
}
message.error(res.error.message);
setConfirmOpen(false);
setConflictOpen(false);
return;
}
setConfirmOpen(false);
setConflictOpen(false);
if (res.data.mail_status === "SUCCESS") {
message.success("导入成功");
router.push(`/logs?mail_id=${mail.id}`);
} else if (res.data.mail_status === "PARTIAL_SUCCESS") {
message.success("本条预报已导入,请继续确认其余指令");
router.push(`/mails/${mail.id}`);
} else {
const rawErr =
res.data.imports?.[0]?.last_error ||
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);
message.destroy("cc-import");
}
};
const canForceSkip =
isAdmin &&
forceImportEnabled &&
(conflictReason === "CONFLICT_CONTAINER" ||
conflictReason === "CONFLICT_CHECK_FAILED");
return (
<Space
direction="vertical"
size={24}
style={{ width: "100%", paddingBottom: 96 }}
>
<Space
style={{ width: "100%", justifyContent: "space-between" }}
align="start"
>
<Space direction="vertical" size={8}>
<Link href={`/mails/${mail.id}`} prefetch>
<Button type="default" icon={<LeftOutlined />}>
{UI_COPY.backToDetail}
</Button>
</Link>
<Typography.Title
level={2}
style={{ margin: 0, maxWidth: "100%" }}
ellipsis={{ tooltip: mail.subject }}
>
{unit.label}
</Typography.Title>
<Typography.Text type="secondary">{mail.subject}</Typography.Text>
<Typography.Text
style={{
fontFamily: "monospace",
fontSize: 20,
color: "var(--color-primary)",
}}
>
{form.F_ContainerNo || "-"}
</Typography.Text>
</Space>
<Space direction="vertical" align="end" size={4}>
<CcCustomerProfileSelect
mailId={mail.id}
items={ccProfile.items}
loading={ccProfile.loading}
value={ccProfile.profileId}
onChange={ccProfile.setProfileId}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
客户端 · SaveContainer
</Typography.Text>
</Space>
</Space>
{mail.status === "IMPORTING" ? (
<Alert type="warning" showIcon message={UI_COPY.alertImporting} />
) : null}
{unitImported ? (
<Alert type="success" showIcon message="该预报指令已导入,当前只读" />
) : null}
{softLimitExceeded ? (
<Alert type="warning" showIcon message={UI_COPY.softLimitAlert} />
) : null}
{!pageReadonly && importIssues.blockers.length ? (
<Alert
type="error"
showIcon
message="当前无法确认导入"
description={
<ul style={{ margin: "4px 0 0", paddingLeft: 18 }}>
{importIssues.blockers.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
}
/>
) : null}
{!pageReadonly &&
!importIssues.blockers.length &&
importIssues.notes.length ? (
<Alert
type="warning"
showIcon
message={importIssues.notes.join(" ")}
/>
) : null}
{hasContainerConflict ? (
<Alert
type="error"
showIcon
message={UI_COPY.containerConflictAlert}
description={
<Radio.Group
disabled={pageReadonly}
value={containerSource || undefined}
onChange={(e) => setContainerSource(e.target.value)}
style={{ marginTop: 8 }}
>
<Space direction="vertical">
<Radio value="body">
正文柜号:
<span className="mono">{containerConflict?.body}</span>
</Radio>
<Radio value="attachment">
附件柜号:
<span className="mono">{containerConflict?.attachment}</span>
</Radio>
</Space>
</Radio.Group>
}
/>
) : null}
{errors.containerSource ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
{errors.containerSource}
</Typography.Text>
) : null}
{hasUnmappedSelected ? (
<div className={`cc-fo-ack${errors.ack ? " is-invalid" : ""}`}>
<Checkbox
disabled={pageReadonly}
checked={ackUnmapped}
onChange={(e) => setAckUnmapped(e.target.checked)}
>
{UI_COPY.ackUnmapped}
</Checkbox>
{errors.ack ? (
<div className="cc-fo-error" style={{ marginTop: 4 }}>
{errors.ack}
</div>
) : null}
</div>
) : null}
<CcForecastFormOrder
mode={pageReadonly ? "readonly" : "edit"}
portal="client"
value={form}
shipments={shipments}
shippingLines={shippingLines}
onFetchShippingLine={pageReadonly ? undefined : onFetchShippingLine}
fetchShippingLineLoading={shippingFetchLoading}
selected={selected}
onSelectedChange={setSelected}
onChange={(patch) => setForm((p) => ({ ...p, ...patch }))}
onShipmentsChange={pageReadonly ? undefined : setShipments}
packingImportLoading={packingImportLoading}
onImportPackingFile={pageReadonly ? undefined : onImportPackingFile}
errors={errors}
step={step}
onStepChange={onStepChange}
softLimitExceeded={softLimitExceeded}
containerNoLocked={hasContainerConflict}
onFinish={onPrimary}
finishLoading={submitting}
finishDisabled={
importIssues.blockers.length > 0 || !ccProfile.profileId
}
finishBlockers={pageReadonly ? [] : importIssues.blockers}
finishNotes={pageReadonly ? [] : importIssues.notes}
/>
<div className="sticky-footer">
<Typography.Text>
已选 <b>{selectedValid.length}</b> / 有效 <b>{validRows.length}</b>
{!pageReadonly && importIssues.blockers.length ? (
<span style={{ color: "var(--color-error)", marginLeft: 12 }}>
{importIssues.blockers[0]}
</span>
) : null}
</Typography.Text>
<Link href={`/mails/${mail.id}`} prefetch>
<Button>取消</Button>
</Link>
</div>
<ConfirmCcCustomerModal
open={customerConfirmOpen}
customerName={ccProfile.selected?.name || ""}
operationLabel="新增预报"
onCancel={() => setCustomerConfirmOpen(false)}
onOk={onCustomerConfirmOk}
/>
<ConfirmImportModal
open={confirmOpen}
loading={submitting}
containerNo={form.F_ContainerNo}
selectedCount={selectedValid.length}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void onConfirmSubmit(false)}
/>
<ConflictModal
open={conflictOpen}
reason={conflictReason}
message={conflictMessage}
failureKind={conflictKind}
detailError={conflictDetail}
canForceSkip={canForceSkip}
loading={submitting}
onClose={() => setConflictOpen(false)}
onForceSkip={() => void onConfirmSubmit(true)}
/>
</Space>
);
}