diff --git a/.env.example b/.env.example index 2d0aaee..6a5f392 100644 --- a/.env.example +++ b/.env.example @@ -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"] diff --git a/__tests__/api/admin-markup-routes.test.ts b/__tests__/api/admin-markup-routes.test.ts index 9763d0b..a279fdc 100644 --- a/__tests__/api/admin-markup-routes.test.ts +++ b/__tests__/api/admin-markup-routes.test.ts @@ -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 () => { diff --git a/__tests__/api/flock-checkout-route.test.ts b/__tests__/api/flock-checkout-route.test.ts new file mode 100644 index 0000000..2c0ba89 --- /dev/null +++ b/__tests__/api/flock-checkout-route.test.ts @@ -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(); + }); +}); diff --git a/__tests__/api/flock-hold-routes.test.ts b/__tests__/api/flock-hold-routes.test.ts new file mode 100644 index 0000000..ea36990 --- /dev/null +++ b/__tests__/api/flock-hold-routes.test.ts @@ -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 分钟/); + }); +}); diff --git a/__tests__/api/ms-checkout-route.test.ts b/__tests__/api/ms-checkout-route.test.ts new file mode 100644 index 0000000..52e787b --- /dev/null +++ b/__tests__/api/ms-checkout-route.test.ts @@ -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(); + }); +}); diff --git a/__tests__/api/ms-refine-hold-routes.test.ts b/__tests__/api/ms-refine-hold-routes.test.ts new file mode 100644 index 0000000..0e2d61e --- /dev/null +++ b/__tests__/api/ms-refine-hold-routes.test.ts @@ -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); + }); +}); diff --git a/__tests__/components/flock-logged-in-quote-form.test.ts b/__tests__/components/flock-logged-in-quote-form.test.ts index b9b0b97..613dca1 100644 --- a/__tests__/components/flock-logged-in-quote-form.test.ts +++ b/__tests__/components/flock-logged-in-quote-form.test.ts @@ -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", diff --git a/__tests__/components/mothership-logged-in-quote-sidebar.test.ts b/__tests__/components/mothership-logged-in-quote-sidebar.test.ts new file mode 100644 index 0000000..1df7f15 --- /dev/null +++ b/__tests__/components/mothership-logged-in-quote-sidebar.test.ts @@ -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 { + 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"); + }); +}); diff --git a/__tests__/components/mothership-logged-in-shipment-form.test.ts b/__tests__/components/mothership-logged-in-shipment-form.test.ts index cb742ad..5f96737 100644 --- a/__tests__/components/mothership-logged-in-shipment-form.test.ts +++ b/__tests__/components/mothership-logged-in-shipment-form.test.ts @@ -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("货物类型对齐官网完整列表", () => { diff --git a/__tests__/hooks/use-quote-polling.test.ts b/__tests__/hooks/use-quote-polling.test.ts index 1ccebd2..d2e8126 100644 --- a/__tests__/hooks/use-quote-polling.test.ts +++ b/__tests__/hooks/use-quote-polling.test.ts @@ -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(); }); }); diff --git a/__tests__/lib/constants/flock-quote-hold.test.ts b/__tests__/lib/constants/flock-quote-hold.test.ts new file mode 100644 index 0000000..51099f7 --- /dev/null +++ b/__tests__/lib/constants/flock-quote-hold.test.ts @@ -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); + }); +}); diff --git a/__tests__/lib/constants/flock-scheduling-unlock.test.ts b/__tests__/lib/constants/flock-scheduling-unlock.test.ts new file mode 100644 index 0000000..25f5802 --- /dev/null +++ b/__tests__/lib/constants/flock-scheduling-unlock.test.ts @@ -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); + }); +}); diff --git a/__tests__/lib/constants/mothership-ui-tiers.test.ts b/__tests__/lib/constants/mothership-ui-tiers.test.ts index 165bf0d..fb047c9 100644 --- a/__tests__/lib/constants/mothership-ui-tiers.test.ts +++ b/__tests__/lib/constants/mothership-ui-tiers.test.ts @@ -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("平台承运"); + }); }); diff --git a/__tests__/lib/constants/ms-refine-hold.test.ts b/__tests__/lib/constants/ms-refine-hold.test.ts new file mode 100644 index 0000000..6b938c7 --- /dev/null +++ b/__tests__/lib/constants/ms-refine-hold.test.ts @@ -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 { + 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(/报价单不存在/); + }); +}); diff --git a/__tests__/lib/embed/host-bridge.test.ts b/__tests__/lib/embed/host-bridge.test.ts new file mode 100644 index 0000000..57e8209 --- /dev/null +++ b/__tests__/lib/embed/host-bridge.test.ts @@ -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"); + }); +}); diff --git a/__tests__/lib/embed/map-quotes-selection.test.ts b/__tests__/lib/embed/map-quotes-selection.test.ts new file mode 100644 index 0000000..f5b0b07 --- /dev/null +++ b/__tests__/lib/embed/map-quotes-selection.test.ts @@ -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); + }); +}); diff --git a/__tests__/lib/embed/sso-params.test.ts b/__tests__/lib/embed/sso-params.test.ts index 55e9c0d..7aca9c4 100644 --- a/__tests__/lib/embed/sso-params.test.ts +++ b/__tests__/lib/embed/sso-params.test.ts @@ -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); diff --git a/__tests__/lib/flock/env-quote-wait.test.ts b/__tests__/lib/flock/env-quote-wait.test.ts new file mode 100644 index 0000000..1c345c1 --- /dev/null +++ b/__tests__/lib/flock/env-quote-wait.test.ts @@ -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); + }); +}); diff --git a/__tests__/lib/flock/flock-checkout-details-rules.test.ts b/__tests__/lib/flock/flock-checkout-details-rules.test.ts new file mode 100644 index 0000000..ba668a6 --- /dev/null +++ b/__tests__/lib/flock/flock-checkout-details-rules.test.ts @@ -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(/灵活性/); + }); +}); diff --git a/__tests__/lib/flock/flock-checkout-selection.test.ts b/__tests__/lib/flock/flock-checkout-selection.test.ts new file mode 100644 index 0000000..eb1c47b --- /dev/null +++ b/__tests__/lib/flock/flock-checkout-selection.test.ts @@ -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/); + }); +}); diff --git a/__tests__/lib/flock/flock-checkout-store.test.ts b/__tests__/lib/flock/flock-checkout-store.test.ts new file mode 100644 index 0000000..2d1bf8e --- /dev/null +++ b/__tests__/lib/flock/flock-checkout-store.test.ts @@ -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(/正在官网/); + }); +}); diff --git a/__tests__/lib/flock/flock-quote-hold-store.test.ts b/__tests__/lib/flock/flock-quote-hold-store.test.ts new file mode 100644 index 0000000..c1c1ba7 --- /dev/null +++ b/__tests__/lib/flock/flock-quote-hold-store.test.ts @@ -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(); + }); +}); diff --git a/__tests__/lib/flock/freight-class-density.test.ts b/__tests__/lib/flock/freight-class-density.test.ts new file mode 100644 index 0000000..dccc202 --- /dev/null +++ b/__tests__/lib/flock/freight-class-density.test.ts @@ -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(); + }); +}); diff --git a/__tests__/lib/flock/logged-in-rpa-options.test.ts b/__tests__/lib/flock/logged-in-rpa-options.test.ts index 585e68a..88688dc 100644 --- a/__tests__/lib/flock/logged-in-rpa-options.test.ts +++ b/__tests__/lib/flock/logged-in-rpa-options.test.ts @@ -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); }); }); diff --git a/__tests__/lib/flock/rpa-progress.test.ts b/__tests__/lib/flock/rpa-progress.test.ts index b3f855a..9b32307 100644 --- a/__tests__/lib/flock/rpa-progress.test.ts +++ b/__tests__/lib/flock/rpa-progress.test.ts @@ -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); + }); }); diff --git a/__tests__/lib/flock/saved-freight-presets.test.ts b/__tests__/lib/flock/saved-freight-presets.test.ts deleted file mode 100644 index 72d71e6..0000000 --- a/__tests__/lib/flock/saved-freight-presets.test.ts +++ /dev/null @@ -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 { - 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, - ); - }); -}); diff --git a/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts b/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts index 4de3bcb..0e9e420 100644 --- a/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts +++ b/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts @@ -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", diff --git a/__tests__/lib/frontend/query-log-copy.test.ts b/__tests__/lib/frontend/query-log-copy.test.ts new file mode 100644 index 0000000..f4c7d50 --- /dev/null +++ b/__tests__/lib/frontend/query-log-copy.test.ts @@ -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"]); + }); +}); diff --git a/__tests__/lib/frontend/scrub-provider-brand.test.ts b/__tests__/lib/frontend/scrub-provider-brand.test.ts new file mode 100644 index 0000000..f581e9d --- /dev/null +++ b/__tests__/lib/frontend/scrub-provider-brand.test.ts @@ -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"); + }); +}); diff --git a/__tests__/lib/mothership/dashboard-direct-quote.test.ts b/__tests__/lib/mothership/dashboard-direct-quote.test.ts new file mode 100644 index 0000000..72d44ed --- /dev/null +++ b/__tests__/lib/mothership/dashboard-direct-quote.test.ts @@ -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; + }); +}); diff --git a/__tests__/lib/mothership/logged-in-constraints.test.ts b/__tests__/lib/mothership/logged-in-constraints.test.ts index 662adad..0be83d2 100644 --- a/__tests__/lib/mothership/logged-in-constraints.test.ts +++ b/__tests__/lib/mothership/logged-in-constraints.test.ts @@ -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 周末日期", () => { diff --git a/__tests__/lib/mothership/ms-checkout-selection.test.ts b/__tests__/lib/mothership/ms-checkout-selection.test.ts new file mode 100644 index 0000000..9044975 --- /dev/null +++ b/__tests__/lib/mothership/ms-checkout-selection.test.ts @@ -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(/超出允许范围/); + }); +}); diff --git a/__tests__/lib/mothership/ms-checkout-store.test.ts b/__tests__/lib/mothership/ms-checkout-store.test.ts new file mode 100644 index 0000000..af5d8db --- /dev/null +++ b/__tests__/lib/mothership/ms-checkout-store.test.ts @@ -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(/正在官网/); + }); +}); diff --git a/__tests__/lib/mothership/ms-details-field-tips.test.ts b/__tests__/lib/mothership/ms-details-field-tips.test.ts new file mode 100644 index 0000000..38b7568 --- /dev/null +++ b/__tests__/lib/mothership/ms-details-field-tips.test.ts @@ -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"]), + ); + }); +}); diff --git a/__tests__/lib/mothership/option-compat.test.ts b/__tests__/lib/mothership/option-compat.test.ts index c096a9e..1f4c6c2 100644 --- a/__tests__/lib/mothership/option-compat.test.ts +++ b/__tests__/lib/mothership/option-compat.test.ts @@ -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", () => { diff --git a/__tests__/lib/mothership/portal-quote-messages.test.ts b/__tests__/lib/mothership/portal-quote-messages.test.ts index 25e5a96..9433dc6 100644 --- a/__tests__/lib/mothership/portal-quote-messages.test.ts +++ b/__tests__/lib/mothership/portal-quote-messages.test.ts @@ -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); + }); }); diff --git a/__tests__/lib/rpa/direct-quote-fallback.test.ts b/__tests__/lib/rpa/direct-quote-fallback.test.ts index d4b446e..28c1283 100644 --- a/__tests__/lib/rpa/direct-quote-fallback.test.ts +++ b/__tests__/lib/rpa/direct-quote-fallback.test.ts @@ -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); diff --git a/__tests__/modules/alert/alert-presentation.test.ts b/__tests__/modules/alert/alert-presentation.test.ts index 8f6265c..0a98c2a 100644 --- a/__tests__/modules/alert/alert-presentation.test.ts +++ b/__tests__/modules/alert/alert-presentation.test.ts @@ -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", diff --git a/__tests__/modules/customer/business-customer-user-service.test.ts b/__tests__/modules/customer/business-customer-user-service.test.ts new file mode 100644 index 0000000..4857e67 --- /dev/null +++ b/__tests__/modules/customer/business-customer-user-service.test.ts @@ -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"); + }); +}); diff --git a/__tests__/modules/flock/flock-hold-user-journeys.test.ts b/__tests__/modules/flock/flock-hold-user-journeys.test.ts new file mode 100644 index 0000000..1afc21e --- /dev/null +++ b/__tests__/modules/flock/flock-hold-user-journeys.test.ts @@ -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); + }); +}); diff --git a/__tests__/modules/flock/hold-service.test.ts b/__tests__/modules/flock/hold-service.test.ts new file mode 100644 index 0000000..75bf178 --- /dev/null +++ b/__tests__/modules/flock/hold-service.test.ts @@ -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>, +) { + 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( + /询价会话已失效/, + ); + }); +}); diff --git a/__tests__/modules/flock/pricing-options-service.test.ts b/__tests__/modules/flock/pricing-options-service.test.ts new file mode 100644 index 0000000..96813df --- /dev/null +++ b/__tests__/modules/flock/pricing-options-service.test.ts @@ -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" }), + ); + }); +}); diff --git a/__tests__/modules/flock/quote-storage.test.ts b/__tests__/modules/flock/quote-storage.test.ts index 214a71b..851c9d4 100644 --- a/__tests__/modules/flock/quote-storage.test.ts +++ b/__tests__/modules/flock/quote-storage.test.ts @@ -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, diff --git a/__tests__/modules/pricing/engine.test.ts b/__tests__/modules/pricing/engine.test.ts index 834a934..2a72da9 100644 --- a/__tests__/modules/pricing/engine.test.ts +++ b/__tests__/modules/pricing/engine.test.ts @@ -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(); }); }); diff --git a/__tests__/modules/quote/orchestrator.test.ts b/__tests__/modules/quote/orchestrator.test.ts index f89de09..81592c8 100644 --- a/__tests__/modules/quote/orchestrator.test.ts +++ b/__tests__/modules/quote/orchestrator.test.ts @@ -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, diff --git a/__tests__/modules/quote/quote-completeness.test.ts b/__tests__/modules/quote/quote-completeness.test.ts index 4104b50..0575c5f 100644 --- a/__tests__/modules/quote/quote-completeness.test.ts +++ b/__tests__/modules/quote/quote-completeness.test.ts @@ -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); + }); }); diff --git a/__tests__/modules/quote/quote-error-messages.test.ts b/__tests__/modules/quote/quote-error-messages.test.ts index d20ab26..fcedab3 100644 --- a/__tests__/modules/quote/quote-error-messages.test.ts +++ b/__tests__/modules/quote/quote-error-messages.test.ts @@ -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); + }); }); diff --git a/__tests__/modules/quote/quote-serializer-ms-needs-details.test.ts b/__tests__/modules/quote/quote-serializer-ms-needs-details.test.ts new file mode 100644 index 0000000..69bd74e --- /dev/null +++ b/__tests__/modules/quote/quote-serializer-ms-needs-details.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/__tests__/modules/quote/quote-serializer.test.ts b/__tests__/modules/quote/quote-serializer.test.ts index 8e6a9ff..8c07d1d 100644 --- a/__tests__/modules/quote/quote-serializer.test.ts +++ b/__tests__/modules/quote/quote-serializer.test.ts @@ -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); }); }); diff --git a/__tests__/modules/quote/validation.test.ts b/__tests__/modules/quote/validation.test.ts index 162982a..6162631 100644 --- a/__tests__/modules/quote/validation.test.ts +++ b/__tests__/modules/quote/validation.test.ts @@ -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); }); }); diff --git a/__tests__/workers/rpa/mothership-logged-in-quote.test.ts b/__tests__/workers/rpa/mothership-logged-in-quote.test.ts index 7a46a54..9d81b67 100644 --- a/__tests__/workers/rpa/mothership-logged-in-quote.test.ts +++ b/__tests__/workers/rpa/mothership-logged-in-quote.test.ts @@ -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); + }); +}); diff --git a/__tests__/workers/rpa/parked-quote-session.test.ts b/__tests__/workers/rpa/parked-quote-session.test.ts index ca522dd..cb7d799 100644 --- a/__tests__/workers/rpa/parked-quote-session.test.ts +++ b/__tests__/workers/rpa/parked-quote-session.test.ts @@ -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(); + }); }); diff --git a/api调用文档/api对接文档.md b/api调用文档/api对接文档.md index be5ce08..2904578 100644 --- a/api调用文档/api对接文档.md +++ b/api调用文档/api对接文档.md @@ -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", diff --git a/api调用文档/api调用文档.md b/api调用文档/api调用文档.md index 434db33..f89254d 100644 --- a/api调用文档/api调用文档.md +++ b/api调用文档/api调用文档.md @@ -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` 时不加价。 --- diff --git a/api调用文档/宿主客户同步.md b/api调用文档/宿主客户同步.md new file mode 100644 index 0000000..8a1cbf1 --- /dev/null +++ b/api调用文档/宿主客户同步.md @@ -0,0 +1,37 @@ +# 宿主客户同步 API + +`POST /api/host/customers/sync` + +鉴权:`Authorization: Bearer ` + `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` diff --git a/api调用文档/嵌入-postMessage对接文档.md b/api调用文档/嵌入-postMessage对接文档.md new file mode 100644 index 0000000..1e99117 --- /dev/null +++ b/api调用文档/嵌入-postMessage对接文档.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=&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`。 diff --git a/api调用文档/嵌入对接文档.md b/api调用文档/嵌入对接文档.md new file mode 100644 index 0000000..b9d4000 --- /dev/null +++ b/api调用文档/嵌入对接文档.md @@ -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 浏览器地址栏直开(推荐先测) + +把 `` 换成真实 Key,整段粘贴到浏览器: + +```text +https://if.dev.51track.vip/embed-demo?login_type=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":""}' +``` + +成功示例(字段可能略多,以 `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 + + + + + 查价嵌入示例 + + + + + + + +``` + +服务端伪代码: + +```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=&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`。 diff --git a/app/admin/customers/page.tsx b/app/admin/customers/page.tsx index b5b1e84..2512cd1 100644 --- a/app/admin/customers/page.tsx +++ b/app/admin/customers/page.tsx @@ -303,8 +303,8 @@ export default function AdminCustomersPage() { return ( { @@ -312,7 +312,7 @@ export default function AdminCustomersPage() { setCreateOpen(true); }} > - 新建客户 + 新建租户 } /> @@ -387,7 +387,7 @@ export default function AdminCustomersPage() { - + @@ -431,7 +431,7 @@ export default function AdminCustomersPage() { void openCredDrawer(row)} > - 查价网站账密 + 租户账密 void handleToggleStatus(row)} @@ -446,7 +446,12 @@ export default function AdminCustomersPage() { 轮换 Key - 加价配置 + 租户加价 + + + 业务客户 diff --git a/app/admin/markup/page.tsx b/app/admin/markup/page.tsx index 67dd59c..efbdf14 100644 --- a/app/admin/markup/page.tsx +++ b/app/admin/markup/page.tsx @@ -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([]); 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(null); const [markupType, setMarkupType] = useState("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() { { - setPage(1); - setKeyword(e.target.value); - }} + label="搜索租户/客户" + placeholder="租户 / 客户代码 SP012 / 名称 / 账号 STARPOST" + value={keywordInput} + onChange={(e) => setKeywordInput(e.target.value)} /> } @@ -162,7 +203,9 @@ function AdminMarkupPageContent() { {status === "empty" && ( - 未找到匹配的客户 + {keyword + ? `未找到匹配「${keyword}」的租户或业务客户。可试:客户代码 SP012、名称 STAR、登录账号 STARPOST(数字 0 勿输成字母 O)` + : "暂无业务客户,请先在「租户管理」同步或新增客户"} )} @@ -172,7 +215,8 @@ function AdminMarkupPageContent() {
客户 ID租户 ID 名称 状态 当前 API Key
- + + @@ -183,8 +227,16 @@ function AdminMarkupPageContent() { {rows.map((row) => ( - + + @@ -246,6 +298,7 @@ function AdminMarkupPageContent() { >

编辑加价 · {editing.customer_id} + {` / ${formatBusinessCustomerLabel(editing)}`}

百分比仅对运费加价(上限 30%,步长 0.01%);固定金额为每档报价叠加固定金额。 diff --git a/app/admin/queues/page.tsx b/app/admin/queues/page.tsx index bf76f6a..c2ee514 100644 --- a/app/admin/queues/page.tsx +++ b/app/admin/queues/page.tsx @@ -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 = { 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 { + 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 ( + + ); +} + +function DetailField({ + label, + value, + copyTextValue, + copyLabel, + mono, +}: { + label: string; + value: string; + copyTextValue?: string; + copyLabel?: string; + mono?: boolean; +}) { + return ( +

+
+
{label}
+ {copyTextValue ? ( + + ) : null} +
+
+ {value} +
+
+ ); } 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(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() { {Array.from({ length: 6 }).map((_, i) => ( - + ))} )} {status === "error" && ( void load(page, outcomeFilter)}>重试} + action={ + void load(page, outcomeFilter)}> + 重试 + + } > 加载查价记录失败 @@ -122,48 +244,97 @@ export default function QueryLogsPage() { {status === "success" && (
-
-
客户 ID租户 ID业务客户 加价方式 加价规则 操作人
{row.customer_id} +
+ {formatBusinessCustomerLabel(row)} +
+
{row.markup_type === "fixed" ? "固定金额" : "百分比"}
+
+
+ + + + + + + + - - - - - - - + + + + + + - {rows.map((row) => ( - - - - - - - + + + - - - ))} +

+ 送 {delivery || "—"} +

+ + + + + + ); + })}
查询时间客户提货(联想)派送(联想)货物结果完成时间查询时间客户线路结果报价操作
- {formatDateTime(row.created_at)} - {row.customer_id} - {row.pickup_selected} - - {row.delivery_selected} - {row.cargo_summary} - - {OUTCOME_LABEL[row.outcome]} - - {row.failure_reason && row.outcome !== "success" && ( -

- {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 ( +

+ {formatDateTime(row.created_at)} + + + {row.customer_id} + + +

+ 提 {pickup || "—"}

- )} -
- {row.completed_at ? formatDateTime(row.completed_at) : "—"} -
+ + {OUTCOME_LABEL[row.outcome]} + + + {(() => { + const summary = formatQuotedSummary(row); + return ( + <> +

{summary.primary}

+ {summary.secondary ? ( +

+ {summary.secondary} +

+ ) : null} + + ); + })()} +
+ +
@@ -191,6 +362,139 @@ export default function QueryLogsPage() { )} + + {detail && ( +
setDetail(null)} + > +
e.stopPropagation()} + role="dialog" + aria-modal + aria-labelledby="query-log-detail-title" + > +
+
+

+ 询价详情 +

+

+ 可复制完整地址与货物参数 +

+
+ +
+
+
+ + + + + + + + +
+
+
+ 报价列表 + {quotesOf(detail).length > 0 + ? `(${quotesOf(detail).length} 档)` + : ""} +
+ {quotesOf(detail).length > 0 ? ( + + ) : null} +
+
+ {quotesOf(detail).length === 0 ? ( +

+ {detail.quoted_total != null + ? `${displayOrDash(detail.quoted_carrier)} · ${formatQuotedTotal(detail.quoted_total)}` + : "—"} +

+ ) : ( + quotesOf(detail).map((q, i) => ( +

+ {formatQueryLogQuoteLine(q)} +

+ )) + )} +
+
+
+
+
+ setDetail(null)}> + 关闭 + +
+
+
+ )}
); } diff --git a/app/admin/tenants/[customer_id]/customers/page.tsx b/app/admin/tenants/[customer_id]/customers/page.tsx new file mode 100644 index 0000000..29b7457 --- /dev/null +++ b/app/admin/tenants/[customer_id]/customers/page.tsx @@ -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("loading"); + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + /** 业务客户 → 当前加价摘要(打开/保存后刷新) */ + const [markupByBcId, setMarkupByBcId] = useState>({}); + + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [createForm, setCreateForm] = useState({ + business_customer_id: "", + name: "", + external_code: "", + remark: "", + }); + + const [editing, setEditing] = useState(null); + const [savingEdit, setSavingEdit] = useState(false); + const [editError, setEditError] = useState(null); + const [editForm, setEditForm] = useState({ + name: "", + external_code: "", + remark: "", + status: "active" as "active" | "disabled", + }); + + const [markupTarget, setMarkupTarget] = useState( + null, + ); + const [markupType, setMarkupType] = useState("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(null); + const [markupSuccess, setMarkupSuccess] = useState(null); + + const [usersTarget, setUsersTarget] = useState( + null, + ); + const [usersStatus, setUsersStatus] = useState("empty"); + const [users, setUsers] = useState([]); + const [usersError, setUsersError] = useState(null); + const [onlyWithUsers, setOnlyWithUsers] = useState(true); + const [allUsersOpen, setAllUsersOpen] = useState(false); + const [allUsersKeyword, setAllUsersKeyword] = useState(""); + const [allUsersStatus, setAllUsersStatus] = useState("empty"); + const [allUsers, setAllUsers] = useState([]); + const [allUsersError, setAllUsersError] = useState(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 ( + + + { + setAllUsersOpen(true); + void loadAllUsers(allUsersKeyword); + }} + > + 全部登录账号 + + setCreateOpen(true)}> + 新增业务客户 + + + } + /> + + {status === "loading" && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {status === "error" && ( + void load()}>重试}> + {error ?? "加载业务客户失败"} + + )} + + {status === "empty" && ( + + 当前租户还没有业务客户 + + )} + + {status === "success" && ( + +
+ +
+ + + + + + + + + + + + + + + {sortedRows.map((row) => ( + + + + + + + + + + + ))} + +
客户代码客户名称加价规则登录账号数状态备注更新时间操作
+ {row.external_code || "—"} + {row.name} + {markupByBcId[row.business_customer_id] ?? "—"} + {row.user_count ?? 0}{row.status === "active" ? "启用" : "停用"}{row.remark || "—"} + {formatDateTime(row.updated_at)} + +
+ openEdit(row)}> + 编辑 + + void openMarkup(row)} + > + 客户加价 + + { + 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", + ); + }} + > + 登录账号 + +
+
+
+ )} + + {createOpen && ( +
!creating && setCreateOpen(false)} + > +
e.stopPropagation()} + > +

新增业务客户

+
+ + setCreateForm((prev) => ({ + ...prev, + business_customer_id: e.target.value, + })) + } + /> + + setCreateForm((prev) => ({ ...prev, name: e.target.value })) + } + /> + + setCreateForm((prev) => ({ + ...prev, + external_code: e.target.value, + })) + } + /> + + setCreateForm((prev) => ({ ...prev, remark: e.target.value })) + } + /> + {createError ? {createError} : null} +
+ setCreateOpen(false)}>取消 + void handleCreate()}> + 保存 + +
+
+
+
+ )} + + {editing && ( +
!savingEdit && setEditing(null)} + > +
e.stopPropagation()} + > +

编辑业务客户

+
+ + setEditForm((prev) => ({ ...prev, name: e.target.value })) + } + /> + + setEditForm((prev) => ({ + ...prev, + external_code: e.target.value, + })) + } + /> + + setEditForm((prev) => ({ + ...prev, + status: e.target.value as "active" | "disabled", + })) + } + /> + + setEditForm((prev) => ({ ...prev, remark: e.target.value })) + } + /> + {editError ? {editError} : null} +
+ setEditing(null)}>取消 + void handleSaveEdit()}> + 保存 + +
+
+
+
+ )} + + {markupTarget && ( +
!savingMarkup && setMarkupTarget(null)} + > +
e.stopPropagation()} + > +

+ 客户加价 ·{" "} + {[markupTarget.external_code, markupTarget.name] + .filter(Boolean) + .join(" · ") || markupTarget.business_customer_id} +

+

+ 仅对本业务客户生效;未配置时询价按 0% 处理。 +

+
+ {loadingMarkup ? ( + + ) : null} + { + setMarkupType(e.target.value as MarkupType); + setMarkupError(null); + setMarkupSuccess(null); + }} + /> + {markupType === "percent" ? ( + { + setMarkupPercent(e.target.value); + setMarkupError(null); + setMarkupSuccess(null); + }} + /> + ) : ( + { + setMarkupFixedAmount(e.target.value); + setMarkupError(null); + setMarkupSuccess(null); + }} + /> + )} + setMarkupRemark(e.target.value)} + /> + + 保存后按客户组织 ID 匹配加价。百分比上限 30%;固定金额为每档报价叠加。空值不会保存,也不会覆盖已有规则。 + + {markupError ? {markupError} : null} + {markupSuccess ? ( + + {markupSuccess} + + ) : null} +
+ setMarkupTarget(null)} + > + {markupSuccess ? "关闭" : "取消"} + + void handleSaveMarkup()} + > + 保存 + +
+
+
+
+ )} + + {usersTarget && ( +
setUsersTarget(null)} + > +
e.stopPropagation()} + > +

+ 登录账号 ·{" "} + {[usersTarget.external_code, usersTarget.name] + .filter(Boolean) + .join(" · ") || usersTarget.business_customer_id} +

+

+ 来自 ccnew PC/Web 登录账号(LR_Base_User),用于询价识别加价客户。 +

+
+ {usersStatus === "loading" ? : null} + {usersError ? {usersError} : null} + {usersStatus === "empty" ? ( +

暂无同步账号

+ ) : null} + {usersStatus === "success" ? ( + + + + + + + + + + {users.map((u) => ( + + + + + + ))} + +
账号姓名状态
{u.account}{u.display_name || "—"} + {u.status === "active" ? "启用" : "停用"} +
+ ) : null} +
+
+ setUsersTarget(null)}>关闭 +
+
+
+ )} + + {allUsersOpen && ( +
setAllUsersOpen(false)} + > +
e.stopPropagation()} + > +

全部登录账号

+

+ 对应 ccnew「用户管理」中的客户账号;询价靠账号识别所属客户代码再套加价。 +

+
+ setAllUsersKeyword(e.target.value)} + /> +
+ void loadAllUsers(allUsersKeyword)}> + 查询 + +
+
+
+ {allUsersStatus === "loading" ? ( + + ) : null} + {allUsersError ? {allUsersError} : null} + {allUsersStatus === "empty" ? ( +

暂无账号

+ ) : null} + {allUsersStatus === "success" ? ( + + + + + + + + + + + + {allUsers.map((u) => ( + + + + + + + + ))} + +
账号姓名客户代码客户名称状态
{u.account}{u.display_name || "—"} + {u.business_customer_code || "—"} + + {u.business_customer_name || "—"} + + {u.status === "active" ? "启用" : "停用"} +
+ ) : null} +
+
+ setAllUsersOpen(false)}>关闭 +
+
+
+ )} +
+ ); +} diff --git a/app/admin/tenants/page.tsx b/app/admin/tenants/page.tsx new file mode 100644 index 0000000..503fb32 --- /dev/null +++ b/app/admin/tenants/page.tsx @@ -0,0 +1 @@ +export { default } from "@/app/admin/customers/page"; diff --git a/app/api/admin/markup-configs/[customer_id]/business-customers/[business_customer_id]/route.ts b/app/api/admin/markup-configs/[customer_id]/business-customers/[business_customer_id]/route.ts new file mode 100644 index 0000000..fdc801f --- /dev/null +++ b/app/api/admin/markup-configs/[customer_id]/business-customers/[business_customer_id]/route.ts @@ -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); +} diff --git a/app/api/admin/markup-configs/[customer_id]/route.ts b/app/api/admin/markup-configs/[customer_id]/route.ts index 5a762ce..a44ebd3 100644 --- a/app/api/admin/markup-configs/[customer_id]/route.ts +++ b/app/api/admin/markup-configs/[customer_id]/route.ts @@ -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, diff --git a/app/api/admin/query-logs/route.ts b/app/api/admin/query-logs/route.ts index ea2f48e..7dc20ab 100644 --- a/app/api/admin/query-logs/route.ts +++ b/app/api/admin/query-logs/route.ts @@ -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, }); diff --git a/app/api/admin/tenants/[customer_id]/business-customers/[business_customer_id]/route.ts b/app/api/admin/tenants/[customer_id]/business-customers/[business_customer_id]/route.ts new file mode 100644 index 0000000..19b0e8f --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/business-customers/[business_customer_id]/route.ts @@ -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); + } +} diff --git a/app/api/admin/tenants/[customer_id]/business-customers/[business_customer_id]/users/route.ts b/app/api/admin/tenants/[customer_id]/business-customers/[business_customer_id]/users/route.ts new file mode 100644 index 0000000..c4e3158 --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/business-customers/[business_customer_id]/users/route.ts @@ -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; + } +} diff --git a/app/api/admin/tenants/[customer_id]/business-customers/route.ts b/app/api/admin/tenants/[customer_id]/business-customers/route.ts new file mode 100644 index 0000000..01fe1bf --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/business-customers/route.ts @@ -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); + } +} diff --git a/app/api/admin/tenants/[customer_id]/provider-credentials/route.ts b/app/api/admin/tenants/[customer_id]/provider-credentials/route.ts new file mode 100644 index 0000000..ef4dc6f --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/provider-credentials/route.ts @@ -0,0 +1 @@ +export { GET, PUT } from "@/app/api/admin/customers/[customer_id]/provider-credentials/route"; diff --git a/app/api/admin/tenants/[customer_id]/rotate-key/route.ts b/app/api/admin/tenants/[customer_id]/rotate-key/route.ts new file mode 100644 index 0000000..fb5979a --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/rotate-key/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/admin/customers/[customer_id]/rotate-key/route"; diff --git a/app/api/admin/tenants/[customer_id]/route.ts b/app/api/admin/tenants/[customer_id]/route.ts new file mode 100644 index 0000000..7565f31 --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/route.ts @@ -0,0 +1 @@ +export { PATCH } from "@/app/api/admin/customers/[customer_id]/route"; diff --git a/app/api/admin/tenants/[customer_id]/users/lookup/route.ts b/app/api/admin/tenants/[customer_id]/users/lookup/route.ts new file mode 100644 index 0000000..50366c8 --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/users/lookup/route.ts @@ -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); +} diff --git a/app/api/admin/tenants/[customer_id]/users/route.ts b/app/api/admin/tenants/[customer_id]/users/route.ts new file mode 100644 index 0000000..cdd8db3 --- /dev/null +++ b/app/api/admin/tenants/[customer_id]/users/route.ts @@ -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 }); +} diff --git a/app/api/admin/tenants/route.ts b/app/api/admin/tenants/route.ts new file mode 100644 index 0000000..3d56b02 --- /dev/null +++ b/app/api/admin/tenants/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/admin/customers/route"; diff --git a/app/api/host/customers/sync/route.ts b/app/api/host/customers/sync/route.ts new file mode 100644 index 0000000..36379cc --- /dev/null +++ b/app/api/host/customers/sync/route.ts @@ -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 + * 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; + } +} diff --git a/app/api/host/quote/submit/route.ts b/app/api/host/quote/submit/route.ts index e980012..651ad62 100644 --- a/app/api/host/quote/submit/route.ts +++ b/app/api/host/quote/submit/route.ts @@ -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); } } diff --git a/app/api/markup-configs/[customer_id]/business-customers/[business_customer_id]/route.ts b/app/api/markup-configs/[customer_id]/business-customers/[business_customer_id]/route.ts new file mode 100644 index 0000000..767ee97 --- /dev/null +++ b/app/api/markup-configs/[customer_id]/business-customers/[business_customer_id]/route.ts @@ -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); +} diff --git a/app/api/markup-configs/[customer_id]/route.ts b/app/api/markup-configs/[customer_id]/route.ts index 717e609..fb86630 100644 --- a/app/api/markup-configs/[customer_id]/route.ts +++ b/app/api/markup-configs/[customer_id]/route.ts @@ -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, diff --git a/app/api/markup-configs/route.ts b/app/api/markup-configs/route.ts index cb68e85..b21d079 100644 --- a/app/api/markup-configs/route.ts +++ b/app/api/markup-configs/route.ts @@ -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 }), diff --git a/app/api/quotes/[quote_id]/route.ts b/app/api/quotes/[quote_id]/route.ts index 1af9c72..d9c01e5 100644 --- a/app/api/quotes/[quote_id]/route.ts +++ b/app/api/quotes/[quote_id]/route.ts @@ -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); } diff --git a/app/api/quotes/flock-checkout/route.ts b/app/api/quotes/flock-checkout/route.ts new file mode 100644 index 0000000..a69508e --- /dev/null +++ b/app/api/quotes/flock-checkout/route.ts @@ -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); + } +} diff --git a/app/api/quotes/flock-hold/continue/route.ts b/app/api/quotes/flock-hold/continue/route.ts new file mode 100644 index 0000000..8a26c46 --- /dev/null +++ b/app/api/quotes/flock-hold/continue/route.ts @@ -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); + } +} diff --git a/app/api/quotes/flock-hold/decline/route.ts b/app/api/quotes/flock-hold/decline/route.ts new file mode 100644 index 0000000..62d1572 --- /dev/null +++ b/app/api/quotes/flock-hold/decline/route.ts @@ -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); + } +} diff --git a/app/api/quotes/flock-pricing-options/route.ts b/app/api/quotes/flock-pricing-options/route.ts new file mode 100644 index 0000000..41f19aa --- /dev/null +++ b/app/api/quotes/flock-pricing-options/route.ts @@ -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); + } +} diff --git a/app/api/quotes/history/route.ts b/app/api/quotes/history/route.ts index e137b60..0335da0 100644 --- a/app/api/quotes/history/route.ts +++ b/app/api/quotes/history/route.ts @@ -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 }), diff --git a/app/api/quotes/manual-fallback/route.ts b/app/api/quotes/manual-fallback/route.ts index 478e597..6b3ff7e 100644 --- a/app/api/quotes/manual-fallback/route.ts +++ b/app/api/quotes/manual-fallback/route.ts @@ -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 }; diff --git a/app/api/quotes/ms-checkout/route.ts b/app/api/quotes/ms-checkout/route.ts new file mode 100644 index 0000000..99477de --- /dev/null +++ b/app/api/quotes/ms-checkout/route.ts @@ -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); + } +} diff --git a/app/api/quotes/refine-details/route.ts b/app/api/quotes/refine-details/route.ts new file mode 100644 index 0000000..30e67da --- /dev/null +++ b/app/api/quotes/refine-details/route.ts @@ -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); + } +} diff --git a/app/api/quotes/refine-hold/continue/route.ts b/app/api/quotes/refine-hold/continue/route.ts new file mode 100644 index 0000000..8541c5a --- /dev/null +++ b/app/api/quotes/refine-hold/continue/route.ts @@ -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); + } +} diff --git a/app/api/quotes/refine-hold/decline/route.ts b/app/api/quotes/refine-hold/decline/route.ts new file mode 100644 index 0000000..d8cf7fa --- /dev/null +++ b/app/api/quotes/refine-hold/decline/route.ts @@ -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); + } +} diff --git a/app/api/quotes/route.ts b/app/api/quotes/route.ts index 4f7bcf4..6383b08 100644 --- a/app/api/quotes/route.ts +++ b/app/api/quotes/route.ts @@ -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); } } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 8577f9e..9f4b114 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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, }, ]; diff --git a/app/e2e-fixtures/flock-hold/page.tsx b/app/e2e-fixtures/flock-hold/page.tsx new file mode 100644 index 0000000..757f721 --- /dev/null +++ b/app/e2e-fixtures/flock-hold/page.tsx @@ -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( + null, + ); + const [selectedFlexibility, setSelectedFlexibility] = + useState(null); + const [checkoutMessage, setCheckoutMessage] = useState(null); + const [lastAction, setLastAction] = useState("idle"); + + return ( +
+

Flock Hold E2E Fixture

+

lastAction={lastAction}

+

holdAccepted={String(holdAccepted)}

+ + { + 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 ? ( + { + setHoldAccepted(true); + setHoldOpen(false); + setLastAction("hold-continue"); + }} + onDecline={() => { + setHoldAccepted(false); + setHoldOpen(false); + setLastAction("hold-decline"); + }} + /> + ) : null} +
+ ); +} diff --git a/app/e2e-fixtures/layout.tsx b/app/e2e-fixtures/layout.tsx new file mode 100644 index 0000000..90b0c11 --- /dev/null +++ b/app/e2e-fixtures/layout.tsx @@ -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; +} diff --git a/app/e2e-fixtures/ms-l2-ui/page.tsx b/app/e2e-fixtures/ms-l2-ui/page.tsx new file mode 100644 index 0000000..adb355e --- /dev/null +++ b/app/e2e-fixtures/ms-l2-ui/page.tsx @@ -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 ( +
+
+ setLastAction("details-submit")} + /> +
+
+ setLastAction("checkout-ok")} + /> +
+

+ lastAction={lastAction} +

+
+ ); +} diff --git a/app/embed-demo/embed-demo-client.tsx b/app/embed-demo/embed-demo-client.tsx index 774469f..14b2226 100644 --- a/app/embed-demo/embed-demo-client.tsx +++ b/app/embed-demo/embed-demo-client.tsx @@ -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(null); const [bootError, setBootError] = useState(null); const [hostedShell, setHostedShell] = useState(false); + const [entryModule, setEntryModule] = useState(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 = ( + + + + ); + + if (hostedShell) { + return
{quoteBody}
; + } + return ( - -
-

- 当前登录客户 · {isKeyLogin ? "API Key 登录" : "账号密码登录"} - {hostedShell ? " · 宿主直连" : ""} -

-

{session.customer_id}

-

- 请选择 MotherShip 或 Flock Freight 模块开始查价 -

-
- {!hostedShell ? ( - void handleLogout()}> - 退出登录 - - ) : null} -
- - +
+ void handleLogout()}> + 退出登录 + +
+ {quoteBody}
); } diff --git a/components/embed/embedded-quote-widget.tsx b/components/embed/embedded-quote-widget.tsx index fb7f1a3..44b30a8 100644 --- a/components/embed/embedded-quote-widget.tsx +++ b/components/embed/embedded-quote-widget.tsx @@ -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( 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("idle"); const [quote, setQuote] = useState(null); const [error, setError] = useState(null); const [submitLocked, setSubmitLocked] = useState(false); const [loggedInDraft, setLoggedInDraft] = useState(null); + const [loggedInDetails, setLoggedInDetails] = + useState(null); const [loggedInStep, setLoggedInStep] = useState<"create" | "details">( "create", ); + const [refineHold, setRefineHold] = useState< + NonNullable | 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( + null, + ); + const [checkoutBusy, setCheckoutBusy] = useState(false); + const [checkoutMessage, setCheckoutMessage] = useState(null); + const checkoutAbortRef = useRef(null); + const rootRef = useRef(null); + const isEmbeddedHost = + typeof window !== "undefined" && window.parent !== window; const [disambiguationOpen, setDisambiguationOpen] = useState(false); const [confirmedPickup, setConfirmedPickup] = useState( 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((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 ( -
+

卡派查价

@@ -334,135 +830,257 @@ export function EmbeddedQuoteWidget({

)} -
-
- {mothershipLoggedInUi ? ( - loggedInStep === "details" && loggedInDraft ? ( + {mothershipLoggedInUi && + !(loggedInStep === "details" && loggedInDraft) ? ( + /* 一级「创建新货件」:取消右侧「下一步」占位,表单占满整行 */ +
+ { + 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 + : "无法构建询价请求", + ); + } + }} + /> +
+ +
+
+ ) : ( +
+
+ {mothershipLoggedInUi ? ( <>
setLoggedInStep("create")} > - 返回一级表单 + 返回上一步
{ + 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 ? ( +
+ +

+ 补全必要信息后提交,系统将重新获取更准确报价。 +

+
+ ) : null} ) : ( <> - { - 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)} />
- + 获取报价 +
- ) - ) : ( - <> - { - setConfirmedPickup(null); - setConfirmedDelivery(null); - }} - onCargoChange={() => { - setQuote(null); - setError(null); - if (status === "success" || status === "fallback") { - setStatus("idle"); - } - }} - onValidSubmit={(body) => void handleSubmit(body)} - /> -
- - 获取报价 - -
- - )} -
-
- {mothershipLoggedInUi ? ( - loggedInStep === "details" && loggedInDraft ? ( + )} +
+
+ {mothershipLoggedInUi && loggedInDraft ? ( void handleMsCheckout(intent)} + onSelectQuote={ + isEmbeddedHost && hostBridge + ? handleSelectQuoteForHost + : undefined + } /> ) : ( -
-

下一步

-

- 点「继续」后进入详情页并开始询价,右侧展示承运商报价。 -

-
- ) - ) : ( - setStatus("expired")} - onExpiredConfirm={reset} - /> - )} + setStatus("expired")} + onExpiredConfirm={reset} + /> + )} +
-
+ )} {!mothershipLoggedInUi && ( )} + + {mothershipLoggedInUi && refineHold && quote?.quote_id ? ( + { + 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} )}
diff --git a/components/embed/quote-provider-switch.tsx b/components/embed/quote-provider-switch.tsx index 3429aa1..f606a55 100644 --- a/components/embed/quote-provider-switch.tsx +++ b/components/embed/quote-provider-switch.tsx @@ -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 ( diff --git a/components/embed/quote-route-hub.tsx b/components/embed/quote-route-hub.tsx index f40126f..2ef79a0 100644 --- a/components/embed/quote-route-hub.tsx +++ b/components/embed/quote-route-hub.tsx @@ -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 @@ -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 ; + } return (
{MODULES.map((mod) => { @@ -143,6 +200,30 @@ export function QuoteRouteHub({ const requiresAccount = mode === "login"; const canQuote = !requiresAccount || providerCred.has_password; + const quoteArea = canQuote ? ( + + ) : ( + +

+ 当前客户未配置承运商官网账号,请在管理端「租户管理」中配置后再查价。 +

+
+ ); + + if (hideChrome) { + return
{quoteArea}
; + } + return (
@@ -167,7 +248,7 @@ export function QuoteRouteHub({
); } diff --git a/components/flock/flock-logged-in-details-form.tsx b/components/flock/flock-logged-in-details-form.tsx new file mode 100644 index 0000000..51f2090 --- /dev/null +++ b/components/flock/flock-logged-in-details-form.tsx @@ -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 ( + + ); +} + +function LocationAddressFields({ + side, + loc, + errors, + disabled, + addressLocked, + onPatch, +}: { + side: "pickup" | "delivery"; + loc: FlockCheckoutLocationDraft; + errors?: Partial>; + disabled?: boolean; + addressLocked: boolean; + onPatch: (patch: Partial) => void; +}) { + const locked = Boolean(disabled || addressLocked); + return ( + <> + + onPatch({ address1: e.target.value })} + /> + + + onPatch({ address2: e.target.value })} + /> + + + onPatch({ city: e.target.value })} + /> + + + onPatch({ state: e.target.value })} + /> + + + onPatch({ zip: e.target.value })} + /> + + + ); +} + +/** 二级结账详情:对齐官网 Pickup / Delivery / Shipment details */ +export const FlockLoggedInDetailsForm = forwardRef< + FlockLoggedInDetailsFormHandle, + Props +>(function FlockLoggedInDetailsForm( + { + disabled = false, + pickupZip = "", + deliveryZip = "", + shipmentSummary = "", + }, + ref, +) { + const [draft, setDraft] = useState(() => + emptyFlockCheckoutDetailsDraft({ pickupZip, deliveryZip }), + ); + const [errors, setErrors] = useState({}); + const [formError, setFormError] = useState(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) => { + setDraft((d) => ({ ...d, pickup: { ...d.pickup, ...patch } })); + }; + + const patchDelivery = (patch: Partial) => { + setDraft((d) => ({ ...d, delivery: { ...d.delivery, ...patch } })); + }; + + return ( +
+
+

结账详情

+

+ {FLOCK_CHECKOUT_FIELD_HINTS.review} +

+
+ + {formError ? {formError} : null} + +
+

您的角色

+
+ {FLOCK_CHECKOUT_ROLE_OPTIONS.map((opt) => ( + + ))} +
+ {errors.role ? ( +

{errors.role}

+ ) : null} +
+ +
+
+

提货地点

+ +
+

+ {FLOCK_CHECKOUT_FIELD_HINTS.useBilling} +

+
+ + patchPickup({ company_name: e.target.value })} + /> + + + + patchPickup({ contact_name: e.target.value })} + /> + + + patchPickup({ contact_phone: e.target.value })} + placeholder="(626) 595-1180" + /> + + + patchPickup({ contact_email: e.target.value })} + /> + + + + + + + +
+
+ +
+
+

派送地点

+ +
+

+ {FLOCK_CHECKOUT_FIELD_HINTS.useBilling} +

+
+ + + patchDelivery({ company_name: e.target.value }) + } + /> + + + + + patchDelivery({ contact_name: e.target.value }) + } + /> + + + + patchDelivery({ contact_phone: e.target.value }) + } + placeholder="(415) 621-8840" + /> + + + + patchDelivery({ contact_email: e.target.value }) + } + /> + + + + + + + +
+
+

+ 该地点是否允许周末派送? +

+
+ {([true, false] as const).map((yes) => ( + + ))} +
+

+ {FLOCK_CHECKOUT_FIELD_HINTS.weekend} +

+
+
+ +
+

运输详情

+ {shipmentSummary ? ( +
+

货件摘要

+

{shipmentSummary}

+

+ {FLOCK_CHECKOUT_FIELD_HINTS.shipmentItems} +

+
+ ) : null} +
+ + + setDraft((d) => ({ ...d, nmfc: e.target.value })) + } + placeholder="如 100240-01" + /> + + + + setDraft((d) => ({ ...d, po_number: e.target.value })) + } + /> + +
+ +