功能完善测试第一版

Co-authored-by: Cursor <cursoragent@cursor.com>
master
你的GitHub用户名 1 month ago
parent ced49c80e4
commit ec7de57ff6

@ -156,6 +156,8 @@ RPA_SELECTOR_TRANSIT=text=business days
# 可选:仅当入口跳转 login / 客户账密登录后查价时使用
MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD=
# 登录态 dashboard Direct(跳过 DOM 填表);默认开,设 false 强制 DOM
# MS_DASHBOARD_DIRECT_QUOTE=true
# session-manager loginIfNeeded 使用(2026-07-15 登录页实探)
# RPA_SELECTOR_LOGIN_EMAIL=role=textbox[name="Email address"]
# RPA_SELECTOR_LOGIN_PASSWORD=role=textbox[name="Password"]

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/admin/markup-configs/route";
import { PUT } from "@/app/api/admin/markup-configs/[customer_id]/route";
import { resetServiceTokenCache } from "@/modules/auth/service-token";
@ -7,12 +7,22 @@ vi.mock("@/lib/prisma", () => ({
prisma: {
markupConfig: {
findMany: vi.fn(),
upsert: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
hostCustomer: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
},
businessCustomer: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
count: vi.fn().mockResolvedValue(1),
},
businessCustomerUser: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn(),
},
@ -41,10 +51,31 @@ describe("admin markup-configs API", () => {
process.env.CUSTOMER_REGISTRY = "CUST_002,CUST_003";
});
it("GET 返回注册客户合并配置", async () => {
it("GET 仅返回业务客户加价配置", async () => {
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
process.env.CUSTOMER_REGISTRY = "";
vi.mocked(prisma.businessCustomer.findMany).mockResolvedValue([
{
customerId: "CUST_001",
businessCustomerId: "BC_001",
name: "客户A",
externalCode: null,
status: "active",
remark: null,
createdAt: new Date("2026-07-28T10:00:00Z"),
updatedAt: new Date("2026-07-28T10:00:00Z"),
_count: { users: 0 },
},
] as never);
vi.mocked(prisma.markupConfig.findMany).mockResolvedValue([
{
customerId: "CUST_002",
customerId: "CUST_001",
businessCustomerId: "BC_001",
markupType: "percent",
markupPercent: 10,
markupFixedAmount: null,
@ -63,22 +94,100 @@ describe("admin markup-configs API", () => {
expect(res.status).toBe(200);
expect(body.code).toBe(0);
expect(body.data.total).toBe(3);
expect(body.data.list[1].customer_id).toBe("CUST_002");
expect(body.data.list[1].markup_percent).toBe(10);
expect(body.data.total).toBe(1);
expect(body.data.list[0].customer_id).toBe("CUST_001");
expect(body.data.list[0].business_customer_id).toBe("BC_001");
expect(body.data.list[0].business_customer_name).toBe("客户A");
expect(body.data.list[0].markup_percent).toBe(10);
});
it("PUT 固定金额加价成功", async () => {
vi.mocked(prisma.markupConfig.upsert).mockResolvedValue({
customerId: "CUST_001",
markupType: "fixed",
markupPercent: 0,
markupFixedAmount: 25,
operatorId: "admin_demo",
remark: "测试",
updatedAt: new Date("2026-06-24T10:00:00Z"),
} as never);
it("GET keyword=客户代码 能命中(不因租户 ID 过滤丢结果)", async () => {
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_004",
permissions: ["pricing:markup:write"],
},
});
process.env.CUSTOMER_REGISTRY = "";
vi.mocked(prisma.businessCustomer.findMany).mockResolvedValue([
{
customerId: "CUST_004",
businessCustomerId: "29bb985b-54ae-4d89-a760-052ca9c09f1d",
name: "STAR",
externalCode: "SP012",
status: "active",
remark: null,
createdAt: new Date("2026-07-28T10:00:00Z"),
updatedAt: new Date("2026-07-28T10:00:00Z"),
_count: { users: 1 },
},
] as never);
vi.mocked(prisma.markupConfig.findMany).mockResolvedValue([
{
customerId: "CUST_004",
businessCustomerId: "29bb985b-54ae-4d89-a760-052ca9c09f1d",
markupType: "fixed",
markupPercent: 0,
markupFixedAmount: 3,
operatorId: "admin_demo",
remark: null,
updatedAt: new Date("2026-07-30T10:00:00Z"),
},
] as never);
const res = await GET(
new Request(
"http://localhost/api/admin/markup-configs?page=1&size=10&keyword=SP012",
{ headers: ADMIN_HEADERS },
),
);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.code).toBe(0);
expect(body.data.total).toBe(1);
expect(body.data.list[0].business_customer_code).toBe("SP012");
expect(body.data.list[0].markup_type).toBe("fixed");
expect(body.data.list[0].markup_fixed_amount).toBe(3);
});
it("GET keyword=SPO(字母O)也能命中 SP012", async () => {
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_004",
permissions: ["pricing:markup:write"],
},
});
process.env.CUSTOMER_REGISTRY = "";
vi.mocked(prisma.businessCustomer.findMany).mockResolvedValue([
{
customerId: "CUST_004",
businessCustomerId: "29bb985b-54ae-4d89-a760-052ca9c09f1d",
name: "STAR",
externalCode: "SP012",
status: "active",
remark: null,
createdAt: new Date("2026-07-28T10:00:00Z"),
updatedAt: new Date("2026-07-28T10:00:00Z"),
_count: { users: 1 },
},
] as never);
vi.mocked(prisma.markupConfig.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.businessCustomerUser.findMany).mockResolvedValue([] as never);
const res = await GET(
new Request(
"http://localhost/api/admin/markup-configs?page=1&size=10&keyword=SPO",
{ headers: ADMIN_HEADERS },
),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.total).toBe(1);
expect(body.data.list[0].business_customer_code).toBe("SP012");
});
it("PUT 拒绝租户级加价", async () => {
const res = await PUT(
new Request("http://localhost/api/admin/markup-configs/CUST_001", {
method: "PUT",
@ -93,9 +202,52 @@ describe("admin markup-configs API", () => {
);
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("仅支持业务客户加价,租户不加价");
});
it("PUT 支持业务客户覆盖加价", async () => {
vi.mocked(prisma.markupConfig.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.markupConfig.create).mockResolvedValue({
customerId: "CUST_001",
businessCustomerId: "BC_001",
markupType: "percent",
markupPercent: 12,
markupFixedAmount: null,
operatorId: "admin_demo",
remark: "业务客户覆盖",
updatedAt: new Date("2026-07-28T10:00:00Z"),
} as never);
vi.mocked(prisma.businessCustomer.findFirst).mockResolvedValue({
customerId: "CUST_001",
businessCustomerId: "BC_001",
name: "客户A",
externalCode: "CODE_A",
status: "active",
remark: null,
createdAt: new Date("2026-07-28T10:00:00Z"),
updatedAt: new Date("2026-07-28T10:00:00Z"),
} as never);
const res = await PUT(
new Request("http://localhost/api/admin/markup-configs/CUST_001", {
method: "PUT",
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
business_customer_id: "BC_001",
markup_percent: 12,
remark: "业务客户覆盖",
}),
}),
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.markup_type).toBe("fixed");
expect(body.data.markup_fixed_amount).toBe(25);
expect(body.data.business_customer_id).toBe("BC_001");
expect(body.data.business_customer_code).toBe("CODE_A");
expect(body.data.business_customer_name).toBe("客户A");
expect(body.data.markup_percent).toBe(12);
});
it("PUT 未知客户 400", async () => {

@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/auth-context", () => ({
parseServiceAuth: vi.fn(),
assertCustomerMatch: vi.fn(),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/modules/flock/checkout-service", () => ({
submitFlockCheckoutFill: vi.fn(),
}));
import { parseServiceAuth, assertCustomerMatch } from "@/lib/api/auth-context";
import { POST } from "@/app/api/quotes/flock-checkout/route";
import { submitFlockCheckoutFill } from "@/modules/flock/checkout-service";
function authOk() {
vi.mocked(parseServiceAuth).mockResolvedValue({
authType: "service",
customerId: "CUST_001",
permissions: [],
} as never);
vi.mocked(assertCustomerMatch).mockImplementation(() => undefined);
}
function jsonReq(body: unknown) {
return new Request("http://localhost/api/quotes/flock-checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/quotes/flock-checkout", () => {
beforeEach(() => {
vi.clearAllMocks();
authOk();
});
it("缺 preferred_tier → 400", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
}),
);
expect(res.status).toBe(400);
});
it("缺 preferred_flexibility 且无 preferred_carrier → 400", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_tier: "flock_direct",
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/灵活性|承运商/);
expect(submitFlockCheckoutFill).not.toHaveBeenCalled();
});
it("preferred_carrier 可入队", async () => {
vi.mocked(submitFlockCheckoutFill).mockResolvedValue({
quote_id: "q1",
status: "processing",
});
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_tier: "standard",
preferred_carrier: "Forward Air",
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(submitFlockCheckoutFill).toHaveBeenCalledWith(
expect.objectContaining({ preferredCarrier: "Forward Air" }),
);
});
it("成功入队", async () => {
vi.mocked(submitFlockCheckoutFill).mockResolvedValue({
quote_id: "q1",
status: "processing",
});
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_tier: "flock_direct",
preferred_flexibility: "2_day",
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.status).toBe("processing");
expect(submitFlockCheckoutFill).toHaveBeenCalled();
});
it("非法 tier → 400", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_tier: "express",
preferred_flexibility: "none",
}),
);
expect(res.status).toBe(400);
expect(submitFlockCheckoutFill).not.toHaveBeenCalled();
});
it("flock_details 任意键拒绝", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_tier: "standard",
preferred_flexibility: "1_day",
flock_details: { evil: true, pickup: { hack: 1 } },
}),
);
expect(res.status).toBe(400);
expect(submitFlockCheckoutFill).not.toHaveBeenCalled();
});
});

@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/auth-context", () => ({
parseServiceAuth: vi.fn(),
assertCustomerMatch: vi.fn(),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/modules/flock/hold-service", () => ({
declineFlockQuoteHold: vi.fn(),
continueFlockQuoteHold: vi.fn(),
}));
import { parseServiceAuth, assertCustomerMatch } from "@/lib/api/auth-context";
import { POST as declinePost } from "@/app/api/quotes/flock-hold/decline/route";
import { POST as continuePost } from "@/app/api/quotes/flock-hold/continue/route";
import {
continueFlockQuoteHold,
declineFlockQuoteHold,
} from "@/modules/flock/hold-service";
import { FLOCK_HOLD_MSG } from "@/lib/constants/flock-quote-hold";
const SESSION = "flock_q1";
function authOk() {
vi.mocked(parseServiceAuth).mockResolvedValue({
authType: "service",
customerId: "CUST_001",
permissions: [],
} as never);
vi.mocked(assertCustomerMatch).mockImplementation(() => undefined);
}
function jsonReq(url: string, body: unknown) {
return new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("flock-hold continue / decline API", () => {
beforeEach(() => {
vi.clearAllMocks();
authOk();
});
it("decline:缺 session → 400 中文", async () => {
const res = await declinePost(
jsonReq("http://localhost/api/quotes/flock-hold/decline", {
customer_id: "CUST_001",
quote_id: "q1",
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).not.toBe(0);
expect(String(body.message)).toMatch(/询价会话|会话标识|Required/);
});
it("decline:非法 JSON → 400", async () => {
const res = await declinePost(
new Request("http://localhost/api/quotes/flock-hold/decline", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{",
}),
);
expect(res.status).toBe(400);
});
it("decline:成功 → released", async () => {
vi.mocked(declineFlockQuoteHold).mockResolvedValue({ released: true });
const res = await declinePost(
jsonReq("http://localhost/api/quotes/flock-hold/decline", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.released).toBe(true);
});
it("continue:成功 → filling", async () => {
vi.mocked(continueFlockQuoteHold).mockResolvedValue({ status: "filling" });
const res = await continuePost(
jsonReq("http://localhost/api/quotes/flock-hold/continue", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.status).toBe("filling");
});
it("continue:60s 超时业务错误 → 400 中文", async () => {
vi.mocked(continueFlockQuoteHold).mockRejectedValue(
new Error(FLOCK_HOLD_MSG.decisionTimeoutApi),
);
const res = await continuePost(
jsonReq("http://localhost/api/quotes/flock-hold/continue", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.message).toBe(FLOCK_HOLD_MSG.decisionTimeoutApi);
});
it("continue:5min 超时 → 400 中文含 5 分钟", async () => {
vi.mocked(continueFlockQuoteHold).mockRejectedValue(
new Error(FLOCK_HOLD_MSG.totalTimeoutApi),
);
const res = await continuePost(
jsonReq("http://localhost/api/quotes/flock-hold/continue", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
const body = await res.json();
expect(body.message).toMatch(/5 分钟/);
});
});

@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/auth-context", () => ({
parseServiceAuth: vi.fn(),
assertCustomerMatch: vi.fn(),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/modules/mothership/checkout-service", () => ({
submitMsCheckoutFill: vi.fn(),
}));
import { parseServiceAuth, assertCustomerMatch } from "@/lib/api/auth-context";
import { POST } from "@/app/api/quotes/ms-checkout/route";
import { submitMsCheckoutFill } from "@/modules/mothership/checkout-service";
function authOk() {
vi.mocked(parseServiceAuth).mockResolvedValue({
authType: "service",
customerId: "CUST_001",
permissions: [],
} as never);
vi.mocked(assertCustomerMatch).mockImplementation(() => undefined);
}
function jsonReq(body: unknown) {
return new Request("http://localhost/api/quotes/ms-checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/quotes/ms-checkout", () => {
beforeEach(() => {
vi.clearAllMocks();
authOk();
});
it("freight_protect 缺货值 → 400", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_carrier: "ABF Direct",
coverage: "freight_protect",
}),
);
expect(res.status).toBe(400);
});
it("成功入队", async () => {
vi.mocked(submitMsCheckoutFill).mockResolvedValue({
quote_id: "q1",
status: "processing",
});
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_carrier: "Roadrunner Interline",
coverage: "freight_protect",
cargo_value_usd: 5000,
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.status).toBe("processing");
expect(submitMsCheckoutFill).toHaveBeenCalled();
});
it("承运商过长 → 400", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_carrier: "x".repeat(129),
coverage: "basic",
}),
);
expect(res.status).toBe(400);
expect(submitMsCheckoutFill).not.toHaveBeenCalled();
});
it("mothership_details 任意键拒绝", async () => {
const res = await POST(
jsonReq({
customer_id: "CUST_001",
quote_id: "q1",
preferred_carrier: "ABF",
coverage: "basic",
mothership_details: { evil: true, pickup: { hack: 1 } },
}),
);
expect(res.status).toBe(400);
expect(submitMsCheckoutFill).not.toHaveBeenCalled();
});
});

@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/auth-context", () => ({
parseServiceAuth: vi.fn(),
assertCustomerMatch: vi.fn(),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/modules/mothership/refine-service", () => ({
declineMsRefineHold: vi.fn(),
continueMsRefineHold: vi.fn(),
submitMsRefineDetails: vi.fn(),
}));
import { parseServiceAuth, assertCustomerMatch } from "@/lib/api/auth-context";
import { POST as declinePost } from "@/app/api/quotes/refine-hold/decline/route";
import { POST as continuePost } from "@/app/api/quotes/refine-hold/continue/route";
import { POST as refineDetailsPost } from "@/app/api/quotes/refine-details/route";
import {
continueMsRefineHold,
declineMsRefineHold,
submitMsRefineDetails,
} from "@/modules/mothership/refine-service";
const SESSION = "00000000-0000-4000-8000-000000000001";
function authOk() {
vi.mocked(parseServiceAuth).mockResolvedValue({
authType: "service",
customerId: "CUST_001",
permissions: [],
} as never);
vi.mocked(assertCustomerMatch).mockImplementation(() => undefined);
}
function jsonReq(url: string, body: unknown) {
return new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("refine-hold / refine-details API", () => {
beforeEach(() => {
vi.clearAllMocks();
authOk();
});
it("decline:参数非法 → 400", async () => {
const res = await declinePost(
jsonReq("http://localhost/api/quotes/refine-hold/decline", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: "not-uuid",
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).not.toBe(0);
});
it("decline:成功 → ok released", async () => {
vi.mocked(declineMsRefineHold).mockResolvedValue({ released: true });
const res = await declinePost(
jsonReq("http://localhost/api/quotes/refine-hold/decline", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.released).toBe(true);
});
it("continue:成功 → filling", async () => {
vi.mocked(continueMsRefineHold).mockResolvedValue({ status: "filling" });
const res = await continuePost(
jsonReq("http://localhost/api/quotes/refine-hold/continue", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.status).toBe("filling");
});
it("continue:业务超时 → 400 中文", async () => {
vi.mocked(continueMsRefineHold).mockRejectedValue(
new Error("确认窗口已超时,请重新询价"),
);
const res = await continuePost(
jsonReq("http://localhost/api/quotes/refine-hold/continue", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/确认窗口已超时/);
});
it("refine-details:成功入队", async () => {
vi.mocked(submitMsRefineDetails).mockResolvedValue({
quote_id: "q1",
status: "processing",
});
const res = await refineDetailsPost(
jsonReq("http://localhost/api/quotes/refine-details", {
customer_id: "CUST_001",
quote_id: "q1",
quote_session_id: SESSION,
mothership_details: { pickup: { company_name: "Acme" } },
}),
);
const body = await res.json();
expect(body.code).toBe(0);
expect(body.data.status).toBe("processing");
});
it("refine-details:缺 quote_id → 校验失败", async () => {
const res = await refineDetailsPost(
jsonReq("http://localhost/api/quotes/refine-details", {
customer_id: "CUST_001",
quote_session_id: SESSION,
}),
);
expect(res.status).toBe(400);
});
});

@ -49,32 +49,41 @@ describe("flock logged-in quote form constants", () => {
);
});
it("调度选项按总重 >5000 动态启用", async () => {
it("调度:During pickup window / NEED delivery 常可选;其余高级项按件数>5或总重>5000解锁", async () => {
const mod = await import("@/components/flock/flock-logged-in-quote-form");
expect(mod.flockSchedulingWeightLockedHint()).toBe(
"整票总重须大于 5000 lb 才可选用",
expect(mod.flockSchedulingWeightLockedHint()).toMatch(
/件数大于 5.*总重大于 5000/,
);
expect(mod.isFlockSchedulingOptionLocked(5, 5000, true)).toBe(true);
expect(mod.isFlockSchedulingOptionLocked(6, 200, true)).toBe(false);
expect(mod.isFlockSchedulingOptionLocked(2, 5001, true)).toBe(false);
expect(mod.isFlockSchedulingOptionLocked(1, 1, false)).toBe(false);
// 官网:与 Standard 一样常可选
expect(
mod.isFlockSchedulingOptionLocked(4000, true),
).toBe(true);
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")
?.weightLocked,
).toBe(false);
expect(
mod.isFlockSchedulingOptionLocked(5001, true),
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "standard_fcfs")
?.weightLocked,
).toBe(false);
expect(
mod.isFlockSchedulingOptionLocked(4000, false),
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "need_appointment")
?.weightLocked,
).toBe(false);
// 官网灰色:HAVE / NEED pickup、During delivery、HAVE delivery、Must arrive by
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "have_appointment")
?.weightLocked,
).toBe(true);
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "standard_fcfs")
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "need_appointment")
?.weightLocked,
).toBe(false);
).toBe(true);
expect(
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "need_appointment")
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "during_window")
?.weightLocked,
).toBe(false);
).toBe(true);
expect(
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "must_arrive_by")
?.weightLocked,
@ -83,6 +92,41 @@ describe("flock logged-in quote form constants", () => {
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")?.detail,
).toContain("预设时段");
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "need_appointment")
?.detail,
).toMatch(/协调预约/);
expect(mod.flockSchedulingPickupPanelId("standard_fcfs")).toBeNull();
expect(mod.flockSchedulingPickupPanelId("during_window")).toBe(
"during_window",
);
expect(mod.flockSchedulingPickupPanelId("have_appointment")).toBe(
"have_appointment",
);
expect(mod.flockSchedulingPickupPanelId("need_appointment")).toBeNull();
expect(mod.flockSchedulingDeliveryPanelId("need_appointment")).toBeNull();
expect(mod.flockSchedulingDeliveryPanelId("standard_fcfs")).toBe(
"standard_call_before",
);
expect(mod.flockSchedulingDeliveryPanelId("must_arrive_by")).toBe(
"must_arrive_by",
);
expect(mod.flockSchedulingShowDeliveryCallBefore("standard_fcfs")).toBe(
true,
);
expect(mod.flockSchedulingShowDeliveryCallBefore("during_window")).toBe(
false,
);
expect(mod.flockSchedulingShowDeliveryCallBefore("need_appointment")).toBe(
false,
);
expect(mod.flockSchedulingShowDeliveryCallBefore("must_arrive_by")).toBe(
false,
);
expect(mod.flockSchedulingShowPickupCallBefore("standard_fcfs")).toBe(true);
expect(mod.flockSchedulingShowPickupCallBefore("during_window")).toBe(
false,
);
});
it("mapFlockLoggedInToApiInput 透传调度子字段", () => {
@ -134,6 +178,104 @@ describe("flock logged-in quote form constants", () => {
expect(mapped.call_for_delivery_appointment).toBe(true);
});
it("件数>5 或总重>5000 时 map 透传 load_bars / straps", () => {
const base = {
mode: "quick" as const,
pickupDate: "2026-07-16",
pickupZip: "60611",
pickupType: "business_with_dock" as const,
pickupLiftgate: false,
pickupInside: false,
pickupPalletJack: false,
deliveryZip: "78701",
deliveryType: "business_without_dock" as const,
deliveryLiftgate: false,
deliveryInside: false,
deliveryPalletJack: false,
additionalServices: [] as const,
vehicleTypes: ["dry_van"] as const,
pickupService: "standard_fcfs",
deliveryService: "standard_fcfs",
callBeforePickup: false,
callBeforeDelivery: false,
additionalInsurance: false,
};
const light = mapFlockLoggedInToApiInput({
...base,
items: [
{
quantity: 6,
packagingType: "pallets_48x40",
lengthIn: 48,
widthIn: 40,
heightIn: 48,
totalWeightLb: 200,
freightClass: "density",
description: "papers",
stackable: true,
turnable: false,
},
],
loadBars: 2,
straps: 4,
});
expect(light.load_bars).toBe(2);
expect(light.straps).toBe(4);
});
it("额外保险:是 + 货值透传;否不带货值", () => {
const baseItems = [
{
quantity: 2,
packagingType: "pallets_48x40" as const,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
totalWeightLb: 1000,
freightClass: "70" as const,
description: "papers",
stackable: true,
turnable: false,
},
];
const common = {
mode: "quick" as const,
pickupDate: "2026-07-16",
pickupZip: "60611",
pickupType: "business_with_dock" as const,
pickupLiftgate: false,
pickupInside: false,
pickupPalletJack: false,
deliveryZip: "78701",
deliveryType: "business_without_dock" as const,
deliveryLiftgate: false,
deliveryInside: false,
deliveryPalletJack: false,
items: baseItems,
additionalServices: [] as [],
vehicleTypes: ["dry_van"] as ["dry_van"],
pickupService: "standard_fcfs",
deliveryService: "standard_fcfs",
callBeforePickup: false,
callBeforeDelivery: false,
};
const yes = mapFlockLoggedInToApiInput({
...common,
additionalInsurance: true,
shipmentValueUsd: 25000,
});
expect(yes.additional_insurance).toBe(true);
expect(yes.shipment_value_usd).toBe(25000);
const noMapped = mapFlockLoggedInToApiInput({
...common,
additionalInsurance: false,
shipmentValueUsd: 25000,
});
expect(noMapped.additional_insurance).toBe(false);
expect(noMapped.shipment_value_usd).toBeUndefined();
});
it("mapFlockLoggedInToApiInput 总重 ≤5000 时强制关闭提货前致电", () => {
const payload: FlockLoggedInQuotePayload = {
mode: "quick",

@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
buildDisplayedCheckoutSummary,
rateKey,
} from "@/components/mothership/mothership-logged-in-quote-sidebar";
import type { QuoteItem } from "@/lib/frontend/types";
function makeQuote(overrides: Partial<QuoteItem> = {}): QuoteItem {
return {
service_level: "standard",
rate_option: "lowest",
carrier: "Mothership Direct",
transit_days: "3",
transit_description: "3 business days",
raw_freight: 1000,
surcharges: 150,
raw_total: 1150,
markup_percent: 0,
markup_amount: 0,
final_total: 1150,
breakdown: [],
...overrides,
};
}
describe("mothership logged-in quote sidebar price summary", () => {
it("rateKey 区分同承运商不同价", () => {
const a = makeQuote({
service_level: "guaranteed",
rate_option: "bestValue",
carrier: "ABF Freight Direct",
final_total: 1344.9,
raw_total: 1344.9,
transit_days: "3",
});
const b = makeQuote({
service_level: "guaranteed",
rate_option: "bestValue",
carrier: "ABF Freight Direct",
final_total: 1478.84,
raw_total: 1478.84,
transit_days: "2",
});
expect(rateKey(a, 0)).not.toBe(rateKey(b, 1));
expect(rateKey(a, 0)).not.toBe(rateKey(a, 1));
});
it("基础保障仅展示最终报价,不展示加价明细", () => {
const summary = buildDisplayedCheckoutSummary({
item: makeQuote({
markup_percent: 3,
markup_amount: 30,
final_total: 1180,
}),
coverage: "basic",
checkout: null,
});
expect(summary.headline).toContain("$1,180.00");
expect(summary.details).toBeNull();
expect(summary.headline).not.toContain("原价");
expect(summary.headline).not.toContain("客户加价");
});
it("FreightProtect 未确认时,不把承运商价冒充最终价", () => {
const summary = buildDisplayedCheckoutSummary({
item: makeQuote({
markup_amount: 30,
final_total: 1180,
}),
coverage: "full",
checkout: null,
});
expect(summary.headline).toContain("承运商价");
expect(summary.details).toContain("保障后总价待确认");
expect(summary.details).not.toContain("原价");
expect(summary.details).not.toContain("客户加价");
expect(summary.ctaAmountLabel).toBeNull();
});
it("FreightProtect 已确认后,显示确认总价", () => {
const summary = buildDisplayedCheckoutSummary({
item: makeQuote({
markup_amount: 30,
final_total: 1180,
}),
coverage: "full",
checkout: {
status: "done",
selected_total: 1202.55,
coverage: "freight_protect",
cargo_value_usd: 5000,
stage: "review",
selected_carrier: "Mothership Direct",
message: "已填齐详情(未支付)",
},
});
expect(summary.headline).toContain("确认价");
expect(summary.details).toContain("$1,202.55");
expect(summary.details).not.toContain("原价");
expect(summary.ctaAmountLabel).toBe("$1,202.55");
});
});

@ -8,34 +8,36 @@ import {
} from "@/components/mothership/mothership-logged-in-shipment-form";
describe("mothership logged-in shipment form constants", () => {
it("提货附加服务含官网录制项", () => {
it("提货附加服务顺序对齐官网", () => {
const ids = MS_PICKUP_ACCESSORIALS.map((x) => x.id);
expect(ids).toEqual(
expect.arrayContaining([
"cfs",
"liftgate",
"limitedAccess",
"inside",
"residential",
"tradeshow",
]),
expect(ids).toEqual([
"liftgate",
"cfs",
"residential",
"tradeshow",
"inside",
"limitedAccess",
]);
expect(MS_PICKUP_ACCESSORIALS.every((x) => x.description.length > 0)).toBe(
true,
);
});
it("送货附加服务含 Amazon 预约等录制项", () => {
it("送货附加服务顺序对齐官网(含预约)", () => {
const ids = MS_DELIVERY_ACCESSORIALS.map((x) => x.id);
expect(ids).toEqual(
expect.arrayContaining([
"fbaAppointment",
"appointment",
"cfs",
"liftgate",
"limitedAccess",
"inside",
"residential",
"tradeshow",
]),
);
expect(ids).toEqual([
"liftgate",
"cfs",
"residential",
"tradeshow",
"inside",
"limitedAccess",
"appointment",
"fbaAppointment",
]);
expect(
MS_DELIVERY_ACCESSORIALS.every((x) => x.description.length > 0),
).toBe(true);
});
it("货物类型对齐官网完整列表", () => {

@ -65,16 +65,16 @@ describe("pollQuoteUntilDone", () => {
...doneQuote(),
status: "failed",
error_code: "ADDRESS_SUGGESTION_NOT_FOUND",
error_message: "未找到用户确认的 MotherShip 地址",
error_message: "未找到用户确认的 承运商 地址",
},
}));
expect(result.type).toBe("error");
if (result.type === "error") {
expect(result.message).toBe("未找到用户确认的 MotherShip 地址");
expect(result.message).toBe("未找到用户确认的 承运商 地址");
}
});
it("processing 超过 10s 后使用更短轮询间隔", async () => {
it("processing 使用短轮询间隔", async () => {
vi.useFakeTimers();
let calls = 0;
const intervals: number[] = [];
@ -87,19 +87,18 @@ describe("pollQuoteUntilDone", () => {
}
last = now;
calls += 1;
if (calls < 7) {
if (calls < 4) {
return { ok: true, data: processingQuote() };
}
return { ok: true, data: doneQuote() };
});
for (let i = 0; i < 6; i += 1) {
await vi.advanceTimersByTimeAsync(2_000);
for (let i = 0; i < 4; i += 1) {
await vi.advanceTimersByTimeAsync(400);
}
await vi.advanceTimersByTimeAsync(800);
const result = await promise;
expect(result.type).toBe("done");
expect(intervals.some((gap) => gap <= 800)).toBe(true);
expect(intervals.every((gap) => gap <= 400)).toBe(true);
vi.useRealTimers();
});
});

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
FLOCK_HOLD_DECISION_MS,
FLOCK_HOLD_MSG,
FLOCK_HOLD_TOTAL_MS,
buildFlockQuoteHoldState,
flockHoldSessionId,
formatFlockHoldRemainingLabel,
isFlockQuoteHoldExpired,
shouldAutoDeclineFlockHold,
toFlockQuoteHoldPublic,
} from "@/lib/constants/flock-quote-hold";
describe("flock quote hold — 60s/5min", () => {
it("构建 60s 决策窗 + 5min 总硬限", () => {
const now = 1_700_000_000_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: flockHoldSessionId("q1"),
customerId: "CUST_001",
nowMs: now,
});
expect(s.decision_deadline_ms).toBe(now + FLOCK_HOLD_DECISION_MS);
expect(s.total_deadline_ms).toBe(now + FLOCK_HOLD_TOTAL_MS);
expect(s.quote_session_id).toBe("flock_q1");
});
it("60s 边界:恰好到期应 auto-decline;filling 不因决策窗 decline", () => {
const now = 1_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: "flock_q1",
customerId: "CUST_001",
nowMs: now,
});
expect(shouldAutoDeclineFlockHold(s, now + FLOCK_HOLD_DECISION_MS - 1)).toBe(
false,
);
expect(shouldAutoDeclineFlockHold(s, now + FLOCK_HOLD_DECISION_MS)).toBe(
true,
);
expect(
shouldAutoDeclineFlockHold(
{ ...s, status: "filling" },
now + FLOCK_HOLD_DECISION_MS + 10_000,
),
).toBe(false);
});
it("5 分钟总硬限边界", () => {
const now = 1_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: "flock_q1",
customerId: "CUST_001",
nowMs: now,
});
expect(isFlockQuoteHoldExpired(s, now + FLOCK_HOLD_TOTAL_MS - 1)).toBe(
false,
);
expect(isFlockQuoteHoldExpired(s, now + FLOCK_HOLD_TOTAL_MS)).toBe(true);
});
it("倒计时文案", () => {
const now = 10_000;
expect(formatFlockHoldRemainingLabel(now + 45_000, now)).toBe("45秒");
expect(formatFlockHoldRemainingLabel(now + 65_000, now)).toBe("1分05秒");
});
it("超时提示含保留首价语义", () => {
expect(FLOCK_HOLD_MSG.decisionTimeout).toMatch(/初步报价|超时/);
expect(FLOCK_HOLD_MSG.totalTimeoutApi).toMatch(/5 分钟/);
});
it("public.available:awaiting 过决策窗 false;filling 仍 true 直至总硬限", () => {
const now = 5_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: "flock_q1",
customerId: "CUST_001",
nowMs: now,
});
expect(toFlockQuoteHoldPublic(s, now).available).toBe(true);
expect(
toFlockQuoteHoldPublic(s, now + FLOCK_HOLD_DECISION_MS).available,
).toBe(false);
expect(
toFlockQuoteHoldPublic(
{ ...s, status: "filling" },
now + FLOCK_HOLD_DECISION_MS + 1_000,
).available,
).toBe(true);
expect(
toFlockQuoteHoldPublic(
{ ...s, status: "filling" },
now + FLOCK_HOLD_TOTAL_MS,
).available,
).toBe(false);
});
});

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import {
FLOCK_LIMITS,
isFlockSchedulingAdvancedUnlocked,
} from "@/lib/constants/flock-limits";
describe("isFlockSchedulingAdvancedUnlocked", () => {
it("件数=5 或 重量=5000 仍未解锁;任一严格大于才解锁", () => {
expect(isFlockSchedulingAdvancedUnlocked(5, 5000)).toBe(false);
expect(isFlockSchedulingAdvancedUnlocked(5, 200)).toBe(false);
expect(isFlockSchedulingAdvancedUnlocked(2, 5000)).toBe(false);
expect(isFlockSchedulingAdvancedUnlocked(6, 200)).toBe(true);
expect(isFlockSchedulingAdvancedUnlocked(2, 5001)).toBe(true);
expect(isFlockSchedulingAdvancedUnlocked(6, 5001)).toBe(true);
expect(FLOCK_LIMITS.schedulingPiecesMin).toBe(5);
expect(FLOCK_LIMITS.schedulingWeightMinLb).toBe(5000);
});
});

@ -56,4 +56,39 @@ describe("mothership-ui-tiers", () => {
expect(getMotherShipRateOptionLabel("standard", "bestValue")).toBe("最优性价比");
expect(getMotherShipRateOptionLabel("standard", "lowest")).toBe("最低价格");
});
it("同承运商不同价均保留", () => {
const ui = filterQuotesForMotherShipUiDisplay([
{
service_level: "standard",
rate_option: "bestValue",
carrier: "ABF Freight Direct",
raw_total: 100,
},
{
service_level: "standard",
rate_option: "bestValue",
carrier: "ABF Freight Direct",
raw_total: 130,
},
{
service_level: "standard",
rate_option: "bestValue",
carrier: "XPO Logistics Direct",
raw_total: 90,
},
]);
expect(ui).toHaveLength(3);
});
it("MotherShip 承运商名对外脱敏为平台承运", () => {
const ui = filterQuotesForMotherShipUiDisplay([
{
service_level: "standard",
rate_option: "lowest",
carrier: "MotherShip",
},
]);
expect(ui[0]?.carrier).toBe("平台承运");
});
});

@ -0,0 +1,701 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
MS_REFINE_DECISION_MS,
MS_REFINE_MSG,
MS_REFINE_TOTAL_MS,
buildMsRefineHoldState,
formatMsRefineRemainingLabel,
isMsRefineHoldExpired,
shouldAutoDeclineRefineHold,
toMsRefineHoldPublic,
} from "@/lib/constants/ms-refine-hold";
import {
collectMsDetailsRefineInvalidFields,
collectMsDetailsRefineMissing,
snapMsReadyTimeToOpensAt,
validateMsDetailsForRefine,
} from "@/lib/mothership/ms-details-refine-validation";
import type { MothershipLoggedInDetailsPayload } from "@/components/mothership/mothership-logged-in-details-form";
const SESSION = "00000000-0000-4000-8000-000000000001";
function fullDetails(
patch?: Partial<MothershipLoggedInDetailsPayload>,
): MothershipLoggedInDetailsPayload {
return {
pickup: {
companyName: "Acme Pickup",
suite: "",
contactFirst: "A",
contactLast: "B",
contactEmail: "pickup@acme.com",
contactPhone: "555-1111",
reference: "",
notes: "",
opensAt: "8:00 AM",
closesAt: "5:00 PM",
},
delivery: {
companyName: "Acme Delivery",
suite: "",
contactFirst: "C",
contactLast: "D",
contactEmail: "delivery@acme.com",
contactPhone: "555-2222",
reference: "",
notes: "",
opensAt: "9:00 AM",
closesAt: "5:00 PM",
},
requestDeliveryAppointment: false,
fbaNumber: "",
fbaPoNumber: "",
cargo: [
{
pieceCountType: "Cartons",
pieceCountQty: 2,
description: "General freight",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
...patch,
};
}
describe("ms refine hold — 无客户时限逼迫", () => {
it("构建决策窗与总会话窗(默认 1 小时)", () => {
const now = 1_700_000_000_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(s.decision_deadline_ms).toBe(now + MS_REFINE_DECISION_MS);
expect(s.total_deadline_ms).toBe(now + MS_REFINE_TOTAL_MS);
expect(MS_REFINE_TOTAL_MS).toBe(3_600_000);
});
it("不再因决策窗自动放弃", () => {
const now = 1_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(shouldAutoDeclineRefineHold(s, now + MS_REFINE_DECISION_MS - 1)).toBe(
false,
);
expect(shouldAutoDeclineRefineHold(s, now + MS_REFINE_DECISION_MS)).toBe(
false,
);
expect(
shouldAutoDeclineRefineHold(
{ ...s, status: "filling" },
now + MS_REFINE_DECISION_MS + 10_000,
),
).toBe(false);
});
it("总会话窗边界(内部资源回收)", () => {
const now = 1_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(isMsRefineHoldExpired(s, now + MS_REFINE_TOTAL_MS - 1)).toBe(false);
expect(isMsRefineHoldExpired(s, now + MS_REFINE_TOTAL_MS)).toBe(true);
});
it("倒计时文案:秒 / 分秒 / 归零", () => {
const now = 10_000;
expect(formatMsRefineRemainingLabel(now + 45_000, now)).toBe("45秒");
expect(formatMsRefineRemainingLabel(now + 65_000, now)).toBe("1分05秒");
expect(formatMsRefineRemainingLabel(now - 1, now)).toBe("0秒");
});
it("客户可见文案不含超时/二级/官网敏感词", () => {
expect(MS_REFINE_MSG.decisionTimeout).toBe("请补充必要信息后重新获取报价");
expect(MS_REFINE_MSG.totalTimeout).toBe("请补充必要信息后重新获取报价");
expect(MS_REFINE_MSG.totalTimeoutApi).not.toMatch(/超时|二级|5 分钟/);
expect(MS_REFINE_MSG.decisionTimeoutApi).not.toMatch(/超时|二级/);
});
it("public.available:filling/refining 始终 true;awaiting 在总会话内可用", () => {
const now = 5_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(toMsRefineHoldPublic(s, now + 1_000).available).toBe(true);
expect(
toMsRefineHoldPublic(s, now + MS_REFINE_DECISION_MS + 1).available,
).toBe(false);
const filling = { ...s, status: "filling" as const };
expect(
toMsRefineHoldPublic(filling, now + MS_REFINE_DECISION_MS + 1).available,
).toBe(true);
expect(
toMsRefineHoldPublic(filling, now + MS_REFINE_TOTAL_MS).available,
).toBe(true);
});
});
describe("ms details refine — 必填预防", () => {
it("完整填写通过", () => {
expect(validateMsDetailsForRefine(fullDetails()).ok).toBe(true);
});
it("空公司名/邮箱/电话/营业时间 → 列出缺失项", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
companyName: " ",
contactEmail: "",
contactPhone: "",
opensAt: "",
closesAt: "",
},
}),
);
expect(missing.some((m) => m.includes("提货") && m.includes("公司名"))).toBe(
true,
);
expect(missing.some((m) => m.includes("邮箱"))).toBe(true);
expect(missing.some((m) => m.includes("电话"))).toBe(true);
expect(missing.some((m) => m.includes("开门"))).toBe(true);
});
it("邮箱格式无效 → 明确提示", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
delivery: { ...fullDetails().delivery, contactEmail: "not-an-email" },
}),
);
expect(missing.some((m) => m.includes("邮箱格式无效"))).toBe(true);
});
it("无货描 → 拦截;件数类型/数量为空不拦截", () => {
const r = validateMsDetailsForRefine(
fullDetails({
cargo: [
{
pieceCountType: "",
pieceCountQty: 0,
description: "",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
}),
);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.message).toMatch(/信息不完整/);
expect(r.missing.some((m) => m.includes("货物描述"))).toBe(true);
expect(r.missing.some((m) => m.includes("件数类型"))).toBe(false);
expect(r.missing.some((m) => m.includes("件数数量"))).toBe(false);
}
});
it("有货描、无件数类型 → 通过(官网件数类型非必填)", () => {
expect(
validateMsDetailsForRefine(
fullDetails({
cargo: [
{
pieceCountType: "",
pieceCountQty: 0,
description: "Rugs",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
}),
).ok,
).toBe(true);
});
it("地址空白 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(fullDetails(), {
pickupAddress: " ",
deliveryAddress: "789 Elm",
});
expect(missing.some((m) => m.includes("提货") && m.includes("地址"))).toBe(
true,
);
expect(missing.some((m) => m.includes("送货") && m.includes("地址"))).toBe(
false,
);
});
it("提货与送货邮箱相同 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
delivery: {
...fullDetails().delivery,
contactEmail: "pickup@acme.com",
},
}),
);
expect(missing.some((m) => m.includes("邮箱不能相同"))).toBe(true);
});
it("提货与送货邮箱大小写不同但相同 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
contactEmail: "Same@Acme.com",
},
delivery: {
...fullDetails().delivery,
contactEmail: "same@acme.com",
},
}),
);
expect(missing.some((m) => m.includes("邮箱不能相同"))).toBe(true);
});
it("货运就绪时刻早于提货开门 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
opensAt: "9:00 AM",
closesAt: "5:00 PM",
},
}),
{ readyTime: "7:00 AM" },
);
expect(missing.some((m) => m.includes("就绪时刻不能早于"))).toBe(true);
});
it("货运就绪时刻不早于开门 → 通过", () => {
expect(
validateMsDetailsForRefine(
fullDetails({
pickup: {
...fullDetails().pickup,
opensAt: "8:00 AM",
closesAt: "5:00 PM",
},
}),
{ readyTime: "11:00 AM" },
).ok,
).toBe(true);
});
it("开门不早于关门 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
opensAt: "5:00 PM",
closesAt: "8:00 AM",
},
}),
);
expect(missing.some((m) => m.includes("开门时间须早于关门时间"))).toBe(
true,
);
});
it("snapMsReadyTimeToOpensAt 拨到开门整点", () => {
expect(snapMsReadyTimeToOpensAt("7:00 AM", "9:00 AM")).toBe("9:00 AM");
expect(snapMsReadyTimeToOpensAt("11:00 AM", "9:00 AM")).toBe("11:00 AM");
});
it("字段键覆盖空公司名与邮箱", () => {
const keys = collectMsDetailsRefineInvalidFields(
fullDetails({
pickup: {
...fullDetails().pickup,
companyName: "",
contactEmail: "bad",
},
}),
);
expect(keys.has("pickup.companyName")).toBe(true);
expect(keys.has("pickup.contactEmail")).toBe(true);
});
});
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/address/parked-session-queue", () => ({
requestReleaseParkedSession: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/mothership/refine-queue", () => ({
enqueueMsRefineDetailsJob: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/quote/rpa-queue", () => ({
enqueueQuoteJob: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/mothership/dashboard-direct-quote", () => ({
fetchMothershipLoggedInDirectQuote: vi.fn(),
isMsDashboardDirectQuoteEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/rpa/env", () => ({
isInlineDirectQuoteEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/rpa/mothership-login-context", () => ({
runWithMothershipLoginContext: vi.fn((_login, _src, fn: () => unknown) => fn()),
}));
vi.mock("@/modules/customer/provider-credentials", () => ({
getCustomerProviderLogin: vi.fn().mockResolvedValue({
email: "ms@example.com",
password: "x",
}),
}));
vi.mock("@/modules/mothership/refine-apply-quotes", () => ({
applyMsRefineQuotes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/mothership/refine-hold-store", () => ({
assertMsRefineHoldUsable: vi.fn(),
clearMsRefineHold: vi.fn().mockResolvedValue(undefined),
readMsRefineHold: vi.fn(),
updateMsRefineHoldStatus: vi.fn().mockResolvedValue(null),
}));
import {
continueMsRefineHold,
declineMsRefineHold,
submitMsRefineDetails,
} from "@/modules/mothership/refine-service";
import {
assertMsRefineHoldUsable,
clearMsRefineHold,
readMsRefineHold,
updateMsRefineHoldStatus,
} from "@/lib/mothership/refine-hold-store";
import { requestReleaseParkedSession } from "@/modules/address/parked-session-queue";
import { enqueueMsRefineDetailsJob } from "@/modules/mothership/refine-queue";
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
import { prisma } from "@/lib/prisma";
import { fetchMothershipLoggedInDirectQuote } from "@/lib/mothership/dashboard-direct-quote";
import { applyMsRefineQuotes } from "@/modules/mothership/refine-apply-quotes";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
describe("refine-service — 用户路径", () => {
const quoteId = "Q_TEST_1";
const customerId = "CUST_001";
const now = Date.now();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchMothershipLoggedInDirectQuote).mockRejectedValue(
new Error("direct-disabled-in-test"),
);
});
it("decline:释放驻留并清 hold", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue(
buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
);
const r = await declineMsRefineHold({
customerId,
quoteId,
sessionId: SESSION,
});
expect(r.released).toBe(true);
expect(updateMsRefineHoldStatus).toHaveBeenCalledWith(SESSION, "declined");
expect(requestReleaseParkedSession).toHaveBeenCalledWith(SESSION);
expect(clearMsRefineHold).toHaveBeenCalledWith(SESSION);
});
it("decline:客户不匹配 → 拒绝", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue(
buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId: "OTHER",
nowMs: now,
}),
);
await expect(
declineMsRefineHold({ customerId, quoteId, sessionId: SESSION }),
).rejects.toThrow(/无权/);
});
it("continue:awaiting → filling", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue(
buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
);
const r = await continueMsRefineHold({
customerId,
quoteId,
sessionId: SESSION,
});
expect(r.status).toBe("filling");
expect(updateMsRefineHoldStatus).toHaveBeenCalledWith(SESSION, "filling");
});
it("continue:决策窗已过仍可进入 filling(不再自动超时放弃)", async () => {
const s = buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now - MS_REFINE_DECISION_MS - 1,
});
vi.mocked(readMsRefineHold).mockResolvedValue(s);
const r = await continueMsRefineHold({
customerId,
quoteId,
sessionId: SESSION,
});
expect(r.status).toBe("filling");
expect(requestReleaseParkedSession).not.toHaveBeenCalled();
});
it("submit:会话窗已过 → 完整重查(不抛超时文案)", async () => {
const s = {
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now - MS_REFINE_TOTAL_MS - 1,
}),
status: "filling" as const,
};
vi.mocked(readMsRefineHold).mockResolvedValue(s);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId,
requestId: "550e8400-e29b-41d4-a716-446655440000",
businessCustomerId: null,
pickupJson: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
deliveryJson: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
} as never);
vi.mocked(prisma.quoteRecord.update).mockResolvedValue({} as never);
const r = await submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
mothershipDetails: {
pickup: { company_name: "Acme", contact_email: "a@b.com" },
},
});
expect(r).toEqual({ quote_id: quoteId, status: "processing" });
expect(enqueueQuoteJob).toHaveBeenCalled();
expect(enqueueMsRefineDetailsJob).not.toHaveBeenCalled();
});
it("submit:filling 态入队刷价", async () => {
const s = {
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
status: "filling" as const,
};
vi.mocked(assertMsRefineHoldUsable).mockResolvedValue(s);
vi.mocked(readMsRefineHold).mockResolvedValue(s);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId,
requestId: "550e8400-e29b-41d4-a716-446655440000",
pickupJson: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
deliveryJson: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
} as never);
vi.mocked(prisma.quoteRecord.update).mockResolvedValue({} as never);
const r = await submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
mothershipDetails: {
pickup: { company_name: "Acme", contact_email: "a@b.com" },
},
});
expect(r).toEqual({ quote_id: quoteId, status: "processing" });
expect(updateMsRefineHoldStatus).toHaveBeenCalledWith(SESSION, "refining");
expect(enqueueMsRefineDetailsJob).toHaveBeenCalled();
});
it("submit:Direct 成功则跳过队列", async () => {
const s = {
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
status: "filling" as const,
};
vi.mocked(assertMsRefineHoldUsable).mockResolvedValue(s);
vi.mocked(readMsRefineHold).mockResolvedValue(s);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId,
requestId: "550e8400-e29b-41d4-a716-446655440000",
pickupJson: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
deliveryJson: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
} as never);
vi.mocked(prisma.quoteRecord.update).mockResolvedValue({} as never);
vi.mocked(fetchMothershipLoggedInDirectQuote).mockResolvedValue([
{
serviceLevel: "standard",
rateOption: "bestValue",
carrier: "XPO Logistics Direct",
transitDays: "3",
transitDescription: "3 business days",
rawFreight: 400,
surcharges: 0,
rawTotal: 400,
},
]);
const r = await submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
mothershipDetails: {
pickup: { company_name: "Acme", contact_email: "a@b.com" },
},
});
expect(r).toEqual({ quote_id: quoteId, status: "processing" });
expect(applyMsRefineQuotes).toHaveBeenCalled();
expect(enqueueMsRefineDetailsJob).not.toHaveBeenCalled();
expect(requestReleaseParkedSession).toHaveBeenCalled();
});
it("submit:报价单不属于客户 → 拒绝", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue({
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
status: "filling",
});
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId: "OTHER",
} as never);
await expect(
submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
}),
).rejects.toThrow(/报价单不存在/);
});
});

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
isChajiaModuleId,
moduleToHubRoute,
parseChajiaPostMessage,
} from "@/lib/embed/host-bridge";
describe("host-bridge postMessage protocol", () => {
it("parse 合法信封", () => {
const msg = parseChajiaPostMessage({
source: "chajia",
version: 1,
type: "chajia:fill",
module: "MS_GUEST",
request_id: "r1",
payload: { form: { pallet_count: 2 } },
});
expect(msg?.type).toBe("chajia:fill");
expect(msg?.module).toBe("MS_GUEST");
expect(msg?.request_id).toBe("r1");
});
it("拒绝非 chajia 来源", () => {
expect(
parseChajiaPostMessage({ source: "other", version: 1, type: "chajia:fill" }),
).toBeNull();
});
it("moduleToHubRoute 四模块", () => {
expect(moduleToHubRoute("MS_GUEST")).toEqual({
provider: "mothership",
mode: "anon",
});
expect(moduleToHubRoute("MS_LOGGED_IN")).toEqual({
provider: "mothership",
mode: "login",
});
expect(moduleToHubRoute("FLOCK_GUEST")).toEqual({
provider: "flock",
mode: "anon",
});
expect(moduleToHubRoute("FLOCK_LOGGED_IN")).toEqual({
provider: "flock",
mode: "login",
});
});
it("parse 宿主 scroll-quotes", () => {
const msg = parseChajiaPostMessage({
source: "chajia",
version: 1,
type: "chajia:scroll-quotes",
});
expect(msg?.type).toBe("chajia:scroll-quotes");
});
it("parse iframe scroll-top", () => {
const msg = parseChajiaPostMessage({
source: "chajia",
version: 1,
type: "chajia:scroll-top",
module: "MS_LOGGED_IN",
payload: { reason: "logged-in-details" },
});
expect(msg?.type).toBe("chajia:scroll-top");
expect(msg?.module).toBe("MS_LOGGED_IN");
});
});

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
isHostSelectedQuote,
mapQuotesWithSelectedFlag,
} from "@/lib/embed/map-quotes-selection";
describe("mapQuotesWithSelectedFlag", () => {
const a = {
carrier: "XPO",
service_level: "standard",
rate_option: "lowest",
final_total: 100,
};
const b = {
carrier: "Echo",
service_level: "standard",
rate_option: "fastest",
final_total: 200,
};
it("仅标记与点选项完全匹配的一行 selected=true", () => {
const mapped = mapQuotesWithSelectedFlag([a, b], b);
expect(mapped.map((q) => q.selected)).toEqual([false, true]);
});
it("final_total 不同则不算选中", () => {
expect(
isHostSelectedQuote(a, { ...a, final_total: 101 }),
).toBe(false);
});
});

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
isEmbedHostedShell,
parseEmbedEntryModule,
parseEmbedSsoParams,
} from "@/lib/embed/sso-params";
import { msLoggedInAddressQueryText } from "@/lib/embed/host-bridge";
describe("parseEmbedSsoParams", () => {
it("解析 login_type=api_key + api_key", () => {
@ -46,6 +48,38 @@ describe("parseEmbedSsoParams", () => {
});
});
describe("parseEmbedEntryModule", () => {
it("module=MS_LOGGED_IN", () => {
expect(
parseEmbedEntryModule(
new URLSearchParams("module=MS_LOGGED_IN&embed=1"),
),
).toBe("MS_LOGGED_IN");
});
it("entry=ms_logged_in", () => {
expect(
parseEmbedEntryModule(new URLSearchParams("entry=ms_logged_in")),
).toBe("MS_LOGGED_IN");
});
it("无效 → null", () => {
expect(parseEmbedEntryModule(new URLSearchParams("module=foo"))).toBeNull();
});
});
describe("msLoggedInAddressQueryText", () => {
it("优先 formatted_address,其次 street", () => {
expect(
msLoggedInAddressQueryText({
street: "A",
formatted_address: "整段地址原文",
}),
).toBe("整段地址原文");
expect(msLoggedInAddressQueryText({ street: "仅街道" })).toBe("仅街道");
});
});
describe("isEmbedHostedShell", () => {
it("embed=1 或带 SSO 凭证视为托管壳", () => {
expect(isEmbedHostedShell(new URLSearchParams("embed=1"))).toBe(true);

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it } from "vitest";
import {
getFlockFieldPauseMs,
getFlockLoggedInQuoteWaitMs,
getFlockQuoteWaitMs,
} from "@/lib/flock/env";
describe("flock quote wait / field pause defaults", () => {
const prev = {
field: process.env.FLOCK_FIELD_PAUSE_MS,
quote: process.env.FLOCK_QUOTE_WAIT_MS,
loggedIn: process.env.FLOCK_LOGGED_IN_QUOTE_WAIT_MS,
};
afterEach(() => {
process.env.FLOCK_FIELD_PAUSE_MS = prev.field;
process.env.FLOCK_QUOTE_WAIT_MS = prev.quote;
process.env.FLOCK_LOGGED_IN_QUOTE_WAIT_MS = prev.loggedIn;
});
it("字段停顿默认 200ms", () => {
delete process.env.FLOCK_FIELD_PAUSE_MS;
expect(getFlockFieldPauseMs()).toBe(200);
});
it("匿名等报价默认 20s,登录态默认 40s", () => {
delete process.env.FLOCK_QUOTE_WAIT_MS;
delete process.env.FLOCK_LOGGED_IN_QUOTE_WAIT_MS;
expect(getFlockQuoteWaitMs()).toBe(20_000);
expect(getFlockLoggedInQuoteWaitMs()).toBe(40_000);
});
});

@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
emptyFlockCheckoutDetailsDraft,
flockDetailsHasErrors,
isPlausibleUsPhone,
normalizeUsPhoneDisplay,
toFlockCheckoutApiDetails,
validateFlockCheckoutDetailsDraft,
} from "@/lib/flock/flock-checkout-details-rules";
import { validateFlockCheckoutSelection } from "@/lib/flock/flock-checkout-selection";
describe("flock-checkout-details-rules", () => {
it("rejects empty required fields", () => {
const errors = validateFlockCheckoutDetailsDraft(
emptyFlockCheckoutDetailsDraft(),
);
expect(flockDetailsHasErrors(errors)).toBe(true);
expect(errors.pickup?.company_name).toBeTruthy();
expect(errors.delivery?.contact_email).toBeTruthy();
expect(errors.delivery?.address1).toBeTruthy();
});
it("rejects 555 and invalid phones", () => {
expect(isPlausibleUsPhone("(555) 123-4567")).toBe(false);
expect(isPlausibleUsPhone("123")).toBe(false);
expect(isPlausibleUsPhone("(626) 595-1180")).toBe(true);
});
it("normalizes and accepts a complete draft (billing pickup)", () => {
const draft = emptyFlockCheckoutDetailsDraft();
draft.use_billing_for_pickup = true;
draft.use_billing_for_delivery = false;
draft.pickup = {
company_name: "Acme Pickup",
address1: "",
address2: "",
city: "",
state: "",
zip: "",
contact_name: "Alice",
contact_phone: "16265951180",
contact_email: "a@example.com",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
};
draft.delivery = {
company_name: "Acme Delivery",
address1: "233 S Wacker Dr",
address2: "Dock 1",
city: "Chicago",
state: "IL",
zip: "60601",
contact_name: "Bob",
contact_phone: "(415) 621-8840",
contact_email: "b@example.com",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
weekend_delivery: false,
};
draft.nmfc = "100240-01";
draft.po_number = "PO-1";
draft.bol_remarks = "fragile";
draft.declaration_statement = "call on arrival";
draft.notes = "gate code 1";
const errors = validateFlockCheckoutDetailsDraft(draft);
expect(flockDetailsHasErrors(errors)).toBe(false);
const api = toFlockCheckoutApiDetails(draft);
expect(api.role).toBe("shipper");
expect(api.use_billing_for_pickup).toBe(true);
expect(api.use_billing_for_delivery).toBe(false);
expect(api.pickup.contact_phone).toBe(
normalizeUsPhoneDisplay("16265951180"),
);
expect(api.po_number).toBe("PO-1");
expect(api.declaration_statement).toBe("call on arrival");
expect(api.notes).toBe("gate code 1");
expect(api.delivery.weekend_delivery).toBe(false);
});
it("skips delivery address when use_billing_for_delivery", () => {
const draft = emptyFlockCheckoutDetailsDraft();
draft.use_billing_for_pickup = true;
draft.use_billing_for_delivery = true;
draft.pickup = {
company_name: "A",
address1: "",
address2: "",
city: "",
state: "",
zip: "",
contact_name: "A",
contact_phone: "(626) 595-1180",
contact_email: "a@example.com",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
};
draft.delivery = {
company_name: "B",
address1: "",
address2: "",
city: "",
state: "",
zip: "",
contact_name: "B",
contact_phone: "(415) 621-8840",
contact_email: "b@example.com",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
};
const errors = validateFlockCheckoutDetailsDraft(draft);
expect(errors.delivery?.address1).toBeUndefined();
expect(flockDetailsHasErrors(errors)).toBe(false);
});
it("rejects same open/close hours", () => {
const draft = emptyFlockCheckoutDetailsDraft();
draft.use_billing_for_pickup = true;
draft.pickup = {
company_name: "A",
address1: "",
address2: "",
city: "",
state: "",
zip: "",
contact_name: "A",
contact_phone: "(626) 595-1180",
contact_email: "a@example.com",
opens_at: "9:00 AM",
closes_at: "9:00 AM",
};
draft.delivery = {
company_name: "B",
address1: "1 Main",
address2: "",
city: "Dallas",
state: "TX",
zip: "75201",
contact_name: "B",
contact_phone: "(415) 621-8840",
contact_email: "b@example.com",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
};
const errors = validateFlockCheckoutDetailsDraft(draft);
expect(errors.pickup?.closes_at).toMatch(/晚于/);
});
});
describe("flock sidebar tier selection", () => {
it("requires preferred_tier and flexibility before checkout", () => {
expect(
validateFlockCheckoutSelection({
preferredTier: "flock_direct",
preferredFlexibility: "2_day",
}),
).toBeNull();
expect(
validateFlockCheckoutSelection({
preferredTier: "standard",
preferredFlexibility: "1_day",
}),
).toBeNull();
expect(
validateFlockCheckoutSelection({
preferredTier: "other" as "standard",
preferredFlexibility: "none",
}),
).toBeTruthy();
expect(
validateFlockCheckoutSelection({ preferredTier: "standard" }),
).toMatch(/灵活性/);
});
});

@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import {
pickLowestRateOptionIndex,
pickFlexibilityOptionIndex,
pickCarrierOptionIndex,
parseFlockFlexibilityKey,
parseFlockRateUsd,
enrichFlexibilityOption,
enrichCarrierOption,
validateFlockCheckoutSelection,
} from "@/lib/flock/flock-checkout-selection";
import {
buildFlockCheckoutDefaults,
isFlockCheckoutPaymentButtonLabel,
isFlockCheckoutPaymentPageText,
} from "@/workers/rpa/flock/checkout";
describe("validateFlockCheckoutSelection", () => {
it("要求 flock_direct 或 standard,且必须选灵活性或承运商", () => {
expect(
validateFlockCheckoutSelection({
preferredTier: "flock_direct",
preferredFlexibility: "2_day",
}),
).toBeNull();
expect(
validateFlockCheckoutSelection({
preferredTier: "standard",
preferredFlexibility: "none",
}),
).toBeNull();
expect(
validateFlockCheckoutSelection({
preferredTier: "standard",
preferredCarrier: "Forward Air",
}),
).toBeNull();
expect(
validateFlockCheckoutSelection({
preferredTier: "other" as "flock_direct",
preferredFlexibility: "2_day",
}),
).toMatch(/请选择/);
expect(
validateFlockCheckoutSelection({ preferredTier: "flock_direct" }),
).toMatch(/灵活性|承运商/);
expect(
validateFlockCheckoutSelection({
preferredTier: "standard",
preferredFlexibility: "2_day",
preferredCarrier: "Forward Air",
}),
).toMatch(/不能同时/);
});
it("接受 preferred_flexibility", () => {
expect(
validateFlockCheckoutSelection({
preferredTier: "flock_direct",
preferredFlexibility: "2_day",
}),
).toBeNull();
});
});
describe("pickLowestRateOptionIndex", () => {
it("选最低价;同价取首项;空列表 -1", () => {
expect(pickLowestRateOptionIndex([])).toBe(-1);
expect(
pickLowestRateOptionIndex([
{ key: "2_day", label: "a", rateUsd: 2068 },
{ key: "1_day", label: "b", rateUsd: 2034 },
{ key: "none", label: "c", rateUsd: 2166 },
]),
).toBe(1);
expect(
pickLowestRateOptionIndex([
{ key: "2_day", label: "a", rateUsd: 100 },
{ key: "1_day", label: "b", rateUsd: 100 },
]),
).toBe(0);
});
});
describe("pickFlexibilityOptionIndex", () => {
it("按 key 选档,找不到则回落最低价", () => {
const opts = [
{ key: "2_day" as const, label: "2-day", rateUsd: 1200 },
{ key: "1_day" as const, label: "1-day", rateUsd: 1250 },
{ key: "none" as const, label: "No", rateUsd: 1300 },
];
expect(pickFlexibilityOptionIndex(opts, "1_day")).toBe(1);
expect(pickFlexibilityOptionIndex(opts, null)).toBe(0);
});
});
describe("parseFlockFlexibilityKey / enrich", () => {
it("从文案解析 key", () => {
expect(parseFlockFlexibilityKey("2-day flexibility Pickup Jul 30")).toBe(
"2_day",
);
expect(parseFlockFlexibilityKey("No flexibility")).toBe("none");
expect(
enrichFlexibilityOption({
label: "1-day flexibility Pickup Jul 30 Deliver by Aug 3 $1,250.00",
rateUsd: 1250,
})?.key,
).toBe("1_day");
});
});
describe("parseFlockRateUsd", () => {
it("解析美元金额", () => {
expect(parseFlockRateUsd("$2,034.00")).toBe(2034);
expect(parseFlockRateUsd("Prices from $1,271")).toBe(1271);
expect(parseFlockRateUsd("no money")).toBeNull();
});
});
describe("flock checkout payment guards", () => {
it("黑名单按钮与支付页文案", () => {
expect(isFlockCheckoutPaymentButtonLabel("Complete your order")).toBe(
true,
);
expect(isFlockCheckoutPaymentButtonLabel("Yes, I want this rate!")).toBe(
false,
);
expect(
isFlockCheckoutPaymentPageText("Complete your order — Pay with card"),
).toBe(true);
});
});
describe("enrichCarrierOption / pickCarrierOptionIndex", () => {
it("从承运商行文案解析字段", () => {
const opt = enrichCarrierOption({
label:
"Forward Air 1 business days Jul 28 – Jul 29 $555.14 Select",
rateUsd: 555.14,
});
expect(opt?.carrierName).toMatch(/Forward Air/i);
expect(opt?.transitDays).toMatch(/1 business/i);
expect(opt?.rateUsd).toBe(555.14);
});
it("按承运商名选档,找不到回落最低价", () => {
const opts = [
{
carrierName: "Forward Air",
transitDays: "1 business days",
rateUsd: 555,
label: "a",
},
{
carrierName: "Roadrunner",
transitDays: "2 business days",
rateUsd: 500,
label: "b",
},
];
expect(pickCarrierOptionIndex(opts, "Roadrunner")).toBe(1);
expect(pickCarrierOptionIndex(opts, null)).toBe(1);
});
});
describe("buildFlockCheckoutDefaults", () => {
it("合并 flock_details 覆盖", () => {
const d = buildFlockCheckoutDefaults({
pickupZip: "90001",
deliveryZip: "75201",
tag: "ab12",
details: {
pickup: { company_name: "Acme Pickup", contact_email: "a@x.com" },
delivery: { company_name: "Acme Del" },
po_number: "PO-99",
declaration_statement: "call dock",
documentation_required: true,
use_billing_for_delivery: true,
},
});
expect(d.pickupCompany).toBe("Acme Pickup");
expect(d.pickupEmail).toBe("a@x.com");
expect(d.deliveryPo).toBe("PO-99");
expect(d.declarationStatement).toBe("call dock");
expect(d.pickupZip).toBe("90001");
expect(d.nmfc).toBeTruthy();
expect(d.documentationRequired).toBe(true);
expect(d.useBillingForPickup).toBe(true);
expect(d.useBillingForDelivery).toBe(true);
expect(d.deliveryPhone).not.toMatch(/555/);
});
});

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { toFlockCheckoutPublic } from "@/lib/flock/flock-checkout-store";
describe("toFlockCheckoutPublic", () => {
it("不暴露 screenshot_path,失败文案脱敏", () => {
const pub = toFlockCheckoutPublic({
quote_id: "q1",
status: "failed",
message: "TimeoutError: page.click",
screenshot_path: "/tmp/secret.png",
updated_at_ms: 1,
});
expect(pub).toEqual({
status: "failed",
stage: null,
preferred_tier: null,
selected_total: null,
message: "官网同步失败,请稍后重试",
});
expect(pub).not.toHaveProperty("screenshot_path");
});
it("done / processing 使用固定中文文案", () => {
expect(
toFlockCheckoutPublic({
quote_id: "q1",
status: "done",
message: "raw rpa ok",
preferred_tier: "flock_direct",
selected_total: 2034,
updated_at_ms: 1,
}).message,
).toBe("已在官网填齐详情(未支付)");
expect(
toFlockCheckoutPublic({
quote_id: "q1",
status: "processing",
message: "internal",
updated_at_ms: 1,
}).message,
).toMatch(/正在官网/);
});
});

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import {
parseFlockHoldFlexibilityJson,
parseFlockQuoteHoldJson,
} from "@/lib/flock/flock-quote-hold-store";
describe("parseFlockQuoteHoldJson", () => {
it("合法 JSON 通过", () => {
const raw = JSON.stringify({
quote_id: "q1",
quote_session_id: "flock_q1",
customer_id: "c1",
parked_at_ms: 1,
decision_deadline_ms: 2,
total_deadline_ms: 3,
status: "awaiting_decision",
});
expect(parseFlockQuoteHoldJson(raw)?.quote_id).toBe("q1");
});
it("缺字段 / 非法 status 返回 null", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(parseFlockQuoteHoldJson("{}")).toBeNull();
expect(
parseFlockQuoteHoldJson(
JSON.stringify({
quote_id: "q1",
quote_session_id: "flock_q1",
customer_id: "c1",
parked_at_ms: 1,
decision_deadline_ms: 2,
total_deadline_ms: 3,
status: "hacked",
}),
),
).toBeNull();
expect(parseFlockQuoteHoldJson("not-json")).toBeNull();
spy.mockRestore();
});
});
describe("parseFlockHoldFlexibilityJson", () => {
it("合法 flex 通过;非法 key 拒绝", () => {
const ok = JSON.stringify({
flock_direct: [
{ key: "2_day", label: "2天", rateUsd: 100 },
],
});
expect(parseFlockHoldFlexibilityJson(ok)?.flock_direct).toHaveLength(1);
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
parseFlockHoldFlexibilityJson(
JSON.stringify({
flock_direct: [{ key: "bad", label: "x", rateUsd: 1 }],
}),
),
).toBeNull();
spy.mockRestore();
});
});

@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
computeFreightDensity,
densityPcfToFreightClass,
formatFreightClassDensityTip,
formatNmfcDensityClassChartZh,
freightClassDensityMismatchMessage,
NMFC_DENSITY_CLASS_BANDS,
NMFC_NON_DENSITY_FREIGHT_CLASSES,
} from "@/lib/flock/freight-class-density";
describe("freight-class-density(NMFTA 2025 十三档,对齐 Flock)", () => {
it("官网样例:6×48×40×48、2000lb → 320 ft³ / 6.25 PCF → Class 125", () => {
const d = computeFreightDensity({
lengthIn: 48,
widthIn: 40,
heightIn: 48,
quantity: 6,
totalWeightLb: 2000,
});
expect(d).not.toBeNull();
expect(d!.cubicFeet).toBeCloseTo(320, 2);
expect(d!.densityPcf).toBeCloseTo(6.25, 2);
expect(d!.suggestedClass).toBe("125");
expect(d!.rangeLabelZh).toBe("6 – <8");
expect(formatFreightClassDensityTip(d)).toContain("基于密度的货运等级:125");
});
it("官网样例:2×48×40×48、5000lb → ~46.88 PCF → Class 55", () => {
const d = computeFreightDensity({
lengthIn: 48,
widthIn: 40,
heightIn: 48,
quantity: 2,
totalWeightLb: 5000,
});
expect(d!.suggestedClass).toBe("55");
expect(d!.densityPcf).toBeCloseTo(46.875, 2);
});
it("十三档边界全部对齐 NMFTA「X but less than Y」", () => {
const cases: Array<[number, string]> = [
[0.5, "400"],
[0.999, "400"],
[1, "300"],
[1.999, "300"],
[2, "250"],
[3.999, "250"],
[4, "175"],
[5.999, "175"],
[6, "125"],
[7.999, "125"],
[8, "100"],
[9.999, "100"],
[10, "92.5"],
[11.999, "92.5"],
[12, "85"],
[14.999, "85"],
[15, "70"],
[22.499, "70"],
[22.5, "65"],
[29.999, "65"],
[30, "60"],
[34.999, "60"],
[35, "55"],
[49.999, "55"],
[50, "50"],
[100, "50"],
];
for (const [pcf, expected] of cases) {
expect(densityPcfToFreightClass(pcf), `pcf=${pcf}`).toBe(expected);
}
});
it("密度推算绝不会落到商品条件档 77.5/110/150/200/500", () => {
const densClasses = new Set(
NMFC_DENSITY_CLASS_BANDS.map((b) => b.freightClass),
);
for (const c of NMFC_NON_DENSITY_FREIGHT_CLASSES) {
expect(densClasses.has(c)).toBe(false);
}
// 扫一圈常见 PCF,结果必须在十三档内
for (let p = 0.1; p <= 60; p += 0.37) {
const c = densityPcfToFreightClass(p);
expect(c).not.toBeNull();
expect(densClasses.has(c!)).toBe(true);
expect(
(NMFC_NON_DENSITY_FREIGHT_CLASSES as readonly string[]).includes(c!),
).toBe(false);
}
});
it("体积↑或重量↓ → 等级数字↑(用户感知:参数越「稀」等级越高)", () => {
const base = {
lengthIn: 48,
widthIn: 40,
heightIn: 48,
quantity: 1,
totalWeightLb: 1000,
};
const dense = computeFreightDensity(base)!; // ~18.75 → 70
const bulkier = computeFreightDensity({ ...base, heightIn: 96 })!; // 体积翻倍
const lighter = computeFreightDensity({ ...base, totalWeightLb: 200 })!;
expect(Number(bulkier.suggestedClass)).toBeGreaterThan(
Number(dense.suggestedClass),
);
expect(Number(lighter.suggestedClass)).toBeGreaterThan(
Number(dense.suggestedClass),
);
});
it("分档表文案含全部十三档与非密度档说明", () => {
const chart = formatNmfcDensityClassChartZh();
for (const b of NMFC_DENSITY_CLASS_BANDS) {
expect(chart).toContain(`等级 ${b.freightClass}`);
}
expect(chart).toContain("77.5");
expect(formatFreightClassDensityTip(null)).toContain("总体积");
});
it("手选与推算不一致才提示", () => {
const d = computeFreightDensity({
lengthIn: 48,
widthIn: 40,
heightIn: 48,
quantity: 6,
totalWeightLb: 2000,
});
expect(freightClassDensityMismatchMessage("100", d)).toMatch(
/请选择货运等级 125/,
);
expect(freightClassDensityMismatchMessage("125", d)).toBeNull();
expect(freightClassDensityMismatchMessage("density", d)).toBeNull();
});
});

@ -7,6 +7,7 @@ import {
FLOCK_PICKUP_SERVICE_RPA_LABELS,
FLOCK_DELIVERY_SERVICE_RPA_LABELS,
FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED,
FLOCK_DELIVERY_SERVICE_WEIGHT_LOCKED,
} from "@/lib/flock/logged-in-rpa-options";
import { FLOCK_LOCATION_TYPES } from "@/components/flock/flock-logged-in-quote-form";
import { validateFlockQuoteInput, defaultFlockPickupDate } from "@/modules/flock/validation";
@ -37,8 +38,10 @@ describe("flock logged-in RPA options", () => {
expect(FLOCK_DELIVERY_SERVICE_RPA_LABELS.must_arrive_by[0]).toMatch(
/Must arrive by date/i,
);
expect(FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED.during_window).toBe(true);
expect(FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED.during_window).toBe(false);
expect(FLOCK_DELIVERY_SERVICE_WEIGHT_LOCKED.need_appointment).toBe(false);
expect(FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED.standard_fcfs).toBe(false);
expect(FLOCK_DELIVERY_SERVICE_WEIGHT_LOCKED.during_window).toBe(true);
});
});

@ -65,4 +65,14 @@ describe("flock rpa-progress", () => {
expect(flockStageIndex("queued")).toBeLessThan(flockStageIndex("login"));
expect(flockStageIndex("login")).toBeLessThan(flockStageIndex("fill_form"));
});
it("build 可带 elapsed_ms / stage_ms", () => {
const payload = buildFlockProgressPayload("wait_quote", {
elapsed_ms: 42_000,
stage_ms: 3_500,
});
const parsed = parseFlockProgress(payload);
expect(parsed?.elapsed_ms).toBe(42_000);
expect(parsed?.stage_ms).toBe(3_500);
});
});

@ -1,91 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildFlockSavedFreightPreset,
filterFlockSavedFreight,
flockSavedFreightStorageKey,
parseFlockSavedFreightList,
removeFlockSavedFreight,
upsertFlockSavedFreight,
type FlockSavedFreightPreset,
} from "@/lib/flock/saved-freight-presets";
const baseSource = {
description: "家电木托",
quantity: "4",
packagingType: "pallets_48x40",
lengthIn: "48",
widthIn: "40",
heightIn: "48",
totalWeightLb: "1200",
freightClass: "70",
stackable: true,
turnable: false,
};
function preset(partial: Partial<FlockSavedFreightPreset>): FlockSavedFreightPreset {
return {
id: "id-1",
name: "家电",
description: "家电木托",
quantity: "4",
packagingType: "pallets_48x40",
lengthIn: "48",
widthIn: "40",
heightIn: "48",
totalWeightLb: "1200",
freightClass: "70",
stackable: true,
turnable: false,
updatedAt: "2026-07-15T00:00:00.000Z",
...partial,
};
}
describe("saved-freight-presets", () => {
it("storage key 按客户隔离", () => {
expect(flockSavedFreightStorageKey("CUST_001")).toContain("CUST_001");
expect(flockSavedFreightStorageKey(" ")).toContain("anon");
});
it("build 用描述作默认名称", () => {
const p = buildFlockSavedFreightPreset(baseSource, "");
expect(p?.name).toBe("家电木托");
expect(p?.description).toBe("家电木托");
});
it("空描述且空名称拒绝", () => {
expect(
buildFlockSavedFreightPreset({ ...baseSource, description: "" }, " "),
).toBeNull();
});
it("同名覆盖且新条目置顶", () => {
const a = preset({ id: "a", name: "木托A" });
const b = preset({ id: "b", name: "木托B" });
const next = upsertFlockSavedFreight(
[a, b],
preset({ id: "c", name: "木托A", description: "新描述" }),
);
expect(next).toHaveLength(2);
expect(next[0]?.description).toBe("新描述");
expect(next[0]?.name).toBe("木托A");
});
it("按名称或描述过滤", () => {
const list = [
preset({ id: "1", name: "家电托", description: "电视" }),
preset({ id: "2", name: "建材", description: "瓷砖" }),
];
expect(filterFlockSavedFreight(list, "瓷")).toHaveLength(1);
expect(filterFlockSavedFreight(list, "家电")).toHaveLength(1);
});
it("删除与解析容错", () => {
const list = [preset({ id: "x" }), preset({ id: "y", name: "Y" })];
expect(removeFlockSavedFreight(list, "x")).toHaveLength(1);
expect(parseFlockSavedFreightList("not-json")).toEqual([]);
expect(parseFlockSavedFreightList('[{"id":"1","name":"n","description":"d"}]')).toHaveLength(
1,
);
});
});

@ -69,6 +69,80 @@ describe("mothership-logged-in-quote-body", () => {
).toBe(5);
});
it("混装:纸箱不计入 pallet_count(仅托盘行)", () => {
// 对齐用户样例:托盘 2+5+6+3=16,纸箱另计
expect(
resolveLoggedInPalletCount(
payload([
{
cargoType: "carton",
quantity: 4,
weightLb: 12,
lengthIn: 2,
widthIn: 2,
heightIn: 3,
},
{
cargoType: "pallet",
quantity: 2,
weightLb: 6,
lengthIn: 12,
widthIn: 12,
heightIn: 12,
},
{
cargoType: "pallet",
quantity: 5,
weightLb: 31,
lengthIn: 31,
widthIn: 31,
heightIn: 31,
},
{
cargoType: "carton",
quantity: 22,
weightLb: 24,
lengthIn: 23,
widthIn: 23,
heightIn: 23,
},
{
cargoType: "carton",
quantity: 33,
weightLb: 33,
lengthIn: 33,
widthIn: 33,
heightIn: 33,
},
{
cargoType: "pallet",
quantity: 6,
weightLb: 56,
lengthIn: 55,
widthIn: 55,
heightIn: 55,
},
{
cargoType: "pallet",
quantity: 3,
weightLb: 66,
lengthIn: 66,
widthIn: 66,
heightIn: 66,
},
{
cargoType: "carton",
quantity: 32,
weightLb: 69,
lengthIn: 21,
widthIn: 12,
heightIn: 55,
},
]),
),
).toBe(16);
});
it("询价体重取首行单件重量", () => {
const body = buildQuoteRequestBodyFromLoggedIn(
payload([
@ -138,7 +212,8 @@ describe("mothership-logged-in-quote-body", () => {
);
expect(body.cargo_lines).toHaveLength(2);
expect(body.cargo_lines?.[1]?.cargo_type).toBe("box");
expect(body.pallet_count).toBe(4);
// 仅合计托盘行:pallet×2,box 不计入
expect(body.pallet_count).toBe(2);
});
it("透传二级 details 字段", () => {
@ -166,6 +241,7 @@ describe("mothership-logged-in-quote-body", () => {
notes: "note",
opensAt: "8:00 AM",
closesAt: "5:00 PM",
accessorials: [],
},
delivery: {
companyName: "Delivery Co",
@ -178,6 +254,7 @@ describe("mothership-logged-in-quote-body", () => {
notes: "",
opensAt: "9:00 AM",
closesAt: "6:00 PM",
accessorials: [],
},
requestDeliveryAppointment: true,
fbaNumber: "FBA-123",

@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import {
buildQueryLogCargoCopy,
buildQueryLogDeliveryCopy,
buildQueryLogFullCopy,
buildQueryLogPickupCopy,
buildQueryLogQuotesCopy,
formatQueryLogQuoteLine,
preferredAdminAddress,
} from "@/lib/frontend/query-log-copy";
import { extractQuotedList } from "@/modules/quote/query-log";
const sample = {
quote_id: "QTE_001",
customer_id: "CUST_001",
pickup_selected: "50 South West Temple, Salt Lake City, UT 84101, USA",
pickup_customer: "50 S W Temple",
delivery_selected: "555 Market St, San Francisco, CA 94105, USA",
delivery_customer: "555 Market",
cargo_summary: "3 托 · 707.00 lb · 48×40×48 in · 普通货物",
quoted_carrier: "XPO",
quoted_total: 128.5,
quotes: [
{
carrier: "XPO",
service_level: "standard",
rate_option: "lowest",
final_total: 128.5,
},
{
carrier: "ABF",
service_level: "standard",
rate_option: "fastest",
final_total: 210,
},
],
};
describe("query-log-copy", () => {
it("优先使用联想确认地址", () => {
expect(preferredAdminAddress("联想地址", "客户地址")).toBe("联想地址");
expect(preferredAdminAddress("—", "客户地址")).toBe("客户地址");
expect(preferredAdminAddress("", "")).toBe("");
});
it("分别生成提货/派送/货物复制文本", () => {
expect(buildQueryLogPickupCopy(sample)).toBe(sample.pickup_selected);
expect(buildQueryLogDeliveryCopy(sample)).toBe(sample.delivery_selected);
expect(buildQueryLogCargoCopy(sample)).toBe(sample.cargo_summary);
});
it("整单复制包含地址与货物参数", () => {
const text = buildQueryLogFullCopy(sample);
expect(text).toContain("报价单号:QTE_001");
expect(text).toContain(`提货地址:${sample.pickup_selected}`);
expect(text).toContain(`派送地址:${sample.delivery_selected}`);
expect(text).toContain(`货物参数:${sample.cargo_summary}`);
expect(text).toContain("报价列表(2 档)");
expect(text).toContain("XPO");
expect(text).toContain("ABF");
expect(text).toContain("USD 128.50");
expect(text).toContain("USD 210.00");
});
it("报价行包含承运商、档位与金额", () => {
expect(formatQueryLogQuoteLine(sample.quotes[0]!)).toMatch(
/XPO · 标准\/最低价格 · USD 128.50/,
);
expect(buildQueryLogQuotesCopy(sample.quotes).split("\n")).toHaveLength(2);
});
});
describe("extractQuotedList", () => {
it("返回全部承运商档位,不只第一档", () => {
const list = extractQuotedList([
{
service_level: "standard",
rate_option: "lowest",
carrier: "XPO",
final_total: 128.5,
},
{
service_level: "standard",
rate_option: "fastest",
carrier: "ABF",
final_total: 210,
},
]);
expect(list).toHaveLength(2);
expect(list.map((q) => q.carrier).sort()).toEqual(["ABF", "XPO"]);
});
});

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
scrubProviderBrandForCustomer,
scrubProviderCarrierLabel,
} from "@/lib/frontend/scrub-provider-brand";
describe("scrubProviderBrandForCustomer", () => {
it("替换尺寸超限文案中的品牌", () => {
expect(scrubProviderBrandForCustomer("尺寸超出 Mothership 允许范围")).toBe(
"尺寸超出 承运商 允许范围",
);
expect(
scrubProviderBrandForCustomer("MotherShip 要求整数:已向上取整"),
).toBe("承运商 要求整数:已向上取整");
});
it("承运商名整段命中时改为平台承运", () => {
expect(scrubProviderCarrierLabel("MotherShip")).toBe("平台承运");
expect(scrubProviderCarrierLabel("Mothership")).toBe("平台承运");
expect(scrubProviderCarrierLabel("TForce Direct")).toBe("TForce Direct");
});
});

@ -0,0 +1,362 @@
import { describe, expect, it } from "vitest";
import {
buildAddressQuery,
buildDashboardCargo,
buildDashboardLocation,
buildPickupDateIso,
decodeMsCarrierLabel,
isMsDashboardDirectQuoteEnabled,
mapDashboardQuoteBodyToItems,
mapMsDashboardCargoType,
parseMsReadyTimeToHm,
zonedWallTimeToUtcIso,
} from "@/lib/mothership/dashboard-direct-quote";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
describe("dashboard-direct-quote", () => {
it("decodeMsCarrierLabel 解码 base64 并附加 Direct", () => {
const label = decodeMsCarrierLabel({
encodedCarrierName: Buffer.from("ABF Freight", "utf8").toString("base64"),
serviceLaneType: "direct",
carrierScac: "abfs",
});
expect(label).toBe("ABF Freight Direct");
});
it("mapDashboardQuoteBodyToItems 从 availableRates 产出多承运商", () => {
const items = mapDashboardQuoteBodyToItems({
rates: {
standard: {
bestValue: {
id: "rate_a",
finalPrice: 100,
days: 3,
serviceLevel: "standard",
serviceType: "bestValue",
carrierScac: "xpol",
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
serviceLaneType: "direct",
},
},
},
availableRates: {
rate_a: {
id: "rate_a",
finalPrice: 100,
days: 3,
serviceLevel: "standard",
serviceType: "bestValue",
carrierScac: "xpol",
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
serviceLaneType: "direct",
},
rate_b: {
id: "rate_b",
finalPrice: 200,
days: 2,
serviceLevel: "standard",
serviceType: "customRate",
carrierScac: "abfs",
encodedCarrierName: Buffer.from("ABF", "utf8").toString("base64"),
serviceLaneType: "direct",
},
},
});
expect(items.length).toBeGreaterThanOrEqual(2);
expect(items.some((i) => /XPO/i.test(i.carrier))).toBe(true);
expect(items.some((i) => /ABF/i.test(i.carrier))).toBe(true);
expect(items.every((i) => i.rawTotal > 0)).toBe(true);
});
it("buildDashboardLocation 写入二级联系人与营业时间", () => {
const loc = buildDashboardLocation(
{
placeId: "p1",
city: "Minneapolis",
state: "MN",
zip: "55402",
street: "800 Nicollet Mall",
timezone: "America/Chicago",
zone: null,
coordinates: { latitude: 1, longitude: 2 },
neighborhood: "",
},
["liftgate"],
{
company_name: "Acme Co.",
suite: "Ste 301",
contact_first: "Ops",
contact_last: "Lead",
contact_email: "ops@example.com",
contact_phone: "5550101",
opens_at: "8:00 AM",
closes_at: "5:00 PM",
},
);
expect(loc.accessorials).toEqual(["liftgate"]);
expect(loc.name).toBe("Acme Co.");
expect(loc.subStreet).toBe("Ste 301");
expect(loc.serviceStartTime).toBe("8:00 AM");
expect(loc.serviceEndTime).toBe("5:00 PM");
expect(loc.email).toBe("ops@example.com");
});
it("buildAddressQuery 优先结构化地址", () => {
const q = buildAddressQuery({
street: "1000 S Alameda St",
city: "Los Angeles",
state: "CA",
zip: "90021",
placeId: "x",
formattedAddress: "ignored",
selectedFromSuggestions: true,
mothershipOptionId: "x",
mothershipDisplayLabel: "ignored",
selectedFromMothership: true,
});
expect(q).toContain("1000 S Alameda St");
expect(q).toContain("Los Angeles");
});
it("buildDashboardCargo 支持多行并向上取整", () => {
const req = {
cargoHash: "h",
pickup: {} as QuoteRequest["pickup"],
delivery: {} as QuoteRequest["delivery"],
weightLb: 10.2,
dimsIn: { l: 48.1, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
cargoLines: [
{
cargoType: "pallet",
quantity: 2,
weightLb: 10.2,
lengthIn: 48.1,
widthIn: 40,
heightIn: 48,
},
],
} as QuoteRequest;
const cargo = buildDashboardCargo(req);
expect(cargo).toHaveLength(1);
expect(cargo[0]!.weight).toBe(11);
expect(cargo[0]!.length).toBe(49);
expect(cargo[0]!.quantity).toBe(2);
expect(cargo[0]!.type).toBe("Pallet");
});
it("mapMsDashboardCargoType 与官网货型对齐,禁止非托盘打成 Pallet", () => {
expect(mapMsDashboardCargoType("case")).toBe("Case");
expect(mapMsDashboardCargoType("drum")).toBe("Drum");
expect(mapMsDashboardCargoType("piece")).toBe("Pieces");
expect(mapMsDashboardCargoType("tote")).toBe("Tote");
expect(mapMsDashboardCargoType("roll")).toBe("Roll");
expect(mapMsDashboardCargoType("skid")).toBe("Skid");
expect(mapMsDashboardCargoType("box")).toBe("Box");
expect(mapMsDashboardCargoType("carton")).toBe("Carton");
expect(mapMsDashboardCargoType("crate")).toBe("Crate");
expect(mapMsDashboardCargoType("pallet")).toBe("Pallet");
expect(mapMsDashboardCargoType("general_freight")).toBe("Pallet");
});
it("buildDashboardCargo 多货型逐行保留官网 type", () => {
const req = {
cargoHash: "h",
pickup: {} as QuoteRequest["pickup"],
delivery: {} as QuoteRequest["delivery"],
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 1,
cargoType: "general_freight",
cargoLines: [
{
cargoType: "pallet",
quantity: 2,
weightLb: 425,
lengthIn: 42,
widthIn: 40,
heightIn: 38,
},
{
cargoType: "drum",
quantity: 8,
weightLb: 329,
lengthIn: 26,
widthIn: 28,
heightIn: 19,
},
{
cargoType: "carton",
quantity: 3,
weightLb: 166,
lengthIn: 28,
widthIn: 19,
heightIn: 29,
},
],
} as QuoteRequest;
const cargo = buildDashboardCargo(req);
expect(cargo.map((c) => c.type)).toEqual(["Pallet", "Drum", "Carton"]);
});
it("buildDashboardCargo piece 行使用官网 API 值 Pieces", () => {
const req = {
cargoHash: "h",
pickup: {} as QuoteRequest["pickup"],
delivery: {} as QuoteRequest["delivery"],
weightLb: 255,
dimsIn: { l: 17, w: 26, h: 24 },
palletCount: 2,
cargoType: "general_freight",
cargoLines: [
{
cargoType: "pallet",
quantity: 2,
weightLb: 735,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
},
{
cargoType: "piece",
quantity: 12,
weightLb: 255,
lengthIn: 17,
widthIn: 26,
heightIn: 24,
},
],
} as QuoteRequest;
const cargo = buildDashboardCargo(req);
expect(cargo.map((c) => c.type)).toEqual(["Pallet", "Pieces"]);
expect(Array.isArray(cargo[1]?.commodities)).toBe(true);
});
it("mapDashboardQuoteBodyToItems 保留同承运商不同价", () => {
const items = mapDashboardQuoteBodyToItems({
availableRates: {
a: {
id: "a",
finalPrice: 100,
days: 5,
serviceLevel: "standard",
serviceType: "customRate",
encodedCarrierName: Buffer.from("ABF Freight", "utf8").toString(
"base64",
),
serviceLaneType: "direct",
},
b: {
id: "b",
finalPrice: 130,
days: 3,
serviceLevel: "standard",
serviceType: "customRate",
encodedCarrierName: Buffer.from("ABF Freight", "utf8").toString(
"base64",
),
serviceLaneType: "direct",
},
c: {
id: "c",
finalPrice: 90,
days: 6,
serviceLevel: "standard",
serviceType: "customRate",
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
serviceLaneType: "direct",
},
},
});
expect(items.length).toBe(3);
expect(items.filter((i) => /ABF/i.test(i.carrier))).toHaveLength(2);
expect(items.filter((i) => /XPO/i.test(i.carrier))).toHaveLength(1);
});
it("mapDashboardQuoteBodyToItems 优先 ratesV2 全量价卡", () => {
const items = mapDashboardQuoteBodyToItems({
ratesV2: [
{
id: "v2_a",
finalPrice: 90,
days: 4,
serviceLevel: "standard",
serviceType: "customRate",
encodedCarrierName: Buffer.from("TForce", "utf8").toString("base64"),
serviceLaneType: "direct",
},
{
id: "v2_b",
finalPrice: 110,
days: 3,
serviceLevel: "standard",
serviceType: "customRate",
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
serviceLaneType: "direct",
},
],
availableRates: {
only_ar: {
id: "only_ar",
finalPrice: 999,
days: 9,
serviceLevel: "standard",
serviceType: "customRate",
encodedCarrierName: Buffer.from("Echo", "utf8").toString("base64"),
serviceLaneType: "direct",
},
},
});
expect(items).toHaveLength(2);
expect(items.some((i) => /Echo/i.test(i.carrier))).toBe(false);
expect(items.some((i) => /TForce/i.test(i.carrier))).toBe(true);
});
it("buildPickupDateIso 使用提货时区 + 就绪时刻", () => {
expect(parseMsReadyTimeToHm("8:00 AM")).toEqual({ h: 8, m: 0 });
expect(parseMsReadyTimeToHm("12:00 PM")).toEqual({ h: 12, m: 0 });
expect(parseMsReadyTimeToHm("4:00 PM")).toEqual({ h: 16, m: 0 });
const iso = zonedWallTimeToUtcIso(
"2026-08-12",
8,
0,
"America/Los_Angeles",
);
const asLa = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Los_Angeles",
hour: "numeric",
hourCycle: "h23",
day: "2-digit",
}).formatToParts(new Date(iso));
expect(Number(asLa.find((p) => p.type === "hour")?.value)).toBe(8);
const req = {
cargoHash: "h",
pickup: {} as QuoteRequest["pickup"],
delivery: {} as QuoteRequest["delivery"],
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 1,
cargoType: "pallet",
readyDate: "2026-08-12",
readyTime: "4:00 PM",
} as QuoteRequest;
const pickupIso = buildPickupDateIso(req, "America/Los_Angeles");
const hourLa = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Los_Angeles",
hour: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(pickupIso));
expect(Number(hourLa.find((p) => p.type === "hour")?.value)).toBe(16);
});
it("isMsDashboardDirectQuoteEnabled 默认开启", () => {
const prev = process.env.MS_DASHBOARD_DIRECT_QUOTE;
delete process.env.MS_DASHBOARD_DIRECT_QUOTE;
expect(isMsDashboardDirectQuoteEnabled()).toBe(true);
process.env.MS_DASHBOARD_DIRECT_QUOTE = "false";
expect(isMsDashboardDirectQuoteEnabled()).toBe(false);
if (prev === undefined) delete process.env.MS_DASHBOARD_DIRECT_QUOTE;
else process.env.MS_DASHBOARD_DIRECT_QUOTE = prev;
});
});

@ -1,12 +1,32 @@
import { describe, expect, it } from "vitest";
import {
findMothershipAvgWeightBlockMessage,
findMothershipCargoWeightBlockMessage,
findMothershipTotalWeightBlockMessage,
isMothershipAverageWeightEachAllowed,
isMothershipTotalWeightAllowed,
isMothershipWeekendIso,
isMothershipWeightEachAllowed,
MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE,
MOTHERSHIP_MAX_TOTAL_WEIGHT_LB,
MOTHERSHIP_TOTAL_WEIGHT_BLOCK_MESSAGE,
normalizeMothershipReadyDateIso,
resolveMothershipAverageWeightEachLb,
snapMothershipReadyDateToWeekday,
sumMothershipTotalWeightLb,
ceilMothershipNumeric,
hasMothershipFractionalPart,
} from "@/lib/mothership/logged-in-constraints";
describe("mothership logged-in constraints", () => {
it("小数向上取整(进一)", () => {
expect(ceilMothershipNumeric(25.68)).toBe(26);
expect(ceilMothershipNumeric(25)).toBe(25);
expect(ceilMothershipNumeric(40.01)).toBe(41);
expect(hasMothershipFractionalPart(25.68)).toBe(true);
expect(hasMothershipFractionalPart(26)).toBe(false);
});
it("识别周末", () => {
expect(isMothershipWeekendIso("2026-07-18")).toBe(true); // Sat
expect(isMothershipWeekendIso("2026-07-19")).toBe(true); // Sun
@ -19,10 +39,50 @@ describe("mothership logged-in constraints", () => {
expect(snapMothershipReadyDateToWeekday("2026-07-20")).toBe("2026-07-20");
});
it("单件重量 ≤5000", () => {
expect(isMothershipWeightEachAllowed(5000)).toBe(true);
expect(isMothershipWeightEachAllowed(5000.01)).toBe(false);
it("单件平均重量 ≤5000 可询价,超过则不可", () => {
expect(isMothershipAverageWeightEachAllowed(5000)).toBe(true);
expect(isMothershipAverageWeightEachAllowed(5000.01)).toBe(false);
expect(isMothershipWeightEachAllowed(50)).toBe(true);
expect(resolveMothershipAverageWeightEachLb(4800, 10)).toBe(4800);
});
it("整票总重 ≤45000 可询价", () => {
expect(isMothershipTotalWeightAllowed(45000)).toBe(true);
expect(isMothershipTotalWeightAllowed(45001)).toBe(false);
expect(sumMothershipTotalWeightLb([{ weightLb: 1000, quantity: 45 }])).toBe(
45000,
);
expect(
findMothershipTotalWeightBlockMessage([
{ weightLb: 1000, quantity: 46 },
]),
).toBe(MOTHERSHIP_TOTAL_WEIGHT_BLOCK_MESSAGE);
});
it("多行任一超均重则阻断", () => {
expect(
findMothershipAvgWeightBlockMessage([
{ weightLb: 400, quantity: 30 },
{ weightLb: 5001, quantity: 1 },
]),
).toBe(MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE);
expect(
findMothershipAvgWeightBlockMessage([{ weightLb: 400, quantity: 40 }]),
).toBeNull();
});
it("均重与总重合并校验", () => {
expect(
findMothershipCargoWeightBlockMessage([
{ weightLb: 1200, quantity: 37 },
]),
).toBeNull();
expect(
findMothershipCargoWeightBlockMessage([
{ weightLb: 1200, quantity: 38 },
]),
).toBe(MOTHERSHIP_TOTAL_WEIGHT_BLOCK_MESSAGE);
expect(MOTHERSHIP_MAX_TOTAL_WEIGHT_LB).toBe(45000);
});
it("normalize 周末日期", () => {

@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
parseCargoValueUsd,
sanitizeCargoValueUsdInput,
validateMsCheckoutSelection,
} from "@/lib/mothership/ms-checkout-selection";
import { pickLoggedInRateCardIndex } from "@/workers/rpa/mothership-logged-in-quote";
describe("ms checkout selection", () => {
it("指定承运商优先", () => {
const items = [
{ carrier: "TForce Direct", rawTotal: 500 },
{ carrier: "ABF Direct", rawTotal: 600 },
{ carrier: "Roadrunner Interline", rawTotal: 416.89 },
];
expect(pickLoggedInRateCardIndex(items, "Roadrunner")).toBe(2);
expect(pickLoggedInRateCardIndex(items, "ABF")).toBe(1);
expect(pickLoggedInRateCardIndex(items)).toBe(2); // 最低价
});
it("basic 无需货值", () => {
expect(
validateMsCheckoutSelection({
preferredCarrier: "ABF Direct",
coverage: "basic",
}),
).toBeNull();
});
it("freight_protect 缺货值 / 非法货值", () => {
expect(
validateMsCheckoutSelection({
preferredCarrier: "ABF",
coverage: "freight_protect",
}),
).toMatch(/货物价值/);
expect(
validateMsCheckoutSelection({
preferredCarrier: "ABF",
coverage: "freight_protect",
cargoValueUsd: 0,
}),
).toMatch(/货物价值/);
});
it("freight_protect 合法货值通过", () => {
expect(
validateMsCheckoutSelection({
preferredCarrier: "Roadrunner Interline",
coverage: "freight_protect",
cargoValueUsd: 5000,
}),
).toBeNull();
});
it("parseCargoValueUsd 兼容 $ 与逗号,并保留小数", () => {
expect(parseCargoValueUsd("$5,000")).toBe(5000);
expect(parseCargoValueUsd("300.55")).toBe(300.55);
expect(parseCargoValueUsd("300.556")).toBe(300.56);
expect(parseCargoValueUsd(" ")).toBeNull();
expect(parseCargoValueUsd("-1")).toBeNull();
});
it("sanitizeCargoValueUsdInput 允许输入小数", () => {
expect(sanitizeCargoValueUsdInput("300.")).toBe("300.");
expect(sanitizeCargoValueUsdInput("300.5")).toBe("300.5");
expect(sanitizeCargoValueUsdInput("300.555")).toBe("300.55");
expect(sanitizeCargoValueUsdInput("$1,200.3")).toBe("1200.3");
});
it("freight_protect 小数货值通过", () => {
expect(
validateMsCheckoutSelection({
preferredCarrier: "ABF",
coverage: "freight_protect",
cargoValueUsd: 300.55,
}),
).toBeNull();
});
it("未选承运商", () => {
expect(
validateMsCheckoutSelection({
preferredCarrier: " ",
coverage: "basic",
}),
).toMatch(/承运商/);
});
it("承运商过长 / 货值超上限", () => {
expect(
validateMsCheckoutSelection({
preferredCarrier: "x".repeat(129),
coverage: "basic",
}),
).toMatch(/过长/);
expect(
validateMsCheckoutSelection({
preferredCarrier: "ABF",
coverage: "freight_protect",
cargoValueUsd: 1_000_001,
}),
).toMatch(/超出允许范围/);
});
});

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { toMsCheckoutPublic } from "@/lib/mothership/ms-checkout-store";
describe("toMsCheckoutPublic", () => {
it("不暴露 screenshot_path,失败文案脱敏", () => {
const pub = toMsCheckoutPublic({
quote_id: "q1",
status: "failed",
message: "TimeoutError: page.click",
screenshot_path: "/tmp/secret.png",
updated_at_ms: 1,
});
expect(pub).toEqual({
status: "failed",
stage: null,
selected_carrier: null,
selected_total: null,
coverage: null,
cargo_value_usd: null,
message: "官网同步失败,请稍后重试",
});
expect(pub).not.toHaveProperty("screenshot_path");
});
it("done / processing 使用固定中文文案", () => {
expect(
toMsCheckoutPublic({
quote_id: "q1",
status: "done",
message: "raw rpa ok",
updated_at_ms: 1,
}).message,
).toBe("已在官网填齐详情(未支付)");
expect(
toMsCheckoutPublic({
quote_id: "q1",
status: "processing",
message: "internal",
updated_at_ms: 1,
}).message,
).toMatch(/正在官网/);
});
});

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
MS_DETAILS_FIELD_TIPS,
MS_DETAILS_FIELD_TIPS_EN,
} from "@/lib/mothership/ms-details-field-tips";
import { MS_PIECE_COUNT_TYPES } from "@/components/mothership/mothership-logged-in-shipment-form";
describe("ms-details-field-tips", () => {
it("中文 tip 覆盖二级 ⓘ 字段且无官网品牌词", () => {
expect(MS_DETAILS_FIELD_TIPS.cargoType).toMatch(/件(Piece)/);
expect(MS_DETAILS_FIELD_TIPS.pieceCountType).toMatch(/包装类型/);
expect(MS_DETAILS_FIELD_TIPS.nmfc).toMatch(/改费|调整运费/);
expect(MS_DETAILS_FIELD_TIPS.freightClass).toMatch(/运费等级/);
expect(MS_DETAILS_FIELD_TIPS.reference).toMatch(/采购单|参考号/);
expect(MS_DETAILS_FIELD_TIPS.accessorials).toMatch(/附加费/);
expect(MS_DETAILS_FIELD_TIPS.freightReady).toMatch(/周六|周日/);
for (const zh of Object.values(MS_DETAILS_FIELD_TIPS)) {
expect(zh).not.toMatch(/MotherShip|Mothership/i);
}
});
it("英文原文与官网截图一致(货物类型 / 件数类型 / NMFC)", () => {
expect(MS_DETAILS_FIELD_TIPS_EN.cargoType).toBe(
"Select how you are shipping your cargo. Loose item has been renamed to Piece.",
);
expect(MS_DETAILS_FIELD_TIPS_EN.pieceCountType).toBe(
"Select the packaging type of your cargo contents. This is usually different than your cargo type.",
);
expect(MS_DETAILS_FIELD_TIPS_EN.nmfc).toBe(
"Add this code to mitigate potential carrier adjustment charges.",
);
expect(MS_DETAILS_FIELD_TIPS_EN.accessorials).toBe(
"Select services to avoid accessorial fees.",
);
});
it("件数类型选项十余项且含 Pieces/Cartons", () => {
expect(MS_PIECE_COUNT_TYPES.length).toBeGreaterThanOrEqual(15);
expect(MS_PIECE_COUNT_TYPES.map((t) => t.id)).toEqual(
expect.arrayContaining(["Pieces", "Cartons", "Units", "Boxes", "Drums"]),
);
});
});

@ -6,15 +6,15 @@ import {
} from "@/lib/mothership/option-compat";
describe("toggleMothershipAccessorial", () => {
it("禁止提货勾选住宅", () => {
it("允许提货勾选住宅,但提示可能无报价", () => {
const r = toggleMothershipAccessorial({
side: "pickup",
selected: ["liftgate"],
id: "residential",
});
expect(r.applied).toBe(false);
expect(r.next).toEqual(["liftgate"]);
expect(r.message).toMatch(/不支持住宅/);
expect(r.applied).toBe(true);
expect(r.next).toEqual(["liftgate", "residential"]);
expect(r.message).toMatch(/可能得不到报价/);
});
it("预约与 Amazon 预约互斥:勾选后者去掉前者", () => {
@ -40,12 +40,13 @@ describe("toggleMothershipAccessorial", () => {
});
describe("evaluateMothershipAccessorialCompat", () => {
it("提货已含住宅 → block", () => {
it("提货已含住宅 → warn", () => {
const issues = evaluateMothershipAccessorialCompat({
side: "pickup",
selected: ["residential"],
});
expect(mothershipAccessorialHasBlock(issues)).toBe(true);
expect(mothershipAccessorialHasBlock(issues)).toBe(false);
expect(issues.some((i) => i.code === "ms_pickup_residential")).toBe(true);
});
it("住宅派送无尾板 → warn", () => {

@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import {
extractMothershipPortalMessagesFromAlerts,
extractMothershipPortalQuoteMessages,
formatMothershipPortalQuoteMessage,
isPortalHardBusinessBlock,
isPortalNeedsDetailsMessage,
looksLikePortalBusinessAlert,
translatePortalMessageToZh,
} from "@/lib/mothership/portal-quote-messages";
describe("portal-quote-messages", () => {
@ -14,6 +19,41 @@ describe("portal-quote-messages", () => {
);
});
it("必填提示判为 NEEDS_DETAILS,不按硬拒价", () => {
const zh = translatePortalMessageToZh(
"No rates found\nPlease review all required fields to proceed",
);
expect(isPortalNeedsDetailsMessage(zh)).toBe(true);
expect(isPortalHardBusinessBlock(zh)).toBe(false);
expect(isPortalNeedsDetailsMessage("Please review all required fields")).toBe(
true,
);
});
it("lane 无价 toast 判为硬拒价", () => {
const zh = translatePortalMessageToZh(
"Unfortunately we were unable to find any rates for this lane and freight details.",
);
expect(isPortalHardBusinessBlock(zh)).toBe(true);
expect(isPortalNeedsDetailsMessage(zh)).toBe(false);
});
it("同时出现 lane 无价与 required fields → 硬拒,禁止引导补二级", () => {
const body =
"No rates found\nPlease review all required fields to proceed.\nUnfortunately we were unable to find any rates for this lane and freight details. Please check your shipment details and try again.";
const msgs = extractMothershipPortalQuoteMessages(body);
expect(
msgs.some((m) => /unable to find any rates for this lane/i.test(m)),
).toBe(true);
expect(
msgs.some((m) => /please review all required fields/i.test(m)),
).toBe(false);
const zh = formatMothershipPortalQuoteMessage(msgs)!;
expect(zh).toMatch(/该线路与货物|暂无可用报价/);
expect(isPortalHardBusinessBlock(zh)).toBe(true);
expect(isPortalNeedsDetailsMessage(zh)).toBe(false);
});
it("提取附加服务/住宅取件阻断(中文)", () => {
const body =
"不支持附加服务,我们的承运合作伙伴不提供住宅地址上门取件服务";
@ -26,10 +66,38 @@ describe("portal-quote-messages", () => {
"Unsupported accessorial. Our carrier partners do not provide residential pickup at this location.";
const msgs = extractMothershipPortalQuoteMessages(body);
expect(
msgs.some((m) =>
/carrier partners? do not provide/i.test(m),
),
msgs.some((m) => /carrier partners? do not provide/i.test(m)),
).toBe(true);
});
it("提取线路红色 toast:unable to find any rates for this lane", () => {
const body =
"No rates found\nPlease review all required fields to proceed.\nUnfortunately we were unable to find any rates for this lane and freight details. Please check your shipment details and try again.";
const msgs = extractMothershipPortalQuoteMessages(body);
expect(
msgs.some((m) => /unable to find any rates for this lane/i.test(m)),
).toBe(true);
const zh = formatMothershipPortalQuoteMessage(msgs)!;
expect(zh).toMatch(/该线路与货物|暂无可用报价/);
// 不得再附带「请填必填」以免前端走 NEEDS_DETAILS
expect(zh).not.toMatch(/必填/);
});
it("提取线路/地址类红色报错", () => {
const body =
"Unable to obtain a quote for this lane. Address is outside our service area.";
const msgs = extractMothershipPortalQuoteMessages(body);
expect(msgs.length).toBeGreaterThan(0);
const zh = formatMothershipPortalQuoteMessage(msgs)!;
expect(zh).toMatch(/报价|服务范围/);
});
it("Freight ready before operation hours → 中文", () => {
expect(
translatePortalMessageToZh(
"Freight ready time is before pickup location operation hours",
),
).toMatch(/就绪时刻不能早于提货点开门时间/);
});
it("format 将英文官网提示译为中文", () => {
@ -46,4 +114,22 @@ describe("portal-quote-messages", () => {
]);
expect(out).toContain("不支持附加服务");
});
it("未知英文仍带回承运商提示前缀", () => {
const zh = translatePortalMessageToZh(
"Something unexpected blocked quoting XYZ",
);
expect(zh.startsWith("承运商提示:")).toBe(true);
expect(zh).toContain("Something unexpected");
});
it("DOM alert 片段可提取业务红字", () => {
const msgs = extractMothershipPortalMessagesFromAlerts([
"Got it",
"该线路暂无可用运力,请更换地址后重试",
]);
expect(msgs.some((m) => m.includes("该线路"))).toBe(true);
expect(looksLikePortalBusinessAlert("该线路暂无可用运力")).toBe(true);
expect(looksLikePortalBusinessAlert("Got it")).toBe(false);
});
});

@ -10,7 +10,7 @@ describe("direct-quote-fallback", () => {
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "");
const err = new RpaError(
"CARRIER_NO_CAPACITY",
"MotherShip 该线路暂无可用报价,请调整地址或货物后重试",
"该线路暂无可用报价,请调整地址或货物后重试",
{ retryable: false },
);
expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(true);

@ -58,6 +58,21 @@ describe("alert-presentation", () => {
expect(cause).not.toMatch(/locator\.waitFor|getByTestId|Call log/i);
});
it("报价单已入库的笼统超时文案在预警详情纠正", () => {
const cause = resolveAlertRootCause(
"RPA_FAILED",
{ message: "ignored" },
{
errorMessage:
"自动化操作超时(等待约 5 秒):未能等到「创建货件/报价表单控件」出现或变为可见。可能原因:官网页面结构变更、附加服务选项未展开、网络过慢或登录态异常。",
errorCode: "PAGE_LOAD_TIMEOUT",
} as never,
);
expect(cause).not.toContain("创建货件/报价表单控件");
expect(cause).not.toMatch(/附加服务选项未展开/);
expect(cause).toMatch(/Inbox|地址|继续|创建货件/);
});
it("报价单 errorMessage 中的英文 Playwright 日志同样中文化", () => {
const cause = resolveAlertRootCause(
"RPA_FAILED",

@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { findFirst } = vi.hoisted(() => ({
findFirst: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
businessCustomerUser: {
findFirst,
findMany: vi.fn(),
},
},
}));
import {
resolveBusinessCustomerByAccount,
resolveBusinessCustomerIdForQuote,
} from "@/modules/customer/business-customer-user-service";
import { ValidationError } from "@/modules/quote/types";
describe("business-customer-user-service", () => {
beforeEach(() => {
findFirst.mockReset();
});
it("按账号命中业务客户", async () => {
findFirst.mockResolvedValue({
customerId: "CUST_004",
account: "demo_user",
businessCustomer: {
businessCustomerId: "bc-guid-1",
externalCode: "76642888",
name: "测试客户",
},
});
const hit = await resolveBusinessCustomerByAccount("CUST_004", "demo_user");
expect(hit?.business_customer_id).toBe("bc-guid-1");
expect(hit?.external_code).toBe("76642888");
});
it("询价:已有 BC 不覆盖", async () => {
const id = await resolveBusinessCustomerIdForQuote({
customerId: "CUST_004",
businessCustomerId: "bc-explicit",
businessUserAccount: "any",
});
expect(id).toBe("bc-explicit");
expect(findFirst).not.toHaveBeenCalled();
});
it("询价:无 BC + 账号未命中 → ValidationError", async () => {
findFirst.mockResolvedValue(null);
await expect(
resolveBusinessCustomerIdForQuote({
customerId: "CUST_004",
businessUserAccount: "missing_user",
}),
).rejects.toBeInstanceOf(ValidationError);
});
it("询价:无 BC + 账号命中 → 回填 BC", async () => {
findFirst.mockResolvedValue({
customerId: "CUST_004",
account: "demo_user",
businessCustomer: {
businessCustomerId: "bc-guid-2",
externalCode: "CM072",
name: "OpenTrucking Inc",
},
});
const id = await resolveBusinessCustomerIdForQuote({
customerId: "CUST_004",
businessUserAccount: "demo_user",
});
expect(id).toBe("bc-guid-2");
});
});

@ -0,0 +1,287 @@
/**
* Flock 登录态 hold + 选档结账:用户旅程防护清单(QA)
* 覆盖:超时提醒、必填缺失、未选档、未解锁二级、非法输入
*/
import { describe, expect, it } from "vitest";
import {
FLOCK_HOLD_DECISION_MS,
FLOCK_HOLD_MSG,
FLOCK_HOLD_TOTAL_MS,
buildFlockQuoteHoldState,
formatFlockHoldRemainingLabel,
isFlockQuoteHoldExpired,
shouldAutoDeclineFlockHold,
toFlockQuoteHoldPublic,
} from "@/lib/constants/flock-quote-hold";
import { validateFlockCheckoutSelection } from "@/lib/flock/flock-checkout-selection";
import {
emptyFlockCheckoutDetailsDraft,
flockDetailsHasErrors,
validateFlockCheckoutDetailsDraft,
} from "@/lib/flock/flock-checkout-details-rules";
import { flockCheckoutSchema } from "@/modules/flock/checkout-validation";
import { flockHoldContinueSchema } from "@/modules/flock/hold-validation";
/** 模拟 UI 结账前置门禁(与 widget 文案对齐) */
function gateCheckoutUi(input: {
holdAccepted: boolean;
selectedTier: "flock_direct" | "standard" | null;
selectedFlexibility: "2_day" | "1_day" | "none" | null;
detailsOk: boolean;
}): string | null {
if (!input.holdAccepted) {
return "请先在确认窗口选择「继续填写」,再完善二级信息并下单";
}
if (!input.selectedTier) {
return "请先选择服务档与灵活性报价";
}
if (!input.selectedFlexibility) {
return "请先选择服务档与灵活性报价";
}
if (!input.detailsOk) {
return "请先完善左侧结账详情";
}
return null;
}
describe("用户旅程 · 60s / 5min 超时提醒", () => {
it("决策窗内:提示剩余秒数;到期 auto-decline", () => {
const now = 10_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: "flock_q1",
customerId: "C1",
nowMs: now,
});
expect(formatFlockHoldRemainingLabel(s.decision_deadline_ms, now)).toBe(
"1分00秒",
);
expect(shouldAutoDeclineFlockHold(s, now + FLOCK_HOLD_DECISION_MS - 1)).toBe(
false,
);
expect(shouldAutoDeclineFlockHold(s, now + FLOCK_HOLD_DECISION_MS)).toBe(
true,
);
expect(FLOCK_HOLD_MSG.decisionTimeout).toMatch(/初步报价/);
expect(FLOCK_HOLD_MSG.decisionTimeoutApi).toMatch(/重新询价/);
});
it("继续填写后:决策窗到期不再 auto-decline;5min 仍硬停", () => {
const now = 1_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: "flock_q1",
customerId: "C1",
nowMs: now,
});
const filling = { ...s, status: "filling" as const };
expect(
shouldAutoDeclineFlockHold(filling, now + FLOCK_HOLD_DECISION_MS + 10_000),
).toBe(false);
expect(toFlockQuoteHoldPublic(filling, now + 90_000).available).toBe(true);
expect(isFlockQuoteHoldExpired(filling, now + FLOCK_HOLD_TOTAL_MS)).toBe(
true,
);
expect(FLOCK_HOLD_MSG.totalTimeoutApi).toMatch(/5 分钟/);
expect(FLOCK_HOLD_MSG.totalTimeout).toMatch(/保留/);
});
it("GET 侧 available:决策窗过后对 awaiting 变 false,避免继续操作假象", () => {
const now = 5_000;
const s = buildFlockQuoteHoldState({
quoteId: "q1",
sessionId: "flock_q1",
customerId: "C1",
nowMs: now,
});
expect(toFlockQuoteHoldPublic(s, now).available).toBe(true);
expect(
toFlockQuoteHoldPublic(s, now + FLOCK_HOLD_DECISION_MS).available,
).toBe(false);
});
});
describe("用户旅程 · 选档 / 灵活价缺失提醒", () => {
it("未选服务档", () => {
expect(
validateFlockCheckoutSelection({
preferredTier: "express" as "standard",
preferredFlexibility: "2_day",
}),
).toMatch(/FlockDirect|Standard/);
});
it("未选灵活性 → 明确中文", () => {
expect(
validateFlockCheckoutSelection({ preferredTier: "flock_direct" }),
).toMatch(/灵活性/);
expect(
validateFlockCheckoutSelection({
preferredTier: "standard",
preferredFlexibility: null,
}),
).toMatch(/灵活性/);
});
it("非法灵活性 key", () => {
expect(
validateFlockCheckoutSelection({
preferredTier: "standard",
preferredFlexibility: "3_day" as "none",
}),
).toMatch(/有效的灵活性/);
});
it("完整选档通过", () => {
expect(
validateFlockCheckoutSelection({
preferredTier: "flock_direct",
preferredFlexibility: "2_day",
}),
).toBeNull();
});
it("API schema:缺 preferred_flexibility → 失败", () => {
const r = flockCheckoutSchema.safeParse({
customer_id: "C1",
quote_id: "q1",
preferred_tier: "standard",
});
expect(r.success).toBe(false);
if (!r.success) {
expect(r.error.issues[0]?.message).toMatch(/灵活性/);
}
});
});
describe("用户旅程 · 二级详情必填缺失提醒", () => {
it("空表单:提货/送货公司名、地址、联系人、邮箱、电话均有错", () => {
const errors = validateFlockCheckoutDetailsDraft(
emptyFlockCheckoutDetailsDraft(),
);
expect(flockDetailsHasErrors(errors)).toBe(true);
expect(errors.pickup?.company_name).toBeTruthy();
expect(errors.pickup?.contact_name).toBeTruthy();
expect(errors.pickup?.contact_phone).toBeTruthy();
expect(errors.pickup?.contact_email).toBeTruthy();
expect(errors.delivery?.company_name).toBeTruthy();
expect(errors.delivery?.address1).toBeTruthy();
expect(errors.delivery?.city).toBeTruthy();
expect(errors.delivery?.state).toBeTruthy();
expect(errors.delivery?.zip).toBeTruthy();
expect(errors.delivery?.contact_email).toBeTruthy();
});
it("use_billing 时仍要求提货联系人;送货地址仍必填", () => {
const draft = emptyFlockCheckoutDetailsDraft();
draft.use_billing_for_pickup = true;
draft.pickup.contact_name = "";
draft.pickup.contact_phone = "";
draft.pickup.contact_email = "";
const errors = validateFlockCheckoutDetailsDraft(draft);
expect(errors.pickup?.contact_name).toBeTruthy();
expect(errors.delivery?.address1).toBeTruthy();
});
it("邮箱 / 电话格式错误有中文提示", () => {
const draft = emptyFlockCheckoutDetailsDraft();
draft.use_billing_for_pickup = true;
draft.pickup = {
...draft.pickup,
company_name: "A",
contact_name: "Alice",
contact_phone: "555-123-4567",
contact_email: "not-an-email",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
};
draft.delivery = {
company_name: "B",
address1: "1 Main",
address2: "",
city: "Austin",
state: "TX",
zip: "78701",
contact_name: "Bob",
contact_phone: "123",
contact_email: "bob@",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
};
const errors = validateFlockCheckoutDetailsDraft(draft);
expect(errors.pickup?.contact_email).toBeTruthy();
expect(errors.pickup?.contact_phone).toBeTruthy();
expect(errors.delivery?.contact_phone).toBeTruthy();
expect(errors.delivery?.contact_email).toBeTruthy();
});
it("widget 表单失败时应展示统一汇总文案(契约)", () => {
// 与 flock-logged-in-details-form validateAndGetDetails 一致
expect("请先修正结账详情中的必填项与格式错误").toMatch(/必填|格式/);
});
});
describe("用户旅程 · 结账门禁顺序(预防跳步)", () => {
it("未点继续填写 → 拦截", () => {
expect(
gateCheckoutUi({
holdAccepted: false,
selectedTier: "flock_direct",
selectedFlexibility: "2_day",
detailsOk: true,
}),
).toMatch(/继续填写/);
});
it("未选档 / 未选灵活 → 拦截", () => {
expect(
gateCheckoutUi({
holdAccepted: true,
selectedTier: null,
selectedFlexibility: null,
detailsOk: true,
}),
).toMatch(/灵活性|服务档/);
});
it("详情未过校验 → 拦截", () => {
expect(
gateCheckoutUi({
holdAccepted: true,
selectedTier: "standard",
selectedFlexibility: "none",
detailsOk: false,
}),
).toMatch(/结账详情/);
});
it("全部就绪 → 放行", () => {
expect(
gateCheckoutUi({
holdAccepted: true,
selectedTier: "flock_direct",
selectedFlexibility: "1_day",
detailsOk: true,
}),
).toBeNull();
});
});
describe("用户旅程 · hold API 入参防护", () => {
it("continue schema:缺 quote_session_id", () => {
const r = flockHoldContinueSchema.safeParse({
customer_id: "C1",
quote_id: "q1",
});
expect(r.success).toBe(false);
});
it("continue schema:完整通过", () => {
const r = flockHoldContinueSchema.safeParse({
customer_id: "C1",
quote_id: "q1",
quote_session_id: "flock_q1",
});
expect(r.success).toBe(true);
});
});

@ -0,0 +1,169 @@
/**
* Flock hold-service:继续 / 放弃 / 超时 / 越权
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/modules/address/parked-session-queue", () => ({
requestReleaseParkedSession: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/flock/flock-quote-hold-store", () => ({
assertFlockQuoteHoldUsable: vi.fn(),
clearFlockQuoteHold: vi.fn().mockResolvedValue(undefined),
readFlockQuoteHold: vi.fn(),
updateFlockQuoteHoldStatus: vi.fn(),
}));
import { requestReleaseParkedSession } from "@/modules/address/parked-session-queue";
import {
assertFlockQuoteHoldUsable,
clearFlockQuoteHold,
readFlockQuoteHold,
updateFlockQuoteHoldStatus,
} from "@/lib/flock/flock-quote-hold-store";
import {
FLOCK_HOLD_DECISION_MS,
FLOCK_HOLD_MSG,
FLOCK_HOLD_TOTAL_MS,
buildFlockQuoteHoldState,
} from "@/lib/constants/flock-quote-hold";
import {
continueFlockQuoteHold,
declineFlockQuoteHold,
} from "@/modules/flock/hold-service";
const SESSION = "flock_q-hold-1";
const BASE = {
customerId: "CUST_001",
quoteId: "q-hold-1",
sessionId: SESSION,
};
function makeState(
patch?: Partial<ReturnType<typeof buildFlockQuoteHoldState>>,
) {
const now = Date.now();
return {
...buildFlockQuoteHoldState({
quoteId: BASE.quoteId,
sessionId: SESSION,
customerId: BASE.customerId,
nowMs: now,
}),
...patch,
};
}
describe("continueFlockQuoteHold / declineFlockQuoteHold", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("decline:释放驻留并清理 Redis", async () => {
vi.mocked(readFlockQuoteHold).mockResolvedValue(makeState());
vi.mocked(updateFlockQuoteHoldStatus).mockResolvedValue(
makeState({ status: "declined" }),
);
const r = await declineFlockQuoteHold(BASE);
expect(r.released).toBe(true);
expect(requestReleaseParkedSession).toHaveBeenCalledWith(SESSION);
expect(clearFlockQuoteHold).toHaveBeenCalledWith(SESSION);
});
it("decline:客户不匹配 → 无权", async () => {
vi.mocked(readFlockQuoteHold).mockResolvedValue(
makeState({ customer_id: "OTHER" }),
);
await expect(declineFlockQuoteHold(BASE)).rejects.toThrow(/无权/);
});
it("decline:quote 不匹配 → 报错", async () => {
vi.mocked(readFlockQuoteHold).mockResolvedValue(
makeState({ quote_id: "other-q" }),
);
await expect(declineFlockQuoteHold(BASE)).rejects.toThrow(/不匹配/);
});
it("continue:awaiting → filling", async () => {
vi.mocked(assertFlockQuoteHoldUsable).mockResolvedValue(makeState());
vi.mocked(updateFlockQuoteHoldStatus).mockResolvedValue(
makeState({ status: "filling" }),
);
const r = await continueFlockQuoteHold(BASE);
expect(r.status).toBe("filling");
expect(updateFlockQuoteHoldStatus).toHaveBeenCalledWith(
SESSION,
"filling",
);
});
it("continue:已 filling / checking_out → 幂等返回", async () => {
vi.mocked(assertFlockQuoteHoldUsable).mockResolvedValue(
makeState({ status: "filling" }),
);
const r = await continueFlockQuoteHold(BASE);
expect(r.status).toBe("filling");
expect(updateFlockQuoteHoldStatus).not.toHaveBeenCalled();
vi.mocked(assertFlockQuoteHoldUsable).mockResolvedValue(
makeState({ status: "checking_out" }),
);
await expect(continueFlockQuoteHold(BASE)).resolves.toEqual({
status: "filling",
});
});
it("continue:60s 决策窗已过 → 自动 decline + 中文超时", async () => {
const parked = 1_000_000;
const state = makeState({
parked_at_ms: parked,
decision_deadline_ms: parked + FLOCK_HOLD_DECISION_MS,
total_deadline_ms: parked + FLOCK_HOLD_TOTAL_MS,
status: "awaiting_decision",
});
// 模拟「现在」已过决策窗:直接构造已到期的 deadline
const expiredDecision = {
...state,
decision_deadline_ms: Date.now() - 1,
total_deadline_ms: Date.now() + 60_000,
};
vi.mocked(assertFlockQuoteHoldUsable).mockResolvedValue(expiredDecision);
vi.mocked(readFlockQuoteHold).mockResolvedValue(expiredDecision);
vi.mocked(updateFlockQuoteHoldStatus).mockResolvedValue({
...expiredDecision,
status: "declined",
});
await expect(continueFlockQuoteHold(BASE)).rejects.toThrow(
FLOCK_HOLD_MSG.decisionTimeoutApi,
);
expect(requestReleaseParkedSession).toHaveBeenCalled();
});
it("continue:5min 总硬限已过 → totalTimeoutApi", async () => {
const expired = makeState({
status: "filling",
total_deadline_ms: Date.now() - 1,
decision_deadline_ms: Date.now() - 120_000,
});
vi.mocked(assertFlockQuoteHoldUsable).mockResolvedValue(expired);
vi.mocked(readFlockQuoteHold).mockResolvedValue(expired);
vi.mocked(updateFlockQuoteHoldStatus).mockResolvedValue({
...expired,
status: "declined",
});
await expect(continueFlockQuoteHold(BASE)).rejects.toThrow(
FLOCK_HOLD_MSG.totalTimeoutApi,
);
});
it("continue:会话不存在 → assert 抛出", async () => {
vi.mocked(assertFlockQuoteHoldUsable).mockRejectedValue(
new Error(FLOCK_HOLD_MSG.sessionGone),
);
await expect(continueFlockQuoteHold(BASE)).rejects.toThrow(
/询价会话已失效/,
);
});
});

@ -0,0 +1,100 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/flock/flock-pricing-options-store", () => ({
saveFlockPricingOptions: vi.fn(),
readFlockPricingOptions: vi.fn(),
}));
vi.mock("@/modules/flock/pricing-options-queue", () => ({
enqueueFlockPricingOptionsJob: vi.fn(),
}));
vi.mock("@/lib/flock/flock-quote-hold-store", () => ({
readFlockQuoteHoldByQuoteId: vi.fn(),
}));
import { prisma } from "@/lib/prisma";
import {
saveFlockPricingOptions,
readFlockPricingOptions,
} from "@/lib/flock/flock-pricing-options-store";
import { readFlockQuoteHoldByQuoteId } from "@/lib/flock/flock-quote-hold-store";
import { enqueueFlockPricingOptionsJob } from "@/modules/flock/pricing-options-queue";
import { submitFlockPricingOptions } from "@/modules/flock/pricing-options-service";
describe("submitFlockPricingOptions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(readFlockQuoteHoldByQuoteId).mockResolvedValue(null);
vi.mocked(readFlockPricingOptions).mockResolvedValue(null);
vi.mocked(enqueueFlockPricingOptionsJob).mockResolvedValue(undefined as never);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId: "q1",
customerId: "c1",
requestId: "r1",
status: "done",
pickupJson: { zip: "90001", flock_pickup_date: "2026-07-30" },
deliveryJson: { zip: "10001" },
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
quotesJson: {
provider: "flock",
reference: "JKR-RH3X",
lines: [],
},
} as never);
});
it("仅选档位即可入队(不要求已选灵活档)", async () => {
const r = await submitFlockPricingOptions({
customerId: "c1",
quoteId: "q1",
preferredTier: "standard",
});
expect(r.status).toBe("processing");
expect(enqueueFlockPricingOptionsJob).toHaveBeenCalled();
expect(saveFlockPricingOptions).toHaveBeenCalled();
});
it("非法档位拒绝", async () => {
await expect(
submitFlockPricingOptions({
customerId: "c1",
quoteId: "q1",
preferredTier: "nope" as never,
}),
).rejects.toThrow(/FlockDirect|Standard/);
});
it("有效 hold 时入队携带 quoteSessionId", async () => {
const now = Date.now();
vi.mocked(readFlockQuoteHoldByQuoteId).mockResolvedValue({
quote_id: "q1",
customer_id: "c1",
quote_session_id: "flock_QTE_hold",
status: "awaiting_decision",
parked_at_ms: now,
decision_deadline_ms: now + 60_000,
total_deadline_ms: now + 300_000,
});
await submitFlockPricingOptions({
customerId: "c1",
quoteId: "q1",
preferredTier: "flock_direct",
});
expect(enqueueFlockPricingOptionsJob).toHaveBeenCalledWith(
expect.objectContaining({ quoteSessionId: "flock_QTE_hold" }),
);
});
});

@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import {
applyMarkupToFlockCarrierOptions,
applyMarkupToFlockFlexibilityOptions,
applyMarkupToFlockLines,
applyMarkupToFlockRateUsd,
buildFlockStoredQuotes,
parseFlockStoredQuotes,
} from "@/modules/flock/quote-storage";
@ -40,6 +43,30 @@ describe("flock quote storage", () => {
expect(marked[1]!.final_total_usd).toBe(550);
});
it("档内承运商/灵活价叠加固定加价", () => {
const rule = { type: "fixed" as const, percent: 0, fixedAmount: 3 };
expect(applyMarkupToFlockRateUsd(5255.63, rule)).toBe(5258.63);
expect(
applyMarkupToFlockCarrierOptions(
[
{
carrierName: "Echo FTL",
transitDays: "3",
rateUsd: 5255.63,
label: "Echo FTL",
},
],
rule,
)[0]!.rateUsd,
).toBe(5258.63);
expect(
applyMarkupToFlockFlexibilityOptions(
[{ key: "2_day", label: "2-day", rateUsd: 1000 }],
rule,
)[0]!.rateUsd,
).toBe(1003);
});
it("序列化与解析", () => {
const stored = buildFlockStoredQuotes(
lines,

@ -3,6 +3,7 @@ import {
applyMarkup,
applyMarkupToTier,
getMarkupPercent,
getMarkupRule,
} from "@/modules/pricing/engine";
vi.mock("@/lib/prisma", () => ({
@ -91,17 +92,44 @@ describe("getMarkupPercent", () => {
mockedFindFirst.mockReset();
});
it("未配置 customer_id → 0.0", async () => {
mockedFindFirst.mockResolvedValue(null);
expect(await getMarkupPercent("CUST_UNKNOWN")).toBe(0);
it("未传业务客户 → 0.0(租户不加价)", async () => {
expect(await getMarkupPercent("CUST_002")).toBe(0);
expect(mockedFindFirst).not.toHaveBeenCalled();
});
});
describe("getMarkupRule", () => {
beforeEach(() => {
mockedFindFirst.mockReset();
});
it("已配置 10.0% → 10.0", async () => {
it("仅业务客户配置生效", async () => {
mockedFindFirst.mockResolvedValue({
markupType: "percent",
markupPercent: 10.0,
markupPercent: 5,
markupFixedAmount: null,
} as never);
expect(await getMarkupPercent("CUST_002")).toBe(10);
const rule = await getMarkupRule("CUST_004", "BC_001");
expect(rule.percent).toBe(5);
expect(mockedFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
customerId: "CUST_004",
businessCustomerId: "BC_001",
}),
}),
);
});
it("未配置业务客户加价 → 0", async () => {
mockedFindFirst.mockResolvedValue(null);
const rule = await getMarkupRule("CUST_004", "BC_001");
expect(rule.percent).toBe(0);
});
it("不传业务客户 → 直接 0,不回退租户配置", async () => {
const rule = await getMarkupRule("CUST_004");
expect(rule.percent).toBe(0);
expect(mockedFindFirst).not.toHaveBeenCalled();
});
});

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { submitQuote } from "@/modules/quote/orchestrator";
vi.mock("@/modules/cache/redis-cache", () => ({
@ -22,6 +22,10 @@ vi.mock("@/modules/quote/rpa-queue", () => ({
enqueueQuoteJob: vi.fn(),
}));
vi.mock("@/lib/mothership/refine-hold-store", () => ({
createMsRefineHold: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/pricing/engine", () => ({
getMarkupRule: vi.fn().mockResolvedValue({ type: "percent", percent: 0, fixedAmount: 0 }),
applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes),
@ -31,11 +35,38 @@ vi.mock("@/lib/axel/quote-from-request", () => ({
fetchAxelQuoteItems: vi.fn(),
}));
vi.mock("@/lib/mothership/dashboard-direct-quote", () => ({
fetchMothershipLoggedInDirectQuote: vi.fn(),
isMsDashboardDirectQuoteEnabled: vi.fn(() => true),
readMothershipIdToken: vi.fn(() => null),
resolveLoggedInStoragePath: vi.fn(() => ".rpa/mothership-logged-in-storage.json"),
}));
vi.mock("@/lib/rpa/env", () => ({
isAxelDirectQuoteMode: vi.fn(() => true),
isInlineDirectQuoteEnabled: vi.fn(() => true),
}));
vi.mock("@/modules/customer/provider-credentials", () => ({
hasCustomerProviderCredential: vi.fn().mockResolvedValue(false),
getCustomerProviderLogin: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/modules/customer/business-customer-service", () => ({
assertBusinessCustomerBelongsToTenant: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/customer/business-customer-user-service", () => ({
resolveBusinessCustomerIdForQuote: vi.fn(async (input: { businessCustomerId?: string }) =>
input.businessCustomerId ?? null,
),
}));
vi.mock("@/modules/quote/query-log", () => ({
recordQuoteQueryStart: vi.fn().mockResolvedValue(undefined),
recordQuoteQueryOutcome: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/metrics/collector", () => ({
safeRecord: vi.fn(),
recordPostTotal: vi.fn().mockResolvedValue(undefined),
@ -107,8 +138,14 @@ const mockedSaveIdem = vi.mocked(saveIdempotency);
const mockedEnqueue = vi.mocked(enqueueQuoteJob);
describe("submitQuote", () => {
const prevEmail = process.env.MOTHERSHIP_EMAIL;
const prevPassword = process.env.MOTHERSHIP_PASSWORD;
beforeEach(() => {
vi.clearAllMocks();
// 为何:shell/dotenv 残留账密会使 forceLoggedInQuote=true,跳过 L2/匿名 Direct
delete process.env.MOTHERSHIP_EMAIL;
delete process.env.MOTHERSHIP_PASSWORD;
mockedCheckIdem.mockResolvedValue({ hit: false });
mockedGetL2.mockResolvedValue(null);
mockedGenerateId.mockResolvedValue("QTE_20260616_0001");
@ -118,6 +155,13 @@ describe("submitQuote", () => {
mockedFetchInline.mockRejectedValue(new Error("inline disabled for test"));
});
afterEach(() => {
if (prevEmail === undefined) delete process.env.MOTHERSHIP_EMAIL;
else process.env.MOTHERSHIP_EMAIL = prevEmail;
if (prevPassword === undefined) delete process.env.MOTHERSHIP_PASSWORD;
else process.env.MOTHERSHIP_PASSWORD = prevPassword;
});
it("TC-105:L1 命中 → 返回原 quote_id", async () => {
mockedCheckIdem.mockResolvedValue({
hit: true,

@ -99,4 +99,13 @@ describe("prepareMotherShipStorageQuotes", () => {
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);
});
it("仅 guaranteed/dedicated 无 standard → 仍入库", () => {
const ui = prepareMotherShipStorageQuotes([
{ service_level: "guaranteed", rate_option: "bestValue" },
{ service_level: "dedicated", rate_option: "bestValue" },
]);
expect(ui).toHaveLength(2);
expect(ui.every((q) => q.service_level !== "standard")).toBe(true);
});
});

@ -31,16 +31,56 @@ describe("quote-error-messages", () => {
expect(msg).not.toMatch(/locator\.waitFor/i);
});
it("Playwright 超时译为具体中文原因,不再吞成通用「暂时无法获取」", () => {
it("Playwright 超时译为具体控件原因,不再套用不相关「附加服务未展开」", () => {
const msg = toChineseUserFacingMessage(
"locator.waitFor: Timeout 30000ms exceeded.\nCall log:\n - waiting for getByTestId('ship-create-continue-button')",
"QUOTE_UNAVAILABLE",
);
expect(msg).toContain("自动化操作超时");
expect(msg).toContain("创建货件/报价表单控件");
expect(msg).toContain("继续");
expect(msg).not.toMatch(/附加服务选项未展开/);
expect(msg).not.toContain("创建货件/报价表单控件");
expect(msg).not.toBe("暂时无法获取报价,请稍后重试");
});
it("短超时点击提货地址框给出遮挡/加载原因", () => {
const msg = toChineseUserFacingMessage(
"locator.click: Timeout 5000ms exceeded.\nCall log:\n - waiting for getByTestId('quote-create-pickup-input-search')",
"PAGE_LOAD_TIMEOUT",
);
expect(msg).toContain("提货地址搜索框");
expect(msg).toMatch(/Inbox|遮挡|加载/);
expect(msg).not.toMatch(/附加服务选项未展开/);
});
it("历史笼统「创建货件/报价表单控件」文案展示时纠正", () => {
const legacy =
"自动化操作超时(等待约 5 秒):未能等到「创建货件/报价表单控件」出现或变为可见。可能原因:官网页面结构变更、附加服务选项未展开、网络过慢或登录态异常。";
const msg = formatQuoteErrorMessage("PAGE_LOAD_TIMEOUT", legacy);
expect(msg).not.toContain("创建货件/报价表单控件");
expect(msg).not.toMatch(/附加服务选项未展开/);
expect(msg).toMatch(/Inbox|继续|地址|创建货件/);
});
it("rate-card 等待超时指向报价卡片而非笼统表单", () => {
const msg = toChineseUserFacingMessage(
"locator.waitFor: Timeout 45000ms exceeded.\nCall log:\n - waiting for getByTestId('rate-card')",
"PAGE_LOAD_TIMEOUT",
);
expect(msg).toContain("承运商报价卡片");
expect(msg).toMatch(/算价|运力|二级详情/);
});
it("可提货日 getByRole 超时译为可提货日专用文案", () => {
const msg = toChineseUserFacingMessage(
"locator.click: Timeout 30000ms exceeded.\nCall log:\n - waiting for getByRole('button', { name: /Tue, Jul 28th, 2026.*Chevron/i })",
"PAGE_LOAD_TIMEOUT",
);
expect(msg).toContain("可提货日");
expect(msg).toContain("Inbox");
expect(msg).not.toMatch(/locator\.|getByRole/i);
});
it("RpaDataInvalidError 映射为 RPA_DATA_INVALID 并保留中文原因", () => {
expect(resolveQuoteFailureCode(new RpaDataInvalidError("缺少标准档报价"))).toBe(
"RPA_DATA_INVALID",
@ -122,4 +162,12 @@ describe("quote-error-messages", () => {
"派送附加服务「appointment」在官网下拉中不可用或未展开(可见选项:liftgate)。请取消该附加服务后重试,或确认地址类型是否支持。";
expect(toChineseUserFacingMessage(raw, "CARRIER_NO_CAPACITY")).toBe(raw);
});
it("登录态日历失败剥离英文 want/sample,仅保留中文", () => {
const raw =
"登录态日历未点到 2026-08-05 want=Wed Aug 5 sample=Chevron right|Select month and year|Wed Jul 01 2026";
const msg = toChineseUserFacingMessage(raw, "RPA_DATA_INVALID");
expect(msg).toBe("未能选择可提货日 2026-08-05,请稍后重试或更换日期");
expect(msg).not.toMatch(/Chevron|want=|sample=|Wed Aug/i);
});
});

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { QuoteRecord } from "@prisma/client";
import {
MS_NEEDS_DETAILS_ERROR_CODE,
MS_NEEDS_DETAILS_MESSAGE,
} from "@/lib/constants/ms-refine-hold";
import { serializeQuoteDetail } from "@/modules/quote/quote-serializer";
function baseRecord(overrides: Partial<QuoteRecord> = {}): QuoteRecord {
return {
id: 1,
quoteId: "Q-MS-ND-001",
requestId: "req-nd-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: [],
markupPercent: 0,
validUntil: new Date(Date.now() + 180_000),
errorCode: MS_NEEDS_DETAILS_ERROR_CODE,
errorMessage: MS_NEEDS_DETAILS_MESSAGE,
isDeleted: false,
createdAt: new Date("2026-07-29T00:00:00Z"),
updatedAt: new Date("2026-07-29T00:00:00Z"),
...overrides,
} as QuoteRecord;
}
describe("MS_NEEDS_DETAILS quote detail", () => {
it("serializes done + empty quotes + informational error", () => {
const detail = serializeQuoteDetail(baseRecord());
expect(detail.status).toBe("done");
expect(detail.error_code).toBe(MS_NEEDS_DETAILS_ERROR_CODE);
expect(detail.error_message).toBe(MS_NEEDS_DETAILS_MESSAGE);
expect(detail.quotes).toEqual([]);
});
});

@ -88,14 +88,15 @@ describe("serializeQuoteDetail", () => {
expect(result.error_message).toContain("420 秒");
});
it("failed → 优先返回库内 error_message", () => {
it("failed → 优先返回库内 error_message(脱敏上游品牌)", () => {
const result = serializeQuoteDetail({
...BASE_RECORD,
status: "failed",
errorCode: "QUOTE_UNAVAILABLE",
errorMessage: "MotherShip 页面加载失败",
});
expect(result.error_message).toBe("MotherShip 页面加载失败");
expect(result.error_message).toBe("承运商 页面加载失败");
expect(result.error_message).not.toMatch(/mothership/i);
});
});

@ -46,6 +46,20 @@ describe("validateQuoteInput", () => {
expect(result.weightLb).toBe(500);
});
it("保留业务客户标识但不进入 cargoHash", () => {
const a = validateQuoteInput({
...VALID_BODY,
business_customer_id: "BC_001",
});
const b = validateQuoteInput({
...VALID_BODY,
business_customer_id: "BC_002",
});
expect(a.businessCustomerId).toBe("BC_001");
expect(b.businessCustomerId).toBe("BC_002");
expect(a.cargoHash).toBe(b.cargoHash);
});
it("TC-201:weight 缺失 → VALIDATION_FAILED", () => {
const { weight: _w, ...rest } = VALID_BODY;
expect(() => validateQuoteInput(rest)).toThrow(ValidationError);
@ -57,7 +71,7 @@ describe("validateQuoteInput", () => {
...VALID_BODY,
weight: { value: 0, unit: "lb" },
}),
).toThrow(/有效的重量|超出 Mothership/);
).toThrow(/有效的重量|超出允许范围/);
});
it("空邮编通过(MotherShip 不要求填写邮编)", () => {
@ -116,10 +130,15 @@ describe("validateQuoteInput", () => {
).toThrow("请使用托盘数 pallet_count");
});
it("E4.6:pallet_count 超 25", () => {
it("E4.6:pallet_count 可为 >25(已取消 1–25 硬限)", () => {
const result = validateQuoteInput({ ...VALID_BODY, pallet_count: 26 });
expect(result.palletCount).toBe(26);
});
it("E4.6:pallet_count 非正整数拒绝", () => {
expect(() =>
validateQuoteInput({ ...VALID_BODY, pallet_count: 26 }),
).toThrow(/托盘数超出/);
validateQuoteInput({ ...VALID_BODY, pallet_count: 0 }),
).toThrow(/托盘数须为正整数/);
});
it("E4.6:单托重量超 9999", () => {
@ -217,10 +236,171 @@ describe("validateQuoteInput", () => {
}),
).toThrow(ValidationError);
});
it("登录态 cargo_lines:托数>25 不硬拒,单件均重≤5000 可通过", () => {
const result = validateQuoteInput({
...VALID_BODY,
pallet_count: 40,
weight: { value: 400, unit: "lb" },
cargo_lines: [
{
cargo_type: "pallet",
quantity: 40,
weight_lb: 400,
length_in: 48,
width_in: 40,
height_in: 48,
},
],
});
expect(result.palletCount).toBe(40);
});
it("二级刷价仅 mothership_details:托数>25 不硬拒(无 cargo_lines)", () => {
const result = validateQuoteInput({
...VALID_BODY,
pallet_count: 107,
mothership_details: {
pickup: {
company_name: "Acme",
contact_email: "a@b.com",
contact_phone: "555",
opens_at: "8:00 AM",
closes_at: "5:00 PM",
},
delivery: {
company_name: "Beta",
contact_email: "c@d.com",
contact_phone: "555",
opens_at: "9:00 AM",
closes_at: "5:00 PM",
},
},
});
expect(result.palletCount).toBe(107);
});
it("登录态 cargo_lines:混装托盘+纸箱,pallet_count=托盘合计可通过", () => {
const result = validateQuoteInput({
...VALID_BODY,
pallet_count: 16,
weight: { value: 12, unit: "lb" },
dimensions: { length: 2, width: 2, height: 3, unit: "in" },
cargo_lines: [
{
cargo_type: "carton",
quantity: 4,
weight_lb: 12,
length_in: 2,
width_in: 2,
height_in: 3,
},
{
cargo_type: "pallet",
quantity: 2,
weight_lb: 6,
length_in: 12,
width_in: 12,
height_in: 12,
},
{
cargo_type: "pallet",
quantity: 5,
weight_lb: 31,
length_in: 31,
width_in: 31,
height_in: 31,
},
{
cargo_type: "carton",
quantity: 22,
weight_lb: 24,
length_in: 23,
width_in: 23,
height_in: 23,
},
{
cargo_type: "carton",
quantity: 33,
weight_lb: 33,
length_in: 33,
width_in: 33,
height_in: 33,
},
{
cargo_type: "pallet",
quantity: 6,
weight_lb: 56,
length_in: 55,
width_in: 55,
height_in: 55,
},
{
cargo_type: "pallet",
quantity: 3,
weight_lb: 66,
length_in: 66,
width_in: 66,
height_in: 66,
},
{
cargo_type: "carton",
quantity: 32,
weight_lb: 69,
length_in: 21,
width_in: 12,
height_in: 55,
},
],
});
expect(result.palletCount).toBe(16);
expect(result.cargoLines?.length).toBe(8);
});
it("登录态 cargo_lines:单件均重>5000 拒绝", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
pallet_count: 1,
weight: { value: 5001, unit: "lb" },
cargo_lines: [
{
cargo_type: "pallet",
quantity: 1,
weight_lb: 5001,
length_in: 48,
width_in: 40,
height_in: 48,
},
],
}),
).toThrow(/单件平均重量超过 5000/);
});
it("登录态 cargo_lines:整票总重>45000 拒绝", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
pallet_count: 40,
weight: { value: 1200, unit: "lb" },
cargo_lines: [
{
cargo_type: "pallet",
quantity: 40,
weight_lb: 1200,
length_in: 48,
width_in: 40,
height_in: 48,
},
],
}),
).toThrow(/53 英尺货车最大载重/);
});
});
describe("MOTHERSHIP_LIMITS", () => {
it("托盘 1-25", () => {
expect(MOTHERSHIP_LIMITS.palletCount.max).toBe(25);
it("托盘数不再以 25 为硬上限", () => {
expect(MOTHERSHIP_LIMITS.palletCount.min).toBe(1);
expect(MOTHERSHIP_LIMITS.palletCount.max).toBeGreaterThan(25);
});
});

@ -1,12 +1,19 @@
import { describe, expect, it } from "vitest";
import {
addressLooksResidential,
buildLoggedInAddressSearchQuery,
buildMsCheckoutDetailsDefaults,
formatLoggedInReadyGridCell,
formatLoggedInReadyGridCellWithYear,
isMsCheckoutPaymentButtonLabel,
isMsCheckoutPaymentPageText,
MOTHERSHIP_CREATE_SHIPMENT_URL,
parseLoggedInRateCardText,
pickLoggedInRateCardIndex,
} from "@/workers/rpa/mothership-logged-in-quote";
import { filterQuotesForMotherShipUiDisplay } from "@/lib/constants/mothership-ui-tiers";
import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
describe("MOTHERSHIP_CREATE_SHIPMENT_URL", () => {
it("登录态一级表单直达 /ship(避免菜单文案超时)", () => {
@ -54,6 +61,13 @@ describe("formatLoggedInReadyGridCell", () => {
it("ISO 日期转为 gridcell 名", () => {
expect(formatLoggedInReadyGridCell("2026-07-16")).toBe("Thu Jul 16");
});
it("带年份日格匹配零填充日", () => {
const re = formatLoggedInReadyGridCellWithYear("2026-08-05");
expect(re.test("Wed Aug 05 2026")).toBe(true);
expect(re.test("Wed Aug 5 2026")).toBe(true);
expect(re.test("Wed Jul 05 2026")).toBe(false);
});
});
describe("buildLoggedInAddressSearchQuery", () => {
@ -113,3 +127,118 @@ describe("filterQuotesForMotherShipUiDisplay 多承运商", () => {
expect(ui).toHaveLength(2);
});
});
describe("checkout helpers", () => {
it("支付按钮黑名单", () => {
expect(isMsCheckoutPaymentButtonLabel("Pay now")).toBe(true);
expect(isMsCheckoutPaymentButtonLabel("Place order")).toBe(true);
expect(isMsCheckoutPaymentButtonLabel("Complete payment")).toBe(true);
expect(isMsCheckoutPaymentButtonLabel("Add payment method")).toBe(true);
expect(isMsCheckoutPaymentButtonLabel("Agree")).toBe(false);
expect(isMsCheckoutPaymentButtonLabel("Proceed to checkout")).toBe(false);
});
it("支付页正文识别", () => {
expect(isMsCheckoutPaymentPageText("Add payment method\nCard number")).toBe(
true,
);
expect(
isMsCheckoutPaymentPageText("Review your shipment\nAgree to terms"),
).toBe(false);
});
it("选价:hint 优先,否则最低价", () => {
const items = [
{ carrier: "ABF Direct", rawTotal: 888.67 },
{ carrier: "SAIA Direct", rawTotal: 1051.01 },
{ carrier: "Old Dominion", rawTotal: 1803.19 },
];
expect(pickLoggedInRateCardIndex(items)).toBe(0);
expect(pickLoggedInRateCardIndex(items, "SAIA")).toBe(1);
expect(pickLoggedInRateCardIndex(items, "no-match")).toBe(0);
expect(pickLoggedInRateCardIndex([])).toBe(-1);
});
it("Details 默认值含双侧姓名与分邮箱", () => {
const req = {
cargoHash: "t",
pickup: {
street: "a",
city: "b",
state: "CA",
zip: "90001",
placeId: "p",
formattedAddress: "a",
selectedFromSuggestions: true,
},
delivery: {
street: "c",
city: "d",
state: "TX",
zip: "75201",
placeId: "d",
formattedAddress: "c",
selectedFromSuggestions: true,
},
weightLb: 100,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
} satisfies QuoteRequest;
const d = buildMsCheckoutDetailsDefaults(req);
expect(d.pickupFirst).toBe("Ops");
expect(d.deliveryLast).toBe("Receiver");
expect(d.pickupEmail).not.toBe(d.deliveryEmail);
expect(d.pieceCountQty).toBe("2");
expect(d.cargoDescription).toBe("General freight pallets");
expect(d.pickupReference).toBe("PO-PICKUP-001");
expect(d.deliveryNotes).toContain("Receiver");
});
});
describe("addressLooksResidential", () => {
const base = {
city: "Columbus",
state: "OH",
zip: "43217",
placeId: "x",
formattedAddress: "",
selectedFromSuggestions: true,
};
it("商业 ste/suite 地址不算住宅(与官网一致,可直接出价)", () => {
expect(
addressLooksResidential({
...base,
street: "6600 Don Eisele Rd ste 2",
formattedAddress: "6600 Don Eisele Rd ste 2, Columbus, OH 43217, USA",
mothershipDisplayLabel:
"6600 Don Eisele Rd ste 2, Columbus, OH 43217, USA",
}),
).toBe(false);
expect(
addressLooksResidential({
...base,
street: "555 Market St Suite 1001",
city: "San Francisco",
state: "CA",
zip: "94105",
}),
).toBe(false);
});
it("明确 apt/apartment 才视为住宅", () => {
expect(
addressLooksResidential({
...base,
street: "100 Main St Apt 4B",
}),
).toBe(true);
expect(
addressLooksResidential({
...base,
street: "100 Main St Apartment 2",
}),
).toBe(true);
});
});

@ -14,6 +14,8 @@ import {
releaseParkedQuoteSession,
takeParkedQuoteSession,
canPersistParkedQuoteSession,
getParkedQuoteSessionTtlMs,
sweepExpiredParkedSessions,
} from "@/workers/rpa/parked-quote-session";
function mockSession(id: string) {
@ -71,4 +73,20 @@ describe("parked-quote-session", () => {
vi.stubEnv("RPA_WORKER_ID", "w1");
expect(canPersistParkedQuoteSession()).toBe(true);
});
it("自定义 ttlMs:过期后 sweep 释放", async () => {
vi.useFakeTimers();
const { id, page, context } = mockSession("sess-ttl");
await parkQuoteSession(id, page, context, { ttlMs: 5_000 });
expect(getParkedQuoteSessionTtlMs(id)).toBe(5_000);
expect(hasParkedQuoteSession(id)).toBe(true);
vi.advanceTimersByTime(5_001);
expect(sweepExpiredParkedSessions()).toBe(1);
// release 为 fire-and-forget,等微任务关掉 page
await Promise.resolve();
await Promise.resolve();
expect(hasParkedQuoteSession(id)).toBe(false);
expect(page.close).toHaveBeenCalled();
vi.useRealTimers();
});
});

@ -28,7 +28,7 @@
| 形态 | 说明 | 见章节 |
|------|------|--------|
| OpenAPI 服务端调用 | 宿主后端带 API Key 调两步查价接口 | §2~§6 |
| 网页嵌入(iframe) | 宿主页面嵌查价 UI;**须直传类型+Key,跳过登录页** | §2.5 |
| 网页嵌入(iframe) | 宿主页面嵌查价 UI;**须直传类型+Key,跳过登录页** | §2.5;独立稿见 **`嵌入对接文档.md`**;**postMessage 预填/回传**见 **`嵌入-postMessage对接文档.md`** |
> **安全要求**:API Key、承运商官网账密仅允许保存在宿主服务端或经我方管理端加密存储;禁止写入宿主公开前端仓库、移动端安装包。OpenAPI 调用禁止把 Key 放进 URL;iframe 嵌入仅允许由**宿主服务端**在生成 `iframe.src` 时短时拼入查询串(见 §2.5)。
@ -110,7 +110,7 @@ Content-Type: application/json
|------|------|------|
| `login_type` | 建议 | `api_key` 或 `password`;别名:`type`、`lt` |
| `api_key` | Key 登录必填 | 客户 API Key;别名:`key`、`token` |
| `customer_id` | 密码登录必填 | 客户编号;别名:`customerId` |
| `customer_id` | 密码登录必填 | 租户编号;别名:`customerId` |
| `password` | 密码登录必填 | 嵌入页客户密码;别名:`pwd` |
| `embed` | 可选 | `1` / `true`:宿主托管壳(隐藏「退出登录」);别名:`hosted`、`from_cc`、`from_host` |
@ -248,13 +248,13 @@ https://if.dev.51track.vip/embed-demo?login_type=password&customer_id=CUST_001&p
## 4. 承运商账密(路线开关)
账密由我方管理端按客户绑定,RPA 运行时解密使用,**不进入询价请求体**。
账密由我方管理端按租户绑定,RPA 运行时解密使用,**不进入询价请求体**。
### 4.1 管理端接口(内部运维,非宿主日常调用)
```http
GET /api/admin/customers/{customer_id}/provider-credentials
PUT /api/admin/customers/{customer_id}/provider-credentials
GET /api/admin/tenants/{customer_id}/provider-credentials
PUT /api/admin/tenants/{customer_id}/provider-credentials
```
需管理员 JWT。PUT 示例:
@ -482,7 +482,7 @@ PUT /api/admin/customers/{customer_id}/provider-credentials
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| customer_id | String | 是 | 与鉴权客户一致 | 客户 ID |
| customer_id | String | 是 | 与鉴权租户一致 | 租户 ID |
| query | String | 是 | 去空白后 ≥ 3 字符 | 用户键入的地址关键字 |
**请求示例**:
@ -513,7 +513,8 @@ PUT /api/admin/customers/{customer_id}/provider-credentials
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| request_id | String | 是 | 非空;建议 UUID | 幂等 / 追踪 |
| customer_id | String | 是 | 与鉴权一致 | 客户 ID |
| customer_id | String | 是 | 与鉴权一致 | 租户 ID |
| business_customer_id | String | 否 | 租户下业务客户 ID | 不传则不加价(0%) |
| pickup_address | Object | 是 | 见下表 | 提货地址(须已确认) |
| delivery_address | Object | 是 | 见下表 | 派送地址(须已确认) |
| weight | Object | 是 | value 正数;unit `lb`/`kg`/`t` | 单托重量(兼容字段) |
@ -566,6 +567,7 @@ PUT /api/admin/customers/{customer_id}/provider-credentials
{
"request_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9",
"customer_id": "CUST_001",
"business_customer_id": "BC_001",
"pickup_address": {
"street": "1234 Warehouse Street",
"city": "Los Angeles",
@ -651,7 +653,7 @@ PUT /api/admin/customers/{customer_id}/provider-credentials
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| request_id | String | 是 | 非空 | 请求标识 |
| customer_id | String | 是 | 与鉴权一致 | 客户 ID |
| customer_id | String | 是 | 与鉴权一致 | 租户 ID |
| flock_input | Object | 是 | 见下表 | Flock 表单参数 |
**flock_input(免账号)**
@ -683,6 +685,7 @@ PUT /api/admin/customers/{customer_id}/provider-credentials
{
"request_id": "a1b2c3d4-e5f6-4789-a012-3456789abcde",
"customer_id": "CUST_001",
"business_customer_id": "BC_001",
"flock_input": {
"pickup_date": "07/22/2026",
"pickup_zip": "90001",

@ -52,7 +52,7 @@ Content-Type: application/json
| 类型 | 形态 | 来源 |
|------|------|------|
| **客户 API Key(推荐给第三方)** | 通常以 `chj_` 开头 | 管理端「客户管理」新建客户 / 轮换 Key 后发放 |
| **租户 API Key(推荐给第三方)** | 通常以 `chj_` 开头 | 管理端「租户管理」新建租户 / 轮换 Key 后发放 |
| Service Token | 自定义字符串,如 `demo-host-token` | 服务器 `.env` 的 `HOST_SERVICE_TOKENS`(联调/内网) |
第三方正式对接请使用管理端发放的 **API Key**,不要把 Key 写进前端页面或公开仓库。
@ -360,7 +360,7 @@ curl.exe -s -w "`nHTTP: %{http_code}`n" `
---
## 8. 客户管理接口(管理员)
## 8. 租户管理接口(管理员)
供**管理端 / 内部运维**使用,不提供给第三方宿主直接调用。
@ -373,76 +373,76 @@ Content-Type: application/json
管理员 JWT 通过 `POST /api/auth/login` 获取(账号密码登录)。
### 8.2 获取客户列表
### 8.2 获取租户列表
```http
GET /api/admin/customers?page=1&size=10&keyword=CUST_001
GET /api/admin/tenants?page=1&size=10&keyword=CUST_001
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `page` | 是 | 页码,从 1 开始 |
| `size` | 是 | 每页 1–100 |
| `keyword` | 否 | 按客户 ID / 名称模糊搜索 |
| `keyword` | 否 | 按租户 ID / 名称模糊搜索 |
成功时 `data.list[]` 主要字段:
| 字段 | 说明 |
|------|------|
| `customer_id` | 客户 ID |
| `customer_id` | 租户 ID |
| `status` | `active` / `disabled` |
| `embed_password_set` | 是否已设 `/embed-demo` 密码 |
| `active_api_key` | 当前有效 API Key;历史未保存原文时可能为 `null` |
| `api_keys[]` | Key 摘要(不含完整原文,仅前缀等) |
### 8.3 新建客户
### 8.3 新建租户
```http
POST /api/admin/customers
POST /api/admin/tenants
```
```json
{
"name": "演示客户 003",
"remark": "重点客户",
"name": "演示租户 003",
"remark": "重点租户",
"embed_password": "123456"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 客户名称 |
| `name` | 是 | 租户名称 |
| `remark` | 否 | 备注 |
| `embed_password` | 否 | `/embed-demo` 登录密码;不传默认 `123456` |
响应中 `api_key` **仅在新建时额外返回一次**;同时写入 `active_api_key`。请立即保存发给第三方。
### 8.4 更新客户
### 8.4 更新租户
```http
PATCH /api/admin/customers/{customer_id}
PATCH /api/admin/tenants/{customer_id}
```
可更新:`name`、`remark`、`status`(`active`/`disabled`)、`embed_password`(至少 6 位)。
### 8.5 轮换 API Key
### 8.5 轮换租户 API Key
```http
POST /api/admin/customers/{customer_id}/rotate-key
POST /api/admin/tenants/{customer_id}/rotate-key
```
- 原有效 Key 立即失效
- 响应中的 `api_key` 与 `active_api_key` 为新 Key
- 轮换后须通知第三方更换凭证
### 8.6 承运商官网账密(管理端)
### 8.6 租户承运商官网账密(管理端)
```http
GET /api/admin/customers/{customer_id}/provider-credentials
PUT /api/admin/customers/{customer_id}/provider-credentials
GET /api/admin/tenants/{customer_id}/provider-credentials
PUT /api/admin/tenants/{customer_id}/provider-credentials
```
用于绑定客户在 MotherShip / Flock 官网的登录账密(RPA 使用),**非第三方宿主对接接口**。
用于绑定租户在 MotherShip / Flock 官网的登录账密(RPA 使用),**非第三方宿主对接接口**。
PUT 请求体示例:
@ -458,7 +458,19 @@ PUT 请求体示例:
|------|------|
| `provider` | 仅 `mothership` 或 `flock` |
| 写入策略 | 仅允许首次绑定;已绑定后不可修改 / 清除 |
| 清除 | 不支持 `clear`;更换需新建客户 |
| 清除 | 不支持 `clear`;更换需新建租户 |
### 8.7 业务客户管理(管理员)
```http
GET /api/admin/tenants/{customer_id}/business-customers
POST /api/admin/tenants/{customer_id}/business-customers
PATCH /api/admin/tenants/{customer_id}/business-customers/{business_customer_id}
```
- `customer_id` 仍代表租户 ID。
- `business_customer_id` 代表租户下业务客户 ID。
- 该层用于管理“租户下多个客户”的加价覆盖,不负责 API Key / 演示密码 / 承运商账密。
---
@ -479,7 +491,7 @@ POST /api/embed-demo/login
}
```
- 密码以管理端「客户管理」中为该客户设置的为准
- 密码以管理端「租户管理」中为该租户设置的为准
- 成功后写入 HttpOnly Cookie:`embed_demo_session`
### 9.2 当前会话 / 退出
@ -489,7 +501,7 @@ GET /api/embed-demo/me
POST /api/embed-demo/logout
```
`me` 依赖登录 Cookie,返回当前客户及加价配置。
`me` 依赖登录 Cookie,返回当前租户,以及可选 `business_customers[]` 列表;未传 `business_customer_id` 时不加价。
---

@ -0,0 +1,37 @@
# 宿主客户同步 API
`POST /api/host/customers/sync`
鉴权:`Authorization: Bearer <Service API Key>` + `X-Customer-Id: CUST_004`
```json
{
"customers": [
{
"business_customer_id": "guid",
"name": "简称或名称",
"external_code": "客户代码",
"remark": null,
"markup_percent": 5
}
],
"users": [
{
"business_customer_id": "guid",
"external_user_id": "userGuid",
"account": "login_account",
"display_name": "张三",
"status": "active"
}
]
}
```
本地导入:
```bash
npm run import:ccnew-customers:users
npm run verify:ccnew-sync -- --account <账号>
```
详见:`X:/work/ccnew/new/docs/私卡询价联调验收.md`

@ -0,0 +1,255 @@
# 查价系统 · 宿主 postMessage 对接说明
**版本**:1.1
**更新日期**:2026-07-24
**适用对象**:在 iframe 中嵌入查价页的宿主系统(如 ShipRo / 元子侧适配方)
**嵌入页**:`/embed-demo`(须先按 `嵌入对接文档.md` 完成 API Key 直登)
> 原则:**由查价系统定义「要填什么 / 查完回什么」**;宿主按本协议发 `postMessage` 预填、收结果。字段与程序内部询价结构对齐,宿主无需再猜哪些字段可用。
---
## 0. ShipRo 联调要点(v1.1)
### 0.1 URL 直进 MotherShip 登录后表单(方案 A)
```text
https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=<Key>&embed=1&module=MS_LOGGED_IN
```
等价:`entry=ms_logged_in`
登录成功后**跳过** MotherShip/Flock 卡片与「免账号/登录后」点选,直接进入「创建新货件」。须该客户已绑定 MotherShip 官网账密,否则停在账密面板。
### 0.2 fill 可晚到 / 可重复 / 先缓存再灌表(方案 B)
- 收到 `chajia:fill` **立刻缓存** `payload.form`(不要求当前已在表单页)。
- 切入 `module` 后,表单挂载时写入 UI;用户在表单页再点「填入数据」会**再次**灌表(`seq` 递增)。
- 先回 `chajia:fill-ack`(`ok: true`,缓存确认);表单真正写入后再回一次 ack(`ui_applied: true`)。
- **不依赖**宿主必须先等 `chajia:ready`(ready 仍会发,便于时序;晚到 fill 同样有效)。
### 0.3 `MS_LOGGED_IN` 地址可只传整段文本
宿主可不传 `place_id` / `mothership_option_id`。查价页把 `formatted_address` 或 `street` 写入提货/送货**搜索框**;用户须点选联想确认后才能「继续」。`cargo_lines` 写入货物行(inch/lb);空数组不改货物。
---
## 1. 总览
```text
宿主页面
└─ iframe → .../embed-demo?...&embed=1[&module=MS_LOGGED_IN]
├─ iframe → 宿主:chajia:ready / chajia:fill-ack / chajia:quote-result / chajia:error
└─ 宿主 → iframe:chajia:select-module / chajia:fill / chajia:ping
```
| 方向 | 类型 | 作用 |
|------|------|------|
| iframe→宿主 | `chajia:ready` | 会话就绪(可开始发 fill;非强制前置) |
| 宿主→iframe | `chajia:select-module` | 切入四模块之一 |
| 宿主→iframe | `chajia:fill` | 按模块写入/缓存表单字段(**必带 module**,可重复) |
| 宿主→iframe | `chajia:scroll-quotes` | 请求 iframe 滚到「选择承运商」区(私卡出报价后) |
| iframe→宿主 | `chajia:fill-ack` | 已缓存;表单写入后再带 `ui_applied` |
| iframe→宿主 | `chajia:quote-result` | 查价结束(成功/失败/过期);**及** MS 侧栏点选承运商时再次推送(带 `selected`) |
| iframe→宿主 | `chajia:quote-save` | 用户点「保存本次询价记录」(可选;写快照) |
| iframe→宿主 | `chajia:scroll-top` | 进入二级详情等:请求宿主把查价 iframe 区域滚到视口顶部 |
| iframe→宿主 | `chajia:error` | 协议层错误(缺 module 等) |
| iframe→宿主 | `chajia:module-changed` | 用户或宿主切换了模块 |
信封统一格式:
```json
{
"source": "chajia",
"version": 1,
"type": "chajia:fill",
"request_id": "宿主生成的 UUID,可选,用于配对",
"module": "MS_LOGGED_IN",
"payload": {}
}
```
- **必须**校验 `source === "chajia"` 且 `version === 1`,忽略其它来源。
- `targetOrigin`:生产建议写成查价域名;联调可用 `*`。
- 仅当页面在 **iframe** 中时,查价页才会向 `parent` 回传。
---
## 2. 模块 ID(必须区分)
与 OpenAPI 路线一致,**四选一、互斥**:
| module | 含义 | 门户表现 |
|--------|------|----------|
| `MS_GUEST` | MotherShip 免账号 | MotherShip → 免账号 |
| `MS_LOGGED_IN` | MotherShip 登录后 | MotherShip → 登录后(须已绑官网账密) |
| `FLOCK_GUEST` | Flock 免账号 | Flock → 免账号 |
| `FLOCK_LOGGED_IN` | Flock 登录后 | Flock → 登录后(须已绑官网账密) |
`chajia:fill` **必须**带 `module`。不同模块的 `payload.form` 字段不同,不可混用。
---
## 3. 宿主 → iframe:预填(按模块)
### 3.1 通用写法
```js
iframe.contentWindow.postMessage(
{
source: "chajia",
version: 1,
type: "chajia:fill",
request_id: crypto.randomUUID(),
module: "MS_LOGGED_IN",
payload: {
select_module: true,
form: { /* 见 3.3 */ }
},
},
"https://if.dev.51track.vip",
);
```
也可把 `form` 直接作为 `payload`(无 `select_module` 包装)。
### 3.2 `MS_GUEST`
```json
{
"pickup": { "street": "1234 Warehouse Blvd", "city": "Los Angeles", "state": "CA" },
"delivery": { "street": "5678 Distribution Dr", "city": "Dallas", "state": "TX" },
"weight": { "value": 500, "unit": "lb" },
"dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" },
"pallet_count": 2,
"cargo_type": "general_freight"
}
```
### 3.3 `MS_LOGGED_IN`(ShipRo 现网:整段文本 + cargo_lines)
```json
{
"pickup_address": {
"street": "整段发货地址原文",
"formatted_address": "整段发货地址原文"
},
"delivery_address": {
"street": "整段目的地址原文",
"formatted_address": "整段目的地址原文"
},
"cargo_lines": [
{
"cargo_type": "pallet",
"quantity": 1,
"weight_lb": 100,
"length_in": 48,
"width_in": 40,
"height_in": 48
}
]
}
```
| 字段 | 说明 |
|------|------|
| 地址 | `formatted_address` 或 `street` → 搜索框;可不传 place_id(用户点选联想) |
| cargo_lines | inch/lb;**空数组不改**货物 |
| ready_* / accessorials | 可选;不传用页默认 |
### 3.4 仅切模块
```json
{ "source": "chajia", "version": 1, "type": "chajia:select-module", "module": "MS_LOGGED_IN" }
```
---
## 4. 查价结果
`chajia:quote-result` → `payload.quotes[].carrier` / `final_total`;失败读 `error_message`。
宿主若需明确区分“官网原价”和“客户加价后展示价”,可直接读:
- `base_total_before_markup`:未加价原价
- `customer_markup_amount`:该客户加价金额
- `customer_final_total`:该客户最终展示价(等同当前 `final_total`)
- `is_customer_markup_applied`:是否已叠加客户加价
- `pricing_mode = "customer_final"`:宿主可直接按客户最终价展示
### 4.1 侧栏点选承运商即时同步(宿主锁价)
MotherShip 登录后侧栏(`MS_LOGGED_IN`):
1. 用户点选任意承运商卡片、默认选中出价、或切换保障导致生效费率变化时
2. iframe 再次发出 **`chajia:quote-result`**(**不依赖**绿色「保存本次询价记录」)
3. `payload.quotes[]` 中当前生效那条 **`selected: true`**,其余 `false`
4. 宿主取 `selected === true` 的档(无则兜底首条)即可锁定承运商与 `final_total` / `customer_final_total`
5. 绿色「保存」仍可选发 `chajia:quote-save`(`action: "save"` + 同样 `selected`),仅用于写快照;私卡询价跳转**不必**等保存
触发时机代码入口:`onSelectQuote` → `notifyHostQuoteSelection` → `reportQuoteResult`。
### 4.2 出报价后视野定位(需求3)
1. **查价页**:报价列表出现后自动 `scrollIntoView` 到侧栏「选择承运商」(`#ms-choose-carrier`);「已选」角标与承运商名同一行,不遮挡标题
2. **宿主**:收到 `quote-result` 后左侧滚到「当前选中报价」,并可向 iframe 发:
```json
{ "source": "chajia", "version": 1, "type": "chajia:scroll-quotes" }
```
iframe 收到后再次滚承运商区。需查价页与 cc-client **一起部署** 才完整。
### 4.3 「继续」进入二级后置顶
用户在一级点「继续」进入提货/送货详情时:
1. **查价页**:iframe 内 `scrollTo(0)` + 根节点 `scrollIntoView`
2. **同时**向宿主发:
```json
{
"source": "chajia",
"version": 1,
"type": "chajia:scroll-top",
"module": "MS_LOGGED_IN",
"payload": { "reason": "logged-in-details" }
}
```
3. **宿主**:收到后将私卡询价弹窗/iframe 容器滚到视口顶部
需查价页与 cc-client 一并更新。
---
## 5. 联调顺序(ShipRo)
1. iframe:`...&embed=1&module=MS_LOGGED_IN`
2. 进「创建新货件」后点「填入数据」→ `chajia:fill`
3. 收 `fill-ack`(缓存)→ 再收 `ui_applied` ack
4. 用户点选地址联想 → 继续 → `chajia:quote-result`(出票)
5. 用户在侧栏点选承运商 → 再收 `chajia:quote-result`(带 `selected`)→ 宿主锁价后可「确认询价并新建集装箱」
亦可打开后立即 fill(先缓存,表单挂载后灌入)。
| 能力 | 状态 |
|------|------|
| URL `module` / `entry` 直进 | 已实现 |
| fill 缓存 + 可重复 + 晚到灌表 | 已实现 |
| MS_LOGGED_IN 搜索框 + cargo_lines | 已实现 |
| MS_GUEST 预填 / quote-result | 已实现 |
| 侧栏点选承运商 → quote-result + selected | 已实现 |
| 出报价后自动滚到「选择承运商」/ scroll-quotes | 已实现 |
| 「继续」进二级 → scroll-top 置顶 | 已实现 |
---
## 6. 文档关系
| 文档 | 用途 |
|------|------|
| `嵌入对接文档.md` | iframe URL、API Key、`module` |
| 本文 | postMessage |
| `api对接文档.md` | OpenAPI |
代码:`lib/embed/host-bridge.ts`、`parseEmbedEntryModule`(`lib/embed/sso-params.ts`)、`map-quotes-selection.ts`、`mothership-logged-in-quote-sidebar` / `embedded-quote-widget`。

@ -0,0 +1,211 @@
# 查价系统 · 网页嵌入对接文档(iframe)
**版本**:1.0
**更新日期**:2026-07-22
**适用对象**:CC / 宿主前端 / 需在自有页面内嵌查价 UI 的第三方
**联调环境**:`https://if.dev.51track.vip`
**正式环境**:以我方书面通知为准(路径不变)
> 本文档可独立使用。完整 OpenAPI(服务端两步查价)见同目录 `api对接文档.md`。
> **宿主 postMessage 预填 / 回传报价**:见同目录 **`嵌入-postMessage对接文档.md`**(四模块区分 + 填什么 / 回什么)。
---
## 1. 一句话结论
宿主用 **iframe** 打开下面 URL(**由宿主服务端**把客户 API Key 拼进 `src`),用户**不会看到**嵌入登录页,直接进入 **MotherShip / Flock Freight** 两模块门户。
```text
https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=<客户API_Key>&embed=1
```
直进 MotherShip 登录后「创建新货件」(跳过模块点选;须已绑官网账密):
```text
https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=<客户API_Key>&embed=1&module=MS_LOGGED_IN
```
等价参数:`entry=ms_logged_in`。宿主 postMessage 预填见 **`嵌入-postMessage对接文档.md`**。
---
## 2. 5 分钟自测(复制即用)
### 2.1 准备
| 项 | 值 |
|----|-----|
| Base | `https://if.dev.51track.vip` |
| 路径 | `/embed-demo` |
| 凭证 | 管理端发给该客户的 **API Key**(与 OpenAPI 同一把) |
**联调环境样例 Key**(仅 `if.dev`,与 `.env.example` 一致;正式客户勿用):
```text
https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=demo-host-token&embed=1
```
已在联调环境实测:打开后直进两模块门户(CUST_001),地址栏 Key 被剥离。
### 2.2 浏览器地址栏直开(推荐先测)
把 `<API_Key>` 换成真实 Key,整段粘贴到浏览器:
```text
https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=<API_Key>&embed=1
```
**通过标准**:
1. 页面**不出现**「客户登录」表单(账号/密码/API Key 输入框)。
2. 出现 **MotherShip**、**Flock Freight** 两个模块入口(卡片)。
3. 地址栏里的 `api_key` 等敏感参数在进入后会被清掉(只剩 `/embed-demo` 或少量无关参数)。
4. 点进某一模块可继续选「免账号 / 登录后」查价。
**失败对照**:
| 现象 | 原因 | 处理 |
|------|------|------|
| 仍停在登录页 | 未带 Key,或参数名写错 | 核对 `api_key` / `login_type` |
| 红字「API Key 无效」等 | Key 错、已轮换、客户停用 | 管理端核对 |
| 空白 / 一直骨架屏 | 网络或域名不可达 | 换网络;确认 Base |
### 2.3 仅测登录接口(不打开页面)
```bash
curl -sS -X POST "https://if.dev.51track.vip/api/embed-demo/login" \
-H "Content-Type: application/json" \
-d '{"login_type":"api_key","api_key":"<API_Key>"}'
```
成功示例(字段可能略多,以 `code=0` 为准):
```json
{
"code": 0,
"message": "ok",
"data": {
"customer_id": "CUST_001",
"login_type": "api_key"
}
}
```
失败:`code != 0`,HTTP 多为 401/403/400。
### 2.4 宿主页面 iframe(上线形态)
**Key 必须由宿主后端渲染进 HTML**,禁止写死在公开前端仓库 / 静态 CDN。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>查价嵌入示例</title>
<style>
html, body { margin: 0; height: 100%; }
iframe { width: 100%; height: 100%; border: 0; display: block; }
</style>
</head>
<body>
<!-- src 由服务端模板注入,勿在纯静态站写死 Key -->
<iframe
title="查价"
src="https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=REPLACE_WITH_API_KEY&embed=1"
allow="clipboard-read; clipboard-write"
></iframe>
</body>
</html>
```
服务端伪代码:
```text
src = base + "/embed-demo"
+ "?login_type=api_key"
+ "&api_key=" + url_encode(客户API_Key)
+ "&embed=1"
```
---
## 3. URL 参数一览
| 参数 | 必填 | 说明 | 别名 |
|------|------|------|------|
| `login_type` | 建议填 | `api_key`(推荐)或 `password` | `type`、`lt` |
| `api_key` | Key 登录必填 | 客户 API Key | `key`、`token` |
| `customer_id` | 密码登录必填 | 客户编号 | `customerId` |
| `password` | 密码登录必填 | 嵌入页客户密码 | `pwd` |
| `embed` | 建议 `1` | 宿主托管壳(隐藏「退出登录」等) | `hosted`、`from_cc`、`from_host` |
| `module` | 否 | 直进模块:`MS_GUEST` / `MS_LOGGED_IN` / `FLOCK_GUEST` / `FLOCK_LOGGED_IN` | `quote_module` |
| `entry` | 否 | 同上短写:`ms_logged_in` 等 | `default_module` |
规则:
- 只带 `api_key`(或 `key`)且不写 `login_type` → 按 **API Key 登录**。
- 短写等价:`?type=api_key&key=<API_Key>&embed=1`
账号密码直登(少用):
```text
https://if.dev.51track.vip/embed-demo?login_type=password&customer_id=<客户编号>&password=<密码>&embed=1
```
---
## 4. 登录成功后用户看到什么
1. **两模块门户**:MotherShip、Flock Freight。
2. 每个模块内可选:
- **免账号**:不绑官网账密即可查(受官网/货物硬限约束)。
- **登录后**:须已在管理端或嵌入页内绑定对应官网账密。
3. 查价结果在嵌入页内展示;不替代宿主侧 OpenAPI 下单流程(若有)。
---
## 5. 行为与安全(必读)
| 步骤 | 行为 |
|------|------|
| 1 | 打开 `/embed-demo` → 前端解析查询串 |
| 2 | `POST /api/embed-demo/login`(`login_type` + 凭证) |
| 3 | 成功 → 写嵌入会话 Cookie → 进门户(**跳过登录表单**) |
| 4 | `history.replaceState` 去掉地址栏中的 Key/密码 |
| 5 | 失败 → 错误文案 + 可回落手动登录 |
安全约定:
- **仅允许**宿主服务端短时把 Key 拼进 `iframe.src`;用户侧会短暂可见,成功后剥离。
- 生产 Cookie:`SameSite=None; Secure`(跨站 iframe 必需 HTTPS)。
- OpenAPI 仍走 `Authorization` 头,**不能**用本嵌入 URL 代替服务端调价接口鉴权。
- 勿把 Key 提交进 Git、前端包、移动端安装包。
---
## 6. 与 OpenAPI 的关系
| | 网页嵌入(本文) | OpenAPI |
|--|------------------|---------|
| 用途 | 给人点选查价 UI | 宿主服务端程序化查价 |
| 鉴权 | URL 短时带 Key → Cookie | 请求头 `Authorization` |
| 文档 | 本文 | `api对接文档.md` |
两条可并存:同一客户同一把 API Key。
---
## 7. 验收清单(对接方打勾)
- [ ] 地址栏直开带 Key:无登录表单,有两模块卡片
- [ ] `POST /api/embed-demo/login` 返回 `code=0`
- [ ] 宿主 HTTPS 页 iframe 可加载且会话不丢(刷新仍登录)
- [ ] Key **未**写进公开前端仓库
- [ ] 错 Key 时有明确错误,而非白屏
---
## 8. 联系与变更
参数名、路径变更以本文件版本号为准;重大变更另行通知。联调问题请提供:完整 `iframe.src`(**打码 Key**)、浏览器控制台 Network 中 `/api/embed-demo/login` 的状态码与响应 `code`/`message`。

@ -303,8 +303,8 @@ export default function AdminCustomersPage() {
return (
<AdminLayout>
<PageHeader
title="客户管理"
subtitle="在管理端创建客户并签发 API Key,无需修改服务器 .env"
title="租户管理"
subtitle="管理接入租户、API Key、演示密码与查价网站账密"
action={
<PrimaryButton
onClick={() => {
@ -312,7 +312,7 @@ export default function AdminCustomersPage() {
setCreateOpen(true);
}}
>
新建客户
新建租户
</PrimaryButton>
}
/>
@ -387,7 +387,7 @@ export default function AdminCustomersPage() {
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium">客户 ID</th>
<th className="px-4 py-3 font-medium">租户 ID</th>
<th className="px-4 py-3 font-medium">名称</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">当前 API Key</th>
@ -431,7 +431,7 @@ export default function AdminCustomersPage() {
<SecondaryButton
onClick={() => void openCredDrawer(row)}
>
查价网站账密
租户账密
</SecondaryButton>
<SecondaryButton
onClick={() => void handleToggleStatus(row)}
@ -446,7 +446,12 @@ export default function AdminCustomersPage() {
轮换 Key
</SecondaryButton>
<Link href={`/admin/markup?keyword=${encodeURIComponent(row.customer_id)}`}>
<SecondaryButton>加价配置</SecondaryButton>
<SecondaryButton>租户加价</SecondaryButton>
</Link>
<Link
href={`/admin/tenants/${encodeURIComponent(row.customer_id)}/customers`}
>
<SecondaryButton>业务客户</SecondaryButton>
</Link>
</div>
</td>

@ -1,6 +1,6 @@
"use client";
"use client";
import { Suspense, useCallback, useEffect, useState } from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
@ -29,6 +29,18 @@ function formatMarkupSummary(row: MarkupConfig): string {
return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%";
}
/** 业务客户展示:始终「客户代码 · 客户名称」(不展示表 UUID) */
function formatBusinessCustomerLabel(row: MarkupConfig): string {
const code = (row.business_customer_code ?? "").trim();
const name = (row.business_customer_name ?? "").trim();
if (code && name) {
return `${code} · ${name}`;
}
if (code) return code;
if (name) return name;
return row.business_customer_id || "—";
}
function AdminMarkupPageContent() {
const { token, user } = useAuth();
const searchParams = useSearchParams();
@ -36,9 +48,13 @@ function AdminMarkupPageContent() {
const [rows, setRows] = useState<MarkupConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keywordInput, setKeywordInput] = useState(
() => searchParams.get("keyword") ?? "",
);
const [keyword, setKeyword] = useState(
() => searchParams.get("keyword") ?? "",
);
const loadSeqRef = useRef(0);
const [editing, setEditing] = useState<MarkupConfig | null>(null);
const [markupType, setMarkupType] = useState<MarkupType>("percent");
@ -51,8 +67,10 @@ function AdminMarkupPageContent() {
const load = useCallback(
async (p: number, kw: string) => {
if (!token) return;
const seq = ++loadSeqRef.current;
setStatus("loading");
const res = await adminGetMarkupConfigs("", token, p, PAGE_SIZE, kw);
if (seq !== loadSeqRef.current) return;
if (res.code !== 0) {
setStatus("error");
return;
@ -64,6 +82,18 @@ function AdminMarkupPageContent() {
[token],
);
useEffect(() => {
const t = window.setTimeout(() => {
const next = keywordInput.trim();
setKeyword((prev) => {
if (prev === next) return prev;
setPage(1);
return next;
});
}, 300);
return () => window.clearTimeout(t);
}, [keywordInput]);
useEffect(() => {
void load(page, keyword);
}, [load, page, keyword]);
@ -103,6 +133,7 @@ function AdminMarkupPageContent() {
markupType === "percent" ? Number(percent) : undefined,
markup_fixed_amount:
markupType === "fixed" ? Number(fixedAmount) : undefined,
business_customer_id: editing.business_customer_id ?? undefined,
remark,
});
setSaving(false);
@ -113,7 +144,20 @@ function AdminMarkupPageContent() {
}
setRows((list) =>
list.map((r) => (r.customer_id === res.data.customer_id ? res.data : r)),
list.map((r) =>
r.customer_id === res.data.customer_id &&
(r.business_customer_id ?? null) ===
(res.data.business_customer_id ?? null)
? {
...res.data,
// 为何这样改:旧 PUT 响应可能无 name/code,保留原展示字段避免变成 UUID
business_customer_code:
res.data.business_customer_code ?? r.business_customer_code,
business_customer_name:
res.data.business_customer_name ?? r.business_customer_name,
}
: r,
),
);
setEditing(null);
};
@ -124,17 +168,14 @@ function AdminMarkupPageContent() {
<AdminLayout>
<PageHeader
title="加价配置"
subtitle="按客户对卡派报价设置运费百分比加价或固定金额加价"
subtitle="仅业务客户加价生效;未配置时按 0% 处理"
action={
<div className="w-64">
<InputField
label="搜索客户"
placeholder="客户 ID"
value={keyword}
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
label="搜索租户/客户"
placeholder="租户 / 客户代码 SP012 / 名称 / 账号 STARPOST"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
/>
</div>
}
@ -162,7 +203,9 @@ function AdminMarkupPageContent() {
{status === "empty" && (
<Card className="p-8 text-center text-text-secondary">
未找到匹配的客户
{keyword
? `未找到匹配「${keyword}」的租户或业务客户。可试:客户代码 SP012、名称 STAR、登录账号 STARPOST(数字 0 勿输成字母 O)`
: "暂无业务客户,请先在「租户管理」同步或新增客户"}
</Card>
)}
@ -172,7 +215,8 @@ function AdminMarkupPageContent() {
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium">客户 ID</th>
<th className="px-4 py-3 font-medium">租户 ID</th>
<th className="px-4 py-3 font-medium">业务客户</th>
<th className="px-4 py-3 font-medium">加价方式</th>
<th className="px-4 py-3 font-medium">加价规则</th>
<th className="px-4 py-3 font-medium">操作人</th>
@ -183,8 +227,16 @@ function AdminMarkupPageContent() {
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.customer_id} className="border-t border-border">
<tr
key={`${row.customer_id}:${row.business_customer_id ?? "default"}`}
className="border-t border-border"
>
<td className="px-4 py-3 font-mono">{row.customer_id}</td>
<td className="px-4 py-3">
<div className="font-medium text-text">
{formatBusinessCustomerLabel(row)}
</div>
</td>
<td className="px-4 py-3">
{row.markup_type === "fixed" ? "固定金额" : "百分比"}
</td>
@ -246,6 +298,7 @@ function AdminMarkupPageContent() {
>
<h3 className="text-lg font-semibold">
编辑加价 · {editing.customer_id}
{` / ${formatBusinessCustomerLabel(editing)}`}
</h3>
<p className="mt-1 text-sm text-text-secondary">
百分比仅对运费加价(上限 30%,步长 0.01%);固定金额为每档报价叠加固定金额。

@ -11,8 +11,18 @@ import { Card } from "@/components/ui/card";
import { formatDateTime } from "@/lib/frontend/format";
import { adminGetQueryLogs } from "@/lib/frontend/api-client";
import { useAuth } from "@/hooks/use-auth";
import {
buildQueryLogCargoCopy,
buildQueryLogDeliveryCopy,
buildQueryLogFullCopy,
buildQueryLogPickupCopy,
buildQueryLogQuotesCopy,
formatQueryLogQuoteLine,
preferredAdminAddress,
} from "@/lib/frontend/query-log-copy";
import type {
DataPageStatus,
QuoteQueryLogQuotePreview,
QuoteQueryLogRecord,
QuoteQueryOutcome,
} from "@/lib/frontend/types";
@ -34,11 +44,117 @@ const OUTCOME_LABEL: Record<QuoteQueryOutcome, string> = {
processing: "进行中",
};
function outcomeClass(outcome: QuoteQueryOutcome): string {
if (outcome === "success") return "text-success";
if (outcome === "failed") return "text-error";
if (outcome === "stale") return "text-warning";
return "text-text-secondary";
function outcomeBadgeClass(outcome: QuoteQueryOutcome): string {
if (outcome === "success") return "bg-emerald-50 text-success";
if (outcome === "failed") return "bg-red-50 text-error";
if (outcome === "stale") return "bg-amber-50 text-warning";
return "bg-slate-100 text-text-secondary";
}
function formatQuotedTotal(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "—";
}
return `USD ${value.toFixed(2)}`;
}
function quotesOf(row: QuoteQueryLogRecord): QuoteQueryLogQuotePreview[] {
return Array.isArray(row.quotes) ? row.quotes : [];
}
function formatQuotedSummary(row: QuoteQueryLogRecord): {
primary: string;
secondary: string | null;
} {
const quotes = quotesOf(row);
if (quotes.length > 1) {
return {
primary: `${quotes.length} 档`,
secondary: `最低 ${formatQuotedTotal(row.quoted_total)}`,
};
}
if (quotes.length === 1) {
return {
primary: formatQuotedTotal(quotes[0]?.final_total ?? row.quoted_total),
secondary: quotes[0]?.carrier?.trim() || null,
};
}
return { primary: formatQuotedTotal(row.quoted_total), secondary: null };
}
function displayOrDash(value: string | null | undefined): string {
const t = (value ?? "").trim();
return t || "—";
}
async function copyText(value: string): Promise<boolean> {
const text = value.trim();
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
function CopyButton({
text,
label = "复制",
}: {
text: string;
label?: string;
}) {
const [copied, setCopied] = useState(false);
const disabled = !text.trim();
return (
<button
type="button"
disabled={disabled}
onClick={() => {
void copyText(text).then((ok) => {
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
});
}}
className="inline-flex h-8 shrink-0 items-center rounded-md border border-border bg-surface px-2.5 text-xs font-medium text-text-primary hover:bg-bg disabled:cursor-not-allowed disabled:opacity-40"
>
{copied ? "已复制" : label}
</button>
);
}
function DetailField({
label,
value,
copyTextValue,
copyLabel,
mono,
}: {
label: string;
value: string;
copyTextValue?: string;
copyLabel?: string;
mono?: boolean;
}) {
return (
<div className="border-b border-border py-3">
<div className="flex items-start justify-between gap-3">
<dt className="text-xs text-text-secondary">{label}</dt>
{copyTextValue ? (
<CopyButton text={copyTextValue} label={copyLabel ?? "复制"} />
) : null}
</div>
<dd
className={`mt-1 break-words text-sm leading-relaxed text-text-primary ${
mono ? "font-mono text-xs" : ""
}`}
>
{value}
</dd>
</div>
);
}
export default function QueryLogsPage() {
@ -48,6 +164,7 @@ export default function QueryLogsPage() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [outcomeFilter, setOutcomeFilter] = useState("");
const [detail, setDetail] = useState<QuoteQueryLogRecord | null>(null);
const load = useCallback(
async (p: number, outcome: string) => {
@ -59,6 +176,7 @@ export default function QueryLogsPage() {
p,
PAGE_SIZE,
undefined,
undefined,
outcome || undefined,
);
if (res.code !== 0) {
@ -82,7 +200,7 @@ export default function QueryLogsPage() {
<AdminLayout>
<PageHeader
title="查价记录"
subtitle="最近 1000 条询价流水:时间、客户、地址、货物与报价结果"
subtitle="最近 1000 条询价流水。列表只看结果,完整地址与货物参数在详情中复制。"
action={
<div className="w-36">
<SelectField
@ -103,13 +221,17 @@ export default function QueryLogsPage() {
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner
action={<SecondaryButton onClick={() => void load(page, outcomeFilter)}>重试</SecondaryButton>}
action={
<SecondaryButton onClick={() => void load(page, outcomeFilter)}>
重试
</SecondaryButton>
}
>
加载查价记录失败
</ErrorBanner>
@ -122,48 +244,97 @@ export default function QueryLogsPage() {
{status === "success" && (
<div className="space-y-4">
<div className="overflow-x-auto rounded-lg border border-border bg-surface">
<table className="min-w-full text-sm">
<div className="overflow-hidden rounded-lg border border-border bg-surface">
<table className="w-full table-fixed text-sm">
<colgroup>
<col className="w-[10.5rem]" />
<col className="w-[7.5rem]" />
<col />
<col className="w-[5.75rem]" />
<col className="w-[7.25rem]" />
<col className="w-[4.75rem]" />
</colgroup>
<thead className="border-b border-border bg-bg text-left text-text-secondary">
<tr>
<th className="px-3 py-3 font-medium">查询时间</th>
<th className="px-3 py-3 font-medium">客户</th>
<th className="px-3 py-3 font-medium">提货(联想)</th>
<th className="px-3 py-3 font-medium">派送(联想)</th>
<th className="px-3 py-3 font-medium">货物</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">完成时间</th>
<th className="px-4 py-3 font-medium">查询时间</th>
<th className="px-4 py-3 font-medium">客户</th>
<th className="px-4 py-3 font-medium">线路</th>
<th className="px-4 py-3 font-medium">结果</th>
<th className="px-4 py-3 font-medium">报价</th>
<th className="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.quote_id} className="border-b border-border align-top">
<td className="px-3 py-3 whitespace-nowrap text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-3 py-3 font-mono text-xs">{row.customer_id}</td>
<td className="max-w-[180px] px-3 py-3 text-xs leading-relaxed">
{row.pickup_selected}
</td>
<td className="max-w-[180px] px-3 py-3 text-xs leading-relaxed">
{row.delivery_selected}
</td>
<td className="px-3 py-3 text-xs">{row.cargo_summary}</td>
<td className="px-3 py-3">
<span className={`text-xs font-medium ${outcomeClass(row.outcome)}`}>
{OUTCOME_LABEL[row.outcome]}
</span>
{row.failure_reason && row.outcome !== "success" && (
<p className="mt-1 max-w-[200px] text-xs text-text-secondary leading-relaxed">
{row.failure_reason}
{rows.map((row) => {
const pickup = preferredAdminAddress(
row.pickup_selected,
row.pickup_customer,
);
const delivery = preferredAdminAddress(
row.delivery_selected,
row.delivery_customer,
);
return (
<tr
key={row.quote_id}
className="border-b border-border last:border-b-0"
>
<td className="px-4 py-3 align-middle text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-4 py-3 align-middle font-mono text-xs">
<span className="block truncate" title={row.customer_id}>
{row.customer_id}
</span>
</td>
<td className="px-4 py-3 align-middle">
<p
className="truncate text-text-primary"
title={pickup || "—"}
>
提 {pickup || "—"}
</p>
)}
</td>
<td className="px-3 py-3 whitespace-nowrap text-text-secondary">
{row.completed_at ? formatDateTime(row.completed_at) : "—"}
</td>
</tr>
))}
<p
className="mt-0.5 truncate text-text-secondary"
title={delivery || "—"}
>
送 {delivery || "—"}
</p>
</td>
<td className="px-4 py-3 align-middle">
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${outcomeBadgeClass(row.outcome)}`}
>
{OUTCOME_LABEL[row.outcome]}
</span>
</td>
<td className="px-4 py-3 align-middle text-text-primary">
{(() => {
const summary = formatQuotedSummary(row);
return (
<>
<p>{summary.primary}</p>
{summary.secondary ? (
<p className="mt-0.5 truncate text-xs text-text-secondary">
{summary.secondary}
</p>
) : null}
</>
);
})()}
</td>
<td className="px-4 py-3 align-middle">
<button
type="button"
onClick={() => setDetail(row)}
className="text-sm font-medium text-primary hover:underline"
>
详情
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@ -191,6 +362,139 @@ export default function QueryLogsPage() {
</div>
</div>
)}
{detail && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setDetail(null)}
>
<div
className="flex h-full w-full max-w-lg flex-col bg-surface shadow-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal
aria-labelledby="query-log-detail-title"
>
<div className="flex items-start justify-between gap-3 border-b border-border px-6 py-4">
<div>
<h3
id="query-log-detail-title"
className="text-lg font-semibold text-text-primary"
>
询价详情
</h3>
<p className="mt-1 text-xs text-text-secondary">
可复制完整地址与货物参数
</p>
</div>
<CopyButton text={buildQueryLogFullCopy(detail)} label="复制全部" />
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<dl>
<DetailField
label="报价单号"
value={detail.quote_id}
copyTextValue={detail.quote_id}
mono
/>
<DetailField
label="客户"
value={detail.customer_id}
copyTextValue={detail.customer_id}
mono
/>
<DetailField
label="查询时间"
value={formatDateTime(detail.created_at)}
/>
<DetailField
label="完成时间"
value={
detail.completed_at
? formatDateTime(detail.completed_at)
: "—"
}
/>
<DetailField
label="结果"
value={
detail.failure_reason && detail.outcome !== "success"
? `${OUTCOME_LABEL[detail.outcome]}:${detail.failure_reason}`
: OUTCOME_LABEL[detail.outcome]
}
/>
<DetailField
label="提货地址"
value={displayOrDash(
preferredAdminAddress(
detail.pickup_selected,
detail.pickup_customer,
),
)}
copyTextValue={buildQueryLogPickupCopy(detail)}
copyLabel="复制地址"
/>
<DetailField
label="派送地址"
value={displayOrDash(
preferredAdminAddress(
detail.delivery_selected,
detail.delivery_customer,
),
)}
copyTextValue={buildQueryLogDeliveryCopy(detail)}
copyLabel="复制地址"
/>
<DetailField
label="货物参数"
value={displayOrDash(detail.cargo_summary)}
copyTextValue={buildQueryLogCargoCopy(detail)}
copyLabel="复制参数"
/>
<div className="border-b border-border py-3">
<div className="flex items-start justify-between gap-3">
<dt className="text-xs text-text-secondary">
报价列表
{quotesOf(detail).length > 0
? `(${quotesOf(detail).length} 档)`
: ""}
</dt>
{quotesOf(detail).length > 0 ? (
<CopyButton
text={buildQueryLogQuotesCopy(quotesOf(detail))}
label="复制报价"
/>
) : null}
</div>
<dd className="mt-2 space-y-1.5">
{quotesOf(detail).length === 0 ? (
<p className="text-sm text-text-secondary">
{detail.quoted_total != null
? `${displayOrDash(detail.quoted_carrier)} · ${formatQuotedTotal(detail.quoted_total)}`
: "—"}
</p>
) : (
quotesOf(detail).map((q, i) => (
<p
key={`${q.carrier}-${q.service_level}-${q.rate_option}-${i}`}
className="text-sm leading-relaxed text-text-primary"
>
{formatQueryLogQuoteLine(q)}
</p>
))
)}
</dd>
</div>
</dl>
</div>
<div className="border-t border-border px-6 py-4">
<SecondaryButton onClick={() => setDetail(null)}>
关闭
</SecondaryButton>
</div>
</div>
</div>
)}
</AdminLayout>
);
}

@ -0,0 +1,797 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
import { Card } from "@/components/ui/card";
import { ErrorBanner } from "@/components/ui/error-banner";
import { InputField } from "@/components/ui/input-field";
import { SelectField } from "@/components/ui/select-field";
import { PrimaryButton, SecondaryButton } from "@/components/ui/primary-button";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import {
adminCreateBusinessCustomer,
adminGetBusinessCustomerUsers,
adminGetBusinessCustomers,
adminGetMarkupConfigs,
adminListTenantUsers,
adminUpdateBusinessCustomer,
adminUpdateMarkupConfig,
} from "@/lib/frontend/api-client";
import { formatDateTime, formatPercent, formatUSD } from "@/lib/frontend/format";
import type {
BusinessCustomerRecord,
BusinessCustomerUserRecord,
DataPageStatus,
MarkupConfig,
MarkupType,
} from "@/lib/frontend/types";
function formatMarkupRule(
row: Pick<
MarkupConfig,
"markup_type" | "markup_percent" | "markup_fixed_amount"
>,
): string {
if (row.markup_type === "fixed") {
const amount = row.markup_fixed_amount ?? 0;
return amount > 0 ? `固定 ${formatUSD(amount)}` : "未配置";
}
return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%";
}
export default function TenantBusinessCustomersPage() {
const params = useParams<{ customer_id: string }>();
const tenantId = decodeURIComponent(params.customer_id);
const { token } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<BusinessCustomerRecord[]>([]);
const [error, setError] = useState<string | null>(null);
/** 业务客户 → 当前加价摘要(打开/保存后刷新) */
const [markupByBcId, setMarkupByBcId] = useState<Record<string, string>>({});
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [createForm, setCreateForm] = useState({
business_customer_id: "",
name: "",
external_code: "",
remark: "",
});
const [editing, setEditing] = useState<BusinessCustomerRecord | null>(null);
const [savingEdit, setSavingEdit] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
name: "",
external_code: "",
remark: "",
status: "active" as "active" | "disabled",
});
const [markupTarget, setMarkupTarget] = useState<BusinessCustomerRecord | null>(
null,
);
const [markupType, setMarkupType] = useState<MarkupType>("percent");
const [markupPercent, setMarkupPercent] = useState("");
const [markupFixedAmount, setMarkupFixedAmount] = useState("");
const [markupRemark, setMarkupRemark] = useState("");
const [savingMarkup, setSavingMarkup] = useState(false);
const [loadingMarkup, setLoadingMarkup] = useState(false);
const [markupError, setMarkupError] = useState<string | null>(null);
const [markupSuccess, setMarkupSuccess] = useState<string | null>(null);
const [usersTarget, setUsersTarget] = useState<BusinessCustomerRecord | null>(
null,
);
const [usersStatus, setUsersStatus] = useState<DataPageStatus>("empty");
const [users, setUsers] = useState<BusinessCustomerUserRecord[]>([]);
const [usersError, setUsersError] = useState<string | null>(null);
const [onlyWithUsers, setOnlyWithUsers] = useState(true);
const [allUsersOpen, setAllUsersOpen] = useState(false);
const [allUsersKeyword, setAllUsersKeyword] = useState("");
const [allUsersStatus, setAllUsersStatus] = useState<DataPageStatus>("empty");
const [allUsers, setAllUsers] = useState<BusinessCustomerUserRecord[]>([]);
const [allUsersError, setAllUsersError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token) return;
setStatus("loading");
setError(null);
const res = await adminGetBusinessCustomers("", token, tenantId);
if (res.code !== 0) {
setStatus("error");
setError(res.message);
return;
}
setRows(res.data.list);
setStatus(res.data.list.length ? "success" : "empty");
}, [tenantId, token]);
useEffect(() => {
void load();
}, [load]);
const sortedRows = useMemo(() => {
const filtered = onlyWithUsers
? rows.filter((r) => (r.user_count ?? 0) > 0)
: rows;
return [...filtered].sort((a, b) => {
const uc = (b.user_count ?? 0) - (a.user_count ?? 0);
if (uc !== 0) return uc;
const ac = (
a.external_code ||
a.name ||
a.business_customer_id
).toLowerCase();
const bc = (
b.external_code ||
b.name ||
b.business_customer_id
).toLowerCase();
return ac.localeCompare(bc);
});
}, [rows, onlyWithUsers]);
const loadAllUsers = useCallback(
async (keyword?: string) => {
if (!token) return;
setAllUsersStatus("loading");
setAllUsersError(null);
const res = await adminListTenantUsers("", token, tenantId, keyword);
if (res.code !== 0) {
setAllUsersStatus("error");
setAllUsersError(res.message);
return;
}
setAllUsers(res.data.list);
setAllUsersStatus(res.data.list.length ? "success" : "empty");
},
[tenantId, token],
);
const openEdit = (row: BusinessCustomerRecord) => {
setEditing(row);
setEditError(null);
setEditForm({
name: row.name,
external_code: row.external_code ?? "",
remark: row.remark ?? "",
status: row.status,
});
};
const handleCreate = async () => {
if (!token) return;
setCreating(true);
setCreateError(null);
const res = await adminCreateBusinessCustomer("", token, tenantId, createForm);
setCreating(false);
if (res.code !== 0) {
setCreateError(res.message);
return;
}
setCreateOpen(false);
setCreateForm({
business_customer_id: "",
name: "",
external_code: "",
remark: "",
});
await load();
};
const handleSaveEdit = async () => {
if (!token || !editing) return;
setSavingEdit(true);
setEditError(null);
const res = await adminUpdateBusinessCustomer(
"",
token,
tenantId,
editing.business_customer_id,
editForm,
);
setSavingEdit(false);
if (res.code !== 0) {
setEditError(res.message);
return;
}
setRows((list) =>
list.map((item) =>
item.business_customer_id === res.data.business_customer_id
? res.data
: item,
),
);
setEditing(null);
};
const openMarkup = async (row: BusinessCustomerRecord) => {
setMarkupTarget(row);
setMarkupType("percent");
setMarkupPercent("");
setMarkupFixedAmount("");
setMarkupRemark("");
setMarkupError(null);
setMarkupSuccess(null);
if (!token) {
setMarkupError("未登录或登录已失效,请重新登录后再保存");
return;
}
setLoadingMarkup(true);
const kw =
row.external_code?.trim() ||
row.business_customer_id ||
row.name ||
"";
const res = await adminGetMarkupConfigs("", token, 1, 50, kw);
setLoadingMarkup(false);
if (res.code !== 0) {
setMarkupError(`读取已有加价失败:${res.message}`);
return;
}
const found = res.data.list.find(
(c) =>
c.customer_id === tenantId &&
(c.business_customer_id ?? "") === row.business_customer_id,
);
if (!found) return;
setMarkupType(found.markup_type);
setMarkupPercent(String(found.markup_percent ?? ""));
setMarkupFixedAmount(
found.markup_fixed_amount !== null && found.markup_fixed_amount !== undefined
? String(found.markup_fixed_amount)
: "",
);
setMarkupRemark(found.remark ?? "");
setMarkupByBcId((m) => ({
...m,
[row.business_customer_id]: formatMarkupRule(found),
}));
};
const handleSaveMarkup = async () => {
if (!markupTarget) return;
if (!token) {
setMarkupError("未登录或登录已失效,请重新登录后再保存");
return;
}
setMarkupError(null);
setMarkupSuccess(null);
let percentValue = 0;
let fixedValue: number | undefined;
if (markupType === "percent") {
const raw = markupPercent.trim();
if (!raw) {
setMarkupError("请填写加价比例(0~30),空值不会保存");
return;
}
const n = Number(raw);
if (Number.isNaN(n) || n < 0 || n > 30) {
setMarkupError("加价比例须在 0~30% 之间");
return;
}
percentValue = n;
} else {
const raw = markupFixedAmount.trim();
if (!raw) {
setMarkupError("请填写固定加价金额(USD),空值不会保存");
return;
}
const n = Number(raw);
if (Number.isNaN(n) || n < 0) {
setMarkupError("固定加价金额须 ≥ 0");
return;
}
fixedValue = n;
}
setSavingMarkup(true);
const res = await adminUpdateMarkupConfig("", token, tenantId, {
business_customer_id: markupTarget.business_customer_id,
markup_type: markupType,
markup_percent: markupType === "percent" ? percentValue : 0,
markup_fixed_amount: markupType === "fixed" ? fixedValue : undefined,
remark: markupRemark,
});
setSavingMarkup(false);
if (res.code !== 0) {
setMarkupError(res.message || "保存失败,请稍后重试");
return;
}
const summary = formatMarkupRule(res.data);
setMarkupByBcId((m) => ({
...m,
[markupTarget.business_customer_id]: summary,
}));
setMarkupSuccess(`已保存:${summary}`);
};
return (
<AdminLayout>
<PageHeader
title="业务客户"
subtitle={`租户 ${tenantId} · 客户组织与登录账号(加价按客户代码,识别靠账号)`}
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
onClick={() => {
setAllUsersOpen(true);
void loadAllUsers(allUsersKeyword);
}}
>
全部登录账号
</SecondaryButton>
<PrimaryButton onClick={() => setCreateOpen(true)}>
新增业务客户
</PrimaryButton>
</div>
}
/>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner action={<SecondaryButton onClick={() => void load()}>重试</SecondaryButton>}>
{error ?? "加载业务客户失败"}
</ErrorBanner>
)}
{status === "empty" && (
<Card className="p-8 text-center text-text-secondary">
当前租户还没有业务客户
</Card>
)}
{status === "success" && (
<Card className="overflow-x-auto p-0">
<div className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-3 text-sm">
<label className="inline-flex items-center gap-2 text-text-secondary">
<input
type="checkbox"
checked={onlyWithUsers}
onChange={(e) => setOnlyWithUsers(e.target.checked)}
/>
仅显示有登录账号的客户({rows.filter((r) => (r.user_count ?? 0) > 0).length}/
{rows.length})
</label>
</div>
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium">客户代码</th>
<th className="px-4 py-3 font-medium">客户名称</th>
<th className="px-4 py-3 font-medium">加价规则</th>
<th className="px-4 py-3 font-medium">登录账号数</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">备注</th>
<th className="px-4 py-3 font-medium">更新时间</th>
<th className="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
{sortedRows.map((row) => (
<tr key={row.business_customer_id} className="border-t border-border">
<td className="px-4 py-3 font-mono">
{row.external_code || "—"}
</td>
<td className="px-4 py-3">{row.name}</td>
<td className="px-4 py-3 font-mono text-primary">
{markupByBcId[row.business_customer_id] ?? "—"}
</td>
<td className="px-4 py-3 font-mono">{row.user_count ?? 0}</td>
<td className="px-4 py-3">{row.status === "active" ? "启用" : "停用"}</td>
<td className="px-4 py-3 text-text-secondary">{row.remark || "—"}</td>
<td className="px-4 py-3 text-text-secondary">
{formatDateTime(row.updated_at)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<SecondaryButton onClick={() => openEdit(row)}>
编辑
</SecondaryButton>
<SecondaryButton
onClick={() => void openMarkup(row)}
>
客户加价
</SecondaryButton>
<SecondaryButton
onClick={async () => {
setUsersTarget(row);
setUsersError(null);
setUsers([]);
setUsersStatus("loading");
if (!token) return;
const res = await adminGetBusinessCustomerUsers(
"",
token,
tenantId,
row.business_customer_id,
);
if (res.code !== 0) {
setUsersStatus("error");
setUsersError(res.message);
return;
}
setUsers(res.data.list);
setUsersStatus(
res.data.list.length ? "success" : "empty",
);
}}
>
登录账号
</SecondaryButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{createOpen && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !creating && setCreateOpen(false)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">新增业务客户</h3>
<div className="mt-6 space-y-4">
<InputField
label="客户 ID"
value={createForm.business_customer_id}
onChange={(e) =>
setCreateForm((prev) => ({
...prev,
business_customer_id: e.target.value,
}))
}
/>
<InputField
label="名称"
value={createForm.name}
onChange={(e) =>
setCreateForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<InputField
label="外部编码"
value={createForm.external_code}
onChange={(e) =>
setCreateForm((prev) => ({
...prev,
external_code: e.target.value,
}))
}
/>
<InputField
label="备注"
value={createForm.remark}
onChange={(e) =>
setCreateForm((prev) => ({ ...prev, remark: e.target.value }))
}
/>
{createError ? <ErrorBanner>{createError}</ErrorBanner> : null}
<div className="flex justify-end gap-2">
<SecondaryButton onClick={() => setCreateOpen(false)}>取消</SecondaryButton>
<PrimaryButton loading={creating} onClick={() => void handleCreate()}>
保存
</PrimaryButton>
</div>
</div>
</div>
</div>
)}
{editing && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !savingEdit && setEditing(null)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">编辑业务客户</h3>
<div className="mt-6 space-y-4">
<InputField
label="名称"
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<InputField
label="外部编码"
value={editForm.external_code}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
external_code: e.target.value,
}))
}
/>
<SelectField
label="状态"
value={editForm.status}
options={[
{ value: "active", label: "启用" },
{ value: "disabled", label: "停用" },
]}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
status: e.target.value as "active" | "disabled",
}))
}
/>
<InputField
label="备注"
value={editForm.remark}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, remark: e.target.value }))
}
/>
{editError ? <ErrorBanner>{editError}</ErrorBanner> : null}
<div className="flex justify-end gap-2">
<SecondaryButton onClick={() => setEditing(null)}>取消</SecondaryButton>
<PrimaryButton loading={savingEdit} onClick={() => void handleSaveEdit()}>
保存
</PrimaryButton>
</div>
</div>
</div>
</div>
)}
{markupTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !savingMarkup && setMarkupTarget(null)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">
客户加价 ·{" "}
{[markupTarget.external_code, markupTarget.name]
.filter(Boolean)
.join(" · ") || markupTarget.business_customer_id}
</h3>
<p className="mt-1 text-sm text-text-secondary">
仅对本业务客户生效;未配置时询价按 0% 处理。
</p>
<div className="mt-6 space-y-4">
{loadingMarkup ? (
<Skeleton className="h-10 w-full" />
) : null}
<SelectField
label="加价方式"
value={markupType}
disabled={savingMarkup || loadingMarkup}
options={[
{ value: "percent", label: "按运费百分比" },
{ value: "fixed", label: "固定金额(USD)" },
]}
onChange={(e) => {
setMarkupType(e.target.value as MarkupType);
setMarkupError(null);
setMarkupSuccess(null);
}}
/>
{markupType === "percent" ? (
<InputField
label="加价比例(%)"
type="number"
step="0.01"
min={0}
max={30}
value={markupPercent}
disabled={savingMarkup || loadingMarkup}
onChange={(e) => {
setMarkupPercent(e.target.value);
setMarkupError(null);
setMarkupSuccess(null);
}}
/>
) : (
<InputField
label="固定金额(USD)"
type="number"
step="0.01"
min={0}
value={markupFixedAmount}
disabled={savingMarkup || loadingMarkup}
onChange={(e) => {
setMarkupFixedAmount(e.target.value);
setMarkupError(null);
setMarkupSuccess(null);
}}
/>
)}
<InputField
label="备注"
value={markupRemark}
disabled={savingMarkup || loadingMarkup}
onChange={(e) => setMarkupRemark(e.target.value)}
/>
<Card className="bg-bg text-sm text-text-secondary">
保存后按客户组织 ID 匹配加价。百分比上限 30%;固定金额为每档报价叠加。空值不会保存,也不会覆盖已有规则。
</Card>
{markupError ? <ErrorBanner>{markupError}</ErrorBanner> : null}
{markupSuccess ? (
<Card className="border border-emerald-200 bg-emerald-50 text-sm text-emerald-900">
{markupSuccess}
</Card>
) : null}
<div className="flex justify-end gap-2">
<SecondaryButton
type="button"
disabled={savingMarkup}
onClick={() => setMarkupTarget(null)}
>
{markupSuccess ? "关闭" : "取消"}
</SecondaryButton>
<PrimaryButton
type="button"
loading={savingMarkup}
disabled={loadingMarkup}
onClick={() => void handleSaveMarkup()}
>
保存
</PrimaryButton>
</div>
</div>
</div>
</div>
)}
{usersTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setUsersTarget(null)}
>
<div
className="flex h-full w-full max-w-lg flex-col bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">
登录账号 ·{" "}
{[usersTarget.external_code, usersTarget.name]
.filter(Boolean)
.join(" · ") || usersTarget.business_customer_id}
</h3>
<p className="mt-1 text-sm text-text-secondary">
来自 ccnew PC/Web 登录账号(LR_Base_User),用于询价识别加价客户。
</p>
<div className="mt-4 flex-1 overflow-auto">
{usersStatus === "loading" ? <Skeleton className="h-24 w-full" /> : null}
{usersError ? <ErrorBanner>{usersError}</ErrorBanner> : null}
{usersStatus === "empty" ? (
<p className="text-sm text-text-secondary">暂无同步账号</p>
) : null}
{usersStatus === "success" ? (
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-3 py-2 font-medium">账号</th>
<th className="px-3 py-2 font-medium">姓名</th>
<th className="px-3 py-2 font-medium">状态</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.external_user_id}
className="border-t border-border"
>
<td className="px-3 py-2 font-mono">{u.account}</td>
<td className="px-3 py-2">{u.display_name || "—"}</td>
<td className="px-3 py-2">
{u.status === "active" ? "启用" : "停用"}
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div className="mt-4 flex justify-end">
<SecondaryButton onClick={() => setUsersTarget(null)}>关闭</SecondaryButton>
</div>
</div>
</div>
)}
{allUsersOpen && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setAllUsersOpen(false)}
>
<div
className="flex h-full w-full max-w-2xl flex-col bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">全部登录账号</h3>
<p className="mt-1 text-sm text-text-secondary">
对应 ccnew「用户管理」中的客户账号;询价靠账号识别所属客户代码再套加价。
</p>
<div className="mt-4 flex gap-2">
<InputField
label="搜索账号 / 客户代码 / 名称"
value={allUsersKeyword}
onChange={(e) => setAllUsersKeyword(e.target.value)}
/>
<div className="flex items-end">
<SecondaryButton onClick={() => void loadAllUsers(allUsersKeyword)}>
查询
</SecondaryButton>
</div>
</div>
<div className="mt-4 flex-1 overflow-auto">
{allUsersStatus === "loading" ? (
<Skeleton className="h-24 w-full" />
) : null}
{allUsersError ? <ErrorBanner>{allUsersError}</ErrorBanner> : null}
{allUsersStatus === "empty" ? (
<p className="text-sm text-text-secondary">暂无账号</p>
) : null}
{allUsersStatus === "success" ? (
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-3 py-2 font-medium">账号</th>
<th className="px-3 py-2 font-medium">姓名</th>
<th className="px-3 py-2 font-medium">客户代码</th>
<th className="px-3 py-2 font-medium">客户名称</th>
<th className="px-3 py-2 font-medium">状态</th>
</tr>
</thead>
<tbody>
{allUsers.map((u) => (
<tr
key={u.external_user_id}
className="border-t border-border"
>
<td className="px-3 py-2 font-mono">{u.account}</td>
<td className="px-3 py-2">{u.display_name || "—"}</td>
<td className="px-3 py-2 font-mono">
{u.business_customer_code || "—"}
</td>
<td className="px-3 py-2">
{u.business_customer_name || "—"}
</td>
<td className="px-3 py-2">
{u.status === "active" ? "启用" : "停用"}
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div className="mt-4 flex justify-end">
<SecondaryButton onClick={() => setAllUsersOpen(false)}>关闭</SecondaryButton>
</div>
</div>
</div>
)}
</AdminLayout>
);
}

@ -0,0 +1 @@
export { default } from "@/app/admin/customers/page";

@ -0,0 +1,75 @@
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 { assertBusinessCustomerBelongsToTenant } from "@/modules/customer/business-customer-service";
import { isKnownCustomerAsync } from "@/modules/auth/service-token";
import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
type MarkupBody = {
markup_type?: unknown;
markup_percent?: unknown;
markup_fixed_amount?: unknown;
remark?: unknown;
};
type RouteContext = {
params: Promise<{ customer_id: string; business_customer_id: string }>;
};
export async function PUT(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: pathCustomerId,
business_customer_id: businessCustomerId,
} = await context.params;
if (!(await isKnownCustomerAsync(pathCustomerId))) {
return fail("VALIDATION_FAILED", "租户不存在", 400);
}
try {
await assertBusinessCustomerBelongsToTenant(pathCustomerId, businessCustomerId);
} catch (error) {
const message = error instanceof Error ? error.message : "业务客户不存在";
return fail("VALIDATION_FAILED", message, 400);
}
let body: MarkupBody;
try {
body = (await request.json()) as MarkupBody;
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const parsed = parseMarkupInput(body);
if ("code" in parsed) {
return fail("VALIDATION_FAILED", parsed.message, 400);
}
const remark =
typeof body.remark === "string" ? body.remark.trim() || null : null;
const config = await upsertMarkupConfig(
pathCustomerId,
businessCustomerId,
parsed,
auth.userId,
remark,
);
await writeAudit(
"markup:update_business_customer",
auth.userId,
`${pathCustomerId}/${businessCustomerId}`,
{
markup_type: config.markup_type,
markup_percent: config.markup_percent,
markup_fixed_amount: config.markup_fixed_amount,
remark,
},
);
return ok(config);
}

@ -7,6 +7,7 @@ import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
type MarkupBody = {
business_customer_id?: unknown;
markup_type?: unknown;
markup_percent?: unknown;
markup_fixed_amount?: unknown;
@ -48,9 +49,17 @@ export async function PUT(request: Request, context: RouteContext) {
const remark =
typeof body.remark === "string" ? body.remark.trim() || null : null;
const businessCustomerId =
typeof body.business_customer_id === "string"
? body.business_customer_id.trim() || null
: null;
if (!businessCustomerId) {
return fail("VALIDATION_FAILED", "仅支持业务客户加价,租户不加价", 400);
}
const config = await upsertMarkupConfig(
pathCustomerId,
businessCustomerId,
parsed,
auth.userId,
remark,

@ -63,6 +63,8 @@ export async function GET(request: Request) {
}
const customerId = searchParams.get("customer_id")?.trim() || undefined;
const businessCustomerId =
searchParams.get("business_customer_id")?.trim() || undefined;
const outcomeRaw = searchParams.get("outcome")?.trim();
if (outcomeRaw && !VALID_OUTCOMES.includes(outcomeRaw as QuoteQueryOutcome)) {
return fail("VALIDATION_FAILED", "无效的查询结果筛选", 400);
@ -72,6 +74,7 @@ export async function GET(request: Request) {
page: pagination.page,
size: pagination.size,
customerId,
businessCustomerId,
outcome: outcomeRaw as QuoteQueryOutcome | undefined,
});

@ -0,0 +1,83 @@
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 {
updateBusinessCustomer,
} from "@/modules/customer/business-customer-service";
import type { BusinessCustomerStatus } from "@/modules/customer/types";
type RouteContext = {
params: Promise<{ customer_id: string; business_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,
business_customer_id: businessCustomerId,
} = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const payload = body as {
name?: unknown;
external_code?: unknown;
status?: unknown;
remark?: unknown;
};
const input: {
name?: string;
externalCode?: string | null;
status?: BusinessCustomerStatus;
remark?: string | null;
} = {};
if (payload.name !== undefined) {
if (typeof payload.name !== "string") {
return fail("VALIDATION_FAILED", "客户名称无效", 400);
}
input.name = payload.name;
}
if (payload.external_code !== undefined) {
input.externalCode =
typeof payload.external_code === "string" ? payload.external_code : null;
}
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;
}
try {
const updated = await updateBusinessCustomer(
customerId,
businessCustomerId,
input,
);
await writeAudit(
"business_customer:update",
auth.userId,
`${customerId}/${businessCustomerId}`,
updated,
);
return ok(updated);
} catch (error) {
const message = error instanceof Error ? error.message : "更新业务客户失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -0,0 +1,41 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { listUsersByBusinessCustomer } from "@/modules/customer/business-customer-user-service";
import { assertBusinessCustomerBelongsToTenant } from "@/modules/customer/business-customer-service";
import { ValidationError } from "@/modules/quote/types";
type RouteContext = {
params: Promise<{ customer_id: string; business_customer_id: string }>;
};
/** GET 业务客户下登录用户列表 */
export async function GET(request: Request, context: RouteContext) {
try {
parseAdminAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const { customer_id: customerId, business_customer_id: businessCustomerId } =
await context.params;
try {
await assertBusinessCustomerBelongsToTenant(customerId, businessCustomerId);
const list = await listUsersByBusinessCustomer(
customerId,
businessCustomerId,
);
return ok({
customer_id: customerId,
business_customer_id: businessCustomerId,
list,
});
} catch (error) {
if (error instanceof ValidationError) {
return fail("VALIDATION_FAILED", error.message, 400);
}
throw error;
}
}

@ -0,0 +1,79 @@
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 {
createBusinessCustomer,
listBusinessCustomers,
} from "@/modules/customer/business-customer-service";
type RouteContext = {
params: Promise<{ customer_id: string }>;
};
export async function GET(request: Request, context: RouteContext) {
try {
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;
const list = await listBusinessCustomers(customerId);
return ok({ customer_id: customerId, list });
}
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;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const payload = body as {
business_customer_id?: unknown;
name?: unknown;
external_code?: unknown;
remark?: unknown;
};
if (typeof payload.business_customer_id !== "string") {
return fail("VALIDATION_FAILED", "业务客户标识无效", 400);
}
if (typeof payload.name !== "string") {
return fail("VALIDATION_FAILED", "客户名称无效", 400);
}
try {
const created = await createBusinessCustomer({
customerId,
businessCustomerId: payload.business_customer_id,
name: payload.name,
externalCode:
typeof payload.external_code === "string"
? payload.external_code
: undefined,
remark: typeof payload.remark === "string" ? payload.remark : undefined,
});
await writeAudit(
"business_customer:create",
auth.userId,
`${customerId}/${created.business_customer_id}`,
created,
);
return ok(created);
} catch (error) {
const message = error instanceof Error ? error.message : "创建业务客户失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -0,0 +1 @@
export { GET, PUT } from "@/app/api/admin/customers/[customer_id]/provider-credentials/route";

@ -0,0 +1 @@
export { POST } from "@/app/api/admin/customers/[customer_id]/rotate-key/route";

@ -0,0 +1 @@
export { PATCH } from "@/app/api/admin/customers/[customer_id]/route";

@ -0,0 +1,30 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { resolveBusinessCustomerByAccount } from "@/modules/customer/business-customer-user-service";
type RouteContext = {
params: Promise<{ customer_id: string }>;
};
/** GET ?account= 按登录账号反查业务客户 */
export async function GET(request: Request, context: RouteContext) {
try {
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;
const account = new URL(request.url).searchParams.get("account")?.trim() || "";
if (!account) {
return fail("VALIDATION_FAILED", "请填写登录账号", 400);
}
const resolved = await resolveBusinessCustomerByAccount(customerId, account);
if (!resolved) {
return fail("NOT_FOUND", "未找到该账号对应的业务客户", 404);
}
return ok(resolved);
}

@ -0,0 +1,25 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { listUsersByTenant } from "@/modules/customer/business-customer-user-service";
type RouteContext = {
params: Promise<{ customer_id: string }>;
};
/** GET 租户下全部客户登录账号(可 ?keyword=) */
export async function GET(request: Request, context: RouteContext) {
try {
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;
const keyword =
new URL(request.url).searchParams.get("keyword")?.trim() || undefined;
const list = await listUsersByTenant(customerId, keyword);
return ok({ customer_id: customerId, list, total: list.length });
}

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/admin/customers/route";

@ -0,0 +1,149 @@
import { parseServiceAuth } from "@/lib/api/auth-context";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import {
createBusinessCustomer,
updateBusinessCustomer,
listBusinessCustomers,
} from "@/modules/customer/business-customer-service";
import { upsertBusinessCustomerUsers } from "@/modules/customer/business-customer-user-service";
import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { ValidationError } from "@/modules/quote/types";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const customerItemSchema = z.object({
business_customer_id: z.string().min(1),
name: z.string().min(1),
external_code: z.string().nullable().optional(),
remark: z.string().nullable().optional(),
markup_percent: z.number().min(0).max(30).optional(),
});
const userItemSchema = z.object({
business_customer_id: z.string().min(1),
external_user_id: z.string().min(1),
account: z.string().min(1),
display_name: z.string().nullable().optional(),
email: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
main_external_user_id: z.string().nullable().optional(),
status: z.enum(["active", "inactive"]).optional(),
});
const bodySchema = z.object({
customers: z.array(customerItemSchema).default([]),
users: z.array(userItemSchema).default([]),
});
/**
* 宿主推送:同步业务客户(代码/简称)+ 下属登录账号
* Authorization: Bearer <Service API Key>
* X-Customer-Id: CUST_004
*/
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;
}
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
const tenantId = auth.customerId;
let customerCreated = 0;
let customerUpdated = 0;
try {
for (const row of parsed.data.customers) {
const existing = await prisma.businessCustomer.findFirst({
where: {
customerId: tenantId,
businessCustomerId: row.business_customer_id,
isDeleted: false,
},
});
if (existing) {
await updateBusinessCustomer(tenantId, row.business_customer_id, {
name: row.name,
externalCode: row.external_code ?? null,
remark: row.remark ?? null,
status: "active",
});
customerUpdated += 1;
} else {
await createBusinessCustomer({
customerId: tenantId,
businessCustomerId: row.business_customer_id,
name: row.name,
externalCode: row.external_code ?? null,
remark: row.remark ?? null,
});
customerCreated += 1;
}
if (typeof row.markup_percent === "number") {
await upsertMarkupConfig(
tenantId,
row.business_customer_id,
{
markupType: "percent",
markupPercent: row.markup_percent,
markupFixedAmount: null,
},
"host-customers-sync",
"宿主同步加价",
);
}
}
const userResult = await upsertBusinessCustomerUsers(
tenantId,
parsed.data.users.map((u) => ({
businessCustomerId: u.business_customer_id,
externalUserId: u.external_user_id,
account: u.account,
displayName: u.display_name,
email: u.email,
mobile: u.mobile,
mainExternalUserId: u.main_external_user_id,
status: u.status,
})),
);
const customers = await listBusinessCustomers(tenantId);
return ok({
customer_id: tenantId,
customers: {
created: customerCreated,
updated: customerUpdated,
total: parsed.data.customers.length,
},
users: userResult,
business_customer_count: customers.length,
});
} catch (error) {
if (error instanceof ValidationError) {
return fail("VALIDATION_FAILED", error.message, 400);
}
throw error;
}
}

@ -1,4 +1,4 @@
import { parseServiceAuth } from "@/lib/api/auth-context";
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";
@ -63,6 +63,10 @@ export async function POST(request: Request) {
return fail("VALIDATION_FAILED", error.message, 400);
}
console.error("[host/quote/submit]", error);
return fail("INTERNAL_ERROR", "询价处理失败", 500);
const detail =
error instanceof Error
? `${error.name}: ${(error.message || "").slice(0, 200)}`
: "UnknownError";
return fail("INTERNAL_ERROR", `询价处理失败:${detail}`, 500);
}
}

@ -0,0 +1,73 @@
import { parseServiceAuth } from "@/lib/api/auth-context";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { requirePermission } from "@/modules/auth/rbac";
import { isKnownCustomerAsync } from "@/modules/auth/service-token";
import { assertBusinessCustomerBelongsToTenant } from "@/modules/customer/business-customer-service";
import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
type MarkupBody = {
markup_type?: unknown;
markup_percent?: unknown;
markup_fixed_amount?: unknown;
operator_id?: unknown;
remark?: unknown;
};
type RouteContext = {
params: Promise<{ customer_id: string; business_customer_id: string }>;
};
export async function PUT(request: Request, context: RouteContext) {
let auth;
try {
auth = await parseServiceAuth(request);
requirePermission(auth, "pricing:markup:write");
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const {
customer_id: pathCustomerId,
business_customer_id: businessCustomerId,
} = await context.params;
if (!(await isKnownCustomerAsync(pathCustomerId))) {
return fail("VALIDATION_FAILED", "租户不存在", 400);
}
if (auth.customerId !== pathCustomerId) {
return fail("FORBIDDEN", "无权修改该租户客户加价配置", 403);
}
try {
await assertBusinessCustomerBelongsToTenant(pathCustomerId, businessCustomerId);
} catch (error) {
const message = error instanceof Error ? error.message : "业务客户不存在";
return fail("VALIDATION_FAILED", message, 400);
}
let body: MarkupBody;
try {
body = (await request.json()) as MarkupBody;
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const parsed = parseMarkupInput(body);
if ("code" in parsed) {
return fail("VALIDATION_FAILED", parsed.message, 400);
}
const operatorId =
typeof body.operator_id === "string" && body.operator_id.trim()
? body.operator_id.trim()
: auth.customerId;
const remark =
typeof body.remark === "string" ? body.remark.trim() || null : null;
const config = await upsertMarkupConfig(
pathCustomerId,
businessCustomerId,
parsed,
operatorId,
remark,
);
return ok(config);
}

@ -7,6 +7,7 @@ import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
type MarkupBody = {
business_customer_id?: unknown;
markup_type?: unknown;
markup_percent?: unknown;
markup_fixed_amount?: unknown;
@ -59,9 +60,17 @@ export async function PUT(request: Request, context: RouteContext) {
const remark =
typeof body.remark === "string" ? body.remark.trim() || null : null;
const businessCustomerId =
typeof body.business_customer_id === "string"
? body.business_customer_id.trim() || null
: null;
if (!businessCustomerId) {
return fail("VALIDATION_FAILED", "仅支持业务客户加价,租户不加价", 400);
}
const config = await upsertMarkupConfig(
pathCustomerId,
businessCustomerId,
parsed,
operatorId,
remark,

@ -32,8 +32,9 @@ function parsePagination(searchParams: URLSearchParams): {
}
export async function GET(request: Request) {
let auth;
try {
await parseServiceAuth(request);
auth = await parseServiceAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
@ -41,13 +42,19 @@ export async function GET(request: Request) {
throw error;
}
const pagination = parsePagination(new URL(request.url).searchParams);
const searchParams = new URL(request.url).searchParams;
const pagination = parsePagination(searchParams);
if (!pagination) {
return fail("VALIDATION_FAILED", "请提供有效的 page 和 size 参数", 400);
}
const { page, size } = pagination;
const where = { isDeleted: false };
const businessCustomerId = searchParams.get("business_customer_id")?.trim();
const where = {
customerId: auth.customerId,
businessCustomerId: businessCustomerId || undefined,
isDeleted: false,
};
const [total, configs] = await Promise.all([
prisma.markupConfig.count({ where }),

@ -4,6 +4,27 @@ import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { recordSecurityEvent } from "@/modules/audit/service";
import { serializeQuoteDetail } from "@/modules/quote/quote-serializer";
import { toMsRefineHoldPublic } from "@/lib/constants/ms-refine-hold";
import { readMsRefineHoldByQuoteId } from "@/lib/mothership/refine-hold-store";
import { readMsCheckoutStatus, toMsCheckoutPublic } from "@/lib/mothership/ms-checkout-store";
import {
readFlockCheckoutStatus,
toFlockCheckoutPublic,
} from "@/lib/flock/flock-checkout-store";
import {
readFlockPricingOptions,
toFlockPricingOptionsPublic,
} from "@/lib/flock/flock-pricing-options-store";
import {
toFlockQuoteHoldPublic,
shouldAutoDeclineFlockHold,
} from "@/lib/constants/flock-quote-hold";
import {
readFlockHoldCarriers,
readFlockHoldFlexibility,
readFlockQuoteHoldByQuoteId,
} from "@/lib/flock/flock-quote-hold-store";
import { declineFlockQuoteHold } from "@/modules/flock/hold-service";
type RouteContext = {
params: Promise<{ quote_id: string }>;
@ -21,6 +42,9 @@ export async function GET(request: Request, context: RouteContext) {
}
const { quote_id: quoteId } = await context.params;
const businessCustomerId = new URL(request.url).searchParams
.get("business_customer_id")
?.trim();
const record = await prisma.quoteRecord.findFirst({
where: { quoteId, isDeleted: false },
@ -42,6 +66,90 @@ export async function GET(request: Request, context: RouteContext) {
);
return fail("FORBIDDEN", "无权访问该报价", 403);
}
if (
businessCustomerId &&
(record.businessCustomerId ?? "") !== businessCustomerId
) {
return fail("FORBIDDEN", "无权访问该业务客户报价", 403);
}
const detail = serializeQuoteDetail(record);
const hold = await readMsRefineHoldByQuoteId(quoteId);
if (hold && hold.customer_id === auth.customerId) {
// 填写中不自动放弃;仅在仍可用时透出 hold
const pub = toMsRefineHoldPublic(hold);
if (pub.available || hold.status === "refining" || hold.status === "filling") {
detail.refine_hold = {
...pub,
available:
hold.status === "refining" || hold.status === "filling"
? true
: pub.available,
};
}
}
const checkout = await readMsCheckoutStatus(quoteId);
if (checkout) {
detail.ms_checkout = toMsCheckoutPublic(checkout);
}
const flockCheckout = await readFlockCheckoutStatus(quoteId);
if (flockCheckout) {
detail.flock_checkout = toFlockCheckoutPublic(flockCheckout);
}
const flockPricing = await readFlockPricingOptions(quoteId);
if (flockPricing) {
detail.flock_pricing_options = toFlockPricingOptionsPublic(flockPricing);
}
const flockHold = await readFlockQuoteHoldByQuoteId(quoteId);
if (flockHold && flockHold.customer_id === auth.customerId) {
if (shouldAutoDeclineFlockHold(flockHold)) {
await declineFlockQuoteHold({
customerId: auth.customerId,
quoteId,
sessionId: flockHold.quote_session_id,
}).catch(() => undefined);
} else if (
(flockHold.status === "filling" ||
flockHold.status === "checking_out") &&
flockHold.total_deadline_ms <= Date.now()
) {
await declineFlockQuoteHold({
customerId: auth.customerId,
quoteId,
sessionId: flockHold.quote_session_id,
}).catch(() => undefined);
} else {
const pub = toFlockQuoteHoldPublic(flockHold);
if (
pub.available ||
flockHold.status === "filling" ||
flockHold.status === "checking_out"
) {
detail.flock_hold = {
...pub,
available:
flockHold.status === "filling" ||
flockHold.status === "checking_out"
? true
: pub.available,
};
}
}
}
const flockFlex = await readFlockHoldFlexibility(quoteId);
if (flockFlex && Object.keys(flockFlex).length > 0) {
detail.flock_flexibility_by_tier = flockFlex;
}
const flockCarriers = await readFlockHoldCarriers(quoteId);
if (flockCarriers && Object.keys(flockCarriers).length > 0) {
detail.flock_carriers_by_tier = flockCarriers;
}
return ok(serializeQuoteDetail(record));
return ok(detail);
}

@ -0,0 +1,57 @@
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 { flockCheckoutSchema } from "@/modules/flock/checkout-validation";
import { submitFlockCheckoutFill } from "@/modules/flock/checkout-service";
/** Flock 登录态:选档 → fill-only 结账(不支付) */
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 = flockCheckoutSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await submitFlockCheckoutFill({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
preferredTier: parsed.data.preferred_tier,
preferredFlexibility: parsed.data.preferred_flexibility,
preferredCarrier: parsed.data.preferred_carrier,
flockDetails: parsed.data.flock_details,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "提交结账失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -0,0 +1,54 @@
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 { flockHoldContinueSchema } from "@/modules/flock/hold-validation";
import { continueFlockQuoteHold } from "@/modules/flock/hold-service";
/** 确认继续填写二级(跳过 60s 决策窗 auto-decline) */
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 = flockHoldContinueSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await continueFlockQuoteHold({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
sessionId: parsed.data.quote_session_id,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "确认失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -0,0 +1,54 @@
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 { flockHoldDeclineSchema } from "@/modules/flock/hold-validation";
import { declineFlockQuoteHold } from "@/modules/flock/hold-service";
/** 不继续填写二级 / 60s 决策超时 → 释放驻留 */
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 = flockHoldDeclineSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await declineFlockQuoteHold({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
sessionId: parsed.data.quote_session_id,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "释放失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -0,0 +1,54 @@
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 { flockPricingOptionsSchema } from "@/modules/flock/pricing-options-validation";
import { submitFlockPricingOptions } from "@/modules/flock/pricing-options-service";
/** Flock 登录态:选档后拉取三档灵活性价(不结账) */
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 = flockPricingOptionsSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await submitFlockPricingOptions({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
preferredTier: parsed.data.preferred_tier,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "拉取档内报价失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -42,13 +42,19 @@ export async function GET(request: Request) {
throw error;
}
const pagination = parsePagination(new URL(request.url).searchParams);
const searchParams = new URL(request.url).searchParams;
const pagination = parsePagination(searchParams);
if (!pagination) {
return fail("VALIDATION_FAILED", "请提供有效的 page 和 size 参数", 400);
}
const { page, size } = pagination;
const where = { customerId: auth.customerId, isDeleted: false };
const businessCustomerId = searchParams.get("business_customer_id")?.trim();
const where = {
customerId: auth.customerId,
businessCustomerId: businessCustomerId || undefined,
isDeleted: false,
};
const [total, records] = await Promise.all([
prisma.quoteRecord.count({ where }),

@ -113,6 +113,7 @@ export async function POST(request: Request) {
quoteId: true,
requestId: true,
customerId: true,
businessCustomerId: true,
cargoHash: true,
status: true,
},
@ -122,7 +123,10 @@ export async function POST(request: Request) {
return fail("QUOTE_NOT_FOUND", "询价记录不存在", 404);
}
const markupRule = await getMarkupRule(record.customerId);
const markupRule = await getMarkupRule(
record.customerId,
record.businessCustomerId,
);
const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule);
const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS);
const cachePayload = { quotes: uiQuotes };

@ -0,0 +1,59 @@
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 { msCheckoutSchema } from "@/modules/mothership/checkout-validation";
import { submitMsCheckoutFill } from "@/modules/mothership/checkout-service";
/** MotherShip 登录态:选承运商 + 保障 → fill-only 结账(不支付) */
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 = msCheckoutSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await submitMsCheckoutFill({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
preferredCarrier: parsed.data.preferred_carrier,
coverage: parsed.data.coverage,
cargoValueUsd: parsed.data.cargo_value_usd,
mothershipDetails: parsed.data.mothership_details,
pickupAccessorials: parsed.data.pickup_accessorials,
deliveryAccessorials: parsed.data.delivery_accessorials,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "提交结账失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -0,0 +1,62 @@
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 { msRefineDetailsSchema } from "@/modules/mothership/refine-validation";
import { submitMsRefineDetails } from "@/modules/mothership/refine-service";
import type { QuoteRequestBody } from "@/lib/frontend/types";
/** 提交二级 Details → 驻留页 Save&update 刷价 */
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 = msRefineDetailsSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await submitMsRefineDetails({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
sessionId: parsed.data.quote_session_id,
mothershipDetails:
parsed.data.mothership_details as QuoteRequestBody["mothership_details"],
pickupAccessorials: parsed.data.pickup_accessorials,
deliveryAccessorials: parsed.data.delivery_accessorials,
readyDate: parsed.data.ready_date,
readyTime: parsed.data.ready_time,
cargoLines: parsed.data.cargo_lines,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "提交刷价失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -0,0 +1,54 @@
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 { msRefineContinueSchema } from "@/modules/mothership/refine-validation";
import { continueMsRefineHold } from "@/modules/mothership/refine-service";
/** 确认继续填写二级(跳过 60s 决策窗 auto-decline) */
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 = msRefineContinueSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await continueMsRefineHold({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
sessionId: parsed.data.quote_session_id,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "确认失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -0,0 +1,54 @@
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 { msRefineDeclineSchema } from "@/modules/mothership/refine-validation";
import { declineMsRefineHold } from "@/modules/mothership/refine-service";
/** 不继续填写二级 / 60s 决策超时 → 释放驻留 */
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 = msRefineDeclineSchema.safeParse(body);
if (!parsed.success) {
return fail(
"VALIDATION_FAILED",
parsed.error.issues[0]?.message ?? "参数无效",
400,
);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
const result = await declineMsRefineHold({
customerId: parsed.data.customer_id,
quoteId: parsed.data.quote_id,
sessionId: parsed.data.quote_session_id,
});
return ok(result);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
const msg = error instanceof Error ? error.message : "释放失败";
return fail("VALIDATION_FAILED", msg, 400);
}
}

@ -1,4 +1,4 @@
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
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";
@ -70,6 +70,10 @@ export async function POST(request: Request) {
return fail("VALIDATION_FAILED", error.message, 400);
}
console.error("[POST /api/quotes] 异常:", error);
return fail("INTERNAL_ERROR", "询价处理失败", 500);
const detail =
error instanceof Error
? `${error.name}: ${(error.message || "").slice(0, 200)}`
: "UnknownError";
return fail("INTERNAL_ERROR", `询价处理失败:${detail}`, 500);
}
}

@ -50,15 +50,15 @@ export default function DashboardPage() {
icon: ListMagnifyingGlass,
},
{
href: "/admin/customers",
title: "客户管理",
desc: "新增客户、签发 API Key 与状态管理",
href: "/admin/tenants",
title: "租户管理",
desc: "新增租户、签发 API Key 与状态管理",
icon: Users,
},
{
href: "/admin/markup",
title: "加价配置",
desc: "按客户设置报价加价比例",
title: "客户加价",
desc: "仅对业务客户配置加价,未配置按 0% 处理",
icon: Percent,
},
];

@ -0,0 +1,166 @@
/**
* E2E fixture:Flock hold 弹窗 + 右栏选档门禁(不依赖官网 RPA)
*/
"use client";
import { useMemo, useState } from "react";
import { FlockQuoteHoldPrompt } from "@/components/flock/flock-quote-hold-prompt";
import { FlockLoggedInQuoteSidebar } from "@/components/flock/flock-logged-in-quote-sidebar";
import type {
FlockCheckoutTier,
FlockFlexibilityKey,
} from "@/lib/flock/flock-checkout-selection";
import type { FlockDisplayLine } from "@/modules/flock/quote-storage";
const LINES: FlockDisplayLine[] = [
{
tier: "flock_direct",
serviceLevel: "guaranteed",
rateOption: "fastest",
carrier: "Flock Freight",
label: "FlockDirect®",
totalUsd: 1221,
transitDays: "3",
transitDescription: "3 天(含周末)",
final_total_usd: 1221,
markup_amount: 0,
},
{
tier: "standard",
serviceLevel: "standard",
rateOption: "lowest",
carrier: "Flock Freight",
label: "Standard",
totalUsd: 1011,
transitDays: "2-5",
transitDescription: "2–5 个工作日(预估)",
final_total_usd: 1011,
markup_amount: 0,
},
];
const FLEX = {
flock_direct: [
{
key: "2_day" as const,
label: "2-day flexibility",
rateUsd: 1221,
pickupWindow: "Jul 30 – Aug 3",
deliverBy: "Aug 6",
},
{
key: "1_day" as const,
label: "1-day flexibility",
rateUsd: 1250,
pickupWindow: "Jul 30 – 31",
deliverBy: "Aug 3",
},
{
key: "none" as const,
label: "No flexibility",
rateUsd: 1292,
pickupWindow: "Jul 30",
deliverBy: "Aug 3",
},
],
standard: [
{
key: "2_day" as const,
label: "2-day flexibility",
rateUsd: 1011,
pickupWindow: "Jul 30 – Aug 3",
deliverBy: "Aug 8",
},
{
key: "1_day" as const,
label: "1-day flexibility",
rateUsd: 1080,
pickupWindow: "Jul 30 – 31",
deliverBy: "Aug 5",
},
{
key: "none" as const,
label: "No flexibility",
rateUsd: 1150,
pickupWindow: "Jul 30",
deliverBy: "Aug 4",
},
],
};
export default function FlockHoldE2eFixturePage() {
const now = useMemo(() => Date.now(), []);
const [holdOpen, setHoldOpen] = useState(true);
const [holdAccepted, setHoldAccepted] = useState(false);
const [selectedTier, setSelectedTier] = useState<FlockCheckoutTier | null>(
null,
);
const [selectedFlexibility, setSelectedFlexibility] =
useState<FlockFlexibilityKey | null>(null);
const [checkoutMessage, setCheckoutMessage] = useState<string | null>(null);
const [lastAction, setLastAction] = useState<string>("idle");
return (
<main className="mx-auto max-w-5xl space-y-4 p-6" data-testid="flock-hold-fixture">
<h1 className="text-xl font-semibold">Flock Hold E2E Fixture</h1>
<p data-testid="last-action">lastAction={lastAction}</p>
<p data-testid="hold-accepted">holdAccepted={String(holdAccepted)}</p>
<FlockLoggedInQuoteSidebar
lines={LINES}
reference="JKR-RH3X"
flexibilityByTier={FLEX}
selectedTier={selectedTier}
selectedFlexibility={selectedFlexibility}
detailsUnlocked={holdAccepted}
checkoutMessage={checkoutMessage}
onSelectTier={(t) => {
setSelectedTier(t);
setSelectedFlexibility(null);
setLastAction(`select-tier:${t}`);
}}
onSelectFlexibility={(k) => {
setSelectedFlexibility(k);
setLastAction(`select-flex:${k}`);
}}
onSelectCarrier={(name) => {
setSelectedFlexibility(null);
setLastAction(`select-carrier:${name}`);
}}
onCheckout={() => {
if (!holdAccepted) {
setCheckoutMessage(
"请先确认「继续填写」,再点击「查看报价选项」选择灵活价",
);
setLastAction("checkout-blocked-hold");
return;
}
if (!selectedTier || !selectedFlexibility) {
setCheckoutMessage("请先选择服务档与灵活性报价");
setLastAction("checkout-blocked-selection");
return;
}
setCheckoutMessage(null);
setLastAction("checkout-ok");
}}
/>
{holdOpen ? (
<FlockQuoteHoldPrompt
decisionDeadlineMs={now + 60_000}
totalDeadlineMs={now + 300_000}
onContinue={() => {
setHoldAccepted(true);
setHoldOpen(false);
setLastAction("hold-continue");
}}
onDecline={() => {
setHoldAccepted(false);
setHoldOpen(false);
setLastAction("hold-decline");
}}
/>
) : null}
</main>
);
}

@ -0,0 +1,20 @@
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
/**
* 生产环境默认关闭 E2E fixture(安全审查 #3)。
* 本地 / CI / 显式 ALLOW_E2E_FIXTURES=1 可访问。
*/
export default function E2eFixturesLayout({
children,
}: {
children: ReactNode;
}) {
const allow =
process.env.NODE_ENV !== "production" ||
process.env.ALLOW_E2E_FIXTURES === "1";
if (!allow) {
notFound();
}
return children;
}

@ -0,0 +1,136 @@
/**
* E2E fixture:MotherShip 二级 Details + 侧栏(不依赖官网 RPA)
*/
"use client";
import { useState } from "react";
import { MothershipLoggedInDetailsForm } from "@/components/mothership/mothership-logged-in-details-form";
import { MothershipLoggedInQuoteSidebar } from "@/components/mothership/mothership-logged-in-quote-sidebar";
import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form";
import type { QuoteDetail } from "@/lib/frontend/types";
const pickup = {
option_id: "fixture-pickup",
display_label: "Los Angeles, CA 90001",
formatted_address: "Los Angeles, CA 90001",
street: "Main St",
city: "Los Angeles",
state: "CA",
zip: "90001",
};
const delivery = {
option_id: "fixture-delivery",
display_label: "Dallas, TX 75201",
formatted_address: "Dallas, TX 75201",
street: "Commerce St",
city: "Dallas",
state: "TX",
zip: "75201",
};
const INITIAL: MothershipLoggedInShipmentPayload = {
pickupQuery: pickup.display_label,
deliveryQuery: delivery.display_label,
pickupConfirmed: pickup,
deliveryConfirmed: delivery,
pickupAccessorials: [],
deliveryAccessorials: [],
readyDate: "2026-07-31",
readyTime: "08:00 AM",
timezone: "America/Los_Angeles",
cargo: [
{
cargoType: "pallet",
quantity: 2,
weightLb: 500,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
},
],
};
const QUOTE: QuoteDetail = {
quote_id: "QTE_FIXTURE_MS_L2",
request_id: "req-fixture-ms-l2",
status: "done",
source_type: "rpa",
is_realtime: true,
currency: "USD",
quotes: [
{
service_level: "standard",
rate_option: "lowest",
carrier: "TForce Freight Direct",
transit_days: "3",
transit_description: "Dedicated Dry Van",
raw_freight: 420,
surcharges: 35,
raw_total: 455,
markup_percent: 0,
markup_amount: 0,
final_total: 455,
breakdown: [],
},
{
service_level: "guaranteed",
rate_option: "fastest",
carrier: "TForce Freight Direct",
transit_days: "2",
transit_description: "Guaranteed",
raw_freight: 510,
surcharges: 35,
raw_total: 545,
markup_percent: 0,
markup_amount: 0,
final_total: 545,
breakdown: [],
},
{
service_level: "standard",
rate_option: "lowest",
carrier: "Echo Global Logistics",
transit_days: "4",
transit_description: "Standard LTL",
raw_freight: 390,
surcharges: 20,
raw_total: 410,
markup_percent: 0,
markup_amount: 0,
final_total: 410,
breakdown: [],
},
],
};
export default function MsL2UiFixturePage() {
const [lastAction, setLastAction] = useState("idle");
return (
<main className="mx-auto grid max-w-6xl gap-4 p-4 md:grid-cols-[1fr_360px]">
<div data-testid="ms-l2-details">
<MothershipLoggedInDetailsForm
initial={INITIAL}
highlightRequiredGaps
onValidSubmit={() => setLastAction("details-submit")}
/>
</div>
<div data-testid="ms-l2-sidebar">
<MothershipLoggedInQuoteSidebar
payload={INITIAL}
quote={QUOTE}
status="success"
error={null}
onCheckout={() => setLastAction("checkout-ok")}
/>
</div>
<p
data-testid="last-action"
className="md:col-span-2 text-xs text-text-secondary"
>
lastAction={lastAction}
</p>
</main>
);
}

@ -5,12 +5,12 @@ import { useSearchParams } from "next/navigation";
import { AppLayout } from "@/components/layout/app-layout";
import { QuoteRouteHub } from "@/components/embed/quote-route-hub";
import { EmbedDemoLogin } from "@/components/embed/embed-demo-login";
import { Card } from "@/components/ui/card";
import { SecondaryButton } from "@/components/ui/primary-button";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorBanner } from "@/components/ui/error-banner";
import {
isEmbedHostedShell,
parseEmbedEntryModule,
parseEmbedSsoParams,
stripEmbedSsoQueryFromUrl,
} from "@/lib/embed/sso-params";
@ -21,6 +21,8 @@ import {
embedDemoMe,
type EmbedDemoSession,
} from "@/lib/frontend/api-client";
import { HostBridgeProvider } from "@/lib/embed/host-bridge-react";
import type { ChajiaModuleId } from "@/lib/embed/host-bridge";
export function EmbedDemoClient() {
const searchParams = useSearchParams();
@ -28,6 +30,7 @@ export function EmbedDemoClient() {
const [session, setSession] = useState<EmbedDemoSession | null>(null);
const [bootError, setBootError] = useState<string | null>(null);
const [hostedShell, setHostedShell] = useState(false);
const [entryModule, setEntryModule] = useState<ChajiaModuleId | null>(null);
useEffect(() => {
let cancelled = false;
@ -36,7 +39,11 @@ export function EmbedDemoClient() {
const params = new URLSearchParams(searchParams.toString());
const sso = parseEmbedSsoParams(params);
const hosted = isEmbedHostedShell(params);
if (!cancelled) setHostedShell(hosted);
const entry = parseEmbedEntryModule(params);
if (!cancelled) {
setHostedShell(hosted);
setEntryModule(entry);
}
// 宿主直传凭证:自动登录,跳过登录页(勿先 logout)
if (sso.mode === "api_key") {
@ -88,8 +95,6 @@ export function EmbedDemoClient() {
};
}, [searchParams]);
const isKeyLogin = session?.login_type === "api_key";
const handleLogout = async () => {
await embedDemoLogout("");
setSession(null);
@ -123,27 +128,29 @@ export function EmbedDemoClient() {
);
}
// 宿主嵌入/正式查价:不展示登录态外壳、账号区与说明卡,只留查价区
const quoteBody = (
<HostBridgeProvider initialModule={entryModule}>
<QuoteRouteHub
customerId={session.customer_id}
apiBaseUrl=""
hideChrome
/>
</HostBridgeProvider>
);
if (hostedShell) {
return <div className="min-h-[100dvh] bg-bg p-2 sm:p-3">{quoteBody}</div>;
}
return (
<AppLayout title="宿主嵌入演示">
<Card className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary">
当前登录客户 · {isKeyLogin ? "API Key 登录" : "账号密码登录"}
{hostedShell ? " · 宿主直连" : ""}
</p>
<p className="text-lg font-semibold">{session.customer_id}</p>
<p className="mt-1 text-xs text-text-secondary">
请选择 MotherShip 或 Flock Freight 模块开始查价
</p>
</div>
{!hostedShell ? (
<SecondaryButton onClick={() => void handleLogout()}>
退出登录
</SecondaryButton>
) : null}
</Card>
<QuoteRouteHub customerId={session.customer_id} apiBaseUrl="" />
<div className="mb-3 flex justify-end">
<SecondaryButton onClick={() => void handleLogout()}>
退出登录
</SecondaryButton>
</div>
{quoteBody}
</AppLayout>
);
}

@ -1,6 +1,6 @@
"use client";
"use client";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
AddressInput,
MothershipAddressCandidate,
@ -11,10 +11,14 @@ import type {
import { SUBMIT_LOCK_MS } from "@/lib/frontend/constants";
import {
hostCreateQuote,
hostContinueMsRefineHold,
hostDeclineMsRefineHold,
hostFetchMothershipCandidates,
hostGetQuote,
hostPreheatQuoteSession,
hostReleaseQuoteSession,
hostSubmitMsRefineDetails,
hostSubmitMsCheckout,
} from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
@ -35,9 +39,43 @@ import {
MothershipLoggedInShipmentForm,
type MothershipLoggedInShipmentPayload,
} from "@/components/mothership/mothership-logged-in-shipment-form";
import { MothershipLoggedInDetailsForm } from "@/components/mothership/mothership-logged-in-details-form";
import {
MothershipLoggedInDetailsForm,
detailsStateToPayload,
type MothershipLoggedInDetailsState,
} from "@/components/mothership/mothership-logged-in-details-form";
import { MothershipLoggedInQuoteSidebar } from "@/components/mothership/mothership-logged-in-quote-sidebar";
import type { MsSidebarCheckoutIntent } from "@/components/mothership/mothership-logged-in-quote-sidebar";
import { MsRefineHoldPrompt } from "@/components/mothership/ms-refine-hold-prompt";
import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body";
import { MS_NEEDS_DETAILS_ERROR_CODE } from "@/lib/constants/ms-refine-hold";
import { useHostBridgeOptional } from "@/lib/embed/host-bridge-react";
import {
postToHost,
type ChajiaModuleId,
} from "@/lib/embed/host-bridge";
import { mapQuotesWithSelectedFlag } from "@/lib/embed/map-quotes-selection";
/** 更新报价后滚到右侧「选择承运商」区域,避免用户停在页面底部按钮处 */
function scrollToMsQuotePanel(reason: string) {
try {
window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
} catch {
/* ignore */
}
const el = document.getElementById("ms-choose-carrier");
el?.scrollIntoView({ behavior: "smooth", block: "start" });
try {
window.dispatchEvent(new Event("chajia-host-scroll-quotes"));
} catch {
/* ignore */
}
postToHost("chajia:scroll-top", {
payload: { reason },
});
}
export interface EmbeddedQuoteWidgetProps {
customerId: string;
@ -65,18 +103,46 @@ export function EmbeddedQuoteWidget({
forcedProvider,
hideProviderSwitch = false,
}: EmbeddedQuoteWidgetProps) {
const hostBridge = useHostBridgeOptional();
const [provider, setProvider] = useState<QuoteProviderId>(
forcedProvider ?? "mothership",
);
const resolveModule = useCallback((): ChajiaModuleId => {
if (provider === "flock") {
return flockLoggedInUi ? "FLOCK_LOGGED_IN" : "FLOCK_GUEST";
}
return mothershipLoggedInUi ? "MS_LOGGED_IN" : "MS_GUEST";
}, [provider, flockLoggedInUi, mothershipLoggedInUi]);
const [status, setStatus] = useState<QuotePageStatus>("idle");
const [quote, setQuote] = useState<QuoteDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitLocked, setSubmitLocked] = useState(false);
const [loggedInDraft, setLoggedInDraft] =
useState<MothershipLoggedInShipmentPayload | null>(null);
const [loggedInDetails, setLoggedInDetails] =
useState<MothershipLoggedInDetailsState | null>(null);
const [loggedInStep, setLoggedInStep] = useState<"create" | "details">(
"create",
);
const [refineHold, setRefineHold] = useState<
NonNullable<QuoteDetail["refine_hold"]> | null
>(null);
const [refineAccepted, setRefineAccepted] = useState(false);
/** 继续填写后保留会话与总硬限(弹窗关闭后仍需 5 分钟到期 decline) */
const [refineActive, setRefineActive] = useState<{
quoteId: string;
quote_session_id: string;
total_deadline_ms: number;
} | null>(null);
const [refineTotalLeftLabel, setRefineTotalLeftLabel] = useState<string | null>(
null,
);
const [checkoutBusy, setCheckoutBusy] = useState(false);
const [checkoutMessage, setCheckoutMessage] = useState<string | null>(null);
const checkoutAbortRef = useRef<AbortController | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const isEmbeddedHost =
typeof window !== "undefined" && window.parent !== window;
const [disambiguationOpen, setDisambiguationOpen] = useState(false);
const [confirmedPickup, setConfirmedPickup] = useState<AddressInput | null>(
null,
@ -100,24 +166,430 @@ export function EmbeddedQuoteWidget({
setConfirmedDelivery(null);
setLoggedInDraft(null);
setLoggedInStep("create");
setRefineHold(null);
setRefineAccepted(false);
setRefineActive(null);
setRefineTotalLeftLabel(null);
setCheckoutBusy(false);
setCheckoutMessage(null);
}, []);
const finishQuote = useCallback((detail: QuoteDetail) => {
setQuote(detail);
if (detail.status === "failed") {
setStatus("error");
setError(
detail.error_message ??
formatQuoteErrorMessage(detail.error_code),
const declineRefineHold = useCallback(
async (
hold: { quote_session_id: string },
quoteId: string,
) => {
setRefineHold(null);
setRefineAccepted(false);
setRefineActive(null);
setRefineTotalLeftLabel(null);
await hostDeclineMsRefineHold(
apiBaseUrl,
serviceToken,
customerId,
quoteId,
hold.quote_session_id,
);
},
[apiBaseUrl, serviceToken, customerId],
);
const finishQuote = useCallback(
(detail: QuoteDetail) => {
setQuote(detail);
const module = resolveModule();
if (detail.status === "failed") {
setStatus("error");
const msg =
detail.error_message ??
formatQuoteErrorMessage(detail.error_code);
setError(msg);
setRefineHold(null);
setRefineActive(null);
hostBridge?.reportQuoteResult({
quote_id: detail.quote_id,
request_id: detail.request_id,
status: "failed",
module,
currency: detail.currency || "USD",
quotes: [],
error_code: detail.error_code,
error_message: msg,
});
return;
}
if (detail.status === "expired") {
setStatus("expired");
setRefineHold(null);
setRefineActive(null);
hostBridge?.reportQuoteResult({
quote_id: detail.quote_id,
request_id: detail.request_id,
status: "expired",
module,
currency: detail.currency || "USD",
quotes: [],
error_code: detail.error_code,
error_message: detail.error_message,
});
return;
}
setStatus(detail.is_realtime === false ? "fallback" : "success");
if (detail.error_code === MS_NEEDS_DETAILS_ERROR_CODE) {
setError(
detail.error_message ??
formatQuoteErrorMessage(detail.error_code),
);
} else {
setError(null);
}
if (
mothershipLoggedInUi &&
detail.refine_hold?.available &&
!refineAccepted
) {
setRefineHold(detail.refine_hold);
} else {
setRefineHold(null);
}
if (!detail.refine_hold?.available) {
setRefineActive(null);
setRefineTotalLeftLabel(null);
}
hostBridge?.reportQuoteResult({
quote_id: detail.quote_id,
request_id: detail.request_id,
status: "done",
module,
currency: detail.currency || "USD",
source_type: detail.source_type,
is_realtime: detail.is_realtime,
quotes: (detail.quotes ?? []).map((q) => ({
carrier: q.carrier,
service_level: q.service_level,
rate_option: q.rate_option,
transit_days: q.transit_days,
transit_description: q.transit_description,
raw_freight: q.raw_freight,
surcharges: q.surcharges,
raw_total: q.raw_total,
markup_percent: q.markup_percent,
markup_amount: q.markup_amount,
final_total: q.final_total,
base_total_before_markup: q.raw_total,
customer_markup_amount: q.markup_amount,
customer_final_total: q.final_total,
is_customer_markup_applied: q.markup_amount > 0,
pricing_mode: "customer_final" as const,
breakdown: q.breakdown ?? [],
estimated_pickup: loggedInDraft?.readyDate ?? null,
guaranteed: /guarant/i.test(q.service_level || ""),
})),
error_code: null,
error_message: null,
});
},
[hostBridge, resolveModule, mothershipLoggedInUi, refineAccepted, loggedInDraft],
);
useEffect(() => {
if (!refineActive || !refineAccepted) {
setRefineTotalLeftLabel(null);
return;
}
if (detail.status === "expired") {
setStatus("expired");
return;
}
setStatus(detail.is_realtime === false ? "fallback" : "success");
setError(null);
// 填写补充信息期间不再倒计时逼迫用户,也不再自动放弃(避免提交按钮消失)
setRefineTotalLeftLabel(null);
}, [refineActive, refineAccepted]);
/** 把当前点选报价参数同步给宿主;asSave=true 时走 quote-save,否则 quote-result 仅更新选中 */
const notifyHostQuoteSelection = useCallback(
(intent: MsSidebarCheckoutIntent, opts?: { asSave?: boolean }) => {
if (!hostBridge || !quote?.quote_id) return;
const module = resolveModule();
const selected = intent.quoteItem;
const isProtect = intent.coverage === "freight_protect";
const readyDate = loggedInDraft?.readyDate ?? null;
const addBusinessDays = (isoDate: string, days: number): string | null => {
const start = new Date(`${isoDate}T12:00:00`);
if (Number.isNaN(start.getTime())) return null;
let left = Math.max(0, days);
const d = new Date(start);
while (left > 0) {
d.setDate(d.getDate() + 1);
const wd = d.getDay();
if (wd !== 0 && wd !== 6) left -= 1;
}
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const parseTransitDays = (item: {
transit_days?: number | string | null;
transit_description?: string | null;
}): number | null => {
const raw = `${item.transit_days ?? ""} ${item.transit_description ?? ""}`;
const m = raw.match(/(\d+)/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
};
const cargo_lines =
loggedInDraft?.cargo?.map((c) => ({
cargo_type: c.cargoType,
quantity: c.quantity,
weight_lb: c.weightLb,
length_in: c.lengthIn,
width_in: c.widthIn,
height_in: c.heightIn,
})) ?? undefined;
const pickupText =
loggedInDraft?.pickupConfirmed?.formatted_address ||
loggedInDraft?.pickupConfirmed?.display_label ||
loggedInDraft?.pickupQuery ||
"";
const deliveryText =
loggedInDraft?.deliveryConfirmed?.formatted_address ||
loggedInDraft?.deliveryConfirmed?.display_label ||
loggedInDraft?.deliveryQuery ||
"";
const quotes = mapQuotesWithSelectedFlag(
(quote.quotes ?? []).map((q) => {
const days = parseTransitDays(q);
const estimated_delivery =
readyDate && days != null ? addBusinessDays(readyDate, days) : null;
return {
carrier: q.carrier,
service_level: q.service_level,
rate_option: q.rate_option,
transit_days: q.transit_days,
transit_description: q.transit_description,
raw_freight: q.raw_freight,
surcharges: q.surcharges,
raw_total: q.raw_total,
markup_percent: q.markup_percent,
markup_amount: q.markup_amount,
final_total: q.final_total,
base_total_before_markup: q.raw_total,
customer_markup_amount: q.markup_amount,
customer_final_total: q.final_total,
is_customer_markup_applied: q.markup_amount > 0,
pricing_mode: "customer_final" as const,
breakdown: q.breakdown ?? [],
guaranteed: isProtect || /guarant/i.test(q.service_level || ""),
guarantee_amount: null as number | null,
estimated_pickup: readyDate,
estimated_delivery,
};
}),
selected,
).map((q) => ({
...q,
guarantee_amount: q.selected ? intent.cargoValueUsd ?? null : null,
}));
if (opts?.asSave) {
hostBridge.reportQuoteSave({
action: "save",
quote_id: quote.quote_id,
request_id: quote.request_id,
status: "done",
module,
currency: quote.currency || "USD",
source_type: quote.source_type,
is_realtime: quote.is_realtime,
selected_carrier: intent.carrier,
coverage: intent.coverage,
cargo_value_usd: intent.cargoValueUsd ?? null,
ready_date: readyDate,
estimated_pickup: readyDate,
pickup_address: pickupText
? { formatted_address: pickupText, street: pickupText }
: undefined,
delivery_address: deliveryText
? { formatted_address: deliveryText, street: deliveryText }
: undefined,
cargo_lines,
quotes,
error_code: null,
error_message: null,
});
return;
}
hostBridge.reportQuoteResult({
quote_id: quote.quote_id,
request_id: quote.request_id,
status: "done",
module,
currency: quote.currency || "USD",
source_type: quote.source_type,
is_realtime: quote.is_realtime,
quotes,
error_code: null,
error_message: null,
});
},
[hostBridge, quote, resolveModule, loggedInDraft],
);
const handleSaveQuoteToHost = useCallback(
(intent: MsSidebarCheckoutIntent) => {
if (!quote?.quote_id) {
setCheckoutMessage("请先完成询价");
return;
}
if (!hostBridge) {
setCheckoutMessage("未检测到宿主桥接,无法保存询价记录");
return;
}
setCheckoutBusy(true);
setCheckoutMessage("正在保存询价记录…");
try {
notifyHostQuoteSelection(intent, { asSave: true });
setCheckoutMessage("已提交保存到宿主「卡派询价快照」");
} catch (err) {
setCheckoutMessage(
err instanceof Error ? err.message : "保存询价记录失败",
);
} finally {
setCheckoutBusy(false);
}
},
[quote, hostBridge, notifyHostQuoteSelection],
);
const handleSelectQuoteForHost = useCallback(
(intent: MsSidebarCheckoutIntent) => {
if (!isEmbeddedHost || !hostBridge) return;
try {
notifyHostQuoteSelection(intent, { asSave: false });
} catch {
/* 宿主同步失败不打断选价 */
}
},
[isEmbeddedHost, hostBridge, notifyHostQuoteSelection],
);
const handleMsCheckout = useCallback(
async (intent: MsSidebarCheckoutIntent) => {
if (isEmbeddedHost && hostBridge) {
handleSaveQuoteToHost(intent);
return;
}
if (!quote?.quote_id) {
setCheckoutMessage("请先完成询价");
return;
}
checkoutAbortRef.current?.abort();
const ac = new AbortController();
checkoutAbortRef.current = ac;
setCheckoutBusy(true);
setCheckoutMessage("正在同步官网选择承运商与保障…");
try {
const detailsPayload = loggedInDetails
? detailsStateToPayload(loggedInDetails)
: undefined;
const mapped = loggedInDraft
? buildQuoteRequestBodyFromLoggedIn(
loggedInDraft,
customerId,
detailsPayload,
)
: null;
const res = await hostSubmitMsCheckout(
apiBaseUrl,
serviceToken,
customerId,
{
quoteId: quote.quote_id,
preferredCarrier: intent.carrier,
coverage: intent.coverage,
cargoValueUsd: intent.cargoValueUsd,
mothershipDetails: mapped?.mothership_details,
pickupAccessorials: mapped?.pickup_accessorials,
deliveryAccessorials: mapped?.delivery_accessorials,
},
);
if (ac.signal.aborted) return;
if (res.code !== 0) {
setCheckoutMessage(res.message);
return;
}
const deadline = Date.now() + 8 * 60_000;
while (Date.now() < deadline) {
if (ac.signal.aborted) return;
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, 2_000);
ac.signal.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
}).catch(() => undefined);
if (ac.signal.aborted) return;
const r = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
);
if (ac.signal.aborted) return;
if (r.code !== 0) continue;
const st = r.data.ms_checkout;
if (!st) continue;
if (st.status === "done") {
setQuote(r.data);
setCheckoutMessage(
st.message ||
`已填齐详情(${st.selected_carrier ?? intent.carrier},未支付)`,
);
return;
}
if (st.status === "failed") {
setCheckoutMessage(st.message || "结账同步失败");
return;
}
setCheckoutMessage(st.message || "正在同步官网…");
}
if (!ac.signal.aborted) {
setCheckoutMessage("结账同步超时,请稍后在侧栏查看状态或重试");
}
} catch (err) {
if (ac.signal.aborted) return;
setCheckoutMessage(
err instanceof Error ? err.message : "结账同步失败",
);
} finally {
if (checkoutAbortRef.current === ac) {
setCheckoutBusy(false);
}
}
},
[
quote,
loggedInDraft,
loggedInDetails,
customerId,
apiBaseUrl,
serviceToken,
isEmbeddedHost,
hostBridge,
handleSaveQuoteToHost,
],
);
useEffect(() => {
return () => {
checkoutAbortRef.current?.abort();
};
}, []);
const submitQuote = useCallback(
@ -293,8 +765,32 @@ export function EmbeddedQuoteWidget({
status === "processing" ||
status === "resolving_address";
// 点击「继续」进入二级提货/送货详情后:页面置顶(避免停在底部「继续」按钮处)
useEffect(() => {
if (loggedInStep !== "details") return;
const t = window.setTimeout(() => {
try {
window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
} catch {
/* ignore */
}
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
// 通知宿主:把私卡询价弹窗/iframe 区域滚到视口顶部
postToHost("chajia:scroll-top", {
module: resolveModule(),
payload: { reason: "logged-in-details" },
});
}, 60);
return () => window.clearTimeout(t);
}, [loggedInStep, resolveModule]);
return (
<div className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-6">
<div
ref={rootRef}
className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-6"
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-text-primary">卡派查价</h2>
<p className="text-sm text-text-secondary">
@ -334,135 +830,257 @@ export function EmbeddedQuoteWidget({
</div>
)}
<div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
{mothershipLoggedInUi ? (
loggedInStep === "details" && loggedInDraft ? (
{mothershipLoggedInUi &&
!(loggedInStep === "details" && loggedInDraft) ? (
/* 一级「创建新货件」:取消右侧「下一步」占位,表单占满整行 */
<div>
<MothershipLoggedInShipmentForm
formId={FORM_ID}
disabled={formDisabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
onValidSubmit={(payload) => {
setLoggedInDraft(payload);
setLoggedInDetails(null);
setLoggedInStep("details");
setQuote(null);
setError(null);
try {
// 一级信息足够询价;二级仅选报价/后续填详情,不阻塞出价
const body = buildQuoteRequestBodyFromLoggedIn(
payload,
customerId,
);
void submitQuote(body);
} catch (err) {
setStatus("error");
setError(
err instanceof Error
? err.message
: "无法构建询价请求",
);
}
}}
/>
<div className="mt-6">
<button
type="submit"
form={FORM_ID}
disabled={formDisabled}
data-testid="ship-create-continue-button"
className={`${quoteCtaCls} w-full sm:w-auto`}
>
继续
</button>
</div>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
{mothershipLoggedInUi ? (
<>
<div className="mb-4">
<SecondaryButton
type="button"
onClick={() => setLoggedInStep("create")}
>
返回一级表单
返回上一步
</SecondaryButton>
</div>
<MothershipLoggedInDetailsForm
initial={loggedInDraft}
formId="ms-logged-in-details-form"
initial={loggedInDraft!}
disabled={formDisabled}
highlightRequiredGaps={refineAccepted}
onChange={setLoggedInDetails}
onValidSubmit={(details) => {
if (!quote?.quote_id || !refineAccepted) {
setError("请先选择继续填写,或等待初步报价完成");
return;
}
const sessionId =
refineActive?.quote_session_id ||
quote.refine_hold?.quote_session_id ||
refineHold?.quote_session_id;
if (!sessionId) {
setError("报价会话已失效,请重新询价");
return;
}
void (async () => {
setStatus("processing");
setError(null);
scrollToMsQuotePanel("update-quote");
const draftReady =
loggedInDetails?.pickup.readyTime ||
loggedInDraft!.readyTime;
const draftWithReady = {
...loggedInDraft!,
readyTime: draftReady,
readyDate:
loggedInDetails?.pickup.readyDate ||
loggedInDraft!.readyDate,
};
setLoggedInDraft(draftWithReady);
const mapped = buildQuoteRequestBodyFromLoggedIn(
draftWithReady,
customerId,
details,
);
const res = await hostSubmitMsRefineDetails(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
sessionId,
mapped.mothership_details,
{
pickupAccessorials: mapped.pickup_accessorials,
deliveryAccessorials: mapped.delivery_accessorials,
},
{
readyDate: mapped.ready_date,
readyTime: mapped.ready_time,
},
mapped.cargo_lines,
);
if (res.code !== 0) {
setStatus("error");
setError(res.message);
scrollToMsQuotePanel("update-quote-error");
return;
}
const pollResult = await pollQuoteUntilDone(
async () => {
try {
const r = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
);
if (r.code !== 0) {
return {
ok: false as const,
errorMessage: r.message,
};
}
return { ok: true as const, data: r.data };
} catch {
return { ok: false as const };
}
},
);
if (pollResult.type === "done") {
setRefineAccepted(false);
setRefineActive(null);
finishQuote(pollResult.quote);
scrollToMsQuotePanel("update-quote-done");
return;
}
setStatus("error");
setError(
pollResult.type === "timeout"
? formatQuoteErrorMessage("QUOTE_TIMEOUT")
: pollResult.message,
);
scrollToMsQuotePanel("update-quote-error");
})();
}}
/>
{refineAccepted ? (
<div className="sticky bottom-0 z-20 mt-6 border-t border-border bg-surface/95 py-4 backdrop-blur-sm">
<button
type="submit"
form="ms-logged-in-details-form"
disabled={formDisabled}
className={`${quoteCtaCls} w-full sm:w-auto`}
>
保存并更新报价
</button>
<p className="mt-2 text-xs text-text-secondary">
补全必要信息后提交,系统将重新获取更准确报价。
</p>
</div>
) : null}
</>
) : (
<>
<MothershipLoggedInShipmentForm
formId={FORM_ID}
disabled={formDisabled}
<QuoteForm
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
onValidSubmit={(payload) => {
setLoggedInDraft(payload);
setLoggedInStep("details");
disabled={formDisabled}
formId={FORM_ID}
confirmedPickup={confirmedPickup}
confirmedDelivery={confirmedDelivery}
hostPrefill={
hostBridge?.msGuestPrefill && !mothershipLoggedInUi
? hostBridge.msGuestPrefill
: null
}
hostPrefillSeq={hostBridge?.fill?.seq ?? 0}
onAddressDraftChange={() => {
setConfirmedPickup(null);
setConfirmedDelivery(null);
}}
onCargoChange={() => {
setQuote(null);
setError(null);
try {
// 一级信息足够询价;二级仅选报价/后续填详情,不阻塞出价
const body = buildQuoteRequestBodyFromLoggedIn(
payload,
customerId,
);
void submitQuote(body);
} catch (err) {
setStatus("error");
setError(
err instanceof Error
? err.message
: "无法构建询价请求",
);
if (status === "success" || status === "fallback") {
setStatus("idle");
}
}}
onValidSubmit={(body) => void handleSubmit(body)}
/>
<div className="mt-6">
<button
<PrimaryButton
type="submit"
form={FORM_ID}
loading={
status === "resolving_address" ||
status === "validating" ||
status === "processing"
}
disabled={formDisabled}
data-testid="ship-create-continue-button"
className={`${quoteCtaCls} w-full sm:w-auto`}
className="w-full sm:w-auto"
>
继续
</button>
获取报价
</PrimaryButton>
</div>
</>
)
) : (
<>
<QuoteForm
customerId={customerId}
disabled={formDisabled}
formId={FORM_ID}
confirmedPickup={confirmedPickup}
confirmedDelivery={confirmedDelivery}
onAddressDraftChange={() => {
setConfirmedPickup(null);
setConfirmedDelivery(null);
}}
onCargoChange={() => {
setQuote(null);
setError(null);
if (status === "success" || status === "fallback") {
setStatus("idle");
}
}}
onValidSubmit={(body) => void handleSubmit(body)}
/>
<div className="mt-6">
<PrimaryButton
type="submit"
form={FORM_ID}
loading={
status === "resolving_address" ||
status === "validating" ||
status === "processing"
}
disabled={formDisabled}
className="w-full sm:w-auto"
>
获取报价
</PrimaryButton>
</div>
</>
)}
</div>
<div className="lg:col-span-2">
{mothershipLoggedInUi ? (
loggedInStep === "details" && loggedInDraft ? (
)}
</div>
<div className="lg:col-span-2">
{mothershipLoggedInUi && loggedInDraft ? (
<MothershipLoggedInQuoteSidebar
payload={loggedInDraft}
quote={quote}
status={status}
error={error}
checkoutBusy={checkoutBusy}
checkoutMessage={checkoutMessage}
actionMode={
isEmbeddedHost && hostBridge ? "save" : "checkout"
}
onCheckout={(intent) => void handleMsCheckout(intent)}
onSelectQuote={
isEmbeddedHost && hostBridge
? handleSelectQuoteForHost
: undefined
}
/>
) : (
<div className="rounded-lg border border-border bg-bg p-4 text-sm text-text-secondary">
<p className="font-medium text-[#1F2937]">下一步</p>
<p className="mt-2 text-xs leading-relaxed">
点「继续」后进入详情页并开始询价,右侧展示承运商报价。
</p>
</div>
)
) : (
<QuoteResultPanel
status={status}
quote={quote}
error={error}
onRetry={reset}
onExpire={() => setStatus("expired")}
onExpiredConfirm={reset}
/>
)}
<QuoteResultPanel
status={status}
quote={quote}
error={error}
onRetry={reset}
onExpire={() => setStatus("expired")}
onExpiredConfirm={reset}
/>
)}
</div>
</div>
</div>
)}
{!mothershipLoggedInUi && (
<AddressDisambiguationModal
@ -475,6 +1093,36 @@ export function EmbeddedQuoteWidget({
onCancel={handleDisambiguationCancel}
/>
)}
{mothershipLoggedInUi && refineHold && quote?.quote_id ? (
<MsRefineHoldPrompt
decisionDeadlineMs={refineHold.decision_deadline_ms}
totalDeadlineMs={refineHold.total_deadline_ms}
needsDetailsFirst={
quote.error_code === MS_NEEDS_DETAILS_ERROR_CODE
}
onContinue={() => {
setRefineAccepted(true);
setRefineActive({
quoteId: quote.quote_id,
quote_session_id: refineHold.quote_session_id,
total_deadline_ms: refineHold.total_deadline_ms,
});
setRefineHold(null);
setLoggedInStep("details");
void hostContinueMsRefineHold(
apiBaseUrl,
serviceToken,
customerId,
quote.quote_id,
refineHold.quote_session_id,
);
}}
onDecline={() => {
void declineRefineHold(refineHold, quote.quote_id);
}}
/>
) : null}
</>
)}
</div>

@ -15,9 +15,9 @@ export function QuoteProviderSwitch({
disabled,
}: QuoteProviderSwitchProps) {
const tabs: Array<{ id: QuoteProviderId; label: string; desc: string }> = [
{ id: "mothership", label: "MotherShip", desc: "四档实时报价" },
{ id: "mothership", label: "私卡询价", desc: "四档实时报价" },
{ id: "priority1", label: "Priority1", desc: "开发中 · 官网模拟填表" },
{ id: "flock", label: "Flock Freight", desc: "ZIP Direct 询价" },
{ id: "flock", label: "合单询价", desc: "ZIP Direct 询价" },
];
return (

@ -15,6 +15,11 @@ import {
embedSaveProviderCredential,
type EmbedProviderCredential,
} from "@/lib/frontend/api-client";
import {
moduleToHubRoute,
useHostBridgeOptional,
} from "@/lib/embed/host-bridge-react";
import type { ChajiaModuleId } from "@/lib/embed/host-bridge";
type QuoteProvider = "mothership" | "flock";
type QuoteMode = "anon" | "login";
@ -25,29 +30,45 @@ interface ModuleDef {
desc: string;
}
/** 门户仅两模块:MotherShip / Flock(进入后可选免账号或登录后) */
/** 门户仅两模块:私卡 / 合单(进入后可选免账号或登录后) */
const MODULES: ModuleDef[] = [
{
provider: "mothership",
title: "MotherShip",
desc: "免账号实时报价,或使用官网账号登录后查价",
title: "私卡询价",
desc: "免账号实时报价,或使用已绑定账号登录后查价",
},
{
provider: "flock",
title: "Flock Freight",
desc: "ZIP Direct 匿名询价,或使用官网账号登录后查价",
title: "合单询价",
desc: "ZIP Direct 匿名询价,或使用已绑定账号登录后查价",
},
];
function hubModuleId(
provider: QuoteProvider,
mode: QuoteMode,
): ChajiaModuleId {
if (provider === "mothership") {
return mode === "login" ? "MS_LOGGED_IN" : "MS_GUEST";
}
return mode === "login" ? "FLOCK_LOGGED_IN" : "FLOCK_GUEST";
}
interface QuoteRouteHubProps {
customerId: string;
apiBaseUrl?: string;
/**
* 隐藏门户外壳:当前模块条、免账号/登录后 Tab、承运商账密表单等。
* 宿主嵌入与正式演示仅展示查价表单本身。
*/
hideChrome?: boolean;
}
export function QuoteRouteHub({
customerId,
apiBaseUrl = "",
hideChrome = false,
}: QuoteRouteHubProps) {
const hostBridge = useHostBridgeOptional();
const [loading, setLoading] = useState(true);
const [creds, setCreds] = useState<
Record<QuoteProvider, EmbedProviderCredential>
@ -91,6 +112,30 @@ export function QuoteRouteHub({
void loadCreds();
}, [loadCreds]);
// 宿主 postMessage 切模块
useEffect(() => {
const m = hostBridge?.activeModule;
if (!m) return;
const route = moduleToHubRoute(m);
const mod = MODULES.find((x) => x.provider === route.provider) ?? null;
if (!mod) return;
setSelected(mod);
setMode(route.mode);
}, [hostBridge?.activeModule]);
// 精简壳:未点选且未指定 entry 时,自动进入已配置账密的模块
useEffect(() => {
if (!hideChrome || loading || selected) return;
if (hostBridge?.activeModule) return;
const preferred =
MODULES.find((m) => creds[m.provider].has_password) ?? MODULES[0];
if (!preferred) return;
const nextMode = creds[preferred.provider].has_password ? "login" : "anon";
setSelected(preferred);
setMode(nextMode);
hostBridge?.setActiveModule(hubModuleId(preferred.provider, nextMode));
}, [hideChrome, loading, selected, creds, hostBridge]);
const handleSaved = useCallback((saved: EmbedProviderCredential) => {
setCreds((prev) => ({ ...prev, [saved.provider]: saved }));
}, []);
@ -98,7 +143,16 @@ export function QuoteRouteHub({
const openModule = (mod: ModuleDef) => {
setSelected(mod);
// 已配置账密时默认进登录后;否则免账号
setMode(creds[mod.provider].has_password ? "login" : "anon");
const nextMode = creds[mod.provider].has_password ? "login" : "anon";
setMode(nextMode);
hostBridge?.setActiveModule(hubModuleId(mod.provider, nextMode));
};
const onModeChange = (next: QuoteMode) => {
setMode(next);
if (selected) {
hostBridge?.setActiveModule(hubModuleId(selected.provider, next));
}
};
if (loading) {
@ -106,6 +160,9 @@ export function QuoteRouteHub({
}
if (!selected) {
if (hideChrome) {
return <Skeleton className="h-64 w-full" />;
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{MODULES.map((mod) => {
@ -143,6 +200,30 @@ export function QuoteRouteHub({
const requiresAccount = mode === "login";
const canQuote = !requiresAccount || providerCred.has_password;
const quoteArea = canQuote ? (
<EmbeddedQuoteWidget
key={`${selected.provider}-${mode}`}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
forcedProvider={selected.provider}
hideProviderSwitch
mothershipLoggedInUi={
selected.provider === "mothership" && requiresAccount
}
flockLoggedInUi={selected.provider === "flock" && requiresAccount}
/>
) : (
<Card className="mt-4">
<p className="text-sm text-text-secondary">
当前客户未配置承运商官网账号,请在管理端「租户管理」中配置后再查价。
</p>
</Card>
);
if (hideChrome) {
return <div>{quoteArea}</div>;
}
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
@ -167,7 +248,7 @@ export function QuoteRouteHub({
<button
key={tab.id}
type="button"
onClick={() => setMode(tab.id)}
onClick={() => onModeChange(tab.id)}
className={`flex min-w-[40%] 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 ${
mode === tab.id
? "bg-surface text-primary shadow-card"
@ -189,25 +270,7 @@ export function QuoteRouteHub({
/>
)}
{canQuote ? (
<EmbeddedQuoteWidget
key={`${selected.provider}-${mode}`}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
forcedProvider={selected.provider}
hideProviderSwitch
mothershipLoggedInUi={
selected.provider === "mothership" && requiresAccount
}
flockLoggedInUi={selected.provider === "flock" && requiresAccount}
/>
) : (
<Card className="mt-4">
<p className="text-sm text-text-secondary">
「登录后」查价需先保存承运商账号密码,保存成功后即可开始。
</p>
</Card>
)}
{quoteArea}
</div>
);
}

@ -0,0 +1,603 @@
"use client";
import { useImperativeHandle, forwardRef, useState, type ReactNode } from "react";
import {
FLOCK_CHECKOUT_FIELD_HINTS,
FLOCK_CHECKOUT_HOURS,
FLOCK_CHECKOUT_ROLE_OPTIONS,
emptyFlockCheckoutDetailsDraft,
flockDetailsHasErrors,
toFlockCheckoutApiDetails,
validateFlockCheckoutDetailsDraft,
type FlockCheckoutDetailsDraft,
type FlockCheckoutLocationDraft,
type FlockCheckoutRole,
type FlockDetailsFieldErrors,
} from "@/lib/flock/flock-checkout-details-rules";
import { ErrorBanner } from "@/components/ui/error-banner";
export type FlockLoggedInDetailsFormHandle = {
validateAndGetDetails: () => ReturnType<
typeof toFlockCheckoutApiDetails
> | null;
};
type Props = {
disabled?: boolean;
pickupZip?: string;
deliveryZip?: string;
/** 首价货件摘要(只读),对齐官网 Shipment details 货件行 */
shipmentSummary?: string;
};
const inputCls =
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-primary disabled:opacity-60";
const inputErrCls =
"w-full rounded-md border border-error bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-error disabled:opacity-60";
function Field({
label,
required,
error,
hint,
children,
}: {
label: string;
required?: boolean;
error?: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block space-y-1">
<span className="text-xs font-medium text-text-secondary">
{label}
{required ? <span className="text-error"> *</span> : null}
</span>
{children}
{error ? <p className="text-[11px] text-error">{error}</p> : null}
{!error && hint ? (
<p className="text-[11px] text-text-disabled">{hint}</p>
) : null}
</label>
);
}
function LocationAddressFields({
side,
loc,
errors,
disabled,
addressLocked,
onPatch,
}: {
side: "pickup" | "delivery";
loc: FlockCheckoutLocationDraft;
errors?: Partial<Record<keyof FlockCheckoutLocationDraft, string>>;
disabled?: boolean;
addressLocked: boolean;
onPatch: (patch: Partial<FlockCheckoutLocationDraft>) => void;
}) {
const locked = Boolean(disabled || addressLocked);
return (
<>
<Field
label="地址 1"
required={!addressLocked}
error={errors?.address1}
hint={
addressLocked
? FLOCK_CHECKOUT_FIELD_HINTS.useBilling
: FLOCK_CHECKOUT_FIELD_HINTS.addressMismatch
}
>
<input
disabled={locked}
className={errors?.address1 ? inputErrCls : inputCls}
value={loc.address1}
onChange={(e) => onPatch({ address1: e.target.value })}
/>
</Field>
<Field label="地址 2">
<input
disabled={locked}
className={inputCls}
value={loc.address2}
onChange={(e) => onPatch({ address2: e.target.value })}
/>
</Field>
<Field label="城市" required={!addressLocked} error={errors?.city}>
<input
disabled={locked}
className={errors?.city ? inputErrCls : inputCls}
value={loc.city}
onChange={(e) => onPatch({ city: e.target.value })}
/>
</Field>
<Field label="州" required={!addressLocked} error={errors?.state}>
<input
disabled={locked}
className={errors?.state ? inputErrCls : inputCls}
value={loc.state}
onChange={(e) => onPatch({ state: e.target.value })}
/>
</Field>
<Field label="邮编" required={!addressLocked} error={errors?.zip}>
<input
disabled={locked}
className={errors?.zip ? inputErrCls : inputCls}
value={loc.zip}
onChange={(e) => onPatch({ zip: e.target.value })}
/>
</Field>
</>
);
}
/** 二级结账详情:对齐官网 Pickup / Delivery / Shipment details */
export const FlockLoggedInDetailsForm = forwardRef<
FlockLoggedInDetailsFormHandle,
Props
>(function FlockLoggedInDetailsForm(
{
disabled = false,
pickupZip = "",
deliveryZip = "",
shipmentSummary = "",
},
ref,
) {
const [draft, setDraft] = useState<FlockCheckoutDetailsDraft>(() =>
emptyFlockCheckoutDetailsDraft({ pickupZip, deliveryZip }),
);
const [errors, setErrors] = useState<FlockDetailsFieldErrors>({});
const [formError, setFormError] = useState<string | null>(null);
useImperativeHandle(ref, () => ({
validateAndGetDetails: () => {
const next = validateFlockCheckoutDetailsDraft(draft);
setErrors(next);
if (flockDetailsHasErrors(next)) {
setFormError("请先修正结账详情中的必填项与格式错误");
return null;
}
setFormError(null);
return toFlockCheckoutApiDetails(draft);
},
}));
const setRole = (role: FlockCheckoutRole) => {
setDraft((d) => ({ ...d, role }));
setErrors((e) => ({ ...e, role: undefined }));
};
const patchPickup = (patch: Partial<FlockCheckoutLocationDraft>) => {
setDraft((d) => ({ ...d, pickup: { ...d.pickup, ...patch } }));
};
const patchDelivery = (patch: Partial<FlockCheckoutLocationDraft>) => {
setDraft((d) => ({ ...d, delivery: { ...d.delivery, ...patch } }));
};
return (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-text-primary">结账详情</h2>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_CHECKOUT_FIELD_HINTS.review}
</p>
</div>
{formError ? <ErrorBanner>{formError}</ErrorBanner> : null}
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-semibold text-text-primary">您的角色</h3>
<div className="flex flex-wrap gap-2">
{FLOCK_CHECKOUT_ROLE_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
disabled={disabled}
onClick={() => setRole(opt.id)}
className={
"rounded-md border px-3 py-1.5 text-sm " +
(draft.role === opt.id
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-text-secondary hover:border-primary/40")
}
>
{opt.label}
</button>
))}
</div>
{errors.role ? (
<p className="text-[11px] text-error">{errors.role}</p>
) : null}
</section>
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">提货地点</h3>
<label className="flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
disabled={disabled}
checked={draft.use_billing_for_pickup}
onChange={(e) =>
setDraft((d) => ({
...d,
use_billing_for_pickup: e.target.checked,
}))
}
/>
使用账单地址
</label>
</div>
<p className="text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.useBilling}
</p>
<div className="grid gap-3 md:grid-cols-2">
<Field
label="公司名称"
required
error={errors.pickup?.company_name}
hint={FLOCK_CHECKOUT_FIELD_HINTS.addressSync}
>
<input
disabled={disabled}
className={
errors.pickup?.company_name ? inputErrCls : inputCls
}
value={draft.pickup.company_name}
onChange={(e) => patchPickup({ company_name: e.target.value })}
/>
</Field>
<LocationAddressFields
side="pickup"
loc={draft.pickup}
errors={errors.pickup}
disabled={disabled}
addressLocked={draft.use_billing_for_pickup}
onPatch={patchPickup}
/>
<Field
label="联系人"
required
error={errors.pickup?.contact_name}
hint={FLOCK_CHECKOUT_FIELD_HINTS.contactHelper}
>
<input
disabled={disabled}
className={
errors.pickup?.contact_name ? inputErrCls : inputCls
}
value={draft.pickup.contact_name}
onChange={(e) => patchPickup({ contact_name: e.target.value })}
/>
</Field>
<Field
label="电话"
required
error={errors.pickup?.contact_phone}
hint={FLOCK_CHECKOUT_FIELD_HINTS.phone}
>
<input
disabled={disabled}
className={
errors.pickup?.contact_phone ? inputErrCls : inputCls
}
value={draft.pickup.contact_phone}
onChange={(e) => patchPickup({ contact_phone: e.target.value })}
placeholder="(626) 595-1180"
/>
</Field>
<Field
label="邮箱"
required
error={errors.pickup?.contact_email}
hint={FLOCK_CHECKOUT_FIELD_HINTS.email}
>
<input
disabled={disabled}
className={
errors.pickup?.contact_email ? inputErrCls : inputCls
}
value={draft.pickup.contact_email}
onChange={(e) => patchPickup({ contact_email: e.target.value })}
/>
</Field>
<Field
label="开门时间"
required
error={errors.pickup?.opens_at}
hint={FLOCK_CHECKOUT_FIELD_HINTS.hours}
>
<select
disabled={disabled}
className={errors.pickup?.opens_at ? inputErrCls : inputCls}
value={draft.pickup.opens_at}
onChange={(e) => patchPickup({ opens_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
<Field label="关门时间" required error={errors.pickup?.closes_at}>
<select
disabled={disabled}
className={errors.pickup?.closes_at ? inputErrCls : inputCls}
value={draft.pickup.closes_at}
onChange={(e) => patchPickup({ closes_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
</div>
</section>
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">派送地点</h3>
<label className="flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
disabled={disabled}
checked={draft.use_billing_for_delivery}
onChange={(e) =>
setDraft((d) => ({
...d,
use_billing_for_delivery: e.target.checked,
}))
}
/>
使用账单地址
</label>
</div>
<p className="text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.useBilling}
</p>
<div className="grid gap-3 md:grid-cols-2">
<Field
label="公司名称"
required
error={errors.delivery?.company_name}
hint={FLOCK_CHECKOUT_FIELD_HINTS.addressSync}
>
<input
disabled={disabled}
className={
errors.delivery?.company_name ? inputErrCls : inputCls
}
value={draft.delivery.company_name}
onChange={(e) =>
patchDelivery({ company_name: e.target.value })
}
/>
</Field>
<LocationAddressFields
side="delivery"
loc={draft.delivery}
errors={errors.delivery}
disabled={disabled}
addressLocked={draft.use_billing_for_delivery}
onPatch={patchDelivery}
/>
<Field
label="联系人"
required
error={errors.delivery?.contact_name}
>
<input
disabled={disabled}
className={
errors.delivery?.contact_name ? inputErrCls : inputCls
}
value={draft.delivery.contact_name}
onChange={(e) =>
patchDelivery({ contact_name: e.target.value })
}
/>
</Field>
<Field
label="电话"
required
error={errors.delivery?.contact_phone}
hint={FLOCK_CHECKOUT_FIELD_HINTS.phone}
>
<input
disabled={disabled}
className={
errors.delivery?.contact_phone ? inputErrCls : inputCls
}
value={draft.delivery.contact_phone}
onChange={(e) =>
patchDelivery({ contact_phone: e.target.value })
}
placeholder="(415) 621-8840"
/>
</Field>
<Field
label="邮箱"
required
error={errors.delivery?.contact_email}
>
<input
disabled={disabled}
className={
errors.delivery?.contact_email ? inputErrCls : inputCls
}
value={draft.delivery.contact_email}
onChange={(e) =>
patchDelivery({ contact_email: e.target.value })
}
/>
</Field>
<Field label="开门时间" required error={errors.delivery?.opens_at}>
<select
disabled={disabled}
className={
errors.delivery?.opens_at ? inputErrCls : inputCls
}
value={draft.delivery.opens_at}
onChange={(e) => patchDelivery({ opens_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
<Field
label="关门时间"
required
error={errors.delivery?.closes_at}
>
<select
disabled={disabled}
className={
errors.delivery?.closes_at ? inputErrCls : inputCls
}
value={draft.delivery.closes_at}
onChange={(e) => patchDelivery({ closes_at: e.target.value })}
>
{FLOCK_CHECKOUT_HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</Field>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">
该地点是否允许周末派送?
</p>
<div className="flex gap-2">
{([true, false] as const).map((yes) => (
<button
key={String(yes)}
type="button"
disabled={disabled}
onClick={() => patchDelivery({ weekend_delivery: yes })}
className={
"rounded-md border px-3 py-1.5 text-sm " +
(draft.delivery.weekend_delivery === yes
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-text-secondary")
}
>
{yes ? "是" : "否"}
</button>
))}
</div>
<p className="text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.weekend}
</p>
</div>
</section>
<section className="space-y-3 rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-semibold text-text-primary">运输详情</h3>
{shipmentSummary ? (
<div className="rounded-md border border-border bg-bg px-3 py-2 text-sm text-text-primary">
<p className="text-xs font-medium text-text-secondary">货件摘要</p>
<p className="mt-1">{shipmentSummary}</p>
<p className="mt-1 text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.shipmentItems}
</p>
</div>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<Field label="NMFC" hint={FLOCK_CHECKOUT_FIELD_HINTS.nmfc}>
<input
disabled={disabled}
className={inputCls}
value={draft.nmfc}
onChange={(e) =>
setDraft((d) => ({ ...d, nmfc: e.target.value }))
}
placeholder="如 100240-01"
/>
</Field>
<Field label="PO 单号" hint={FLOCK_CHECKOUT_FIELD_HINTS.po}>
<input
disabled={disabled}
className={inputCls}
value={draft.po_number}
onChange={(e) =>
setDraft((d) => ({ ...d, po_number: e.target.value }))
}
/>
</Field>
</div>
<Field
label="宣示声明"
hint={FLOCK_CHECKOUT_FIELD_HINTS.declaration}
>
<textarea
disabled={disabled}
rows={2}
className={inputCls}
value={draft.declaration_statement}
onChange={(e) =>
setDraft((d) => ({
...d,
declaration_statement: e.target.value,
}))
}
placeholder="联系人确认、特殊要求等"
/>
</Field>
<Field label="BOL 备注" hint={FLOCK_CHECKOUT_FIELD_HINTS.bol}>
<input
disabled={disabled}
className={inputCls}
value={draft.bol_remarks}
onChange={(e) =>
setDraft((d) => ({ ...d, bol_remarks: e.target.value }))
}
/>
</Field>
<Field label="Flock 备注" hint={FLOCK_CHECKOUT_FIELD_HINTS.notes}>
<textarea
disabled={disabled}
rows={3}
className={inputCls}
value={draft.notes}
onChange={(e) =>
setDraft((d) => ({ ...d, notes: e.target.value }))
}
/>
</Field>
<label className="flex items-start gap-2 text-sm text-text-primary">
<input
type="checkbox"
disabled={disabled}
className="mt-0.5"
checked={draft.documentation_required}
onChange={(e) =>
setDraft((d) => ({
...d,
documentation_required: e.target.checked,
}))
}
/>
<span>
提货需要单据
<span className="mt-0.5 block text-[11px] text-text-disabled">
{FLOCK_CHECKOUT_FIELD_HINTS.documentation}
</span>
</span>
</label>
</section>
</div>
);
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save