|
|
/**
|
|
|
* MotherShip 登录态二级 Details:刷价提交前必填/业务规则校验
|
|
|
* 对齐官网:Freight ready time 不得早于提货点 Opens at
|
|
|
*/
|
|
|
import type { MothershipLoggedInDetailsPayload } from "@/components/mothership/mothership-logged-in-details-form";
|
|
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
|
/** 12 小时制整点:`8:00 AM` / `12:00 PM` → 当日分钟数;无法解析返回 null */
|
|
|
export function parseMsClockToMinutes(label: string): number | null {
|
|
|
const m = /^(\d{1,2}):(\d{2})\s*(AM|PM)$/i.exec(label.trim());
|
|
|
if (!m) return null;
|
|
|
let hour = Number(m[1]);
|
|
|
const minute = Number(m[2]);
|
|
|
const ap = m[3]!.toUpperCase();
|
|
|
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
|
|
if (hour < 1 || hour > 12 || minute < 0 || minute > 59) return null;
|
|
|
if (ap === "AM") {
|
|
|
if (hour === 12) hour = 0;
|
|
|
} else if (hour !== 12) {
|
|
|
hour += 12;
|
|
|
}
|
|
|
return hour * 60 + minute;
|
|
|
}
|
|
|
|
|
|
export const MS_READY_BEFORE_OPENS_MESSAGE =
|
|
|
"货运就绪时刻不能早于提货点开门时间,请调整就绪时刻或开门时间";
|
|
|
|
|
|
export const MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE =
|
|
|
"开门时间须早于关门时间";
|
|
|
|
|
|
/** MotherShip:提货与送货联系人邮箱不得相同 */
|
|
|
export const MS_EMAILS_MUST_DIFFER_MESSAGE =
|
|
|
"提货与送货联系人邮箱不能相同,请分别填写";
|
|
|
|
|
|
function blank(v: string | undefined | null): boolean {
|
|
|
return !String(v ?? "").trim();
|
|
|
}
|
|
|
|
|
|
function normalizeEmail(v: string): string {
|
|
|
return v.trim().toLowerCase();
|
|
|
}
|
|
|
|
|
|
export type MsDetailsRefineAddressOpts = {
|
|
|
pickupAddress?: string;
|
|
|
deliveryAddress?: string;
|
|
|
/** 一级/二级「货运就绪时刻」,与提货 Opens at 对齐官网规则 */
|
|
|
readyTime?: string;
|
|
|
};
|
|
|
|
|
|
/** 二级表单字段键,用于必填空/错时红边高亮 */
|
|
|
export type MsDetailsFieldKey =
|
|
|
| "pickup.address"
|
|
|
| "pickup.companyName"
|
|
|
| "pickup.contactEmail"
|
|
|
| "pickup.contactPhone"
|
|
|
| "pickup.opensAt"
|
|
|
| "pickup.closesAt"
|
|
|
| "pickup.readyTime"
|
|
|
| "delivery.address"
|
|
|
| "delivery.companyName"
|
|
|
| "delivery.contactEmail"
|
|
|
| "delivery.contactPhone"
|
|
|
| "delivery.opensAt"
|
|
|
| "delivery.closesAt"
|
|
|
| `cargo.${number}.pieceCountType`
|
|
|
| `cargo.${number}.pieceCountQty`
|
|
|
| `cargo.${number}.description`;
|
|
|
|
|
|
type MsDetailsIssue = { key: MsDetailsFieldKey; message: string };
|
|
|
|
|
|
/**
|
|
|
* 字段级缺失/冲突;供红边高亮与文案列表共用。
|
|
|
*/
|
|
|
export function collectMsDetailsRefineIssues(
|
|
|
details: MothershipLoggedInDetailsPayload,
|
|
|
opts?: MsDetailsRefineAddressOpts,
|
|
|
): MsDetailsIssue[] {
|
|
|
const issues: MsDetailsIssue[] = [];
|
|
|
|
|
|
if (opts && typeof opts.pickupAddress === "string" && blank(opts.pickupAddress)) {
|
|
|
issues.push({ key: "pickup.address", message: "提货:地址" });
|
|
|
}
|
|
|
if (
|
|
|
opts &&
|
|
|
typeof opts.deliveryAddress === "string" &&
|
|
|
blank(opts.deliveryAddress)
|
|
|
) {
|
|
|
issues.push({ key: "delivery.address", message: "送货:地址" });
|
|
|
}
|
|
|
|
|
|
const checkLoc = (
|
|
|
prefix: "pickup" | "delivery",
|
|
|
side: "提货" | "送货",
|
|
|
loc: MothershipLoggedInDetailsPayload["pickup"],
|
|
|
) => {
|
|
|
if (blank(loc.companyName)) {
|
|
|
issues.push({
|
|
|
key: `${prefix}.companyName`,
|
|
|
message: `${side}:完整公司名`,
|
|
|
});
|
|
|
}
|
|
|
if (blank(loc.contactEmail)) {
|
|
|
issues.push({
|
|
|
key: `${prefix}.contactEmail`,
|
|
|
message: `${side}:现场联系人邮箱`,
|
|
|
});
|
|
|
} else if (!EMAIL_RE.test(loc.contactEmail.trim())) {
|
|
|
issues.push({
|
|
|
key: `${prefix}.contactEmail`,
|
|
|
message: `${side}:现场联系人邮箱格式无效`,
|
|
|
});
|
|
|
}
|
|
|
if (blank(loc.contactPhone)) {
|
|
|
issues.push({
|
|
|
key: `${prefix}.contactPhone`,
|
|
|
message: `${side}:现场联系人电话`,
|
|
|
});
|
|
|
}
|
|
|
if (blank(loc.opensAt)) {
|
|
|
issues.push({ key: `${prefix}.opensAt`, message: `${side}:开门时间` });
|
|
|
}
|
|
|
if (blank(loc.closesAt)) {
|
|
|
issues.push({ key: `${prefix}.closesAt`, message: `${side}:关门时间` });
|
|
|
}
|
|
|
|
|
|
const openMin = parseMsClockToMinutes(loc.opensAt);
|
|
|
const closeMin = parseMsClockToMinutes(loc.closesAt);
|
|
|
if (openMin != null && closeMin != null && openMin >= closeMin) {
|
|
|
issues.push({
|
|
|
key: `${prefix}.opensAt`,
|
|
|
message: `${side}:${MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE}`,
|
|
|
});
|
|
|
issues.push({
|
|
|
key: `${prefix}.closesAt`,
|
|
|
message: `${side}:${MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE}`,
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
|
|
|
checkLoc("pickup", "提货", details.pickup);
|
|
|
checkLoc("delivery", "送货", details.delivery);
|
|
|
|
|
|
const pickupEmail = details.pickup.contactEmail.trim();
|
|
|
const deliveryEmail = details.delivery.contactEmail.trim();
|
|
|
if (
|
|
|
pickupEmail &&
|
|
|
deliveryEmail &&
|
|
|
EMAIL_RE.test(pickupEmail) &&
|
|
|
EMAIL_RE.test(deliveryEmail) &&
|
|
|
normalizeEmail(pickupEmail) === normalizeEmail(deliveryEmail)
|
|
|
) {
|
|
|
issues.push({
|
|
|
key: "pickup.contactEmail",
|
|
|
message: MS_EMAILS_MUST_DIFFER_MESSAGE,
|
|
|
});
|
|
|
issues.push({
|
|
|
key: "delivery.contactEmail",
|
|
|
message: MS_EMAILS_MUST_DIFFER_MESSAGE,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const ready = opts?.readyTime?.trim() ?? "";
|
|
|
const opens = details.pickup.opensAt.trim();
|
|
|
if (ready && opens) {
|
|
|
const readyMin = parseMsClockToMinutes(ready);
|
|
|
const openMin = parseMsClockToMinutes(opens);
|
|
|
if (readyMin != null && openMin != null && readyMin < openMin) {
|
|
|
issues.push({
|
|
|
key: "pickup.readyTime",
|
|
|
message: MS_READY_BEFORE_OPENS_MESSAGE,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!details.cargo.length) {
|
|
|
issues.push({
|
|
|
key: "cargo.0.description",
|
|
|
message: "货物:至少一行明细",
|
|
|
});
|
|
|
}
|
|
|
details.cargo.forEach((line, i) => {
|
|
|
const n = i + 1;
|
|
|
// 官网:Piece count type / quantity 非必填,不拦截
|
|
|
if (blank(line.description)) {
|
|
|
issues.push({
|
|
|
key: `cargo.${i}.description`,
|
|
|
message: `货物第${n}行:货物描述`,
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return issues;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 返回中文缺失/冲突项列表;空数组 = 可提交。
|
|
|
* 与二级表单红色 * 字段对齐。
|
|
|
*/
|
|
|
export function collectMsDetailsRefineMissing(
|
|
|
details: MothershipLoggedInDetailsPayload,
|
|
|
opts?: MsDetailsRefineAddressOpts,
|
|
|
): string[] {
|
|
|
return collectMsDetailsRefineIssues(details, opts).map((i) => i.message);
|
|
|
}
|
|
|
|
|
|
/** 需红边高亮的字段键集合 */
|
|
|
export function collectMsDetailsRefineInvalidFields(
|
|
|
details: MothershipLoggedInDetailsPayload,
|
|
|
opts?: MsDetailsRefineAddressOpts,
|
|
|
): Set<MsDetailsFieldKey> {
|
|
|
return new Set(collectMsDetailsRefineIssues(details, opts).map((i) => i.key));
|
|
|
}
|
|
|
|
|
|
export function validateMsDetailsForRefine(
|
|
|
details: MothershipLoggedInDetailsPayload,
|
|
|
opts?: MsDetailsRefineAddressOpts,
|
|
|
): { ok: true } | { ok: false; message: string; missing: string[] } {
|
|
|
const missing = collectMsDetailsRefineMissing(details, opts);
|
|
|
if (missing.length === 0) return { ok: true };
|
|
|
return {
|
|
|
ok: false,
|
|
|
missing,
|
|
|
message: `信息不完整,请核对后重新提交:${missing.join(";")}`,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 若就绪时刻早于开门,拨到开门整点(与官网 Select time 选项一致)。
|
|
|
*/
|
|
|
export function snapMsReadyTimeToOpensAt(
|
|
|
readyTime: string,
|
|
|
opensAt: string,
|
|
|
): string {
|
|
|
const readyMin = parseMsClockToMinutes(readyTime);
|
|
|
const openMin = parseMsClockToMinutes(opensAt);
|
|
|
if (readyMin == null || openMin == null) return readyTime;
|
|
|
if (readyMin >= openMin) return readyTime.trim();
|
|
|
return opensAt.trim();
|
|
|
}
|