|
|
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<string, string>;
|
|
|
/** 阻断「确认导入」的原因(按钮灰掉时必须展示) */
|
|
|
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<string, number>();
|
|
|
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<string, string> = {};
|
|
|
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 };
|
|
|
}
|