"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(() => 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(() => buildCcForecastFormValue({}), ); 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); 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 ( {unit.label} {mail.subject} {form.F_ContainerNo || "-"} 客户端 · SaveContainer {mail.status === "IMPORTING" ? ( ) : null} {unitImported ? ( ) : null} {softLimitExceeded ? ( ) : null} {!pageReadonly && importIssues.blockers.length ? ( {importIssues.blockers.map((item) => (
  • {item}
  • ))} } /> ) : null} {!pageReadonly && !importIssues.blockers.length && importIssues.notes.length ? ( ) : null} {hasContainerConflict ? ( setContainerSource(e.target.value)} style={{ marginTop: 8 }} > 正文柜号: {containerConflict?.body} 附件柜号: {containerConflict?.attachment} } /> ) : null} {errors.containerSource ? ( {errors.containerSource} ) : null} {hasUnmappedSelected ? (
    setAckUnmapped(e.target.checked)} > {UI_COPY.ackUnmapped} {errors.ack ? (
    {errors.ack}
    ) : null}
    ) : null} 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} />
    已选 {selectedValid.length} / 有效 {validRows.length} {!pageReadonly && importIssues.blockers.length ? ( {importIssues.blockers[0]} ) : null}
    setCustomerConfirmOpen(false)} onOk={onCustomerConfirmOk} /> setConfirmOpen(false)} onConfirm={() => void onConfirmSubmit(false)} /> setConflictOpen(false)} onForceSkip={() => void onConfirmSubmit(true)} />
    ); }