parent
ced49c80e4
commit
ec7de57ff6
@ -0,0 +1,132 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/auth-context", () => ({
|
||||||
|
parseServiceAuth: vi.fn(),
|
||||||
|
assertCustomerMatch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/rate-limit", () => ({
|
||||||
|
enforceRateLimits: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/flock/checkout-service", () => ({
|
||||||
|
submitFlockCheckoutFill: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { parseServiceAuth, assertCustomerMatch } from "@/lib/api/auth-context";
|
||||||
|
import { POST } from "@/app/api/quotes/flock-checkout/route";
|
||||||
|
import { submitFlockCheckoutFill } from "@/modules/flock/checkout-service";
|
||||||
|
|
||||||
|
function authOk() {
|
||||||
|
vi.mocked(parseServiceAuth).mockResolvedValue({
|
||||||
|
authType: "service",
|
||||||
|
customerId: "CUST_001",
|
||||||
|
permissions: [],
|
||||||
|
} as never);
|
||||||
|
vi.mocked(assertCustomerMatch).mockImplementation(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonReq(body: unknown) {
|
||||||
|
return new Request("http://localhost/api/quotes/flock-checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/quotes/flock-checkout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺 preferred_tier → 400", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺 preferred_flexibility 且无 preferred_carrier → 400", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_tier: "flock_direct",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.message).toMatch(/灵活性|承运商/);
|
||||||
|
expect(submitFlockCheckoutFill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preferred_carrier 可入队", async () => {
|
||||||
|
vi.mocked(submitFlockCheckoutFill).mockResolvedValue({
|
||||||
|
quote_id: "q1",
|
||||||
|
status: "processing",
|
||||||
|
});
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_tier: "standard",
|
||||||
|
preferred_carrier: "Forward Air",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(submitFlockCheckoutFill).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ preferredCarrier: "Forward Air" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("成功入队", async () => {
|
||||||
|
vi.mocked(submitFlockCheckoutFill).mockResolvedValue({
|
||||||
|
quote_id: "q1",
|
||||||
|
status: "processing",
|
||||||
|
});
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_tier: "flock_direct",
|
||||||
|
preferred_flexibility: "2_day",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(body.data.status).toBe("processing");
|
||||||
|
expect(submitFlockCheckoutFill).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非法 tier → 400", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_tier: "express",
|
||||||
|
preferred_flexibility: "none",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(submitFlockCheckoutFill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flock_details 任意键拒绝", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_tier: "standard",
|
||||||
|
preferred_flexibility: "1_day",
|
||||||
|
flock_details: { evil: true, pickup: { hack: 1 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(submitFlockCheckoutFill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/auth-context", () => ({
|
||||||
|
parseServiceAuth: vi.fn(),
|
||||||
|
assertCustomerMatch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/rate-limit", () => ({
|
||||||
|
enforceRateLimits: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/mothership/checkout-service", () => ({
|
||||||
|
submitMsCheckoutFill: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { parseServiceAuth, assertCustomerMatch } from "@/lib/api/auth-context";
|
||||||
|
import { POST } from "@/app/api/quotes/ms-checkout/route";
|
||||||
|
import { submitMsCheckoutFill } from "@/modules/mothership/checkout-service";
|
||||||
|
|
||||||
|
function authOk() {
|
||||||
|
vi.mocked(parseServiceAuth).mockResolvedValue({
|
||||||
|
authType: "service",
|
||||||
|
customerId: "CUST_001",
|
||||||
|
permissions: [],
|
||||||
|
} as never);
|
||||||
|
vi.mocked(assertCustomerMatch).mockImplementation(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonReq(body: unknown) {
|
||||||
|
return new Request("http://localhost/api/quotes/ms-checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/quotes/ms-checkout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
authOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("freight_protect 缺货值 → 400", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_carrier: "ABF Direct",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("成功入队", async () => {
|
||||||
|
vi.mocked(submitMsCheckoutFill).mockResolvedValue({
|
||||||
|
quote_id: "q1",
|
||||||
|
status: "processing",
|
||||||
|
});
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_carrier: "Roadrunner Interline",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
cargo_value_usd: 5000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(body.data.status).toBe("processing");
|
||||||
|
expect(submitMsCheckoutFill).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("承运商过长 → 400", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_carrier: "x".repeat(129),
|
||||||
|
coverage: "basic",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(submitMsCheckoutFill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mothership_details 任意键拒绝", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
jsonReq({
|
||||||
|
customer_id: "CUST_001",
|
||||||
|
quote_id: "q1",
|
||||||
|
preferred_carrier: "ABF",
|
||||||
|
coverage: "basic",
|
||||||
|
mothership_details: { evil: true, pickup: { hack: 1 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(submitMsCheckoutFill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDisplayedCheckoutSummary,
|
||||||
|
rateKey,
|
||||||
|
} from "@/components/mothership/mothership-logged-in-quote-sidebar";
|
||||||
|
import type { QuoteItem } from "@/lib/frontend/types";
|
||||||
|
|
||||||
|
function makeQuote(overrides: Partial<QuoteItem> = {}): QuoteItem {
|
||||||
|
return {
|
||||||
|
service_level: "standard",
|
||||||
|
rate_option: "lowest",
|
||||||
|
carrier: "Mothership Direct",
|
||||||
|
transit_days: "3",
|
||||||
|
transit_description: "3 business days",
|
||||||
|
raw_freight: 1000,
|
||||||
|
surcharges: 150,
|
||||||
|
raw_total: 1150,
|
||||||
|
markup_percent: 0,
|
||||||
|
markup_amount: 0,
|
||||||
|
final_total: 1150,
|
||||||
|
breakdown: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mothership logged-in quote sidebar price summary", () => {
|
||||||
|
it("rateKey 区分同承运商不同价", () => {
|
||||||
|
const a = makeQuote({
|
||||||
|
service_level: "guaranteed",
|
||||||
|
rate_option: "bestValue",
|
||||||
|
carrier: "ABF Freight Direct",
|
||||||
|
final_total: 1344.9,
|
||||||
|
raw_total: 1344.9,
|
||||||
|
transit_days: "3",
|
||||||
|
});
|
||||||
|
const b = makeQuote({
|
||||||
|
service_level: "guaranteed",
|
||||||
|
rate_option: "bestValue",
|
||||||
|
carrier: "ABF Freight Direct",
|
||||||
|
final_total: 1478.84,
|
||||||
|
raw_total: 1478.84,
|
||||||
|
transit_days: "2",
|
||||||
|
});
|
||||||
|
expect(rateKey(a, 0)).not.toBe(rateKey(b, 1));
|
||||||
|
expect(rateKey(a, 0)).not.toBe(rateKey(a, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("基础保障仅展示最终报价,不展示加价明细", () => {
|
||||||
|
const summary = buildDisplayedCheckoutSummary({
|
||||||
|
item: makeQuote({
|
||||||
|
markup_percent: 3,
|
||||||
|
markup_amount: 30,
|
||||||
|
final_total: 1180,
|
||||||
|
}),
|
||||||
|
coverage: "basic",
|
||||||
|
checkout: null,
|
||||||
|
});
|
||||||
|
expect(summary.headline).toContain("$1,180.00");
|
||||||
|
expect(summary.details).toBeNull();
|
||||||
|
expect(summary.headline).not.toContain("原价");
|
||||||
|
expect(summary.headline).not.toContain("客户加价");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FreightProtect 未确认时,不把承运商价冒充最终价", () => {
|
||||||
|
const summary = buildDisplayedCheckoutSummary({
|
||||||
|
item: makeQuote({
|
||||||
|
markup_amount: 30,
|
||||||
|
final_total: 1180,
|
||||||
|
}),
|
||||||
|
coverage: "full",
|
||||||
|
checkout: null,
|
||||||
|
});
|
||||||
|
expect(summary.headline).toContain("承运商价");
|
||||||
|
expect(summary.details).toContain("保障后总价待确认");
|
||||||
|
expect(summary.details).not.toContain("原价");
|
||||||
|
expect(summary.details).not.toContain("客户加价");
|
||||||
|
expect(summary.ctaAmountLabel).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FreightProtect 已确认后,显示确认总价", () => {
|
||||||
|
const summary = buildDisplayedCheckoutSummary({
|
||||||
|
item: makeQuote({
|
||||||
|
markup_amount: 30,
|
||||||
|
final_total: 1180,
|
||||||
|
}),
|
||||||
|
coverage: "full",
|
||||||
|
checkout: {
|
||||||
|
status: "done",
|
||||||
|
selected_total: 1202.55,
|
||||||
|
coverage: "freight_protect",
|
||||||
|
cargo_value_usd: 5000,
|
||||||
|
stage: "review",
|
||||||
|
selected_carrier: "Mothership Direct",
|
||||||
|
message: "已填齐详情(未支付)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(summary.headline).toContain("确认价");
|
||||||
|
expect(summary.details).toContain("$1,202.55");
|
||||||
|
expect(summary.details).not.toContain("原价");
|
||||||
|
expect(summary.ctaAmountLabel).toBe("$1,202.55");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isChajiaModuleId,
|
||||||
|
moduleToHubRoute,
|
||||||
|
parseChajiaPostMessage,
|
||||||
|
} from "@/lib/embed/host-bridge";
|
||||||
|
|
||||||
|
describe("host-bridge postMessage protocol", () => {
|
||||||
|
it("parse 合法信封", () => {
|
||||||
|
const msg = parseChajiaPostMessage({
|
||||||
|
source: "chajia",
|
||||||
|
version: 1,
|
||||||
|
type: "chajia:fill",
|
||||||
|
module: "MS_GUEST",
|
||||||
|
request_id: "r1",
|
||||||
|
payload: { form: { pallet_count: 2 } },
|
||||||
|
});
|
||||||
|
expect(msg?.type).toBe("chajia:fill");
|
||||||
|
expect(msg?.module).toBe("MS_GUEST");
|
||||||
|
expect(msg?.request_id).toBe("r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝非 chajia 来源", () => {
|
||||||
|
expect(
|
||||||
|
parseChajiaPostMessage({ source: "other", version: 1, type: "chajia:fill" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moduleToHubRoute 四模块", () => {
|
||||||
|
expect(moduleToHubRoute("MS_GUEST")).toEqual({
|
||||||
|
provider: "mothership",
|
||||||
|
mode: "anon",
|
||||||
|
});
|
||||||
|
expect(moduleToHubRoute("MS_LOGGED_IN")).toEqual({
|
||||||
|
provider: "mothership",
|
||||||
|
mode: "login",
|
||||||
|
});
|
||||||
|
expect(moduleToHubRoute("FLOCK_GUEST")).toEqual({
|
||||||
|
provider: "flock",
|
||||||
|
mode: "anon",
|
||||||
|
});
|
||||||
|
expect(moduleToHubRoute("FLOCK_LOGGED_IN")).toEqual({
|
||||||
|
provider: "flock",
|
||||||
|
mode: "login",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parse 宿主 scroll-quotes", () => {
|
||||||
|
const msg = parseChajiaPostMessage({
|
||||||
|
source: "chajia",
|
||||||
|
version: 1,
|
||||||
|
type: "chajia:scroll-quotes",
|
||||||
|
});
|
||||||
|
expect(msg?.type).toBe("chajia:scroll-quotes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parse iframe scroll-top", () => {
|
||||||
|
const msg = parseChajiaPostMessage({
|
||||||
|
source: "chajia",
|
||||||
|
version: 1,
|
||||||
|
type: "chajia:scroll-top",
|
||||||
|
module: "MS_LOGGED_IN",
|
||||||
|
payload: { reason: "logged-in-details" },
|
||||||
|
});
|
||||||
|
expect(msg?.type).toBe("chajia:scroll-top");
|
||||||
|
expect(msg?.module).toBe("MS_LOGGED_IN");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isHostSelectedQuote,
|
||||||
|
mapQuotesWithSelectedFlag,
|
||||||
|
} from "@/lib/embed/map-quotes-selection";
|
||||||
|
|
||||||
|
describe("mapQuotesWithSelectedFlag", () => {
|
||||||
|
const a = {
|
||||||
|
carrier: "XPO",
|
||||||
|
service_level: "standard",
|
||||||
|
rate_option: "lowest",
|
||||||
|
final_total: 100,
|
||||||
|
};
|
||||||
|
const b = {
|
||||||
|
carrier: "Echo",
|
||||||
|
service_level: "standard",
|
||||||
|
rate_option: "fastest",
|
||||||
|
final_total: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("仅标记与点选项完全匹配的一行 selected=true", () => {
|
||||||
|
const mapped = mapQuotesWithSelectedFlag([a, b], b);
|
||||||
|
expect(mapped.map((q) => q.selected)).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("final_total 不同则不算选中", () => {
|
||||||
|
expect(
|
||||||
|
isHostSelectedQuote(a, { ...a, final_total: 101 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
emptyFlockCheckoutDetailsDraft,
|
||||||
|
flockDetailsHasErrors,
|
||||||
|
isPlausibleUsPhone,
|
||||||
|
normalizeUsPhoneDisplay,
|
||||||
|
toFlockCheckoutApiDetails,
|
||||||
|
validateFlockCheckoutDetailsDraft,
|
||||||
|
} from "@/lib/flock/flock-checkout-details-rules";
|
||||||
|
import { validateFlockCheckoutSelection } from "@/lib/flock/flock-checkout-selection";
|
||||||
|
|
||||||
|
describe("flock-checkout-details-rules", () => {
|
||||||
|
it("rejects empty required fields", () => {
|
||||||
|
const errors = validateFlockCheckoutDetailsDraft(
|
||||||
|
emptyFlockCheckoutDetailsDraft(),
|
||||||
|
);
|
||||||
|
expect(flockDetailsHasErrors(errors)).toBe(true);
|
||||||
|
expect(errors.pickup?.company_name).toBeTruthy();
|
||||||
|
expect(errors.delivery?.contact_email).toBeTruthy();
|
||||||
|
expect(errors.delivery?.address1).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects 555 and invalid phones", () => {
|
||||||
|
expect(isPlausibleUsPhone("(555) 123-4567")).toBe(false);
|
||||||
|
expect(isPlausibleUsPhone("123")).toBe(false);
|
||||||
|
expect(isPlausibleUsPhone("(626) 595-1180")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and accepts a complete draft (billing pickup)", () => {
|
||||||
|
const draft = emptyFlockCheckoutDetailsDraft();
|
||||||
|
draft.use_billing_for_pickup = true;
|
||||||
|
draft.use_billing_for_delivery = false;
|
||||||
|
draft.pickup = {
|
||||||
|
company_name: "Acme Pickup",
|
||||||
|
address1: "",
|
||||||
|
address2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
contact_name: "Alice",
|
||||||
|
contact_phone: "16265951180",
|
||||||
|
contact_email: "a@example.com",
|
||||||
|
opens_at: "9:00 AM",
|
||||||
|
closes_at: "5:00 PM",
|
||||||
|
};
|
||||||
|
draft.delivery = {
|
||||||
|
company_name: "Acme Delivery",
|
||||||
|
address1: "233 S Wacker Dr",
|
||||||
|
address2: "Dock 1",
|
||||||
|
city: "Chicago",
|
||||||
|
state: "IL",
|
||||||
|
zip: "60601",
|
||||||
|
contact_name: "Bob",
|
||||||
|
contact_phone: "(415) 621-8840",
|
||||||
|
contact_email: "b@example.com",
|
||||||
|
opens_at: "9:00 AM",
|
||||||
|
closes_at: "5:00 PM",
|
||||||
|
weekend_delivery: false,
|
||||||
|
};
|
||||||
|
draft.nmfc = "100240-01";
|
||||||
|
draft.po_number = "PO-1";
|
||||||
|
draft.bol_remarks = "fragile";
|
||||||
|
draft.declaration_statement = "call on arrival";
|
||||||
|
draft.notes = "gate code 1";
|
||||||
|
const errors = validateFlockCheckoutDetailsDraft(draft);
|
||||||
|
expect(flockDetailsHasErrors(errors)).toBe(false);
|
||||||
|
const api = toFlockCheckoutApiDetails(draft);
|
||||||
|
expect(api.role).toBe("shipper");
|
||||||
|
expect(api.use_billing_for_pickup).toBe(true);
|
||||||
|
expect(api.use_billing_for_delivery).toBe(false);
|
||||||
|
expect(api.pickup.contact_phone).toBe(
|
||||||
|
normalizeUsPhoneDisplay("16265951180"),
|
||||||
|
);
|
||||||
|
expect(api.po_number).toBe("PO-1");
|
||||||
|
expect(api.declaration_statement).toBe("call on arrival");
|
||||||
|
expect(api.notes).toBe("gate code 1");
|
||||||
|
expect(api.delivery.weekend_delivery).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips delivery address when use_billing_for_delivery", () => {
|
||||||
|
const draft = emptyFlockCheckoutDetailsDraft();
|
||||||
|
draft.use_billing_for_pickup = true;
|
||||||
|
draft.use_billing_for_delivery = true;
|
||||||
|
draft.pickup = {
|
||||||
|
company_name: "A",
|
||||||
|
address1: "",
|
||||||
|
address2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
contact_name: "A",
|
||||||
|
contact_phone: "(626) 595-1180",
|
||||||
|
contact_email: "a@example.com",
|
||||||
|
opens_at: "9:00 AM",
|
||||||
|
closes_at: "5:00 PM",
|
||||||
|
};
|
||||||
|
draft.delivery = {
|
||||||
|
company_name: "B",
|
||||||
|
address1: "",
|
||||||
|
address2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
contact_name: "B",
|
||||||
|
contact_phone: "(415) 621-8840",
|
||||||
|
contact_email: "b@example.com",
|
||||||
|
opens_at: "9:00 AM",
|
||||||
|
closes_at: "5:00 PM",
|
||||||
|
};
|
||||||
|
const errors = validateFlockCheckoutDetailsDraft(draft);
|
||||||
|
expect(errors.delivery?.address1).toBeUndefined();
|
||||||
|
expect(flockDetailsHasErrors(errors)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects same open/close hours", () => {
|
||||||
|
const draft = emptyFlockCheckoutDetailsDraft();
|
||||||
|
draft.use_billing_for_pickup = true;
|
||||||
|
draft.pickup = {
|
||||||
|
company_name: "A",
|
||||||
|
address1: "",
|
||||||
|
address2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
contact_name: "A",
|
||||||
|
contact_phone: "(626) 595-1180",
|
||||||
|
contact_email: "a@example.com",
|
||||||
|
opens_at: "9:00 AM",
|
||||||
|
closes_at: "9:00 AM",
|
||||||
|
};
|
||||||
|
draft.delivery = {
|
||||||
|
company_name: "B",
|
||||||
|
address1: "1 Main",
|
||||||
|
address2: "",
|
||||||
|
city: "Dallas",
|
||||||
|
state: "TX",
|
||||||
|
zip: "75201",
|
||||||
|
contact_name: "B",
|
||||||
|
contact_phone: "(415) 621-8840",
|
||||||
|
contact_email: "b@example.com",
|
||||||
|
opens_at: "9:00 AM",
|
||||||
|
closes_at: "5:00 PM",
|
||||||
|
};
|
||||||
|
const errors = validateFlockCheckoutDetailsDraft(draft);
|
||||||
|
expect(errors.pickup?.closes_at).toMatch(/晚于/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flock sidebar tier selection", () => {
|
||||||
|
it("requires preferred_tier and flexibility before checkout", () => {
|
||||||
|
expect(
|
||||||
|
validateFlockCheckoutSelection({
|
||||||
|
preferredTier: "flock_direct",
|
||||||
|
preferredFlexibility: "2_day",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
validateFlockCheckoutSelection({
|
||||||
|
preferredTier: "standard",
|
||||||
|
preferredFlexibility: "1_day",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
validateFlockCheckoutSelection({
|
||||||
|
preferredTier: "other" as "standard",
|
||||||
|
preferredFlexibility: "none",
|
||||||
|
}),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
validateFlockCheckoutSelection({ preferredTier: "standard" }),
|
||||||
|
).toMatch(/灵活性/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
buildFlockSavedFreightPreset,
|
|
||||||
filterFlockSavedFreight,
|
|
||||||
flockSavedFreightStorageKey,
|
|
||||||
parseFlockSavedFreightList,
|
|
||||||
removeFlockSavedFreight,
|
|
||||||
upsertFlockSavedFreight,
|
|
||||||
type FlockSavedFreightPreset,
|
|
||||||
} from "@/lib/flock/saved-freight-presets";
|
|
||||||
|
|
||||||
const baseSource = {
|
|
||||||
description: "家电木托",
|
|
||||||
quantity: "4",
|
|
||||||
packagingType: "pallets_48x40",
|
|
||||||
lengthIn: "48",
|
|
||||||
widthIn: "40",
|
|
||||||
heightIn: "48",
|
|
||||||
totalWeightLb: "1200",
|
|
||||||
freightClass: "70",
|
|
||||||
stackable: true,
|
|
||||||
turnable: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function preset(partial: Partial<FlockSavedFreightPreset>): FlockSavedFreightPreset {
|
|
||||||
return {
|
|
||||||
id: "id-1",
|
|
||||||
name: "家电",
|
|
||||||
description: "家电木托",
|
|
||||||
quantity: "4",
|
|
||||||
packagingType: "pallets_48x40",
|
|
||||||
lengthIn: "48",
|
|
||||||
widthIn: "40",
|
|
||||||
heightIn: "48",
|
|
||||||
totalWeightLb: "1200",
|
|
||||||
freightClass: "70",
|
|
||||||
stackable: true,
|
|
||||||
turnable: false,
|
|
||||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
|
||||||
...partial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("saved-freight-presets", () => {
|
|
||||||
it("storage key 按客户隔离", () => {
|
|
||||||
expect(flockSavedFreightStorageKey("CUST_001")).toContain("CUST_001");
|
|
||||||
expect(flockSavedFreightStorageKey(" ")).toContain("anon");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("build 用描述作默认名称", () => {
|
|
||||||
const p = buildFlockSavedFreightPreset(baseSource, "");
|
|
||||||
expect(p?.name).toBe("家电木托");
|
|
||||||
expect(p?.description).toBe("家电木托");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("空描述且空名称拒绝", () => {
|
|
||||||
expect(
|
|
||||||
buildFlockSavedFreightPreset({ ...baseSource, description: "" }, " "),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("同名覆盖且新条目置顶", () => {
|
|
||||||
const a = preset({ id: "a", name: "木托A" });
|
|
||||||
const b = preset({ id: "b", name: "木托B" });
|
|
||||||
const next = upsertFlockSavedFreight(
|
|
||||||
[a, b],
|
|
||||||
preset({ id: "c", name: "木托A", description: "新描述" }),
|
|
||||||
);
|
|
||||||
expect(next).toHaveLength(2);
|
|
||||||
expect(next[0]?.description).toBe("新描述");
|
|
||||||
expect(next[0]?.name).toBe("木托A");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("按名称或描述过滤", () => {
|
|
||||||
const list = [
|
|
||||||
preset({ id: "1", name: "家电托", description: "电视" }),
|
|
||||||
preset({ id: "2", name: "建材", description: "瓷砖" }),
|
|
||||||
];
|
|
||||||
expect(filterFlockSavedFreight(list, "瓷")).toHaveLength(1);
|
|
||||||
expect(filterFlockSavedFreight(list, "家电")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("删除与解析容错", () => {
|
|
||||||
const list = [preset({ id: "x" }), preset({ id: "y", name: "Y" })];
|
|
||||||
expect(removeFlockSavedFreight(list, "x")).toHaveLength(1);
|
|
||||||
expect(parseFlockSavedFreightList("not-json")).toEqual([]);
|
|
||||||
expect(parseFlockSavedFreightList('[{"id":"1","name":"n","description":"d"}]')).toHaveLength(
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
scrubProviderBrandForCustomer,
|
||||||
|
scrubProviderCarrierLabel,
|
||||||
|
} from "@/lib/frontend/scrub-provider-brand";
|
||||||
|
|
||||||
|
describe("scrubProviderBrandForCustomer", () => {
|
||||||
|
it("替换尺寸超限文案中的品牌", () => {
|
||||||
|
expect(scrubProviderBrandForCustomer("尺寸超出 Mothership 允许范围")).toBe(
|
||||||
|
"尺寸超出 承运商 允许范围",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
scrubProviderBrandForCustomer("MotherShip 要求整数:已向上取整"),
|
||||||
|
).toBe("承运商 要求整数:已向上取整");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("承运商名整段命中时改为平台承运", () => {
|
||||||
|
expect(scrubProviderCarrierLabel("MotherShip")).toBe("平台承运");
|
||||||
|
expect(scrubProviderCarrierLabel("Mothership")).toBe("平台承运");
|
||||||
|
expect(scrubProviderCarrierLabel("TForce Direct")).toBe("TForce Direct");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,362 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildAddressQuery,
|
||||||
|
buildDashboardCargo,
|
||||||
|
buildDashboardLocation,
|
||||||
|
buildPickupDateIso,
|
||||||
|
decodeMsCarrierLabel,
|
||||||
|
isMsDashboardDirectQuoteEnabled,
|
||||||
|
mapDashboardQuoteBodyToItems,
|
||||||
|
mapMsDashboardCargoType,
|
||||||
|
parseMsReadyTimeToHm,
|
||||||
|
zonedWallTimeToUtcIso,
|
||||||
|
} from "@/lib/mothership/dashboard-direct-quote";
|
||||||
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
||||||
|
|
||||||
|
describe("dashboard-direct-quote", () => {
|
||||||
|
it("decodeMsCarrierLabel 解码 base64 并附加 Direct", () => {
|
||||||
|
const label = decodeMsCarrierLabel({
|
||||||
|
encodedCarrierName: Buffer.from("ABF Freight", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
carrierScac: "abfs",
|
||||||
|
});
|
||||||
|
expect(label).toBe("ABF Freight Direct");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapDashboardQuoteBodyToItems 从 availableRates 产出多承运商", () => {
|
||||||
|
const items = mapDashboardQuoteBodyToItems({
|
||||||
|
rates: {
|
||||||
|
standard: {
|
||||||
|
bestValue: {
|
||||||
|
id: "rate_a",
|
||||||
|
finalPrice: 100,
|
||||||
|
days: 3,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "bestValue",
|
||||||
|
carrierScac: "xpol",
|
||||||
|
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
availableRates: {
|
||||||
|
rate_a: {
|
||||||
|
id: "rate_a",
|
||||||
|
finalPrice: 100,
|
||||||
|
days: 3,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "bestValue",
|
||||||
|
carrierScac: "xpol",
|
||||||
|
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
rate_b: {
|
||||||
|
id: "rate_b",
|
||||||
|
finalPrice: 200,
|
||||||
|
days: 2,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
carrierScac: "abfs",
|
||||||
|
encodedCarrierName: Buffer.from("ABF", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(items.some((i) => /XPO/i.test(i.carrier))).toBe(true);
|
||||||
|
expect(items.some((i) => /ABF/i.test(i.carrier))).toBe(true);
|
||||||
|
expect(items.every((i) => i.rawTotal > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildDashboardLocation 写入二级联系人与营业时间", () => {
|
||||||
|
const loc = buildDashboardLocation(
|
||||||
|
{
|
||||||
|
placeId: "p1",
|
||||||
|
city: "Minneapolis",
|
||||||
|
state: "MN",
|
||||||
|
zip: "55402",
|
||||||
|
street: "800 Nicollet Mall",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
zone: null,
|
||||||
|
coordinates: { latitude: 1, longitude: 2 },
|
||||||
|
neighborhood: "",
|
||||||
|
},
|
||||||
|
["liftgate"],
|
||||||
|
{
|
||||||
|
company_name: "Acme Co.",
|
||||||
|
suite: "Ste 301",
|
||||||
|
contact_first: "Ops",
|
||||||
|
contact_last: "Lead",
|
||||||
|
contact_email: "ops@example.com",
|
||||||
|
contact_phone: "5550101",
|
||||||
|
opens_at: "8:00 AM",
|
||||||
|
closes_at: "5:00 PM",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(loc.accessorials).toEqual(["liftgate"]);
|
||||||
|
expect(loc.name).toBe("Acme Co.");
|
||||||
|
expect(loc.subStreet).toBe("Ste 301");
|
||||||
|
expect(loc.serviceStartTime).toBe("8:00 AM");
|
||||||
|
expect(loc.serviceEndTime).toBe("5:00 PM");
|
||||||
|
expect(loc.email).toBe("ops@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildAddressQuery 优先结构化地址", () => {
|
||||||
|
const q = buildAddressQuery({
|
||||||
|
street: "1000 S Alameda St",
|
||||||
|
city: "Los Angeles",
|
||||||
|
state: "CA",
|
||||||
|
zip: "90021",
|
||||||
|
placeId: "x",
|
||||||
|
formattedAddress: "ignored",
|
||||||
|
selectedFromSuggestions: true,
|
||||||
|
mothershipOptionId: "x",
|
||||||
|
mothershipDisplayLabel: "ignored",
|
||||||
|
selectedFromMothership: true,
|
||||||
|
});
|
||||||
|
expect(q).toContain("1000 S Alameda St");
|
||||||
|
expect(q).toContain("Los Angeles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildDashboardCargo 支持多行并向上取整", () => {
|
||||||
|
const req = {
|
||||||
|
cargoHash: "h",
|
||||||
|
pickup: {} as QuoteRequest["pickup"],
|
||||||
|
delivery: {} as QuoteRequest["delivery"],
|
||||||
|
weightLb: 10.2,
|
||||||
|
dimsIn: { l: 48.1, w: 40, h: 48 },
|
||||||
|
palletCount: 2,
|
||||||
|
cargoType: "general_freight",
|
||||||
|
cargoLines: [
|
||||||
|
{
|
||||||
|
cargoType: "pallet",
|
||||||
|
quantity: 2,
|
||||||
|
weightLb: 10.2,
|
||||||
|
lengthIn: 48.1,
|
||||||
|
widthIn: 40,
|
||||||
|
heightIn: 48,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as QuoteRequest;
|
||||||
|
const cargo = buildDashboardCargo(req);
|
||||||
|
expect(cargo).toHaveLength(1);
|
||||||
|
expect(cargo[0]!.weight).toBe(11);
|
||||||
|
expect(cargo[0]!.length).toBe(49);
|
||||||
|
expect(cargo[0]!.quantity).toBe(2);
|
||||||
|
expect(cargo[0]!.type).toBe("Pallet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapMsDashboardCargoType 与官网货型对齐,禁止非托盘打成 Pallet", () => {
|
||||||
|
expect(mapMsDashboardCargoType("case")).toBe("Case");
|
||||||
|
expect(mapMsDashboardCargoType("drum")).toBe("Drum");
|
||||||
|
expect(mapMsDashboardCargoType("piece")).toBe("Pieces");
|
||||||
|
expect(mapMsDashboardCargoType("tote")).toBe("Tote");
|
||||||
|
expect(mapMsDashboardCargoType("roll")).toBe("Roll");
|
||||||
|
expect(mapMsDashboardCargoType("skid")).toBe("Skid");
|
||||||
|
expect(mapMsDashboardCargoType("box")).toBe("Box");
|
||||||
|
expect(mapMsDashboardCargoType("carton")).toBe("Carton");
|
||||||
|
expect(mapMsDashboardCargoType("crate")).toBe("Crate");
|
||||||
|
expect(mapMsDashboardCargoType("pallet")).toBe("Pallet");
|
||||||
|
expect(mapMsDashboardCargoType("general_freight")).toBe("Pallet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildDashboardCargo 多货型逐行保留官网 type", () => {
|
||||||
|
const req = {
|
||||||
|
cargoHash: "h",
|
||||||
|
pickup: {} as QuoteRequest["pickup"],
|
||||||
|
delivery: {} as QuoteRequest["delivery"],
|
||||||
|
weightLb: 500,
|
||||||
|
dimsIn: { l: 48, w: 40, h: 48 },
|
||||||
|
palletCount: 1,
|
||||||
|
cargoType: "general_freight",
|
||||||
|
cargoLines: [
|
||||||
|
{
|
||||||
|
cargoType: "pallet",
|
||||||
|
quantity: 2,
|
||||||
|
weightLb: 425,
|
||||||
|
lengthIn: 42,
|
||||||
|
widthIn: 40,
|
||||||
|
heightIn: 38,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cargoType: "drum",
|
||||||
|
quantity: 8,
|
||||||
|
weightLb: 329,
|
||||||
|
lengthIn: 26,
|
||||||
|
widthIn: 28,
|
||||||
|
heightIn: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cargoType: "carton",
|
||||||
|
quantity: 3,
|
||||||
|
weightLb: 166,
|
||||||
|
lengthIn: 28,
|
||||||
|
widthIn: 19,
|
||||||
|
heightIn: 29,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as QuoteRequest;
|
||||||
|
const cargo = buildDashboardCargo(req);
|
||||||
|
expect(cargo.map((c) => c.type)).toEqual(["Pallet", "Drum", "Carton"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildDashboardCargo piece 行使用官网 API 值 Pieces", () => {
|
||||||
|
const req = {
|
||||||
|
cargoHash: "h",
|
||||||
|
pickup: {} as QuoteRequest["pickup"],
|
||||||
|
delivery: {} as QuoteRequest["delivery"],
|
||||||
|
weightLb: 255,
|
||||||
|
dimsIn: { l: 17, w: 26, h: 24 },
|
||||||
|
palletCount: 2,
|
||||||
|
cargoType: "general_freight",
|
||||||
|
cargoLines: [
|
||||||
|
{
|
||||||
|
cargoType: "pallet",
|
||||||
|
quantity: 2,
|
||||||
|
weightLb: 735,
|
||||||
|
lengthIn: 48,
|
||||||
|
widthIn: 40,
|
||||||
|
heightIn: 48,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cargoType: "piece",
|
||||||
|
quantity: 12,
|
||||||
|
weightLb: 255,
|
||||||
|
lengthIn: 17,
|
||||||
|
widthIn: 26,
|
||||||
|
heightIn: 24,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as QuoteRequest;
|
||||||
|
const cargo = buildDashboardCargo(req);
|
||||||
|
expect(cargo.map((c) => c.type)).toEqual(["Pallet", "Pieces"]);
|
||||||
|
expect(Array.isArray(cargo[1]?.commodities)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapDashboardQuoteBodyToItems 保留同承运商不同价", () => {
|
||||||
|
const items = mapDashboardQuoteBodyToItems({
|
||||||
|
availableRates: {
|
||||||
|
a: {
|
||||||
|
id: "a",
|
||||||
|
finalPrice: 100,
|
||||||
|
days: 5,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
encodedCarrierName: Buffer.from("ABF Freight", "utf8").toString(
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
id: "b",
|
||||||
|
finalPrice: 130,
|
||||||
|
days: 3,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
encodedCarrierName: Buffer.from("ABF Freight", "utf8").toString(
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
id: "c",
|
||||||
|
finalPrice: 90,
|
||||||
|
days: 6,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(items.length).toBe(3);
|
||||||
|
expect(items.filter((i) => /ABF/i.test(i.carrier))).toHaveLength(2);
|
||||||
|
expect(items.filter((i) => /XPO/i.test(i.carrier))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapDashboardQuoteBodyToItems 优先 ratesV2 全量价卡", () => {
|
||||||
|
const items = mapDashboardQuoteBodyToItems({
|
||||||
|
ratesV2: [
|
||||||
|
{
|
||||||
|
id: "v2_a",
|
||||||
|
finalPrice: 90,
|
||||||
|
days: 4,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
encodedCarrierName: Buffer.from("TForce", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v2_b",
|
||||||
|
finalPrice: 110,
|
||||||
|
days: 3,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
encodedCarrierName: Buffer.from("XPO", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availableRates: {
|
||||||
|
only_ar: {
|
||||||
|
id: "only_ar",
|
||||||
|
finalPrice: 999,
|
||||||
|
days: 9,
|
||||||
|
serviceLevel: "standard",
|
||||||
|
serviceType: "customRate",
|
||||||
|
encodedCarrierName: Buffer.from("Echo", "utf8").toString("base64"),
|
||||||
|
serviceLaneType: "direct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items.some((i) => /Echo/i.test(i.carrier))).toBe(false);
|
||||||
|
expect(items.some((i) => /TForce/i.test(i.carrier))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildPickupDateIso 使用提货时区 + 就绪时刻", () => {
|
||||||
|
expect(parseMsReadyTimeToHm("8:00 AM")).toEqual({ h: 8, m: 0 });
|
||||||
|
expect(parseMsReadyTimeToHm("12:00 PM")).toEqual({ h: 12, m: 0 });
|
||||||
|
expect(parseMsReadyTimeToHm("4:00 PM")).toEqual({ h: 16, m: 0 });
|
||||||
|
const iso = zonedWallTimeToUtcIso(
|
||||||
|
"2026-08-12",
|
||||||
|
8,
|
||||||
|
0,
|
||||||
|
"America/Los_Angeles",
|
||||||
|
);
|
||||||
|
const asLa = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "America/Los_Angeles",
|
||||||
|
hour: "numeric",
|
||||||
|
hourCycle: "h23",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(new Date(iso));
|
||||||
|
expect(Number(asLa.find((p) => p.type === "hour")?.value)).toBe(8);
|
||||||
|
const req = {
|
||||||
|
cargoHash: "h",
|
||||||
|
pickup: {} as QuoteRequest["pickup"],
|
||||||
|
delivery: {} as QuoteRequest["delivery"],
|
||||||
|
weightLb: 500,
|
||||||
|
dimsIn: { l: 48, w: 40, h: 48 },
|
||||||
|
palletCount: 1,
|
||||||
|
cargoType: "pallet",
|
||||||
|
readyDate: "2026-08-12",
|
||||||
|
readyTime: "4:00 PM",
|
||||||
|
} as QuoteRequest;
|
||||||
|
const pickupIso = buildPickupDateIso(req, "America/Los_Angeles");
|
||||||
|
const hourLa = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "America/Los_Angeles",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(new Date(pickupIso));
|
||||||
|
expect(Number(hourLa.find((p) => p.type === "hour")?.value)).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isMsDashboardDirectQuoteEnabled 默认开启", () => {
|
||||||
|
const prev = process.env.MS_DASHBOARD_DIRECT_QUOTE;
|
||||||
|
delete process.env.MS_DASHBOARD_DIRECT_QUOTE;
|
||||||
|
expect(isMsDashboardDirectQuoteEnabled()).toBe(true);
|
||||||
|
process.env.MS_DASHBOARD_DIRECT_QUOTE = "false";
|
||||||
|
expect(isMsDashboardDirectQuoteEnabled()).toBe(false);
|
||||||
|
if (prev === undefined) delete process.env.MS_DASHBOARD_DIRECT_QUOTE;
|
||||||
|
else process.env.MS_DASHBOARD_DIRECT_QUOTE = prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
parseCargoValueUsd,
|
||||||
|
sanitizeCargoValueUsdInput,
|
||||||
|
validateMsCheckoutSelection,
|
||||||
|
} from "@/lib/mothership/ms-checkout-selection";
|
||||||
|
import { pickLoggedInRateCardIndex } from "@/workers/rpa/mothership-logged-in-quote";
|
||||||
|
|
||||||
|
describe("ms checkout selection", () => {
|
||||||
|
it("指定承运商优先", () => {
|
||||||
|
const items = [
|
||||||
|
{ carrier: "TForce Direct", rawTotal: 500 },
|
||||||
|
{ carrier: "ABF Direct", rawTotal: 600 },
|
||||||
|
{ carrier: "Roadrunner Interline", rawTotal: 416.89 },
|
||||||
|
];
|
||||||
|
expect(pickLoggedInRateCardIndex(items, "Roadrunner")).toBe(2);
|
||||||
|
expect(pickLoggedInRateCardIndex(items, "ABF")).toBe(1);
|
||||||
|
expect(pickLoggedInRateCardIndex(items)).toBe(2); // 最低价
|
||||||
|
});
|
||||||
|
|
||||||
|
it("basic 无需货值", () => {
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "ABF Direct",
|
||||||
|
coverage: "basic",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("freight_protect 缺货值 / 非法货值", () => {
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "ABF",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
}),
|
||||||
|
).toMatch(/货物价值/);
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "ABF",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
cargoValueUsd: 0,
|
||||||
|
}),
|
||||||
|
).toMatch(/货物价值/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("freight_protect 合法货值通过", () => {
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "Roadrunner Interline",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
cargoValueUsd: 5000,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parseCargoValueUsd 兼容 $ 与逗号,并保留小数", () => {
|
||||||
|
expect(parseCargoValueUsd("$5,000")).toBe(5000);
|
||||||
|
expect(parseCargoValueUsd("300.55")).toBe(300.55);
|
||||||
|
expect(parseCargoValueUsd("300.556")).toBe(300.56);
|
||||||
|
expect(parseCargoValueUsd(" ")).toBeNull();
|
||||||
|
expect(parseCargoValueUsd("-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizeCargoValueUsdInput 允许输入小数", () => {
|
||||||
|
expect(sanitizeCargoValueUsdInput("300.")).toBe("300.");
|
||||||
|
expect(sanitizeCargoValueUsdInput("300.5")).toBe("300.5");
|
||||||
|
expect(sanitizeCargoValueUsdInput("300.555")).toBe("300.55");
|
||||||
|
expect(sanitizeCargoValueUsdInput("$1,200.3")).toBe("1200.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("freight_protect 小数货值通过", () => {
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "ABF",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
cargoValueUsd: 300.55,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未选承运商", () => {
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: " ",
|
||||||
|
coverage: "basic",
|
||||||
|
}),
|
||||||
|
).toMatch(/承运商/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("承运商过长 / 货值超上限", () => {
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "x".repeat(129),
|
||||||
|
coverage: "basic",
|
||||||
|
}),
|
||||||
|
).toMatch(/过长/);
|
||||||
|
expect(
|
||||||
|
validateMsCheckoutSelection({
|
||||||
|
preferredCarrier: "ABF",
|
||||||
|
coverage: "freight_protect",
|
||||||
|
cargoValueUsd: 1_000_001,
|
||||||
|
}),
|
||||||
|
).toMatch(/超出允许范围/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { findFirst } = vi.hoisted(() => ({
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
businessCustomerUser: {
|
||||||
|
findFirst,
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveBusinessCustomerByAccount,
|
||||||
|
resolveBusinessCustomerIdForQuote,
|
||||||
|
} from "@/modules/customer/business-customer-user-service";
|
||||||
|
import { ValidationError } from "@/modules/quote/types";
|
||||||
|
|
||||||
|
describe("business-customer-user-service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
findFirst.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按账号命中业务客户", async () => {
|
||||||
|
findFirst.mockResolvedValue({
|
||||||
|
customerId: "CUST_004",
|
||||||
|
account: "demo_user",
|
||||||
|
businessCustomer: {
|
||||||
|
businessCustomerId: "bc-guid-1",
|
||||||
|
externalCode: "76642888",
|
||||||
|
name: "测试客户",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hit = await resolveBusinessCustomerByAccount("CUST_004", "demo_user");
|
||||||
|
expect(hit?.business_customer_id).toBe("bc-guid-1");
|
||||||
|
expect(hit?.external_code).toBe("76642888");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("询价:已有 BC 不覆盖", async () => {
|
||||||
|
const id = await resolveBusinessCustomerIdForQuote({
|
||||||
|
customerId: "CUST_004",
|
||||||
|
businessCustomerId: "bc-explicit",
|
||||||
|
businessUserAccount: "any",
|
||||||
|
});
|
||||||
|
expect(id).toBe("bc-explicit");
|
||||||
|
expect(findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("询价:无 BC + 账号未命中 → ValidationError", async () => {
|
||||||
|
findFirst.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
resolveBusinessCustomerIdForQuote({
|
||||||
|
customerId: "CUST_004",
|
||||||
|
businessUserAccount: "missing_user",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("询价:无 BC + 账号命中 → 回填 BC", async () => {
|
||||||
|
findFirst.mockResolvedValue({
|
||||||
|
customerId: "CUST_004",
|
||||||
|
account: "demo_user",
|
||||||
|
businessCustomer: {
|
||||||
|
businessCustomerId: "bc-guid-2",
|
||||||
|
externalCode: "CM072",
|
||||||
|
name: "OpenTrucking Inc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = await resolveBusinessCustomerIdForQuote({
|
||||||
|
customerId: "CUST_004",
|
||||||
|
businessUserAccount: "demo_user",
|
||||||
|
});
|
||||||
|
expect(id).toBe("bc-guid-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { QuoteRecord } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
MS_NEEDS_DETAILS_ERROR_CODE,
|
||||||
|
MS_NEEDS_DETAILS_MESSAGE,
|
||||||
|
} from "@/lib/constants/ms-refine-hold";
|
||||||
|
import { serializeQuoteDetail } from "@/modules/quote/quote-serializer";
|
||||||
|
|
||||||
|
function baseRecord(overrides: Partial<QuoteRecord> = {}): QuoteRecord {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
quoteId: "Q-MS-ND-001",
|
||||||
|
requestId: "req-nd-1",
|
||||||
|
customerId: "cust-1",
|
||||||
|
cargoHash: "hash",
|
||||||
|
status: "done",
|
||||||
|
sourceType: "rpa",
|
||||||
|
isRealtime: true,
|
||||||
|
confidenceScore: 0.95,
|
||||||
|
currency: "USD",
|
||||||
|
pickupJson: {},
|
||||||
|
deliveryJson: {},
|
||||||
|
weightLb: 100,
|
||||||
|
dimLIn: 48,
|
||||||
|
dimWIn: 40,
|
||||||
|
dimHIn: 48,
|
||||||
|
palletCount: 1,
|
||||||
|
cargoType: "general_freight",
|
||||||
|
quotesJson: [],
|
||||||
|
markupPercent: 0,
|
||||||
|
validUntil: new Date(Date.now() + 180_000),
|
||||||
|
errorCode: MS_NEEDS_DETAILS_ERROR_CODE,
|
||||||
|
errorMessage: MS_NEEDS_DETAILS_MESSAGE,
|
||||||
|
isDeleted: false,
|
||||||
|
createdAt: new Date("2026-07-29T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-07-29T00:00:00Z"),
|
||||||
|
...overrides,
|
||||||
|
} as QuoteRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MS_NEEDS_DETAILS quote detail", () => {
|
||||||
|
it("serializes done + empty quotes + informational error", () => {
|
||||||
|
const detail = serializeQuoteDetail(baseRecord());
|
||||||
|
expect(detail.status).toBe("done");
|
||||||
|
expect(detail.error_code).toBe(MS_NEEDS_DETAILS_ERROR_CODE);
|
||||||
|
expect(detail.error_message).toBe(MS_NEEDS_DETAILS_MESSAGE);
|
||||||
|
expect(detail.quotes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
# 宿主客户同步 API
|
||||||
|
|
||||||
|
`POST /api/host/customers/sync`
|
||||||
|
|
||||||
|
鉴权:`Authorization: Bearer <Service API Key>` + `X-Customer-Id: CUST_004`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customers": [
|
||||||
|
{
|
||||||
|
"business_customer_id": "guid",
|
||||||
|
"name": "简称或名称",
|
||||||
|
"external_code": "客户代码",
|
||||||
|
"remark": null,
|
||||||
|
"markup_percent": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"business_customer_id": "guid",
|
||||||
|
"external_user_id": "userGuid",
|
||||||
|
"account": "login_account",
|
||||||
|
"display_name": "张三",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
本地导入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run import:ccnew-customers:users
|
||||||
|
npm run verify:ccnew-sync -- --account <账号>
|
||||||
|
```
|
||||||
|
|
||||||
|
详见:`X:/work/ccnew/new/docs/私卡询价联调验收.md`
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/admin/customers/page";
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { writeAudit } from "@/modules/audit/service";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { assertBusinessCustomerBelongsToTenant } from "@/modules/customer/business-customer-service";
|
||||||
|
import { isKnownCustomerAsync } from "@/modules/auth/service-token";
|
||||||
|
import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
|
||||||
|
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
|
||||||
|
|
||||||
|
type MarkupBody = {
|
||||||
|
markup_type?: unknown;
|
||||||
|
markup_percent?: unknown;
|
||||||
|
markup_fixed_amount?: unknown;
|
||||||
|
remark?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ customer_id: string; business_customer_id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PUT(request: Request, context: RouteContext) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = parseAdminAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
customer_id: pathCustomerId,
|
||||||
|
business_customer_id: businessCustomerId,
|
||||||
|
} = await context.params;
|
||||||
|
if (!(await isKnownCustomerAsync(pathCustomerId))) {
|
||||||
|
return fail("VALIDATION_FAILED", "租户不存在", 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await assertBusinessCustomerBelongsToTenant(pathCustomerId, businessCustomerId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "业务客户不存在";
|
||||||
|
return fail("VALIDATION_FAILED", message, 400);
|
||||||
|
}
|
||||||
|
let body: MarkupBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as MarkupBody;
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
const parsed = parseMarkupInput(body);
|
||||||
|
if ("code" in parsed) {
|
||||||
|
return fail("VALIDATION_FAILED", parsed.message, 400);
|
||||||
|
}
|
||||||
|
const remark =
|
||||||
|
typeof body.remark === "string" ? body.remark.trim() || null : null;
|
||||||
|
const config = await upsertMarkupConfig(
|
||||||
|
pathCustomerId,
|
||||||
|
businessCustomerId,
|
||||||
|
parsed,
|
||||||
|
auth.userId,
|
||||||
|
remark,
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
"markup:update_business_customer",
|
||||||
|
auth.userId,
|
||||||
|
`${pathCustomerId}/${businessCustomerId}`,
|
||||||
|
{
|
||||||
|
markup_type: config.markup_type,
|
||||||
|
markup_percent: config.markup_percent,
|
||||||
|
markup_fixed_amount: config.markup_fixed_amount,
|
||||||
|
remark,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok(config);
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { writeAudit } from "@/modules/audit/service";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import {
|
||||||
|
updateBusinessCustomer,
|
||||||
|
} from "@/modules/customer/business-customer-service";
|
||||||
|
import type { BusinessCustomerStatus } from "@/modules/customer/types";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ customer_id: string; business_customer_id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = parseAdminAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
customer_id: customerId,
|
||||||
|
business_customer_id: businessCustomerId,
|
||||||
|
} = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
const payload = body as {
|
||||||
|
name?: unknown;
|
||||||
|
external_code?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
remark?: unknown;
|
||||||
|
};
|
||||||
|
const input: {
|
||||||
|
name?: string;
|
||||||
|
externalCode?: string | null;
|
||||||
|
status?: BusinessCustomerStatus;
|
||||||
|
remark?: string | null;
|
||||||
|
} = {};
|
||||||
|
if (payload.name !== undefined) {
|
||||||
|
if (typeof payload.name !== "string") {
|
||||||
|
return fail("VALIDATION_FAILED", "客户名称无效", 400);
|
||||||
|
}
|
||||||
|
input.name = payload.name;
|
||||||
|
}
|
||||||
|
if (payload.external_code !== undefined) {
|
||||||
|
input.externalCode =
|
||||||
|
typeof payload.external_code === "string" ? payload.external_code : null;
|
||||||
|
}
|
||||||
|
if (payload.status !== undefined) {
|
||||||
|
if (payload.status !== "active" && payload.status !== "disabled") {
|
||||||
|
return fail("VALIDATION_FAILED", "状态无效", 400);
|
||||||
|
}
|
||||||
|
input.status = payload.status;
|
||||||
|
}
|
||||||
|
if (payload.remark !== undefined) {
|
||||||
|
input.remark = typeof payload.remark === "string" ? payload.remark : null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateBusinessCustomer(
|
||||||
|
customerId,
|
||||||
|
businessCustomerId,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
"business_customer:update",
|
||||||
|
auth.userId,
|
||||||
|
`${customerId}/${businessCustomerId}`,
|
||||||
|
updated,
|
||||||
|
);
|
||||||
|
return ok(updated);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "更新业务客户失败";
|
||||||
|
return fail("VALIDATION_FAILED", message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { listUsersByBusinessCustomer } from "@/modules/customer/business-customer-user-service";
|
||||||
|
import { assertBusinessCustomerBelongsToTenant } from "@/modules/customer/business-customer-service";
|
||||||
|
import { ValidationError } from "@/modules/quote/types";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ customer_id: string; business_customer_id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GET 业务客户下登录用户列表 */
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
parseAdminAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { customer_id: customerId, business_customer_id: businessCustomerId } =
|
||||||
|
await context.params;
|
||||||
|
try {
|
||||||
|
await assertBusinessCustomerBelongsToTenant(customerId, businessCustomerId);
|
||||||
|
const list = await listUsersByBusinessCustomer(
|
||||||
|
customerId,
|
||||||
|
businessCustomerId,
|
||||||
|
);
|
||||||
|
return ok({
|
||||||
|
customer_id: customerId,
|
||||||
|
business_customer_id: businessCustomerId,
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
return fail("VALIDATION_FAILED", error.message, 400);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { writeAudit } from "@/modules/audit/service";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import {
|
||||||
|
createBusinessCustomer,
|
||||||
|
listBusinessCustomers,
|
||||||
|
} from "@/modules/customer/business-customer-service";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ customer_id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
parseAdminAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { customer_id: customerId } = await context.params;
|
||||||
|
const list = await listBusinessCustomers(customerId);
|
||||||
|
return ok({ customer_id: customerId, list });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = parseAdminAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { customer_id: customerId } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
const payload = body as {
|
||||||
|
business_customer_id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
external_code?: unknown;
|
||||||
|
remark?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof payload.business_customer_id !== "string") {
|
||||||
|
return fail("VALIDATION_FAILED", "业务客户标识无效", 400);
|
||||||
|
}
|
||||||
|
if (typeof payload.name !== "string") {
|
||||||
|
return fail("VALIDATION_FAILED", "客户名称无效", 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createBusinessCustomer({
|
||||||
|
customerId,
|
||||||
|
businessCustomerId: payload.business_customer_id,
|
||||||
|
name: payload.name,
|
||||||
|
externalCode:
|
||||||
|
typeof payload.external_code === "string"
|
||||||
|
? payload.external_code
|
||||||
|
: undefined,
|
||||||
|
remark: typeof payload.remark === "string" ? payload.remark : undefined,
|
||||||
|
});
|
||||||
|
await writeAudit(
|
||||||
|
"business_customer:create",
|
||||||
|
auth.userId,
|
||||||
|
`${customerId}/${created.business_customer_id}`,
|
||||||
|
created,
|
||||||
|
);
|
||||||
|
return ok(created);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "创建业务客户失败";
|
||||||
|
return fail("VALIDATION_FAILED", message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { GET, PUT } from "@/app/api/admin/customers/[customer_id]/provider-credentials/route";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { POST } from "@/app/api/admin/customers/[customer_id]/rotate-key/route";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { PATCH } from "@/app/api/admin/customers/[customer_id]/route";
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { resolveBusinessCustomerByAccount } from "@/modules/customer/business-customer-user-service";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ customer_id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GET ?account= 按登录账号反查业务客户 */
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
parseAdminAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { customer_id: customerId } = await context.params;
|
||||||
|
const account = new URL(request.url).searchParams.get("account")?.trim() || "";
|
||||||
|
if (!account) {
|
||||||
|
return fail("VALIDATION_FAILED", "请填写登录账号", 400);
|
||||||
|
}
|
||||||
|
const resolved = await resolveBusinessCustomerByAccount(customerId, account);
|
||||||
|
if (!resolved) {
|
||||||
|
return fail("NOT_FOUND", "未找到该账号对应的业务客户", 404);
|
||||||
|
}
|
||||||
|
return ok(resolved);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { GET, POST } from "@/app/api/admin/customers/route";
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||||
|
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { flockCheckoutSchema } from "@/modules/flock/checkout-validation";
|
||||||
|
import { submitFlockCheckoutFill } from "@/modules/flock/checkout-service";
|
||||||
|
|
||||||
|
/** Flock 登录态:选档 → fill-only 结账(不支付) */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = await parseServiceAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||||
|
if (rateLimited) return rateLimited;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = flockCheckoutSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
parsed.error.issues[0]?.message ?? "参数无效",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCustomerMatch(auth, parsed.data.customer_id);
|
||||||
|
const result = await submitFlockCheckoutFill({
|
||||||
|
customerId: parsed.data.customer_id,
|
||||||
|
quoteId: parsed.data.quote_id,
|
||||||
|
preferredTier: parsed.data.preferred_tier,
|
||||||
|
preferredFlexibility: parsed.data.preferred_flexibility,
|
||||||
|
preferredCarrier: parsed.data.preferred_carrier,
|
||||||
|
flockDetails: parsed.data.flock_details,
|
||||||
|
});
|
||||||
|
return ok(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
const msg = error instanceof Error ? error.message : "提交结账失败";
|
||||||
|
return fail("VALIDATION_FAILED", msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||||
|
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { flockHoldDeclineSchema } from "@/modules/flock/hold-validation";
|
||||||
|
import { declineFlockQuoteHold } from "@/modules/flock/hold-service";
|
||||||
|
|
||||||
|
/** 不继续填写二级 / 60s 决策超时 → 释放驻留 */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = await parseServiceAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||||
|
if (rateLimited) return rateLimited;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = flockHoldDeclineSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
parsed.error.issues[0]?.message ?? "参数无效",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCustomerMatch(auth, parsed.data.customer_id);
|
||||||
|
const result = await declineFlockQuoteHold({
|
||||||
|
customerId: parsed.data.customer_id,
|
||||||
|
quoteId: parsed.data.quote_id,
|
||||||
|
sessionId: parsed.data.quote_session_id,
|
||||||
|
});
|
||||||
|
return ok(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
const msg = error instanceof Error ? error.message : "释放失败";
|
||||||
|
return fail("VALIDATION_FAILED", msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||||
|
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { flockPricingOptionsSchema } from "@/modules/flock/pricing-options-validation";
|
||||||
|
import { submitFlockPricingOptions } from "@/modules/flock/pricing-options-service";
|
||||||
|
|
||||||
|
/** Flock 登录态:选档后拉取三档灵活性价(不结账) */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = await parseServiceAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||||
|
if (rateLimited) return rateLimited;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = flockPricingOptionsSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
parsed.error.issues[0]?.message ?? "参数无效",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCustomerMatch(auth, parsed.data.customer_id);
|
||||||
|
const result = await submitFlockPricingOptions({
|
||||||
|
customerId: parsed.data.customer_id,
|
||||||
|
quoteId: parsed.data.quote_id,
|
||||||
|
preferredTier: parsed.data.preferred_tier,
|
||||||
|
});
|
||||||
|
return ok(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
const msg = error instanceof Error ? error.message : "拉取档内报价失败";
|
||||||
|
return fail("VALIDATION_FAILED", msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||||
|
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { msCheckoutSchema } from "@/modules/mothership/checkout-validation";
|
||||||
|
import { submitMsCheckoutFill } from "@/modules/mothership/checkout-service";
|
||||||
|
|
||||||
|
/** MotherShip 登录态:选承运商 + 保障 → fill-only 结账(不支付) */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = await parseServiceAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||||
|
if (rateLimited) return rateLimited;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = msCheckoutSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
parsed.error.issues[0]?.message ?? "参数无效",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCustomerMatch(auth, parsed.data.customer_id);
|
||||||
|
const result = await submitMsCheckoutFill({
|
||||||
|
customerId: parsed.data.customer_id,
|
||||||
|
quoteId: parsed.data.quote_id,
|
||||||
|
preferredCarrier: parsed.data.preferred_carrier,
|
||||||
|
coverage: parsed.data.coverage,
|
||||||
|
cargoValueUsd: parsed.data.cargo_value_usd,
|
||||||
|
mothershipDetails: parsed.data.mothership_details,
|
||||||
|
pickupAccessorials: parsed.data.pickup_accessorials,
|
||||||
|
deliveryAccessorials: parsed.data.delivery_accessorials,
|
||||||
|
});
|
||||||
|
return ok(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
const msg = error instanceof Error ? error.message : "提交结账失败";
|
||||||
|
return fail("VALIDATION_FAILED", msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||||
|
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { msRefineDetailsSchema } from "@/modules/mothership/refine-validation";
|
||||||
|
import { submitMsRefineDetails } from "@/modules/mothership/refine-service";
|
||||||
|
import type { QuoteRequestBody } from "@/lib/frontend/types";
|
||||||
|
|
||||||
|
/** 提交二级 Details → 驻留页 Save&update 刷价 */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = await parseServiceAuth(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||||
|
if (rateLimited) return rateLimited;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = msRefineDetailsSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
parsed.error.issues[0]?.message ?? "参数无效",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCustomerMatch(auth, parsed.data.customer_id);
|
||||||
|
const result = await submitMsRefineDetails({
|
||||||
|
customerId: parsed.data.customer_id,
|
||||||
|
quoteId: parsed.data.quote_id,
|
||||||
|
sessionId: parsed.data.quote_session_id,
|
||||||
|
mothershipDetails:
|
||||||
|
parsed.data.mothership_details as QuoteRequestBody["mothership_details"],
|
||||||
|
pickupAccessorials: parsed.data.pickup_accessorials,
|
||||||
|
deliveryAccessorials: parsed.data.delivery_accessorials,
|
||||||
|
readyDate: parsed.data.ready_date,
|
||||||
|
readyTime: parsed.data.ready_time,
|
||||||
|
cargoLines: parsed.data.cargo_lines,
|
||||||
|
});
|
||||||
|
return ok(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return fail(error.code, error.message, error.httpStatus);
|
||||||
|
}
|
||||||
|
const msg = error instanceof Error ? error.message : "提交刷价失败";
|
||||||
|
return fail("VALIDATION_FAILED", msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||||
|
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||||
|
import { fail, ok } from "@/lib/response";
|
||||||
|
import { AuthError } from "@/modules/auth/errors";
|
||||||
|
import { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue