parent
360c2c2d71
commit
4ef21f41db
@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
hostPublicDefaultCustomerId,
|
||||
isHostPublicApiEnabled,
|
||||
} from "@/lib/constants/host-public-api";
|
||||
|
||||
describe("host public api env", () => {
|
||||
const prevEnabled = process.env.HOST_PUBLIC_API_ENABLED;
|
||||
const prevCustomer = process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOST_PUBLIC_API_ENABLED = prevEnabled;
|
||||
process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID = prevCustomer;
|
||||
});
|
||||
|
||||
it("is disabled by default", () => {
|
||||
delete process.env.HOST_PUBLIC_API_ENABLED;
|
||||
expect(isHostPublicApiEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("reads enabled flag and default customer", () => {
|
||||
process.env.HOST_PUBLIC_API_ENABLED = "true";
|
||||
process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID = "CUST_MEIMEI";
|
||||
expect(isHostPublicApiEnabled()).toBe(true);
|
||||
expect(hostPublicDefaultCustomerId()).toBe("CUST_MEIMEI");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isHostServiceAuthDisabled } from "@/lib/constants/host-service";
|
||||
|
||||
describe("isHostServiceAuthDisabled", () => {
|
||||
const env = process.env;
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
it("production 始终需鉴权", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.HOST_SERVICE_AUTH_DISABLED = "true";
|
||||
expect(isHostServiceAuthDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("development 默认跳过宿主 Token", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.HOST_SERVICE_AUTH_DISABLED;
|
||||
expect(isHostServiceAuthDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("development 可显式开启鉴权", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
process.env.HOST_SERVICE_AUTH_DISABLED = "false";
|
||||
expect(isHostServiceAuthDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterQuotesForMotherShipUiDisplay,
|
||||
getMotherShipRateOptionLabel,
|
||||
getMotherShipServiceLevelLabel,
|
||||
listMotherShipUiTabsFromQuotes,
|
||||
listVisibleRateOptionsForTab,
|
||||
normalizeMotherShipServiceLevel,
|
||||
} from "@/lib/constants/mothership-ui-tiers";
|
||||
|
||||
const SAMPLE = [
|
||||
{ service_level: "standard", rate_option: "lowest", raw: 235 },
|
||||
{ service_level: "standard", rate_option: "bestValue", raw: 301 },
|
||||
{ service_level: "standard", rate_option: "fastest", raw: 689 },
|
||||
{ service_level: "guaranteed", rate_option: "lowest", raw: 408 },
|
||||
{ service_level: "guaranteed", rate_option: "fastest", raw: 830 },
|
||||
{ service_level: "dedicated", rate_option: "bestValue", raw: 4989 },
|
||||
] as const;
|
||||
|
||||
describe("mothership-ui-tiers", () => {
|
||||
it("normalizeMotherShipServiceLevel 识别专属卡车别名", () => {
|
||||
expect(normalizeMotherShipServiceLevel("专属卡车")).toBe("dedicated");
|
||||
expect(normalizeMotherShipServiceLevel("FTL")).toBe("dedicated");
|
||||
});
|
||||
|
||||
it("Standard 档 lowest 与 bestValue 并存时均保留(与官网三档一致)", () => {
|
||||
const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]);
|
||||
expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "lowest")).toBe(true);
|
||||
expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "bestValue")).toBe(true);
|
||||
expect(ui.find((q) => q.service_level === "standard" && q.rate_option === "lowest")?.raw).toBe(235);
|
||||
});
|
||||
|
||||
it("listMotherShipUiTabsFromQuotes 含 dedicated 第三 Tab", () => {
|
||||
const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]);
|
||||
expect(listMotherShipUiTabsFromQuotes(ui)).toEqual([
|
||||
"standard",
|
||||
"guaranteed",
|
||||
"dedicated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedicated Tab 展示 API 实际返回的子档(不白名单裁剪)", () => {
|
||||
const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]);
|
||||
expect(listVisibleRateOptionsForTab(ui, "dedicated")).toEqual(["bestValue"]);
|
||||
const withExtra = filterQuotesForMotherShipUiDisplay([
|
||||
...SAMPLE,
|
||||
{ service_level: "dedicated", rate_option: "lowest", raw: 4000 },
|
||||
]);
|
||||
expect(listVisibleRateOptionsForTab(withExtra, "dedicated")).toEqual([
|
||||
"bestValue",
|
||||
"lowest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("bestValue 显示为最优性价比", () => {
|
||||
expect(getMotherShipRateOptionLabel("standard", "bestValue")).toBe("最优性价比");
|
||||
expect(getMotherShipRateOptionLabel("standard", "lowest")).toBe("最低价格");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
US_STATES,
|
||||
filterUsStateOptions,
|
||||
isValidUsStateCode,
|
||||
normalizeUsStateCode,
|
||||
} from "@/lib/frontend/us-states";
|
||||
|
||||
describe("us-states", () => {
|
||||
it("包含全部 50 个州码", () => {
|
||||
expect(US_STATES).toHaveLength(50);
|
||||
expect(new Set(US_STATES).size).toBe(50);
|
||||
});
|
||||
|
||||
it("normalizeUsStateCode 支持州码与州名", () => {
|
||||
expect(normalizeUsStateCode("ca")).toBe("CA");
|
||||
expect(normalizeUsStateCode("California")).toBe("CA");
|
||||
expect(normalizeUsStateCode("new york")).toBe("NY");
|
||||
expect(normalizeUsStateCode("XX")).toBeNull();
|
||||
expect(normalizeUsStateCode("")).toBeNull();
|
||||
});
|
||||
|
||||
it("filterUsStateOptions 可按州码或州名过滤", () => {
|
||||
const byCode = filterUsStateOptions("tx");
|
||||
expect(byCode.some((s) => s.code === "TX")).toBe(true);
|
||||
|
||||
const byName = filterUsStateOptions("carolina");
|
||||
expect(byName.map((s) => s.code).sort()).toEqual(["NC", "SC"]);
|
||||
|
||||
expect(filterUsStateOptions("")).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("isValidUsStateCode 校验 2 位州码", () => {
|
||||
expect(isValidUsStateCode("WY")).toBe(true);
|
||||
expect(isValidUsStateCode("wy")).toBe(true);
|
||||
expect(isValidUsStateCode("ZZ")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPriority1CalendarGrid } from "@/lib/priority1/calendar-grid";
|
||||
|
||||
describe("buildPriority1CalendarGrid", () => {
|
||||
it("接受 MM/DD/YYYY 提货日并生成当月日历", () => {
|
||||
const { monthLabel, cells } = buildPriority1CalendarGrid("07/15/2026", []);
|
||||
expect(monthLabel).toBe("Jul");
|
||||
expect(cells.length).toBeGreaterThan(28);
|
||||
});
|
||||
|
||||
it("优先按 FTL 日历行里的实际日期落格", () => {
|
||||
const { cells } = buildPriority1CalendarGrid("07/15/2026", [
|
||||
{
|
||||
rank: 1,
|
||||
carrier: "FTL 日历报价",
|
||||
carrierCode: "",
|
||||
totalUsd: 1652.16,
|
||||
markup_amount: 0,
|
||||
final_total_usd: 1652.16,
|
||||
transitDays: 0,
|
||||
deliveryDate: "DAY:1",
|
||||
expirationDate: null,
|
||||
serviceLevel: "FTL Calendar",
|
||||
quoteId: null,
|
||||
caboUrl: null,
|
||||
source: "ftl-calendar",
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
carrier: "FTL 日历报价",
|
||||
carrierCode: "",
|
||||
totalUsd: 2178.47,
|
||||
markup_amount: 0,
|
||||
final_total_usd: 2178.47,
|
||||
transitDays: 0,
|
||||
deliveryDate: "DAY:6",
|
||||
expirationDate: null,
|
||||
serviceLevel: "FTL Calendar",
|
||||
quoteId: null,
|
||||
caboUrl: null,
|
||||
source: "ftl-calendar",
|
||||
},
|
||||
]);
|
||||
|
||||
const day1 = cells.find((cell) => cell.inMonth && cell.date.getDate() === 1);
|
||||
const day6 = cells.find((cell) => cell.inMonth && cell.date.getDate() === 6);
|
||||
expect(day1?.priceUsd).toBe(1652.16);
|
||||
expect(day6?.priceUsd).toBe(2178.47);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { p1DateFromIso, p1DateToIso } from "@/lib/priority1/date-format";
|
||||
import { resolvePriority1UserMessage } from "@/lib/priority1/api-errors";
|
||||
|
||||
describe("p1 date format", () => {
|
||||
it("converts MM/DD/YYYY to ISO and back", () => {
|
||||
expect(p1DateToIso("07/15/2026")).toBe("2026-07-15");
|
||||
expect(p1DateFromIso("2026-07-15")).toBe("07/15/2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePriority1UserMessage", () => {
|
||||
it("keeps Chinese validation messages", () => {
|
||||
expect(resolvePriority1UserMessage("VALIDATION_FAILED", "提货日须晚于今天")).toBe(
|
||||
"提货日须晚于今天",
|
||||
);
|
||||
});
|
||||
|
||||
it("masks English framework errors", () => {
|
||||
const msg = resolvePriority1UserMessage(
|
||||
"INTERNAL_ERROR",
|
||||
"Attempted to call p1DateToIso() from the server but p1DateToIso is on the client",
|
||||
);
|
||||
expect(msg).toContain("服务器处理失败");
|
||||
expect(msg).toContain("服务配置异常");
|
||||
expect(msg).not.toContain("client");
|
||||
});
|
||||
|
||||
it("maps known error codes", () => {
|
||||
expect(resolvePriority1UserMessage("UNAUTHORIZED", "")).toContain("未授权");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("ensure-storage-state", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.RPA_STORAGE_STATE_PATH;
|
||||
delete process.env.RPA_AUTO_BOOTSTRAP_STORAGE;
|
||||
delete process.env.RPA_MOCK_MODE;
|
||||
});
|
||||
|
||||
it("会话文件已存在时跳过 bootstrap", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-"));
|
||||
const stateFile = path.join(tmpDir, "state.json");
|
||||
fs.writeFileSync(stateFile, '{"cookies":[]}');
|
||||
process.env.RPA_STORAGE_STATE_PATH = stateFile;
|
||||
|
||||
const launch = vi.fn();
|
||||
vi.doMock("@/lib/rpa/browser-launch", () => ({ launchRpaBrowser: launch }));
|
||||
|
||||
const { ensureMothershipStorageState } = await import(
|
||||
"@/lib/rpa/ensure-storage-state"
|
||||
);
|
||||
await ensureMothershipStorageState();
|
||||
expect(launch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("缺文件且关闭自动 bootstrap 时抛错", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-"));
|
||||
const stateFile = path.join(tmpDir, "missing.json");
|
||||
process.env.RPA_STORAGE_STATE_PATH = stateFile;
|
||||
process.env.RPA_AUTO_BOOTSTRAP_STORAGE = "false";
|
||||
|
||||
const { ensureMothershipStorageState } = await import(
|
||||
"@/lib/rpa/ensure-storage-state"
|
||||
);
|
||||
await expect(ensureMothershipStorageState()).rejects.toThrow(/record:mothership/);
|
||||
});
|
||||
|
||||
it("缺文件时自动 bootstrap 并写入 storageState", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-"));
|
||||
const stateFile = path.join(tmpDir, "state.json");
|
||||
process.env.RPA_STORAGE_STATE_PATH = stateFile;
|
||||
process.env.MOTHERSHIP_QUOTE_URLS = "https://www.mothership.com/";
|
||||
|
||||
const persistContext = vi.fn(async () => {
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
fs.writeFileSync(stateFile, '{"cookies":[{"name":"s","value":"1"}]}');
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/rpa/browser-launch", () => ({
|
||||
launchRpaBrowser: vi.fn(async () => ({
|
||||
newContext: vi.fn(async () => ({
|
||||
newPage: vi.fn(async () => ({
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
vi.doMock("@/lib/rpa/storage-state", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rpa/storage-state")>(
|
||||
"@/lib/rpa/storage-state",
|
||||
);
|
||||
return { ...actual, persistContext };
|
||||
});
|
||||
vi.doMock("@/workers/rpa/quote-page-adapter", () => ({
|
||||
quotePageAdapter: {
|
||||
resolveQuoteEntry: vi.fn(async () => ({ activeUrl: "https://x" })),
|
||||
preCheck: vi.fn(async () => true),
|
||||
},
|
||||
}));
|
||||
|
||||
const { ensureMothershipStorageState } = await import(
|
||||
"@/lib/rpa/ensure-storage-state"
|
||||
);
|
||||
await ensureMothershipStorageState();
|
||||
expect(fs.existsSync(stateFile)).toBe(true);
|
||||
expect(persistContext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { forbidden } from "@/modules/auth/errors";
|
||||
import { resolveEnvServiceToken } from "@/modules/auth/service-token-env";
|
||||
|
||||
const DEMO_TOKENS = JSON.stringify({
|
||||
"demo-host-token": {
|
||||
customerId: "CUST_001",
|
||||
permissions: ["pricing:markup:write"],
|
||||
},
|
||||
});
|
||||
|
||||
describe("middleware host auth gate", () => {
|
||||
it("env token 带 X-Customer-Id 时命中快路径", () => {
|
||||
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
|
||||
const verified = resolveEnvServiceToken("demo-host-token", "CUST_001");
|
||||
expect(verified?.customerId).toBe("CUST_001");
|
||||
});
|
||||
|
||||
it("DB token 带 X-Customer-Id 时不应抛错,应返回 null 交给路由层 DB 校验", () => {
|
||||
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
|
||||
const verified = resolveEnvServiceToken(
|
||||
"chj_QUNFn-Le9biXM4UTFv5L4z9qouAKmBu7",
|
||||
"CUST_002",
|
||||
);
|
||||
expect(verified).toBeNull();
|
||||
});
|
||||
|
||||
it("env token 与客户 ID 不匹配时仍抛 forbidden", () => {
|
||||
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
|
||||
expect(() =>
|
||||
resolveEnvServiceToken("demo-host-token", "CUST_002"),
|
||||
).toThrow(forbidden("customer_id 与令牌租户不匹配"));
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAddressCandidatesCacheHash } from "@/modules/address/candidates-cache";
|
||||
|
||||
describe("buildAddressCandidatesCacheHash", () => {
|
||||
const pickup = {
|
||||
street: "5678 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zip: "75201",
|
||||
};
|
||||
const delivery = {
|
||||
street: "1234 Warehouse Blvd",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "90001",
|
||||
};
|
||||
|
||||
it("相同地址生成相同 hash", () => {
|
||||
const a = buildAddressCandidatesCacheHash({ pickup, delivery });
|
||||
const b = buildAddressCandidatesCacheHash({ pickup, delivery });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[a-f0-9]{32}$/);
|
||||
});
|
||||
|
||||
it("地址变化生成不同 hash", () => {
|
||||
const a = buildAddressCandidatesCacheHash({ pickup, delivery });
|
||||
const b = buildAddressCandidatesCacheHash({
|
||||
pickup: { ...pickup, street: "999 Other St" },
|
||||
delivery,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAlertPresentation,
|
||||
formatCustomerAddressForAdmin,
|
||||
formatSelectedAddressForAdmin,
|
||||
resolveAlertRootCause,
|
||||
} from "@/modules/alert/alert-presentation";
|
||||
|
||||
describe("alert-presentation", () => {
|
||||
it("区分客户填写地址与联想确认地址", () => {
|
||||
const addr = {
|
||||
street: "123 Main St",
|
||||
city: "Denver",
|
||||
state: "CO",
|
||||
zip: "80202",
|
||||
formatted_address: "123 Main St, Denver, CO",
|
||||
mothership_display_label: "123 Main St, Denver, CO 80202, USA",
|
||||
};
|
||||
expect(formatCustomerAddressForAdmin(addr)).toBe(
|
||||
"123 Main St, Denver, CO, 80202",
|
||||
);
|
||||
expect(formatSelectedAddressForAdmin(addr)).toBe(
|
||||
"123 Main St, Denver, CO 80202, USA",
|
||||
);
|
||||
});
|
||||
|
||||
it("将 browserType.launch 转为可读根因", () => {
|
||||
const cause = resolveAlertRootCause("RPA_FAILED", {
|
||||
reason: "browserType.launch: Executable doesn't exist",
|
||||
});
|
||||
expect(cause).toContain("无法启动浏览器");
|
||||
expect(cause).not.toContain("{");
|
||||
});
|
||||
|
||||
it("buildAlertPresentation 包含客户与货物字段", () => {
|
||||
const presentation = buildAlertPresentation(
|
||||
"RPA_FAILED",
|
||||
{ reason: "test", customer_id: "CUST_001" },
|
||||
{
|
||||
customerId: "CUST_001",
|
||||
pickupJson: {
|
||||
street: "A",
|
||||
city: "B",
|
||||
state: "CO",
|
||||
zip: "80000",
|
||||
mothership_display_label: "A, B, CO",
|
||||
},
|
||||
deliveryJson: {
|
||||
street: "C",
|
||||
city: "D",
|
||||
state: "MA",
|
||||
zip: "02101",
|
||||
mothership_display_label: "C, D, MA",
|
||||
},
|
||||
weightLb: 500,
|
||||
palletCount: 2,
|
||||
cargoType: "general_freight",
|
||||
dimLIn: 48,
|
||||
dimWIn: 40,
|
||||
dimHIn: 48,
|
||||
} as never,
|
||||
);
|
||||
expect(presentation.customer_id).toBe("CUST_001");
|
||||
expect(presentation.pickup_customer).toContain("A");
|
||||
expect(presentation.pickup_selected).toBe("A, B, CO");
|
||||
expect(presentation.cargo_summary).toContain("2 托");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
generateServiceToken,
|
||||
hashServiceToken,
|
||||
} from "@/modules/customer/token-crypto";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
hostCustomer: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
serviceApiKey: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { resolveDbServiceToken } from "@/modules/customer/customer-service";
|
||||
import {
|
||||
resetServiceTokenCache,
|
||||
verifyServiceTokenAsync,
|
||||
} from "@/modules/auth/service-token";
|
||||
|
||||
const DEMO_TOKENS = JSON.stringify({
|
||||
"demo-host-token": {
|
||||
customerId: "CUST_001",
|
||||
permissions: ["pricing:markup:write"],
|
||||
},
|
||||
});
|
||||
|
||||
describe("verifyServiceTokenAsync", () => {
|
||||
beforeEach(() => {
|
||||
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
|
||||
resetServiceTokenCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("env token 仍可用", async () => {
|
||||
const result = await verifyServiceTokenAsync("demo-host-token", "CUST_001");
|
||||
expect(result.customerId).toBe("CUST_001");
|
||||
});
|
||||
|
||||
it("DB token 校验通过", async () => {
|
||||
const raw = generateServiceToken();
|
||||
vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue({
|
||||
customerId: "CUST_004",
|
||||
permissions: ["pricing:markup:write"],
|
||||
isActive: true,
|
||||
tokenHash: hashServiceToken(raw),
|
||||
customer: { status: "active", isDeleted: false },
|
||||
} as never);
|
||||
|
||||
const result = await verifyServiceTokenAsync(raw, "CUST_004");
|
||||
expect(result.customerId).toBe("CUST_004");
|
||||
expect(prisma.serviceApiKey.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("无效 token 返回 401", async () => {
|
||||
vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue(null);
|
||||
await expect(
|
||||
verifyServiceTokenAsync("bad-token", "CUST_004"),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDbServiceToken", () => {
|
||||
it("按 hash 解析客户", async () => {
|
||||
const raw = generateServiceToken();
|
||||
vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue({
|
||||
customerId: "CUST_002",
|
||||
permissions: ["pricing:markup:write"],
|
||||
} as never);
|
||||
|
||||
const resolved = await resolveDbServiceToken(raw);
|
||||
expect(resolved?.customerId).toBe("CUST_002");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_EMBED_PASSWORD,
|
||||
hashEmbedPassword,
|
||||
validateEmbedPassword,
|
||||
compareEmbedPassword,
|
||||
} from "@/modules/customer/embed-password";
|
||||
|
||||
describe("embed-password", () => {
|
||||
it("默认演示密码为 123456", () => {
|
||||
expect(DEFAULT_EMBED_PASSWORD).toBe("123456");
|
||||
});
|
||||
|
||||
it("校验密码长度", () => {
|
||||
expect(validateEmbedPassword("")).toMatch(/不能为空/);
|
||||
expect(validateEmbedPassword("12345")).toMatch(/至少/);
|
||||
expect(validateEmbedPassword("a".repeat(65))).toMatch(/不能超过/);
|
||||
expect(validateEmbedPassword("123456")).toBeNull();
|
||||
});
|
||||
|
||||
it("哈希后可验证", async () => {
|
||||
const hash = await hashEmbedPassword("my-secret");
|
||||
expect(await compareEmbedPassword("my-secret", hash)).toBe(true);
|
||||
expect(await compareEmbedPassword("wrong", hash)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
generateServiceToken,
|
||||
hashServiceToken,
|
||||
tokenKeyPrefix,
|
||||
} from "@/modules/customer/token-crypto";
|
||||
|
||||
describe("token-crypto", () => {
|
||||
it("生成 chj_ 前缀 token", () => {
|
||||
const token = generateServiceToken();
|
||||
expect(token.startsWith("chj_")).toBe(true);
|
||||
expect(token.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("hash 稳定", () => {
|
||||
const token = "chj_test_token";
|
||||
expect(hashServiceToken(token)).toHaveLength(64);
|
||||
expect(hashServiceToken(token)).toBe(hashServiceToken(token));
|
||||
});
|
||||
|
||||
it("key prefix 用于列表展示", () => {
|
||||
const token = generateServiceToken();
|
||||
expect(tokenKeyPrefix(token).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
EMBED_DEMO_DEFAULT_PASSWORD,
|
||||
EMBED_DEMO_ROLE,
|
||||
getEmbedDemoPassword,
|
||||
signEmbedDemoToken,
|
||||
verifyEmbedDemoToken,
|
||||
} from "@/modules/embed-demo/auth";
|
||||
|
||||
describe("embed-demo auth", () => {
|
||||
const prevSecret = process.env.JWT_SECRET;
|
||||
const prevPassword = process.env.EMBED_DEMO_PASSWORD;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-embed-demo";
|
||||
delete process.env.EMBED_DEMO_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevSecret === undefined) {
|
||||
delete process.env.JWT_SECRET;
|
||||
} else {
|
||||
process.env.JWT_SECRET = prevSecret;
|
||||
}
|
||||
if (prevPassword === undefined) {
|
||||
delete process.env.EMBED_DEMO_PASSWORD;
|
||||
} else {
|
||||
process.env.EMBED_DEMO_PASSWORD = prevPassword;
|
||||
}
|
||||
});
|
||||
|
||||
it("默认演示密码为 123456", () => {
|
||||
expect(getEmbedDemoPassword()).toBe(EMBED_DEMO_DEFAULT_PASSWORD);
|
||||
});
|
||||
|
||||
it("签发与校验演示会话 JWT", async () => {
|
||||
const token = await signEmbedDemoToken("CUST_002");
|
||||
const payload = await verifyEmbedDemoToken(token);
|
||||
expect(payload.sub).toBe("CUST_002");
|
||||
expect(payload.role).toBe(EMBED_DEMO_ROLE);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getHostQuotePollIntervalMs,
|
||||
HOST_QUOTE_POLL_INTERVAL_MS_DEFAULT,
|
||||
} from "@/lib/constants/host-public-api";
|
||||
|
||||
describe("getHostQuotePollIntervalMs", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("默认 500ms", () => {
|
||||
expect(HOST_QUOTE_POLL_INTERVAL_MS_DEFAULT).toBe(500);
|
||||
expect(getHostQuotePollIntervalMs()).toBe(500);
|
||||
});
|
||||
|
||||
it("支持 HOST_QUOTE_POLL_INTERVAL_MS 覆盖", () => {
|
||||
vi.stubEnv("HOST_QUOTE_POLL_INTERVAL_MS", "800");
|
||||
expect(getHostQuotePollIntervalMs()).toBe(800);
|
||||
});
|
||||
|
||||
it("非法值回退默认", () => {
|
||||
vi.stubEnv("HOST_QUOTE_POLL_INTERVAL_MS", "0");
|
||||
expect(getHostQuotePollIntervalMs()).toBe(500);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockCreate = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockGenerateQuoteId = vi.fn();
|
||||
const mockEnqueue = vi.fn();
|
||||
const mockHandleConflict = vi.fn();
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
quoteRecord: {
|
||||
create: (...args: unknown[]) => mockCreate(...args),
|
||||
update: (...args: unknown[]) => mockUpdate(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/quote/quote-id", () => ({
|
||||
generateQuoteId: () => mockGenerateQuoteId(),
|
||||
isQuoteIdUniqueConflict: (error: unknown) =>
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { code?: string }).code === "P2002",
|
||||
handleQuoteIdConflict: (...args: unknown[]) => mockHandleConflict(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/priority1/rpa-queue", () => ({
|
||||
enqueuePriority1QuoteJob: (...args: unknown[]) => mockEnqueue(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/metrics/collector", () => ({
|
||||
safeRecord: (task: () => Promise<void>) => {
|
||||
void task();
|
||||
},
|
||||
markQuoteStarted: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input";
|
||||
import {
|
||||
Priority1EnqueueError,
|
||||
submitPriority1Quote,
|
||||
} from "@/modules/priority1/orchestrator";
|
||||
|
||||
describe("submitPriority1Quote", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGenerateQuoteId.mockResolvedValue("QTE_20260701_0001");
|
||||
mockCreate.mockResolvedValue({});
|
||||
mockUpdate.mockResolvedValue({});
|
||||
mockEnqueue.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("入队成功返回 processing", async () => {
|
||||
const result = await submitPriority1Quote({
|
||||
request_id: "req-1",
|
||||
customer_id: "CUST_001",
|
||||
priority1_input: DEFAULT_PRIORITY1_DEMO,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
quote_id: "QTE_20260701_0001",
|
||||
status: "processing",
|
||||
});
|
||||
expect(mockEnqueue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Redis 入队失败时抛出可读错误并标记 quote 失败", async () => {
|
||||
mockEnqueue.mockRejectedValue(new Error("REDIS_URL 未配置"));
|
||||
|
||||
await expect(
|
||||
submitPriority1Quote({
|
||||
request_id: "req-2",
|
||||
customer_id: "CUST_001",
|
||||
priority1_input: DEFAULT_PRIORITY1_DEMO,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Priority1EnqueueError);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { quoteId: "QTE_20260701_0001" },
|
||||
data: expect.objectContaining({
|
||||
status: "failed",
|
||||
errorCode: "QUOTE_UNAVAILABLE",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPriority1StoredQuotes,
|
||||
inferPriority1DisplayMode,
|
||||
parsePriority1StoredQuotes,
|
||||
resolvePriority1StoredQuotes,
|
||||
} from "@/modules/priority1/quote-storage";
|
||||
import type { Priority1QuoteLine } from "@/workers/rpa/priority1/quote-extract";
|
||||
import { buildPriority1CalendarGrid } from "@/lib/priority1/calendar-grid";
|
||||
|
||||
const ltlLine = (overrides: Partial<Priority1QuoteLine> = {}): Priority1QuoteLine => ({
|
||||
rank: 1,
|
||||
carrier: "Old Dominion Freight Line",
|
||||
totalUsd: 557.04,
|
||||
transitDays: 2,
|
||||
serviceLevel: "STANDARD LTL",
|
||||
source: "visible-dom",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("priority1 quote storage", () => {
|
||||
it("infers ltl_cards for standard LTL lines", () => {
|
||||
expect(inferPriority1DisplayMode([ltlLine()])).toBe("ltl_cards");
|
||||
expect(inferPriority1DisplayMode([ltlLine()], "ltl")).toBe("ltl_cards");
|
||||
});
|
||||
|
||||
it("infers ftl_calendar for FTL shipment or calendar source", () => {
|
||||
expect(inferPriority1DisplayMode([ltlLine()], "ftl")).toBe("ftl_calendar");
|
||||
expect(
|
||||
inferPriority1DisplayMode([
|
||||
ltlLine({ source: "ftl-calendar", carrier: "FTL 日历报价" }),
|
||||
]),
|
||||
).toBe("ftl_calendar");
|
||||
});
|
||||
|
||||
it("builds stored quotes with markup applied", () => {
|
||||
const stored = buildPriority1StoredQuotes(
|
||||
[ltlLine({ totalUsd: 100 })],
|
||||
{ type: "percent", percent: 10, fixedAmount: 0 },
|
||||
"ltl",
|
||||
);
|
||||
expect(stored.provider).toBe("priority1");
|
||||
expect(stored.display_mode).toBe("ltl_cards");
|
||||
expect(stored.lines[0]!.final_total_usd).toBe(110);
|
||||
expect(stored.lines[0]!.markup_amount).toBe(10);
|
||||
});
|
||||
|
||||
it("round-trips parsePriority1StoredQuotes", () => {
|
||||
const stored = buildPriority1StoredQuotes(
|
||||
[ltlLine()],
|
||||
{ type: "fixed", percent: 0, fixedAmount: 5 },
|
||||
"ltl",
|
||||
);
|
||||
expect(parsePriority1StoredQuotes(stored)).toEqual(stored);
|
||||
expect(parsePriority1StoredQuotes([{ rank: 1 }])).toBeNull();
|
||||
});
|
||||
|
||||
it("converts legacy MotherShip tier array for display", () => {
|
||||
const resolved = resolvePriority1StoredQuotes(
|
||||
[
|
||||
{
|
||||
service_level: "standard",
|
||||
rate_option: "lowest",
|
||||
carrier: "Old Dominion",
|
||||
transit_days: "2",
|
||||
raw_total: 557.04,
|
||||
final_total: 557.04,
|
||||
markup_amount: 0,
|
||||
},
|
||||
],
|
||||
{ priority1_shipment_type: "ltl" },
|
||||
);
|
||||
expect(resolved?.display_mode).toBe("ltl_cards");
|
||||
expect(resolved?.lines[0]?.final_total_usd).toBe(557.04);
|
||||
expect(resolved?.lines[0]?.source).toBe("legacy-tier");
|
||||
});
|
||||
|
||||
it("does not treat MotherShip tier array as Priority1 without shipment meta", () => {
|
||||
const resolved = resolvePriority1StoredQuotes(
|
||||
[
|
||||
{
|
||||
service_level: "standard",
|
||||
rate_option: "lowest",
|
||||
carrier: "Frontline Freight",
|
||||
transit_days: "3",
|
||||
raw_total: 332.6,
|
||||
final_total: 332.6,
|
||||
markup_amount: 0,
|
||||
},
|
||||
],
|
||||
{ zip: "90001" },
|
||||
);
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority1 calendar grid", () => {
|
||||
it("places prices on consecutive days from pickup date", () => {
|
||||
const lines = buildPriority1StoredQuotes(
|
||||
[
|
||||
ltlLine({ rank: 1, totalUsd: 1652.16 }),
|
||||
ltlLine({ rank: 2, totalUsd: 1650.78 }),
|
||||
],
|
||||
{ type: "percent", percent: 0, fixedAmount: 0 },
|
||||
"ftl",
|
||||
).lines;
|
||||
const { cells } = buildPriority1CalendarGrid("2026-07-01", lines);
|
||||
const inMonth = cells.filter((c) => c.inMonth && c.priceUsd != null);
|
||||
expect(inMonth.length).toBe(2);
|
||||
expect(inMonth[0]!.date.getDate()).toBe(1);
|
||||
expect(inMonth[0]!.priceUsd).toBe(1652.16);
|
||||
expect(inMonth[1]!.date.getDate()).toBe(2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input";
|
||||
import { hashPriority1Input } from "@/modules/priority1/hash";
|
||||
import { priority1LinesToQuoteItems } from "@/modules/priority1/quote-mapper";
|
||||
import { validatePriority1QuoteInput } from "@/modules/priority1/validation";
|
||||
|
||||
describe("priority1 validation", () => {
|
||||
it("校验合法 LTL 提交", () => {
|
||||
const parsed = validatePriority1QuoteInput({
|
||||
request_id: "req-1",
|
||||
customer_id: "CUST_001",
|
||||
priority1_input: DEFAULT_PRIORITY1_DEMO,
|
||||
});
|
||||
expect(parsed.originZip).toBe("10118");
|
||||
expect(parsed.phone).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("相同输入生成相同哈希", () => {
|
||||
const a = hashPriority1Input(DEFAULT_PRIORITY1_DEMO);
|
||||
const b = hashPriority1Input({ ...DEFAULT_PRIORITY1_DEMO });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toHaveLength(32);
|
||||
});
|
||||
|
||||
it("承运商列表映射为报价项", () => {
|
||||
const items = priority1LinesToQuoteItems([
|
||||
{
|
||||
rank: 1,
|
||||
carrier: "XPO",
|
||||
carrierCode: "XPO",
|
||||
totalUsd: 400,
|
||||
transitDays: 3,
|
||||
deliveryDate: null,
|
||||
expirationDate: null,
|
||||
serviceLevel: "Standard",
|
||||
quoteId: 1,
|
||||
caboUrl: null,
|
||||
source: "api-all",
|
||||
},
|
||||
]);
|
||||
expect(items[0]?.carrier).toBe("XPO");
|
||||
expect(items[0]?.rawTotal).toBe(400);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { QuoteRecord } from "@prisma/client";
|
||||
import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants";
|
||||
import { isPriority1ManualFollowupDetail } from "@/modules/priority1/quote-outcome";
|
||||
import { serializeQuoteDetail } from "@/modules/quote/quote-serializer";
|
||||
import { PRIORITY1_MANUAL_FOLLOWUP_MESSAGE } from "@/workers/rpa/priority1/quote-extract";
|
||||
|
||||
function baseRecord(overrides: Partial<QuoteRecord> = {}): QuoteRecord {
|
||||
return {
|
||||
id: 1,
|
||||
quoteId: "Q-TEST-001",
|
||||
requestId: "req-1",
|
||||
customerId: "cust-1",
|
||||
cargoHash: "hash",
|
||||
status: "done",
|
||||
sourceType: "rpa",
|
||||
isRealtime: true,
|
||||
confidenceScore: 0.95,
|
||||
currency: "USD",
|
||||
pickupJson: {},
|
||||
deliveryJson: {},
|
||||
weightLb: 100,
|
||||
dimLIn: 48,
|
||||
dimWIn: 40,
|
||||
dimHIn: 48,
|
||||
palletCount: 1,
|
||||
cargoType: "general_freight",
|
||||
quotesJson: null,
|
||||
markupPercent: 0,
|
||||
validUntil: null,
|
||||
errorCode: PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE,
|
||||
errorMessage: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
|
||||
isDeleted: false,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
} as QuoteRecord;
|
||||
}
|
||||
|
||||
describe("Priority1 manual_followup quote detail", () => {
|
||||
it("serializes done + PRIORITY1_MANUAL_FOLLOWUP as informational outcome", () => {
|
||||
const detail = serializeQuoteDetail(baseRecord());
|
||||
expect(detail.status).toBe("done");
|
||||
expect(detail.error_code).toBe(PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE);
|
||||
expect(detail.error_message).toBe(PRIORITY1_MANUAL_FOLLOWUP_MESSAGE);
|
||||
expect(detail.quotes).toEqual([]);
|
||||
expect(isPriority1ManualFollowupDetail(detail)).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes priority1 stored quotes without mothership tiers", () => {
|
||||
const detail = serializeQuoteDetail(
|
||||
baseRecord({
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
quotesJson: {
|
||||
provider: "priority1",
|
||||
display_mode: "ltl_cards",
|
||||
lines: [
|
||||
{
|
||||
rank: 1,
|
||||
carrier: "Forward Air",
|
||||
totalUsd: 557,
|
||||
markup_amount: 0,
|
||||
final_total_usd: 557,
|
||||
serviceLevel: "STANDARD LTL",
|
||||
source: "visible-dom",
|
||||
},
|
||||
],
|
||||
},
|
||||
pickupJson: {
|
||||
zip: "32801",
|
||||
priority1_shipment_type: "ltl",
|
||||
priority1_pickup_date: "2026-07-14",
|
||||
},
|
||||
deliveryJson: { zip: "28202" },
|
||||
}),
|
||||
);
|
||||
expect(detail.priority1?.display_mode).toBe("ltl_cards");
|
||||
expect(detail.priority1?.lines).toHaveLength(1);
|
||||
expect(detail.priority1?.shipment_meta?.origin_zip).toBe("32801");
|
||||
expect(detail.quotes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializes legacy tier quotesJson as priority1 payload", () => {
|
||||
const detail = serializeQuoteDetail(
|
||||
baseRecord({
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
quotesJson: [
|
||||
{
|
||||
service_level: "standard",
|
||||
rate_option: "lowest",
|
||||
carrier: "Forward Air",
|
||||
transit_days: "3",
|
||||
raw_total: 428.5,
|
||||
final_total: 428.5,
|
||||
markup_amount: 0,
|
||||
},
|
||||
],
|
||||
pickupJson: { zip: "32801", priority1_shipment_type: "ltl" },
|
||||
deliveryJson: { zip: "28202" },
|
||||
}),
|
||||
);
|
||||
expect(detail.priority1?.lines).toHaveLength(1);
|
||||
expect(detail.priority1?.lines[0]?.carrier).toBe("Forward Air");
|
||||
});
|
||||
|
||||
it("MotherShip tier 数组在无 Priority1 上下文时序列化为 quotes 而非 priority1", () => {
|
||||
const detail = serializeQuoteDetail(
|
||||
baseRecord({
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
quotesJson: [
|
||||
{
|
||||
service_level: "standard",
|
||||
rate_option: "lowest",
|
||||
carrier: "Frontline Freight",
|
||||
transit_days: "3",
|
||||
raw_freight: 300,
|
||||
surcharges: 32.6,
|
||||
raw_total: 332.6,
|
||||
markup_percent: 0,
|
||||
markup_amount: 0,
|
||||
final_total: 332.6,
|
||||
breakdown: [
|
||||
{ item: "运费", amount: 300 },
|
||||
{ item: "附加费", amount: 32.6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
pickupJson: { zip: "90001" },
|
||||
deliveryJson: { zip: "75201" },
|
||||
}),
|
||||
);
|
||||
expect(detail.priority1).toBeUndefined();
|
||||
expect(detail.quotes).toHaveLength(1);
|
||||
expect(detail.quotes?.[0]).toMatchObject({
|
||||
service_level: "standard",
|
||||
rate_option: "lowest",
|
||||
carrier: "Frontline Freight",
|
||||
});
|
||||
});
|
||||
|
||||
it("Priority1 询价无 priority1 数据时返回 failed 而非空结果", () => {
|
||||
const detail = serializeQuoteDetail(
|
||||
baseRecord({
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
quotesJson: null,
|
||||
pickupJson: { zip: "32801", priority1_shipment_type: "ltl" },
|
||||
deliveryJson: { zip: "28202" },
|
||||
}),
|
||||
);
|
||||
expect(detail.status).toBe("failed");
|
||||
expect(detail.priority1).toBeUndefined();
|
||||
expect(detail.quotes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,84 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT_PATH = join(process.cwd(), "scripts", "record-priority1.sh");
|
||||
const TS_SCRIPT_PATH = join(process.cwd(), "scripts", "record-priority1.ts");
|
||||
|
||||
const FORBIDDEN_PATTERNS = [
|
||||
/dashboard\.priority1\.com/i,
|
||||
/api\.priority1\.com/i,
|
||||
];
|
||||
|
||||
function isForbiddenPriority1QuoteUrl(url: string): boolean {
|
||||
return FORBIDDEN_PATTERNS.some((pattern) => pattern.test(url));
|
||||
}
|
||||
|
||||
describe("record-priority1.sh", () => {
|
||||
const content = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
it("存在且包含 headed 与 codegen 启动", () => {
|
||||
expect(content).toContain("RPA_HEADLESS=false");
|
||||
expect(content).toContain("playwright");
|
||||
expect(content).toContain("codegen");
|
||||
});
|
||||
|
||||
it("禁止 Cabo 后台与 API 门户 URL", () => {
|
||||
expect(content).toContain("dashboard.priority1.com");
|
||||
expect(content).toContain("api.priority1.com");
|
||||
expect(content).toContain("check_url");
|
||||
});
|
||||
|
||||
it("产出录制脚本与 storageState 路径", () => {
|
||||
expect(content).toContain(".rpa/recordings");
|
||||
expect(content).toContain("priority1-storage.json");
|
||||
expect(content).toContain("--save-storage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("record-priority1.ts (Windows 跨平台)", () => {
|
||||
const content = readFileSync(TS_SCRIPT_PATH, "utf8");
|
||||
|
||||
it("存在且包含 headed codegen 与入口校验", () => {
|
||||
expect(content).toContain('RPA_HEADLESS: "false"');
|
||||
expect(content).toContain("playwright");
|
||||
expect(content).toContain("codegen");
|
||||
expect(content).toContain("isForbiddenPriority1QuoteUrl");
|
||||
});
|
||||
|
||||
it("产出录制脚本与 storageState 路径", () => {
|
||||
expect(content).toContain(".rpa/recordings");
|
||||
expect(content).toContain("priority1-storage.json");
|
||||
expect(content).toContain("--save-storage");
|
||||
});
|
||||
|
||||
it("默认打开公开询价页", () => {
|
||||
expect(content).toContain(
|
||||
"https://www.priority1.com/instant-freight-quotes/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForbiddenPriority1QuoteUrl", () => {
|
||||
it("拒绝 Cabo 后台与 API 门户", () => {
|
||||
expect(
|
||||
isForbiddenPriority1QuoteUrl(
|
||||
"https://dashboard.priority1.com/account/login",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isForbiddenPriority1QuoteUrl("https://api.priority1.com/docs")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("允许公开询价页与首页", () => {
|
||||
expect(
|
||||
isForbiddenPriority1QuoteUrl(
|
||||
"https://www.priority1.com/instant-freight-quotes/",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isForbiddenPriority1QuoteUrl("https://www.priority1.com/")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPriority1CanonicalTestCases,
|
||||
generateRandomExpeditedCases,
|
||||
generateRandomFtlCases,
|
||||
generateRandomPriority1Cases,
|
||||
parsePriority1DemoFromEnv,
|
||||
PRIORITY1_CANONICAL_PATHS,
|
||||
} from "@/workers/rpa/priority1/demo-input";
|
||||
|
||||
describe("priority1 demo-input", () => {
|
||||
it("canonical 全矩阵 24 条", () => {
|
||||
expect(PRIORITY1_CANONICAL_PATHS).toHaveLength(24);
|
||||
const ltl = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("P"));
|
||||
const ftl = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("F"));
|
||||
const exp = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("E"));
|
||||
expect(ltl).toHaveLength(9);
|
||||
expect(ftl).toHaveLength(11);
|
||||
expect(exp).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("导出系统联调用例含 API 载荷", () => {
|
||||
const cases = buildPriority1CanonicalTestCases({
|
||||
customerId: "CUST_X",
|
||||
requestIdPrefix: "req",
|
||||
});
|
||||
expect(cases).toHaveLength(24);
|
||||
expect(cases[0]?.apiPayload.request_id).toBe("req-P01-ltl-residential");
|
||||
expect(cases[0]?.apiPayload.customer_id).toBe("CUST_X");
|
||||
expect(cases.find((c) => c.id === "E03-expedited-large")?.apiPayload.priority1_input.weightLb).toBe(
|
||||
"10000",
|
||||
);
|
||||
});
|
||||
it("相同 seed 生成相同用例", () => {
|
||||
const a = generateRandomPriority1Cases(10, 42);
|
||||
const b = generateRandomPriority1Cases(10, 42);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toHaveLength(10);
|
||||
expect(a[0]?.id).toBe("P01");
|
||||
});
|
||||
|
||||
it("起运与目的 ZIP 不相同", () => {
|
||||
const cases = generateRandomPriority1Cases(10, 99);
|
||||
for (const c of cases) {
|
||||
expect(c.input.originZip).not.toBe(c.input.destinationZip);
|
||||
expect(c.input.originZip).toMatch(/^\d{5}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("生成 FTL / Expedited 用例", () => {
|
||||
const ftl = generateRandomFtlCases(3, 42);
|
||||
const exp = generateRandomExpeditedCases(3, 42);
|
||||
expect(ftl[0]?.input.shipmentType).toBe("ftl");
|
||||
expect(exp[0]?.input.shipmentType).toBe("expedited");
|
||||
expect(ftl[0]?.id).toBe("F01");
|
||||
expect(exp[0]?.id).toBe("E01");
|
||||
});
|
||||
|
||||
it("解析环境 JSON 覆盖默认字段", () => {
|
||||
const demo = parsePriority1DemoFromEnv(
|
||||
JSON.stringify({ originZip: "30303", weightLb: "999" }),
|
||||
);
|
||||
expect(demo.originZip).toBe("30303");
|
||||
expect(demo.weightLb).toBe("999");
|
||||
expect(demo.destinationZip).toBe("90017");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_EXPEDITED_DEMO,
|
||||
DEFAULT_FTL_FTL_EXPEDITED_DEMO,
|
||||
PRIORITY1_CANONICAL_PATHS,
|
||||
} from "@/workers/rpa/priority1/demo-input";
|
||||
import {
|
||||
validatePriority1FormConstraints,
|
||||
} from "@/workers/rpa/priority1/form-constraints";
|
||||
|
||||
describe("priority1 form-constraints", () => {
|
||||
it("24 条 canonical 无硬限制违规", () => {
|
||||
const errors = PRIORITY1_CANONICAL_PATHS.flatMap((p) =>
|
||||
validatePriority1FormConstraints(p.input).filter((v) => v.severity === "error"),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("Expedited Large 超过 10000 lb 报错", () => {
|
||||
const v = validatePriority1FormConstraints({
|
||||
...DEFAULT_EXPEDITED_DEMO,
|
||||
expeditedTrailer: "large_straight",
|
||||
weightLb: "11000",
|
||||
});
|
||||
expect(v.some((x) => x.code === "WEIGHT_MAX")).toBe(true);
|
||||
});
|
||||
|
||||
it("非 Large Straight 不可勾选 Liftgate", () => {
|
||||
const v = validatePriority1FormConstraints({
|
||||
...DEFAULT_EXPEDITED_DEMO,
|
||||
expeditedTrailer: "sprinter_van",
|
||||
expeditedLiftgate: true,
|
||||
});
|
||||
expect(v.some((x) => x.code === "LIFTGATE_TRAILER")).toBe(true);
|
||||
});
|
||||
|
||||
it("FTL 内嵌 Expedited Large 重量上限 10000", () => {
|
||||
const v = validatePriority1FormConstraints({
|
||||
...DEFAULT_FTL_FTL_EXPEDITED_DEMO,
|
||||
ftlExpeditedTrailer: "large_straight",
|
||||
weightLb: "12000",
|
||||
});
|
||||
expect(v.some((x) => x.code === "WEIGHT_MAX")).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
freightClassOptionMatches,
|
||||
ltlAccessorialDisplayText,
|
||||
ltlAccessorialIsPickupSection,
|
||||
ltlAccessorialLabelMatches,
|
||||
normalizeOpenDeckSubtypeQuery,
|
||||
OPEN_DECK_TYPE_SELECT_SELECTOR,
|
||||
openDeckSelectionOk,
|
||||
} from "@/workers/rpa/priority1/form-interactions";
|
||||
|
||||
describe("priority1 form-interactions", () => {
|
||||
it("Freight Class 选项匹配含 Not Sure", () => {
|
||||
expect(freightClassOptionMatches("70", "70")).toBe(true);
|
||||
expect(freightClassOptionMatches("I'm Not Sure", "Not Sure")).toBe(true);
|
||||
expect(freightClassOptionMatches("400", "70")).toBe(false);
|
||||
});
|
||||
|
||||
it("Open Deck 子类型别名归一", () => {
|
||||
expect(normalizeOpenDeckSubtypeQuery("Flatbed")).toBe("Foot Flatbed");
|
||||
expect(normalizeOpenDeckSubtypeQuery("48 Foot Flatbed")).toBe("48 Foot Flatbed");
|
||||
expect(normalizeOpenDeckSubtypeQuery("Goose Neck RGN")).toBe("Removable Gooseneck");
|
||||
});
|
||||
|
||||
it("Open Deck 选中判定", () => {
|
||||
expect(openDeckSelectionOk("48 Foot Flatbed", "Flatbed")).toBe(true);
|
||||
expect(openDeckSelectionOk("Removable Gooseneck", "Removable Gooseneck")).toBe(true);
|
||||
expect(openDeckSelectionOk("", "Flatbed")).toBe(false);
|
||||
});
|
||||
|
||||
it("Open Deck Type 录制 selector", () => {
|
||||
expect(OPEN_DECK_TYPE_SELECT_SELECTOR).toBe("#PickupLocation3");
|
||||
});
|
||||
|
||||
it("LTL 附加服务 label 匹配", () => {
|
||||
expect(ltlAccessorialLabelMatches("Liftgate at Pickup", "Liftgate at Pickup")).toBe(true);
|
||||
expect(ltlAccessorialLabelMatches(" Inside Pickup ", "Inside Pickup")).toBe(true);
|
||||
expect(ltlAccessorialLabelMatches("Inside Delivery", "Inside Pickup")).toBe(false);
|
||||
expect(ltlAccessorialLabelMatches("Liftgate at Delivery", "Liftgate at Pickup")).toBe(false);
|
||||
});
|
||||
|
||||
it("LTL 附加 canonical → 页面文案", () => {
|
||||
expect(ltlAccessorialDisplayText("Limited Access Pickup")).toBe("Limited Access");
|
||||
expect(ltlAccessorialDisplayText("Inside Pickup")).toBe("Inside Pickup");
|
||||
expect(ltlAccessorialIsPickupSection("Liftgate at Pickup")).toBe(true);
|
||||
expect(ltlAccessorialIsPickupSection("Hazardous Material")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_FTL_DEMO,
|
||||
DEFAULT_FTL_FTL_EXPEDITED_DEMO,
|
||||
DEFAULT_PRIORITY1_DEMO,
|
||||
PRIORITY1_CANONICAL_PATHS,
|
||||
} from "@/workers/rpa/priority1/demo-input";
|
||||
import {
|
||||
buildPriority1FormPlan,
|
||||
collectFieldOptionsCatalog,
|
||||
formatCompleteFormLogicReference,
|
||||
formatFormLogicReference,
|
||||
getNextFields,
|
||||
SCENARIO_CHEATSHEET,
|
||||
STEP2_HASH_FALLBACK,
|
||||
} from "@/workers/rpa/priority1/form-logic-map";
|
||||
|
||||
describe("priority1 form-logic-map", () => {
|
||||
it("LTL 计划包含包装 Pallet 与 Step1 邮编", () => {
|
||||
const plan = buildPriority1FormPlan(DEFAULT_PRIORITY1_DEMO);
|
||||
const keys = plan.map((p) => p.field.key);
|
||||
expect(keys).toContain("packaging");
|
||||
expect(plan.find((p) => p.field.key === "packaging")?.value).toBe("Pallet");
|
||||
expect(plan.find((p) => p.field.key === "originZip")?.value).toBe("10118");
|
||||
});
|
||||
|
||||
it("FTL Dry Van 分支含拖车、货描、重量、托盘", () => {
|
||||
const plan = buildPriority1FormPlan(DEFAULT_FTL_DEMO);
|
||||
const labels = plan.map((p) => p.field.label);
|
||||
expect(labels.some((l) => /Dry Van/i.test(l))).toBe(true);
|
||||
expect(plan.find((p) => p.field.key === "commodity")?.value).toBe("Electronics");
|
||||
expect(plan.find((p) => p.field.key === "weightLb")?.value).toBe("15000");
|
||||
expect(plan.find((p) => p.field.key === "palletCount")?.value).toBe("18");
|
||||
});
|
||||
|
||||
it("getNextFields FTL 未选拖车时只返回拖车卡", () => {
|
||||
const next = getNextFields({ shipmentType: "ftl", step: 2 });
|
||||
expect(next.every((f) => f.key.startsWith("ftlTrailer_"))).toBe(true);
|
||||
});
|
||||
|
||||
it("Open Deck 分支追加子类型字段", () => {
|
||||
const next = getNextFields({
|
||||
shipmentType: "ftl",
|
||||
step: 2,
|
||||
ftlTrailer: "open_deck",
|
||||
});
|
||||
expect(next.some((f) => f.key === "openDeckSubtype")).toBe(true);
|
||||
});
|
||||
|
||||
it("场景速查与哈希回退齐全", () => {
|
||||
expect(SCENARIO_CHEATSHEET.length).toBeGreaterThanOrEqual(6);
|
||||
expect(STEP2_HASH_FALLBACK.ftl).toContain("Truckload");
|
||||
expect(formatFormLogicReference(DEFAULT_FTL_DEMO)).toContain("Dry Van");
|
||||
});
|
||||
|
||||
it("FTL 内嵌 Expedited 计划不含托盘字段", () => {
|
||||
const plan = buildPriority1FormPlan(DEFAULT_FTL_FTL_EXPEDITED_DEMO);
|
||||
expect(plan.some((p) => p.field.key === "palletCount")).toBe(false);
|
||||
});
|
||||
|
||||
it("canonical 路径覆盖三种主类型与全部 FTL 拖车", () => {
|
||||
expect(PRIORITY1_CANONICAL_PATHS.length).toBeGreaterThanOrEqual(20);
|
||||
const types = new Set(PRIORITY1_CANONICAL_PATHS.map((p) => p.input.shipmentType));
|
||||
expect(types).toEqual(new Set(["ltl", "ftl", "expedited"]));
|
||||
const ftlTrailers = PRIORITY1_CANONICAL_PATHS.filter(
|
||||
(p) => p.input.shipmentType === "ftl",
|
||||
).map((p) => p.input.ftlTrailer);
|
||||
expect(new Set(ftlTrailers)).toEqual(
|
||||
new Set(["dry_van", "open_deck", "temperature_controlled", "ftl_expedited"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("完整参考含路径矩阵与字段目录", () => {
|
||||
const md = formatCompleteFormLogicReference(PRIORITY1_CANONICAL_PATHS);
|
||||
expect(md).toContain("路径矩阵");
|
||||
expect(md).toContain("全字段可选值");
|
||||
expect(md).toContain("P01-ltl-residential");
|
||||
expect(collectFieldOptionsCatalog().some((r) => r.key === "packaging")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants";
|
||||
import { PRIORITY1_MANUAL_FOLLOWUP_MESSAGE } from "@/workers/rpa/priority1/quote-extract";
|
||||
|
||||
const { prismaUpdate, runRpa, recordSuccess, closeCircuit } = vi.hoisted(
|
||||
() => ({
|
||||
prismaUpdate: vi.fn(),
|
||||
runRpa: vi.fn(),
|
||||
recordSuccess: vi.fn(),
|
||||
closeCircuit: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
quoteRecord: { update: prismaUpdate },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/priority1/run-quote", () => ({
|
||||
runPriority1QuoteRpa: runRpa,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/cache/circuit-breaker", () => ({
|
||||
isCircuitOpen: vi.fn().mockResolvedValue(false),
|
||||
recordRpaFailure: vi.fn(),
|
||||
recordRpaSuccess: recordSuccess,
|
||||
closeCircuit: closeCircuit,
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/worker-state", () => ({
|
||||
heartbeat: vi.fn(),
|
||||
isWorkerPaused: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/metrics/collector", () => ({
|
||||
recordPostDone: vi.fn(),
|
||||
safeRecord: (fn: () => void) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/pricing/engine", () => ({
|
||||
getMarkupRule: vi.fn(),
|
||||
applyMarkupToQuotes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/quote/fallback-orchestrator", () => ({
|
||||
handleRpaFailure: vi.fn(),
|
||||
}));
|
||||
|
||||
import { processPriority1QuoteJob } from "@/workers/rpa/priority1-job-handler";
|
||||
|
||||
const jobData = {
|
||||
quoteId: "Q-P1-001",
|
||||
requestId: "req-p1",
|
||||
customerId: "cust-1",
|
||||
cargoHash: "hash-p1",
|
||||
input: {
|
||||
shipmentType: "ftl" as const,
|
||||
originZip: "98101",
|
||||
destinationZip: "33101",
|
||||
pickupDate: "07/01/2026",
|
||||
phoneCountry: "US",
|
||||
phone: "2065550100",
|
||||
email: "test@example.com",
|
||||
commodity: "General Freight",
|
||||
weightLb: "5000",
|
||||
ftlTrailer: "temperature_controlled" as const,
|
||||
tempMinF: "32",
|
||||
tempMaxF: "40",
|
||||
palletCount: "1",
|
||||
},
|
||||
};
|
||||
|
||||
describe("processPriority1QuoteJob", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("persists manual_followup as done with PRIORITY1_MANUAL_FOLLOWUP", async () => {
|
||||
runRpa.mockResolvedValue({
|
||||
ok: true,
|
||||
quotes: [],
|
||||
quoteOutcome: "manual_followup",
|
||||
message: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
|
||||
});
|
||||
|
||||
await processPriority1QuoteJob(jobData, "worker-1");
|
||||
|
||||
expect(prismaUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { quoteId: "Q-P1-001" },
|
||||
data: expect.objectContaining({
|
||||
status: "done",
|
||||
errorCode: PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE,
|
||||
errorMessage: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(recordSuccess).toHaveBeenCalled();
|
||||
expect(closeCircuit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isPickupDateAfterToday,
|
||||
minPickupDateDisplay,
|
||||
pickupDateValidationMessage,
|
||||
} from "@/lib/priority1/pickup-date";
|
||||
import { extractPriority1VisibleQuotes } from "@/workers/rpa/priority1/quote-extract";
|
||||
|
||||
const SAMPLE_SUMMARY = JSON.stringify({
|
||||
quote_id: 42830001,
|
||||
LTL_best_price_quote_carrier: "XPO",
|
||||
LTL_best_price_quote_cost: "481.32",
|
||||
LTL_best_price_quote_transit: 8,
|
||||
LTL_second_rate_quote_carrier: "Estes",
|
||||
LTL_second_rate_quote_cost: "503.68",
|
||||
LTL_second_rate_quote_transit: 5,
|
||||
});
|
||||
|
||||
describe("pickup date rules", () => {
|
||||
it("requires date strictly after today", () => {
|
||||
const min = minPickupDateDisplay();
|
||||
expect(min).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
|
||||
expect(pickupDateValidationMessage(min)).toBeNull();
|
||||
expect(isPickupDateAfterToday(min)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects today or past dates", () => {
|
||||
const today = new Date();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(today.getDate()).padStart(2, "0");
|
||||
const yyyy = today.getFullYear();
|
||||
const todayDisplay = `${mm}/${dd}/${yyyy}`;
|
||||
expect(pickupDateValidationMessage(todayDisplay)).toBe("提货日须晚于今天");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVisibleQuoteCardsDom via extractPriority1VisibleQuotes", () => {
|
||||
const sampleDom = `
|
||||
YOUR INSTANT QUOTE OPTIONS
|
||||
QUOTE 1
|
||||
STANDARD LTL
|
||||
Estes
|
||||
Transit: 1 Business Days
|
||||
Quote Expiration Date: 07-09-2026
|
||||
Estimated Delivery Date: 07-15-2026
|
||||
$426.19
|
||||
QUOTE 2
|
||||
STANDARD LTL
|
||||
TForce Freight
|
||||
Transit: 2 Business Days
|
||||
Quote Expiration Date: 07-09-2026
|
||||
Estimated Delivery Date: 07-16-2026
|
||||
$1703.90
|
||||
`;
|
||||
|
||||
it("无网络摘要时以可见 DOM 卡片为准", () => {
|
||||
const lines = extractPriority1VisibleQuotes([], sampleDom);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]!.carrier).toBe("Estes");
|
||||
expect(lines[0]!.totalUsd).toBe(426.19);
|
||||
expect(lines[0]!.expirationDate).toBe("07/09/2026");
|
||||
expect(lines[0]!.deliveryDate).toBe("07/15/2026");
|
||||
expect(lines[1]!.totalUsd).toBe(1703.9);
|
||||
});
|
||||
|
||||
it("DOM 无承运商时从 Feathery 摘要按 QUOTE 序号补全承运商与金额", () => {
|
||||
const dom = `
|
||||
QUOTE 1
|
||||
STANDARD LTL
|
||||
Transit: 1 Business Days
|
||||
Quote Expiration Date: 07-09-2026
|
||||
Estimated Delivery Date: 07-15-2026
|
||||
$999.99
|
||||
QUOTE 2
|
||||
STANDARD LTL
|
||||
Transit: 2 Business Days
|
||||
$888.88
|
||||
`;
|
||||
const lines = extractPriority1VisibleQuotes([SAMPLE_SUMMARY], dom);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]!.carrier).toBe("XPO");
|
||||
expect(lines[0]!.totalUsd).toBe(481.32);
|
||||
expect(lines[0]!.expirationDate).toBe("07/09/2026");
|
||||
expect(lines[1]!.carrier).toBe("Estes");
|
||||
expect(lines[1]!.totalUsd).toBe(503.68);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractPriority1AllApiQuotes,
|
||||
extractPriority1QuotesFromDomText,
|
||||
extractPriority1QuotesFromNetworkBodies,
|
||||
extractPriority1VisibleQuotes,
|
||||
isPriority1ManualFollowupPage,
|
||||
isPriority1QuotePageReady,
|
||||
parseFtlCalendarQuotes,
|
||||
PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
|
||||
resolvePriority1SubmitOutcome,
|
||||
} from "@/workers/rpa/priority1/quote-extract";
|
||||
|
||||
const SAMPLE_API = JSON.stringify({
|
||||
data: {
|
||||
id: 42830001,
|
||||
rateQuotes: [
|
||||
{
|
||||
id: 1,
|
||||
carrierName: "XPO",
|
||||
carrierCode: "XPOL",
|
||||
transitDays: 8,
|
||||
deliveryDate: "2026-07-13T00:00:00",
|
||||
expirationDate: "2026-06-27T07:00:30Z",
|
||||
serviceLevelDescription: "Standard Rate",
|
||||
rateQuoteDetail: { total: 481.32 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
carrierName: "Estes",
|
||||
carrierCode: "EXLA",
|
||||
transitDays: 5,
|
||||
rateQuoteDetail: { total: 503.68 },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
carrierName: "Hidden Carrier",
|
||||
carrierCode: "HIDN",
|
||||
transitDays: 4,
|
||||
rateQuoteDetail: { total: 999.99 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const SAMPLE_SUMMARY = JSON.stringify({
|
||||
quote_id: 42830001,
|
||||
cabo_url: "https://dashboard.priority1.com/ltl/quotes/details/42830001",
|
||||
LTL_best_price_quote_carrier: "XPO",
|
||||
LTL_best_price_quote_cost: "481.32",
|
||||
LTL_best_price_quote_transit: 8,
|
||||
LTL_second_rate_quote_carrier: "Estes",
|
||||
LTL_second_rate_quote_cost: "503.68",
|
||||
LTL_second_rate_quote_transit: 5,
|
||||
});
|
||||
|
||||
const SAMPLE_DOM = `
|
||||
QUOTE 1 Forward $560.27 STANDARD LTL Forward Quote Expiration Date 07-03-2026
|
||||
2 Business Days
|
||||
QUOTE 2 Go2 Logistics $2052.67 STANDARD LTL Go2 Logistics Quote Expiration Date
|
||||
3 Business Days
|
||||
`;
|
||||
|
||||
describe("priority1 quote-extract", () => {
|
||||
it("全量 API 解析 rateQuotes", () => {
|
||||
const lines = extractPriority1AllApiQuotes([SAMPLE_API]);
|
||||
expect(lines.length).toBe(3);
|
||||
expect(lines[0].carrier).toBe("XPO");
|
||||
expect(lines[0].source).toBe("api-all");
|
||||
});
|
||||
|
||||
it("同时有 API 与摘要时优先页面可见摘要", () => {
|
||||
const lines = extractPriority1VisibleQuotes([SAMPLE_API, SAMPLE_SUMMARY]);
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0].carrier).toBe("XPO");
|
||||
expect(lines[0].source).toBe("feathery-summary");
|
||||
});
|
||||
|
||||
it("无摘要时回退 feathery 摘要字段", () => {
|
||||
const lines = extractPriority1QuotesFromNetworkBodies([SAMPLE_SUMMARY]);
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[1].carrier).toBe("Estes");
|
||||
});
|
||||
|
||||
it("解析 QUOTE 卡片 DOM", () => {
|
||||
const lines = extractPriority1QuotesFromDomText(SAMPLE_DOM);
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0].carrier).toBe("Forward");
|
||||
expect(lines[0].totalUsd).toBe(560.27);
|
||||
expect(lines[0].source).toBe("visible-dom");
|
||||
});
|
||||
|
||||
it("解析 FTL 日历表格报价(无 $ 前缀)", () => {
|
||||
const dom = `
|
||||
YOUR INSTANT QUOTE OPTIONS
|
||||
SHIPPING DETAILS From 32801 To 28202 Weight 15000 lb
|
||||
Wed 1 1652.16 Thu 2 1652.16 Fri 3 1650.78
|
||||
Mon 6 2178.47 Tue 7 1652.16
|
||||
REQUEST A NEW QUOTE BOOK NOW IN CABO TMS
|
||||
`;
|
||||
expect(isPriority1QuotePageReady(dom)).toBe(true);
|
||||
const lines = parseFtlCalendarQuotes(dom);
|
||||
expect(lines.length).toBe(5);
|
||||
expect(lines[0].source).toBe("ftl-calendar");
|
||||
expect(lines[0].deliveryDate).toBe("DAY:1");
|
||||
expect(lines[3].deliveryDate).toBe("DAY:6");
|
||||
expect(lines.some((q) => q.totalUsd === 2178.47)).toBe(true);
|
||||
});
|
||||
|
||||
it("识别 MORE INFORMATION NEEDED 人工跟进页", () => {
|
||||
const dom = `
|
||||
MORE INFORMATION NEEDED
|
||||
A Priority1 freight specialist will reach out to confirm the missing details
|
||||
Confirm Details & Lock In Your Rate
|
||||
REQUEST NEW QUOTE
|
||||
`;
|
||||
expect(isPriority1ManualFollowupPage(dom)).toBe(true);
|
||||
const resolved = resolvePriority1SubmitOutcome([], dom);
|
||||
expect(resolved.outcome).toBe("manual_followup");
|
||||
expect(resolved.message).toBe(PRIORITY1_MANUAL_FOLLOWUP_MESSAGE);
|
||||
expect(resolved.quotes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { writeAudit } from "@/modules/audit/service";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import { rotateHostCustomerApiKey } from "@/modules/customer/customer-service";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ customer_id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
let auth;
|
||||
try {
|
||||
auth = parseAdminAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { customer_id: customerId } = await context.params;
|
||||
|
||||
try {
|
||||
const rotated = await rotateHostCustomerApiKey(customerId);
|
||||
await writeAudit("customer:rotate_key", auth.userId, customerId, {
|
||||
key_prefix: rotated.api_keys.find((k) => k.is_active)?.key_prefix,
|
||||
});
|
||||
return ok(rotated);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "轮换密钥失败";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { writeAudit } from "@/modules/audit/service";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import {
|
||||
updateHostCustomer,
|
||||
} from "@/modules/customer/customer-service";
|
||||
import type { HostCustomerStatus } from "@/modules/customer/types";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ customer_id: string }>;
|
||||
};
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
let auth;
|
||||
try {
|
||||
auth = parseAdminAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { customer_id: customerId } = await context.params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const payload = body as {
|
||||
name?: unknown;
|
||||
status?: unknown;
|
||||
remark?: unknown;
|
||||
embed_password?: unknown;
|
||||
};
|
||||
|
||||
const input: {
|
||||
name?: string;
|
||||
status?: HostCustomerStatus;
|
||||
remark?: string | null;
|
||||
embed_password?: string;
|
||||
} = {};
|
||||
|
||||
if (payload.name !== undefined) {
|
||||
if (typeof payload.name !== "string") {
|
||||
return fail("VALIDATION_FAILED", "客户名称无效", 400);
|
||||
}
|
||||
input.name = payload.name;
|
||||
}
|
||||
|
||||
if (payload.status !== undefined) {
|
||||
if (payload.status !== "active" && payload.status !== "disabled") {
|
||||
return fail("VALIDATION_FAILED", "状态无效", 400);
|
||||
}
|
||||
input.status = payload.status;
|
||||
}
|
||||
|
||||
if (payload.remark !== undefined) {
|
||||
input.remark =
|
||||
typeof payload.remark === "string" ? payload.remark : null;
|
||||
}
|
||||
|
||||
if (payload.embed_password !== undefined) {
|
||||
if (typeof payload.embed_password !== "string") {
|
||||
return fail("VALIDATION_FAILED", "演示密码无效", 400);
|
||||
}
|
||||
input.embed_password = payload.embed_password;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateHostCustomer(customerId, input);
|
||||
const auditDetail: Record<string, unknown> = { ...input };
|
||||
if (auditDetail.embed_password !== undefined) {
|
||||
delete auditDetail.embed_password;
|
||||
auditDetail.embed_password_updated = true;
|
||||
}
|
||||
await writeAudit("customer:update", auth.userId, customerId, auditDetail);
|
||||
return ok(updated);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "更新客户失败";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { writeAudit } from "@/modules/audit/service";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import {
|
||||
createHostCustomer,
|
||||
listHostCustomers,
|
||||
} from "@/modules/customer/customer-service";
|
||||
|
||||
function parsePagination(searchParams: URLSearchParams): {
|
||||
page: number;
|
||||
size: number;
|
||||
} | null {
|
||||
const pageRaw = searchParams.get("page");
|
||||
const sizeRaw = searchParams.get("size");
|
||||
|
||||
if (!pageRaw || !sizeRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = Number(pageRaw);
|
||||
const size = Number(sizeRaw);
|
||||
|
||||
if (
|
||||
!Number.isInteger(page) ||
|
||||
!Number.isInteger(size) ||
|
||||
page < 1 ||
|
||||
size < 1 ||
|
||||
size > 100
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, size };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
parseAdminAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const pagination = parsePagination(url.searchParams);
|
||||
if (!pagination) {
|
||||
return fail("VALIDATION_FAILED", "请提供有效的 page 和 size 参数", 400);
|
||||
}
|
||||
|
||||
const keyword = url.searchParams.get("keyword") ?? undefined;
|
||||
const data = await listHostCustomers({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
keyword,
|
||||
});
|
||||
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = parseAdminAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof (body as { name?: unknown }).name === "string"
|
||||
? (body as { name: string }).name
|
||||
: "";
|
||||
const remark =
|
||||
typeof (body as { remark?: unknown }).remark === "string"
|
||||
? (body as { remark: string }).remark
|
||||
: undefined;
|
||||
const embedPassword =
|
||||
typeof (body as { embed_password?: unknown }).embed_password === "string"
|
||||
? (body as { embed_password: string }).embed_password
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const created = await createHostCustomer({
|
||||
name,
|
||||
remark,
|
||||
embed_password: embedPassword,
|
||||
});
|
||||
await writeAudit("customer:create", auth.userId, created.customer_id, {
|
||||
name: created.name,
|
||||
key_prefix: created.api_keys[0]?.key_prefix,
|
||||
});
|
||||
return ok(created);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "创建客户失败";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import { requirePermission } from "@/modules/auth/rbac";
|
||||
import {
|
||||
listQuoteQueryLogs,
|
||||
type QuoteQueryOutcome,
|
||||
} from "@/modules/quote/query-log";
|
||||
|
||||
const VALID_OUTCOMES: readonly QuoteQueryOutcome[] = [
|
||||
"processing",
|
||||
"success",
|
||||
"failed",
|
||||
"stale",
|
||||
];
|
||||
|
||||
function parsePagination(searchParams: URLSearchParams): {
|
||||
page: number;
|
||||
size: number;
|
||||
} | null {
|
||||
const pageRaw = searchParams.get("page");
|
||||
const sizeRaw = searchParams.get("size");
|
||||
|
||||
if (!pageRaw || !sizeRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = Number(pageRaw);
|
||||
const size = Number(sizeRaw);
|
||||
|
||||
if (
|
||||
!Number.isInteger(page) ||
|
||||
!Number.isInteger(size) ||
|
||||
page < 1 ||
|
||||
size < 1 ||
|
||||
size > 100
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { page, size };
|
||||
}
|
||||
|
||||
/** GET /api/admin/query-logs — 查价记录(最多保留 1000 条) */
|
||||
export async function GET(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = parseAdminAuth(request);
|
||||
requirePermission(auth, "alert:read");
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
void auth;
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const pagination = parsePagination(searchParams);
|
||||
if (!pagination) {
|
||||
return fail("VALIDATION_FAILED", "请提供有效的 page 和 size 参数", 400);
|
||||
}
|
||||
|
||||
const customerId = searchParams.get("customer_id")?.trim() || undefined;
|
||||
const outcomeRaw = searchParams.get("outcome")?.trim();
|
||||
if (outcomeRaw && !VALID_OUTCOMES.includes(outcomeRaw as QuoteQueryOutcome)) {
|
||||
return fail("VALIDATION_FAILED", "无效的查询结果筛选", 400);
|
||||
}
|
||||
|
||||
const data = await listQuoteQueryLogs({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
customerId,
|
||||
outcome: outcomeRaw as QuoteQueryOutcome | undefined,
|
||||
});
|
||||
|
||||
return ok(data);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { isKnownCustomerAsync } from "@/modules/auth/service-token";
|
||||
import { isKnownCustomer } from "@/modules/auth/service-token-env";
|
||||
import { isDbCustomerActive, verifyCustomerEmbedPassword } from "@/modules/customer/customer-service";
|
||||
import {
|
||||
EMBED_DEMO_COOKIE,
|
||||
signEmbedDemoToken,
|
||||
} from "@/modules/embed-demo/auth";
|
||||
import {
|
||||
defaultMarkupDto,
|
||||
serializeMarkupConfig,
|
||||
} from "@/modules/pricing/markup-service";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
|
||||
if (isKnownCustomer(customerId)) {
|
||||
return true;
|
||||
}
|
||||
return isDbCustomerActive(customerId);
|
||||
}
|
||||
|
||||
async function loadMarkup(customerId: string) {
|
||||
const markupRow = await prisma.markupConfig.findFirst({
|
||||
where: { customerId, isDeleted: false },
|
||||
});
|
||||
return markupRow
|
||||
? serializeMarkupConfig(markupRow)
|
||||
: defaultMarkupDto(customerId);
|
||||
}
|
||||
|
||||
/** POST /api/embed-demo/login — 客户 ID + 演示密码 */
|
||||
export async function POST(request: Request) {
|
||||
let body: { customer_id?: unknown; password?: unknown };
|
||||
try {
|
||||
body = (await request.json()) as { customer_id?: unknown; password?: unknown };
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
typeof body.customer_id === "string" ? body.customer_id.trim() : "";
|
||||
const password = typeof body.password === "string" ? body.password : "";
|
||||
|
||||
if (!customerId) {
|
||||
return fail("VALIDATION_FAILED", "请填写客户编号", 400);
|
||||
}
|
||||
if (!password) {
|
||||
return fail("VALIDATION_FAILED", "请填写密码", 400);
|
||||
}
|
||||
|
||||
if (!(await isKnownCustomerAsync(customerId))) {
|
||||
return fail("UNAUTHORIZED", "客户编号或密码错误", 401);
|
||||
}
|
||||
if (!(await isEmbedCustomerAllowed(customerId))) {
|
||||
return fail("FORBIDDEN", "客户已停用", 403);
|
||||
}
|
||||
if (!(await verifyCustomerEmbedPassword(customerId, password))) {
|
||||
return fail("UNAUTHORIZED", "客户编号或密码错误", 401);
|
||||
}
|
||||
|
||||
const token = await signEmbedDemoToken(customerId);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(EMBED_DEMO_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return ok({
|
||||
customer_id: customerId,
|
||||
markup: await loadMarkup(customerId),
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ok } from "@/lib/response";
|
||||
import { EMBED_DEMO_COOKIE } from "@/modules/embed-demo/auth";
|
||||
|
||||
/** POST /api/embed-demo/logout */
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(EMBED_DEMO_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return ok({ logged_out: true });
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import {
|
||||
EMBED_DEMO_COOKIE,
|
||||
verifyEmbedDemoToken,
|
||||
} from "@/modules/embed-demo/auth";
|
||||
import { isKnownCustomer } from "@/modules/auth/service-token-env";
|
||||
import { isDbCustomerActive } from "@/modules/customer/customer-service";
|
||||
import {
|
||||
defaultMarkupDto,
|
||||
serializeMarkupConfig,
|
||||
} from "@/modules/pricing/markup-service";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
|
||||
if (isKnownCustomer(customerId)) {
|
||||
return true;
|
||||
}
|
||||
return isDbCustomerActive(customerId);
|
||||
}
|
||||
|
||||
/** GET /api/embed-demo/me — 当前演示会话与加价配置 */
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(EMBED_DEMO_COOKIE)?.value;
|
||||
if (!raw) {
|
||||
return fail("UNAUTHORIZED", "未登录", 401);
|
||||
}
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
const payload = await verifyEmbedDemoToken(raw);
|
||||
customerId = payload.sub;
|
||||
} catch {
|
||||
return fail("UNAUTHORIZED", "演示会话无效或已过期", 401);
|
||||
}
|
||||
|
||||
if (!(await isEmbedCustomerAllowed(customerId))) {
|
||||
return fail("FORBIDDEN", "客户已停用", 403);
|
||||
}
|
||||
|
||||
const markupRow = await prisma.markupConfig.findFirst({
|
||||
where: { customerId, isDeleted: false },
|
||||
});
|
||||
const markup = markupRow
|
||||
? serializeMarkupConfig(markupRow)
|
||||
: defaultMarkupDto(customerId);
|
||||
|
||||
return ok({
|
||||
customer_id: customerId,
|
||||
markup,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { parseServiceAuth } from "@/lib/api/auth-context";
|
||||
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import {
|
||||
hostPublicResolveCandidates,
|
||||
HostPublicError,
|
||||
HostQuoteWaitError,
|
||||
} from "@/modules/host/public-orchestrator";
|
||||
import { hostCandidatesBodySchema } from "@/modules/host/validation";
|
||||
import { RpaError } from "@/modules/rpa/errors";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = await parseServiceAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||
if (rateLimited) {
|
||||
return rateLimited;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const parsed = hostCandidatesBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "参数无效";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await hostPublicResolveCandidates(auth.customerId, parsed.data);
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
if (error instanceof HostPublicError) {
|
||||
return fail(error.code, error.message, 400);
|
||||
}
|
||||
if (error instanceof RpaError) {
|
||||
return fail(error.code, error.message, 503);
|
||||
}
|
||||
console.error("[host/quote/candidates]", error);
|
||||
return fail("INTERNAL_ERROR", "地址联想失败", 503);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { parseServiceAuth } from "@/lib/api/auth-context";
|
||||
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import {
|
||||
hostPublicSubmitAndWait,
|
||||
HostPublicError,
|
||||
HostQuoteWaitError,
|
||||
} from "@/modules/host/public-orchestrator";
|
||||
import { hostSubmitBodySchema } from "@/modules/host/validation";
|
||||
import { ValidationError } from "@/modules/quote/types";
|
||||
|
||||
/** RPA 实时查价 + 内部轮询,与 QUOTE_TIMEOUT_MS 对齐 */
|
||||
export const maxDuration = 420;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = await parseServiceAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||
if (rateLimited) {
|
||||
return rateLimited;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const parsed = hostSubmitBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "参数无效";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await hostPublicSubmitAndWait(parsed.data);
|
||||
return ok(detail);
|
||||
} catch (error) {
|
||||
if (error instanceof HostPublicError) {
|
||||
const status = error.code === "SESSION_EXPIRED" ? 404 : 400;
|
||||
return fail(error.code, error.message, status);
|
||||
}
|
||||
if (error instanceof HostQuoteWaitError) {
|
||||
if (error.code === "QUOTE_TIMEOUT") {
|
||||
return fail(error.code, error.message, 504);
|
||||
}
|
||||
if (error.detail) {
|
||||
return ok(error.detail);
|
||||
}
|
||||
return fail(error.code, error.message, 422);
|
||||
}
|
||||
if (error instanceof ValidationError) {
|
||||
return fail("VALIDATION_FAILED", error.message, 400);
|
||||
}
|
||||
console.error("[host/quote/submit]", error);
|
||||
return fail("INTERNAL_ERROR", "询价处理失败", 500);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import { submitPriority1Quote, Priority1EnqueueError } from "@/modules/priority1/orchestrator";
|
||||
import { resolvePriority1UserMessage } from "@/lib/priority1/api-errors";
|
||||
import { ValidationError, QuoteIdConflictError } from "@/modules/quote/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = await parseServiceAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||
if (rateLimited) return rateLimited;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const bodyCustomerId =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"customer_id" in body &&
|
||||
typeof (body as { customer_id: unknown }).customer_id === "string"
|
||||
? (body as { customer_id: string }).customer_id
|
||||
: null;
|
||||
|
||||
if (!bodyCustomerId) {
|
||||
return fail("VALIDATION_FAILED", "请填写客户标识", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
assertCustomerMatch(auth, bodyCustomerId);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await submitPriority1Quote(body);
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
return fail("VALIDATION_FAILED", error.message, 400);
|
||||
}
|
||||
if (error instanceof QuoteIdConflictError) {
|
||||
return fail(error.code, error.message, 500);
|
||||
}
|
||||
if (error instanceof Priority1EnqueueError) {
|
||||
return fail(error.code, error.message, 503);
|
||||
}
|
||||
console.error("[POST /api/priority1/quotes] 异常:", error);
|
||||
const message = resolvePriority1UserMessage(
|
||||
"INTERNAL_ERROR",
|
||||
error instanceof Error ? error.message : undefined,
|
||||
);
|
||||
return fail("INTERNAL_ERROR", message, 500);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
export type QuoteProviderId = "mothership" | "priority1";
|
||||
|
||||
interface QuoteProviderSwitchProps {
|
||||
value: QuoteProviderId;
|
||||
onChange: (value: QuoteProviderId) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 卡派查价 — Mothership / Priority1 数据源切换 */
|
||||
export function QuoteProviderSwitch({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: QuoteProviderSwitchProps) {
|
||||
const tabs: Array<{ id: QuoteProviderId; label: string; desc: string }> = [
|
||||
{ id: "mothership", label: "MotherShip", desc: "四档实时报价" },
|
||||
{ id: "priority1", label: "Priority1", desc: "官网模拟填表" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex gap-1 rounded-md bg-bg p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`flex flex-1 flex-col items-center rounded-sm px-3 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${
|
||||
value === tab.id
|
||||
? "bg-surface text-primary shadow-card"
|
||||
: "text-text-secondary hover:text-text-primary"
|
||||
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{tab.label}</span>
|
||||
<span className="text-xs text-text-secondary">{tab.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Truck, Van } from "@phosphor-icons/react";
|
||||
import type { Priority1ShipmentType } from "@/workers/rpa/priority1/shipment-types";
|
||||
|
||||
interface FeatheryCardProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** 仿 Priority1 Feathery 卡片:选中黑底白字 */
|
||||
export function FeatheryCard({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
}: FeatheryCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`flex min-h-[88px] flex-col items-start justify-center rounded-md border-2 px-4 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? "border-black bg-black text-white"
|
||||
: "border-neutral-300 bg-white text-neutral-900 hover:border-neutral-500"
|
||||
} ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
{description ? (
|
||||
<span
|
||||
className={`mt-1 text-xs ${selected ? "text-neutral-300" : "text-neutral-500"}`}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatheryCardGrid({
|
||||
children,
|
||||
cols = 3,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
cols?: 2 | 3;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-3 ${cols === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShipmentIcon({
|
||||
type,
|
||||
selected,
|
||||
}: {
|
||||
type: Priority1ShipmentType;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const color = selected ? "text-white" : "text-neutral-900";
|
||||
const size = 48;
|
||||
if (type === "expedited") {
|
||||
return <Van size={size} weight="regular" className={color} aria-hidden />;
|
||||
}
|
||||
return <Truck size={size} weight="regular" className={color} aria-hidden />;
|
||||
}
|
||||
|
||||
/** Step1 运输类型三卡 — 对齐官网布局 */
|
||||
export function ShipmentTypeCard({
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
type: Priority1ShipmentType;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`relative flex min-h-[220px] flex-col items-center rounded-sm border px-4 py-5 text-center transition-colors ${
|
||||
selected
|
||||
? "border-black bg-black text-white"
|
||||
: "border-neutral-300 bg-white text-neutral-900 hover:border-neutral-500"
|
||||
} ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-3 top-3 h-4 w-4 rounded-full border-2 ${
|
||||
selected
|
||||
? "border-[#FFD200] bg-[#FFD200]"
|
||||
: "border-neutral-400 bg-white"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="mb-3 mt-2">
|
||||
<ShipmentIcon type={type} selected={selected} />
|
||||
</div>
|
||||
<span className="text-base font-semibold">{title}</span>
|
||||
<p
|
||||
className={`mt-2 text-xs leading-relaxed ${
|
||||
selected ? "text-neutral-200" : "text-neutral-600"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function P1FieldLabel({
|
||||
children,
|
||||
required,
|
||||
htmlFor,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
htmlFor?: string;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="mb-1 block text-sm italic text-neutral-900"
|
||||
>
|
||||
{children}
|
||||
{required ? <span className="text-[#c41230]"> *</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function P1SectionTitle({
|
||||
step,
|
||||
title,
|
||||
}: {
|
||||
step: 1 | 2;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 border-b border-neutral-200 pb-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Step {step}
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 2 进度条 — 对齐官网「Step 2 of 2」黑条 */
|
||||
export function P1Step2Progress({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-neutral-600">{label}</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-sm bg-black" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 总重量输入 — 右侧「磅」后缀 */
|
||||
export function P1WeightField({
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
disabled,
|
||||
error,
|
||||
unitLabel = "磅",
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
unitLabel?: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<P1FieldLabel required={required} htmlFor={id}>
|
||||
{label}
|
||||
</P1FieldLabel>
|
||||
<div className="flex">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`h-11 min-w-0 flex-1 rounded-l-sm border border-r-0 bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60 ${
|
||||
error ? "border-red-500" : "border-neutral-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex h-11 shrink-0 items-center rounded-r-sm bg-neutral-700 px-3 text-sm font-medium text-white">
|
||||
{unitLabel}
|
||||
</span>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 官网黄色 GET INSTANT QUOTES 主按钮 */
|
||||
export function P1InstantQuotesButton({
|
||||
loading,
|
||||
disabled,
|
||||
children,
|
||||
onClick,
|
||||
type = "button",
|
||||
className = "",
|
||||
}: {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-sm bg-[#FFD200] px-6 py-4 text-center text-base font-black uppercase tracking-wide text-black shadow-sm transition-opacity hover:bg-[#f5c800] disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
>
|
||||
{loading ? "处理中…" : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 P1InstantQuotesButton */
|
||||
export function P1SubmitButton({
|
||||
loading,
|
||||
disabled,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<P1InstantQuotesButton loading={loading} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</P1InstantQuotesButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step1/2 底部固定 CTA 栏 */
|
||||
export function P1StickyActionBar({
|
||||
children,
|
||||
hint,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky bottom-0 z-10 -mx-4 mt-6 border-t border-neutral-200 bg-neutral-50/95 px-4 py-4 backdrop-blur-sm md:-mx-6 md:px-6">
|
||||
{children}
|
||||
<p className="mt-2 text-center text-xs text-neutral-500">
|
||||
{hint ?? "填写完成后点击按钮进入下一步或提交询价"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,833 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Priority1DemoInput } from "@/workers/rpa/priority1/demo-input";
|
||||
import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input";
|
||||
import type {
|
||||
ExpeditedTrailerType,
|
||||
FtlAdditionalService,
|
||||
FtlTrailerType,
|
||||
Priority1ShipmentType,
|
||||
} from "@/workers/rpa/priority1/shipment-types";
|
||||
import {
|
||||
P1_COMMON_UI,
|
||||
P1_DELIVERY_ACCESSORIALS,
|
||||
P1_EXP_TRAILERS,
|
||||
P1_EXPEDITED_UI,
|
||||
P1_FREQUENCY_OPTIONS,
|
||||
P1_FREIGHT_CLASS_OPTIONS,
|
||||
P1_FTL_ADDITIONAL,
|
||||
P1_FTL_LIFTGATE_SERVICE,
|
||||
P1_FTL_TRAILERS,
|
||||
P1_FTL_UI,
|
||||
P1_LTL_UI,
|
||||
P1_LOCATION_OPTIONS,
|
||||
P1_OPEN_DECK_OPTIONS,
|
||||
P1_PACKAGING_OPTIONS,
|
||||
P1_PICKUP_ACCESSORIALS,
|
||||
P1_SHIPMENT_CARDS,
|
||||
P1_SPECIAL_HANDLING,
|
||||
P1_STEP2_HEADING,
|
||||
P1_STEP2_SUBTITLE,
|
||||
} from "@/lib/priority1/ui-labels";
|
||||
import {
|
||||
FeatheryCard,
|
||||
FeatheryCardGrid,
|
||||
P1FieldLabel,
|
||||
P1InstantQuotesButton,
|
||||
P1Step2Progress,
|
||||
P1StickyActionBar,
|
||||
P1WeightField,
|
||||
ShipmentTypeCard,
|
||||
} from "@/components/priority1/feathery-card";
|
||||
import { P1DateField } from "@/components/priority1/p1-date-field";
|
||||
import { P1PhoneField } from "@/components/priority1/p1-phone-field";
|
||||
import { findPhoneCountry } from "@/lib/priority1/phone-countries";
|
||||
import { pickupDateValidationMessage } from "@/lib/priority1/pickup-date";
|
||||
import { SelectField } from "@/components/ui/select-field";
|
||||
|
||||
export interface Priority1SimulatorProps {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onSubmit: (input: Priority1DemoInput) => void;
|
||||
}
|
||||
|
||||
function toggleInList(list: string[], value: string): string[] {
|
||||
return list.includes(value)
|
||||
? list.filter((v) => v !== value)
|
||||
: [...list, value];
|
||||
}
|
||||
|
||||
function toggleService(
|
||||
list: FtlAdditionalService[],
|
||||
svc: FtlAdditionalService,
|
||||
): FtlAdditionalService[] {
|
||||
return list.includes(svc) ? list.filter((s) => s !== svc) : [...list, svc];
|
||||
}
|
||||
|
||||
function P1FeatherySelect({
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
disabled,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<P1FieldLabel required={required} htmlFor={id}>
|
||||
{label}
|
||||
</P1FieldLabel>
|
||||
<select
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-11 w-full rounded-sm border border-neutral-400 bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function P1AccessorialGrid({
|
||||
legend,
|
||||
options,
|
||||
selected,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
legend: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
selected: string[];
|
||||
disabled?: boolean;
|
||||
onToggle: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium italic text-neutral-900">{legend}</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={selected.includes(opt.value)}
|
||||
onChange={() => onToggle(opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function P1TextInput({
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
disabled,
|
||||
error,
|
||||
type = "text",
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
type?: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<P1FieldLabel required={required} htmlFor={id}>
|
||||
{label}
|
||||
</P1FieldLabel>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`h-11 w-full rounded-sm border bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60 ${
|
||||
error ? "border-red-500" : "border-neutral-400"
|
||||
}`}
|
||||
/>
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 仿 Priority1 两步动态表单(与 form-logic-map 分支一致) */
|
||||
export function Priority1Simulator({
|
||||
disabled,
|
||||
loading,
|
||||
onSubmit,
|
||||
}: Priority1SimulatorProps) {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [form, setForm] = useState<Priority1DemoInput>({
|
||||
...DEFAULT_PRIORITY1_DEMO,
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const patch = useCallback((partial: Partial<Priority1DemoInput>) => {
|
||||
setForm((prev) => ({ ...prev, ...partial }));
|
||||
}, []);
|
||||
|
||||
const validateStep1 = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!/^\d{5}$/.test(form.originZip.trim())) {
|
||||
next.originZip = "请输入 5 位起运邮编";
|
||||
}
|
||||
if (!/^\d{5}$/.test(form.destinationZip.trim())) {
|
||||
next.destinationZip = "请输入 5 位目的邮编";
|
||||
}
|
||||
if (form.originZip === form.destinationZip) {
|
||||
next.destinationZip = "起运与目的邮编不能相同";
|
||||
}
|
||||
if (!/^\d{2}\/\d{2}\/\d{4}$/.test(form.pickupDate.trim())) {
|
||||
next.pickupDate = "请选择提货日";
|
||||
} else {
|
||||
const pickupErr = pickupDateValidationMessage(form.pickupDate);
|
||||
if (pickupErr) next.pickupDate = pickupErr;
|
||||
}
|
||||
if (!form.email.trim()) next.email = "请填写邮箱";
|
||||
const phoneDigits = form.phone.replace(/\D/g, "");
|
||||
const country = findPhoneCountry(form.phoneCountry);
|
||||
if (country.code === "US" || country.code === "CA") {
|
||||
if (phoneDigits.length !== 10) next.phone = "请填写 10 位电话号码";
|
||||
} else if (phoneDigits.length < 6 || phoneDigits.length > 15) {
|
||||
next.phone = "请填写有效电话号码";
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const validateStep2 = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (form.shipmentType === "ltl" && !form.weightLb.trim()) {
|
||||
next.weightLb = "请填写总重量";
|
||||
}
|
||||
if (form.shipmentType === "ftl" || form.shipmentType === "expedited") {
|
||||
if (!form.commodity.trim()) next.commodity = "请填写货描";
|
||||
if (!form.weightLb.trim()) next.weightLb = "请填写总重量";
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleStep1Next = () => {
|
||||
if (!validateStep1()) return;
|
||||
setErrors({});
|
||||
setStep(2);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleFinalSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validateStep2()) return;
|
||||
onSubmit({
|
||||
...form,
|
||||
phone: form.phone.replace(/\D/g, "").slice(-10),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-md border border-neutral-200 bg-white p-4 md:p-6">
|
||||
<div className="mb-6 flex items-center gap-2 text-xs font-medium text-neutral-500">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 ${step === 1 ? "bg-black text-white" : "bg-neutral-200"}`}
|
||||
>
|
||||
{P1_COMMON_UI.step1}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-neutral-300" />
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 ${step === 2 ? "bg-black text-white" : "bg-neutral-200"}`}
|
||||
>
|
||||
{P1_COMMON_UI.step2}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-6 pb-2">
|
||||
<div>
|
||||
<P1FieldLabel required>{P1_COMMON_UI.shipmentType}</P1FieldLabel>
|
||||
<div className="mt-2 grid gap-3 md:grid-cols-3">
|
||||
{P1_SHIPMENT_CARDS.map((card) => (
|
||||
<ShipmentTypeCard
|
||||
key={card.id}
|
||||
type={card.id}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
selected={form.shipmentType === card.id}
|
||||
disabled={disabled}
|
||||
onSelect={() => patch({ shipmentType: card.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<P1TextInput
|
||||
id="origin-zip"
|
||||
label={P1_COMMON_UI.originZip}
|
||||
required
|
||||
value={form.originZip}
|
||||
disabled={disabled}
|
||||
error={errors.originZip}
|
||||
onChange={(v) => patch({ originZip: v })}
|
||||
/>
|
||||
<P1TextInput
|
||||
id="destination-zip"
|
||||
label={P1_COMMON_UI.destZip}
|
||||
required
|
||||
value={form.destinationZip}
|
||||
disabled={disabled}
|
||||
error={errors.destinationZip}
|
||||
onChange={(v) => patch({ destinationZip: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<P1FieldLabel required htmlFor="shipment-frequency">
|
||||
{P1_COMMON_UI.frequency}
|
||||
</P1FieldLabel>
|
||||
<select
|
||||
id="shipment-frequency"
|
||||
disabled={disabled}
|
||||
value={form.shipmentFrequency}
|
||||
onChange={(e) => patch({ shipmentFrequency: e.target.value })}
|
||||
className="mt-1 h-11 w-full max-w-md rounded-sm border border-neutral-400 bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60"
|
||||
>
|
||||
{P1_FREQUENCY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<P1DateField
|
||||
label={P1_COMMON_UI.pickupDate}
|
||||
value={form.pickupDate}
|
||||
disabled={disabled}
|
||||
error={errors.pickupDate}
|
||||
onChange={(v) => patch({ pickupDate: v })}
|
||||
/>
|
||||
|
||||
<P1TextInput
|
||||
id="business-email"
|
||||
label={P1_COMMON_UI.email}
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
disabled={disabled}
|
||||
error={errors.email}
|
||||
onChange={(v) => patch({ email: v })}
|
||||
/>
|
||||
|
||||
<P1PhoneField
|
||||
country={form.phoneCountry}
|
||||
value={form.phone}
|
||||
disabled={disabled}
|
||||
error={errors.phone}
|
||||
onCountryChange={(c) => patch({ phoneCountry: c })}
|
||||
onChange={(v) => patch({ phone: v })}
|
||||
/>
|
||||
|
||||
<P1StickyActionBar>
|
||||
<P1InstantQuotesButton
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
onClick={handleStep1Next}
|
||||
>
|
||||
{P1_COMMON_UI.getQuotes}
|
||||
</P1InstantQuotesButton>
|
||||
</P1StickyActionBar>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleFinalSubmit} className="space-y-6 pb-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="text-lg font-bold uppercase tracking-wide text-neutral-900">
|
||||
{P1_STEP2_HEADING[form.shipmentType]}
|
||||
</h3>
|
||||
{form.shipmentType !== "ltl" ? (
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{form.shipmentType === "expedited"
|
||||
? P1_EXPEDITED_UI.subtitle
|
||||
: form.shipmentType === "ftl"
|
||||
? P1_FTL_UI.subtitle
|
||||
: P1_STEP2_SUBTITLE}
|
||||
</p>
|
||||
) : null}
|
||||
<P1Step2Progress label={P1_COMMON_UI.step2Of2} />
|
||||
</div>
|
||||
|
||||
{form.shipmentType === "ltl" ? (
|
||||
<>
|
||||
<P1FieldLabel required>{P1_LTL_UI.freightSection}</P1FieldLabel>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid min-w-[12rem] flex-1 grid-cols-3 gap-2">
|
||||
<P1TextInput
|
||||
id="length"
|
||||
label={P1_LTL_UI.length}
|
||||
required
|
||||
value={form.lengthIn}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ lengthIn: v })}
|
||||
/>
|
||||
<P1TextInput
|
||||
id="width"
|
||||
label={P1_LTL_UI.width}
|
||||
required
|
||||
value={form.widthIn}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ widthIn: v })}
|
||||
/>
|
||||
<P1TextInput
|
||||
id="height"
|
||||
label={P1_LTL_UI.height}
|
||||
required
|
||||
value={form.heightIn}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ heightIn: v })}
|
||||
/>
|
||||
</div>
|
||||
<span className="mb-0 flex h-11 shrink-0 items-center rounded-sm bg-neutral-700 px-3 text-sm font-medium text-white">
|
||||
{P1_LTL_UI.dimensionUnit}
|
||||
</span>
|
||||
<div className="min-w-[10rem] flex-1">
|
||||
<P1WeightField
|
||||
id="weight"
|
||||
label={P1_LTL_UI.totalWeight}
|
||||
required
|
||||
value={form.weightLb}
|
||||
disabled={disabled}
|
||||
error={errors.weightLb}
|
||||
unitLabel={P1_LTL_UI.weightUnit}
|
||||
onChange={(v) => patch({ weightLb: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<P1FeatherySelect
|
||||
id="packaging"
|
||||
label={P1_LTL_UI.packaging}
|
||||
required
|
||||
value={form.packaging}
|
||||
disabled={disabled}
|
||||
options={P1_PACKAGING_OPTIONS}
|
||||
onChange={(v) => patch({ packaging: v })}
|
||||
/>
|
||||
<P1TextInput
|
||||
id="pallets"
|
||||
label={P1_LTL_UI.palletCount}
|
||||
required
|
||||
value={form.palletCount}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ palletCount: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<P1FeatherySelect
|
||||
id="freight-class"
|
||||
label={P1_LTL_UI.freightClass}
|
||||
required
|
||||
value={form.freightClass}
|
||||
disabled={disabled}
|
||||
options={P1_FREIGHT_CLASS_OPTIONS}
|
||||
onChange={(v) => patch({ freightClass: v })}
|
||||
/>
|
||||
|
||||
<P1FieldLabel required>{P1_LTL_UI.pickupSection}</P1FieldLabel>
|
||||
<P1FeatherySelect
|
||||
id="pickup-location"
|
||||
label={P1_LTL_UI.pickupLocation}
|
||||
required
|
||||
value={form.pickupLocation}
|
||||
disabled={disabled}
|
||||
options={P1_LOCATION_OPTIONS}
|
||||
onChange={(v) => patch({ pickupLocation: v })}
|
||||
/>
|
||||
<P1AccessorialGrid
|
||||
legend={P1_LTL_UI.pickupServices}
|
||||
options={P1_PICKUP_ACCESSORIALS}
|
||||
selected={form.pickupServices}
|
||||
disabled={disabled}
|
||||
onToggle={(value) =>
|
||||
patch({
|
||||
pickupServices: toggleInList(form.pickupServices, value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<P1FieldLabel required>{P1_LTL_UI.deliverySection}</P1FieldLabel>
|
||||
<P1FeatherySelect
|
||||
id="delivery-location"
|
||||
label={P1_LTL_UI.deliveryLocation}
|
||||
required
|
||||
value={form.deliveryLocation}
|
||||
disabled={disabled}
|
||||
options={P1_LOCATION_OPTIONS}
|
||||
onChange={(v) => patch({ deliveryLocation: v })}
|
||||
/>
|
||||
<P1AccessorialGrid
|
||||
legend={P1_LTL_UI.deliveryServices}
|
||||
options={P1_DELIVERY_ACCESSORIALS}
|
||||
selected={form.deliveryServices}
|
||||
disabled={disabled}
|
||||
onToggle={(value) =>
|
||||
patch({
|
||||
deliveryServices: toggleInList(
|
||||
form.deliveryServices,
|
||||
value,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{form.shipmentType === "ftl" ? (
|
||||
<>
|
||||
<P1FieldLabel required>{P1_FTL_UI.selectTrailer}</P1FieldLabel>
|
||||
<FeatheryCardGrid cols={2}>
|
||||
{P1_FTL_TRAILERS.map((t) => (
|
||||
<FeatheryCard
|
||||
key={t.id}
|
||||
label={t.label}
|
||||
description={t.desc}
|
||||
selected={form.ftlTrailer === t.id}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = t.id as FtlTrailerType;
|
||||
const partial: Partial<Priority1DemoInput> = {
|
||||
ftlTrailer: next,
|
||||
};
|
||||
if (next === "ftl_expedited") {
|
||||
partial.ftlAdditionalServices = [];
|
||||
}
|
||||
patch(partial);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FeatheryCardGrid>
|
||||
|
||||
{form.ftlTrailer === "open_deck" ? (
|
||||
<SelectField
|
||||
label={P1_FTL_UI.openDeckType}
|
||||
name="openDeckSubtype"
|
||||
required
|
||||
value={form.openDeckSubtype}
|
||||
disabled={disabled}
|
||||
options={P1_OPEN_DECK_OPTIONS}
|
||||
onChange={(e) => patch({ openDeckSubtype: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{form.ftlTrailer === "ftl_expedited" ? (
|
||||
<>
|
||||
<P1FieldLabel required>
|
||||
{P1_FTL_UI.selectExpeditedTrailer}
|
||||
</P1FieldLabel>
|
||||
<FeatheryCardGrid cols={3}>
|
||||
{P1_EXP_TRAILERS.map((t) => (
|
||||
<FeatheryCard
|
||||
key={t.id}
|
||||
label={t.label}
|
||||
description={t.desc}
|
||||
selected={form.ftlExpeditedTrailer === t.id}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = t.id as ExpeditedTrailerType;
|
||||
patch({
|
||||
ftlExpeditedTrailer: next,
|
||||
...(next !== "large_straight"
|
||||
? { expeditedLiftgate: false }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FeatheryCardGrid>
|
||||
|
||||
{form.ftlExpeditedTrailer === "large_straight" ? (
|
||||
<>
|
||||
<P1FieldLabel>{P1_FTL_UI.additionalServices}</P1FieldLabel>
|
||||
<FeatheryCardGrid cols={2}>
|
||||
<FeatheryCard
|
||||
label={P1_FTL_LIFTGATE_SERVICE.label}
|
||||
description={P1_FTL_LIFTGATE_SERVICE.desc}
|
||||
selected={form.expeditedLiftgate}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
patch({
|
||||
expeditedLiftgate: !form.expeditedLiftgate,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FeatheryCardGrid>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<P1DateField
|
||||
label={P1_COMMON_UI.pickupDate}
|
||||
value={form.pickupDate}
|
||||
disabled={disabled}
|
||||
error={errors.pickupDate}
|
||||
onChange={(v) => patch({ pickupDate: v })}
|
||||
/>
|
||||
|
||||
<p className="text-sm font-semibold text-neutral-800">
|
||||
{P1_FTL_UI.freightSection}
|
||||
</p>
|
||||
<P1TextInput
|
||||
id="commodity"
|
||||
label={P1_FTL_UI.commodity}
|
||||
required
|
||||
value={form.commodity}
|
||||
disabled={disabled}
|
||||
error={errors.commodity}
|
||||
onChange={(v) => patch({ commodity: v })}
|
||||
/>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium italic text-neutral-900">
|
||||
{P1_FTL_UI.specialHandling}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{P1_SPECIAL_HANDLING.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={form.specialHandlingServices.includes(
|
||||
opt.value,
|
||||
)}
|
||||
onChange={() =>
|
||||
patch({
|
||||
specialHandlingServices: toggleInList(
|
||||
form.specialHandlingServices,
|
||||
opt.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<P1WeightField
|
||||
id="ftl-weight"
|
||||
label={P1_FTL_UI.totalWeight}
|
||||
required
|
||||
value={form.weightLb}
|
||||
disabled={disabled}
|
||||
error={errors.weightLb}
|
||||
unitLabel={P1_FTL_UI.weightUnit}
|
||||
onChange={(v) => patch({ weightLb: v })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<P1FieldLabel>{P1_FTL_UI.additionalServices}</P1FieldLabel>
|
||||
<FeatheryCardGrid cols={3}>
|
||||
{P1_FTL_ADDITIONAL.map((s) => (
|
||||
<FeatheryCard
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
description={s.desc}
|
||||
selected={form.ftlAdditionalServices.includes(s.id)}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
patch({
|
||||
ftlAdditionalServices: toggleService(
|
||||
form.ftlAdditionalServices,
|
||||
s.id,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FeatheryCardGrid>
|
||||
|
||||
{form.ftlTrailer === "temperature_controlled" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<P1TextInput
|
||||
id="temp-min"
|
||||
label={P1_FTL_UI.tempMin}
|
||||
required
|
||||
value={form.tempMinF}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ tempMinF: v })}
|
||||
/>
|
||||
<P1TextInput
|
||||
id="temp-max"
|
||||
label={P1_FTL_UI.tempMax}
|
||||
required
|
||||
value={form.tempMaxF}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ tempMaxF: v })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm font-semibold text-neutral-800">
|
||||
{P1_FTL_UI.freightSection}
|
||||
</p>
|
||||
<P1TextInput
|
||||
id="commodity"
|
||||
label={P1_FTL_UI.commodity}
|
||||
required
|
||||
value={form.commodity}
|
||||
disabled={disabled}
|
||||
error={errors.commodity}
|
||||
onChange={(v) => patch({ commodity: v })}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<P1WeightField
|
||||
id="ftl-weight"
|
||||
label={P1_FTL_UI.totalWeight}
|
||||
required
|
||||
value={form.weightLb}
|
||||
disabled={disabled}
|
||||
error={errors.weightLb}
|
||||
unitLabel={P1_FTL_UI.weightUnit}
|
||||
onChange={(v) => patch({ weightLb: v })}
|
||||
/>
|
||||
<P1TextInput
|
||||
id="ftl-pallets"
|
||||
label={P1_FTL_UI.palletCount}
|
||||
required
|
||||
value={form.palletCount}
|
||||
disabled={disabled}
|
||||
onChange={(v) => patch({ palletCount: v })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{form.shipmentType === "expedited" ? (
|
||||
<>
|
||||
<P1FieldLabel required>{P1_EXPEDITED_UI.selectTrailer}</P1FieldLabel>
|
||||
<FeatheryCardGrid cols={2}>
|
||||
{P1_EXP_TRAILERS.map((t) => (
|
||||
<FeatheryCard
|
||||
key={t.id}
|
||||
label={t.label}
|
||||
description={t.desc}
|
||||
selected={form.expeditedTrailer === t.id}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = t.id as ExpeditedTrailerType;
|
||||
patch({
|
||||
expeditedTrailer: next,
|
||||
...(next !== "large_straight"
|
||||
? { expeditedLiftgate: false }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FeatheryCardGrid>
|
||||
|
||||
{form.expeditedTrailer === "large_straight" ? (
|
||||
<>
|
||||
<P1FieldLabel>{P1_EXPEDITED_UI.additionalServices}</P1FieldLabel>
|
||||
<FeatheryCardGrid cols={2}>
|
||||
<FeatheryCard
|
||||
label={P1_FTL_LIFTGATE_SERVICE.label}
|
||||
description={P1_FTL_LIFTGATE_SERVICE.desc}
|
||||
selected={form.expeditedLiftgate}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
patch({ expeditedLiftgate: !form.expeditedLiftgate })
|
||||
}
|
||||
/>
|
||||
</FeatheryCardGrid>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm font-semibold text-neutral-800">
|
||||
{P1_EXPEDITED_UI.freightSection}
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<P1TextInput
|
||||
id="commodity"
|
||||
label={P1_EXPEDITED_UI.commodity}
|
||||
required
|
||||
value={form.commodity}
|
||||
disabled={disabled}
|
||||
error={errors.commodity}
|
||||
onChange={(v) => patch({ commodity: v })}
|
||||
/>
|
||||
<P1WeightField
|
||||
id="exp-weight"
|
||||
label={P1_EXPEDITED_UI.totalWeight}
|
||||
required
|
||||
value={form.weightLb}
|
||||
disabled={disabled}
|
||||
error={errors.weightLb}
|
||||
unitLabel={P1_FTL_UI.weightUnit}
|
||||
onChange={(v) => patch({ weightLb: v })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<P1StickyActionBar>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="rounded-sm border border-neutral-400 px-4 py-3 text-sm text-neutral-700 hover:bg-neutral-100 sm:w-auto"
|
||||
onClick={() => {
|
||||
setStep(1);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
← {P1_COMMON_UI.backStep1}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<P1InstantQuotesButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
{P1_COMMON_UI.getQuotes}
|
||||
</P1InstantQuotesButton>
|
||||
</div>
|
||||
</div>
|
||||
</P1StickyActionBar>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
filterUsStateOptions,
|
||||
formatUsStateOption,
|
||||
normalizeUsStateCode,
|
||||
} from "@/lib/frontend/us-states";
|
||||
|
||||
interface StateComboboxFieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
onChange: (code: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export function StateComboboxField({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: StateComboboxFieldProps) {
|
||||
const listId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const options = filterUsStateOptions(draft);
|
||||
|
||||
const commitDraft = () => {
|
||||
const normalized = normalizeUsStateCode(draft);
|
||||
if (normalized) {
|
||||
onChange(normalized);
|
||||
setDraft(normalized);
|
||||
} else {
|
||||
onChange(draft.trim().toUpperCase());
|
||||
}
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const selectOption = (code: string, labelText: string) => {
|
||||
onChange(code);
|
||||
setDraft(labelText);
|
||||
setOpen(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative space-y-1">
|
||||
<label htmlFor={name} className="block text-sm font-medium text-text-primary">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
list={listId}
|
||||
disabled={disabled}
|
||||
value={draft}
|
||||
placeholder="输入州码或选择,如 CA"
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
setOpen(true);
|
||||
const normalized = normalizeUsStateCode(next);
|
||||
if (normalized) onChange(normalized);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
commitDraft();
|
||||
setOpen(false);
|
||||
}
|
||||
}, 120);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && open && options.length > 0) {
|
||||
e.preventDefault();
|
||||
const first = options[0];
|
||||
selectOption(first.code, formatUsStateOption(first));
|
||||
}
|
||||
}}
|
||||
className={`h-10 w-full rounded-sm border px-3 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-bg disabled:opacity-60 ${
|
||||
error ? "border-error" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{open && !disabled && options.length > 0 && (
|
||||
<ul
|
||||
id={listId}
|
||||
role="listbox"
|
||||
className="absolute z-20 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-border bg-surface shadow-card"
|
||||
>
|
||||
{options.map((entry) => {
|
||||
const labelText = formatUsStateOption(entry);
|
||||
return (
|
||||
<li key={entry.code} role="option">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => selectOption(entry.code, labelText)}
|
||||
>
|
||||
{labelText}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue