import { isValidContainerNo } from "@/utils/iso6346"; /** * 这一条「预报」指令是否具备进入确认页的数据与状态。 * 不看整封邮件主类型:同封还可以有转仓/工单/DO,互不拦截。 * 仅柜头预报(有柜号、暂无货件行)也可进入确认页补录/空货件导入。 */ export function canEnterForecastConfirm(input: { status: string; validShipmentCount: number; /** 已解析出可用柜号(ISO 柜头),允许无货件行进入确认 */ hasContainerHeader?: boolean; /** 详情页仅待确认类状态可点导入;确认页还允许多种导入后回看状态 */ allowReadonlyStatuses?: boolean; }): boolean { const { status, validShipmentCount } = input; const hasRows = validShipmentCount >= 1; const headerOnly = Boolean(input.hasContainerHeader) && !hasRows; if (!hasRows && !headerOnly) return false; if (input.allowReadonlyStatuses) { return [ "PENDING_CONFIRM", "FAILED", "IMPORTING", "SUCCESS", "PARTIAL_SUCCESS", ].includes(status); } return ( status === "PENDING_CONFIRM" || status === "FAILED" || status === "PARTIAL_SUCCESS" ); } export type ForecastImportIssues = { /** 字段级错误,挂到对应表单项 */ errors: Record; /** 阻断「确认导入」的原因(按钮灰掉时必须展示) */ blockers: string[]; /** 不阻断提交的提示(如仅柜头、货件全无效) */ notes: string[]; }; function summarizeInvalidRows( rows: Array<{ row_index: number; invalid_reasons?: string[] }>, ): string | null { if (!rows.length) return null; const counts = new Map(); for (const row of rows) { const reasons = row.invalid_reasons?.length ? row.invalid_reasons : ["未标明原因"]; for (const reason of reasons) { counts.set(reason, (counts.get(reason) || 0) + 1); } } const parts = [...counts.entries()].map(([reason, n]) => n > 1 ? `${reason}(${n}行)` : reason, ); 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; selectedValidCount: number; invalidRows?: Array<{ row_index: number; invalid_reasons?: string[] }>; shipmentCount?: number; hasUnmappedSelected: boolean; ackUnmapped: boolean; softLimitExceeded?: boolean; portal?: "admin" | "client"; step?: number; }): ForecastImportIssues { const errors: Record = {}; const blockers: string[] = []; const notes: string[] = []; const invalidRows = input.invalidRows ?? []; const shipmentCount = input.shipmentCount ?? 0; if (input.transMode !== 0 && input.transMode !== 1 && input.transMode !== 3) { const msg = "请选择运输方式(海运 / 空运 / 自送柜)"; errors.transMode = msg; blockers.push(msg); } if ( input.operationType !== 0 && input.operationType !== 2 && input.operationType !== 4 ) { const msg = "请选择业务类型(提拆派 / 直送 / 只清关)"; errors.operationType = msg; blockers.push(msg); } const containerNo = (input.containerNo || "").trim(); if (!containerNo) { const msg = "柜号为空,请填写 4 位字母 + 7 位数字(ISO 6346)"; errors.containerNo = msg; blockers.push(msg); } else if (!isValidContainerNo(containerNo)) { const msg = `柜号「${containerNo.toUpperCase()}」不符合 ISO 6346 校验位,请核对后改正`; errors.containerNo = msg; 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) { const msg = shipmentCount > 0 || invalidRows.length > 0 ? "没有可导入的有效货件行(必填:派送方式、件数、体积、毛重)。请在本页修改或导入装箱表" : "未解析到货件信息。请在本页新增货件,或导入卡派清单(xlsx)后再确认导入"; errors.rows = msg; blockers.push(msg); } else if (input.selectedValidCount === 0) { const msg = input.softLimitExceeded ? "货件超过软限,默认未全选;请筛选后至少勾选一行有效货件" : "请至少勾选一行有效货件"; errors.rows = msg; blockers.push(msg); } if (input.hasUnmappedSelected && !input.ackUnmapped) { const msg = "所选货件含未映射渠道,请勾选「已知晓存在未映射渠道,仍提交原值」"; errors.ack = msg; blockers.push(msg); } const invalidSummary = summarizeInvalidRows(invalidRows); if (input.validRowCount === 0 && invalidSummary) { notes.push(invalidSummary); } return { errors, blockers, notes }; }