同步 MotherShip/Flock 登录态、嵌入 SSO 与对接文档,便于团队从 Gitea 完整拉取开发。

Co-authored-by: Cursor <cursoragent@cursor.com>
master
parent 4ef21f41db
commit ced49c80e4

@ -26,9 +26,59 @@ MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/
# 留空则 Playwright 默认en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容 # 留空则 Playwright 默认en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容
# RPA_BROWSER_LOCALE=en-US # RPA_BROWSER_LOCALE=en-US
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
# 登录后查价录制专用 storage与匿名隔离npm run record:mothership -- --logged-in
# RPA_LOGGED_IN_STORAGE_STATE_PATH=.rpa/mothership-logged-in-storage.json
# 缺会话文件时 headless 自动访问 MOTHERSHIP_QUOTE_URLS 获取 cookies默认 true设 false 则须手动 record:mothership # 缺会话文件时 headless 自动访问 MOTHERSHIP_QUOTE_URLS 获取 cookies默认 true设 false 则须手动 record:mothership
# RPA_AUTO_BOOTSTRAP_STORAGE=true # RPA_AUTO_BOOTSTRAP_STORAGE=true
RPA_MOCK_MODE=false RPA_MOCK_MODE=false
# Flock FreightPRD §14 / 技术设计 §7.6默认关闭Mock 或显式 true 可联调)
# Flock Freight本地开发默认开启生产请显式 FLOCK_RPA_ENABLED=true
FLOCK_RPA_ENABLED=true
FLOCK_QUOTE_URL=https://app.flockfreight.com/get-a-quote
# direct | dom | direct_then_dom默认 Direct 优先,失败回退 DOM 填表)
FLOCK_QUOTE_MODE=direct_then_dom
FLOCK_STORAGE_STATE_PATH=.rpa/flock-storage.json
# 登录后查价录制产物(与匿名 flock-storage.json 隔离npm run record:flock -- --logged-in
# FLOCK_LOGGED_IN_STORAGE_STATE_PATH=.rpa/flock-logged-in-storage.json
# DOM/RPA 保障路径:账号密码登录(配置后禁用 random_qq / 无痕注册;勿提交真实密码)
# FLOCK_LOGIN_EMAIL=
# FLOCK_LOGIN_PASSWORD=
# FLOCK_LOGIN_URL=https://app.flockfreight.com/login
# FLOCK_SELECTOR_LOGIN_EMAIL=input[type="email"], input[name="email"]
# FLOCK_SELECTOR_LOGIN_PASSWORD=input[type="password"], input[name="password"]
# FLOCK_SELECTOR_LOGIN_SUBMIT=button[type="submit"]
# 默认加载 .rpa/flock-storage.json登录成功后回写可跳过重复登录
# FLOCK_LOAD_STORAGE_STATE=false
# 登录模式默认写回 storage显式 false 可关闭
# FLOCK_SAVE_STORAGE_AFTER_QUOTE=false
# 卡顿 ≥20s 自动截图目录 .rpa/flock-stalls/(识别是否报价上限)
# FLOCK_STALL_MS=20000
# Flock selectornpm run record:flock 产出;未配时使用录制内置回退)
# FLOCK_SELECTOR_PICKUP_ZIP=role=textbox[name="Pickup ZIP Code"]
# FLOCK_SELECTOR_DELIVERY_ZIP=role=textbox[name="Delivery ZIP Code"]
# FLOCK_SELECTOR_PALLET_COUNT=role=textbox[name="Number of pallets"]
# FLOCK_SELECTOR_TOTAL_WEIGHT=role=textbox[name="Total shipment weight (lbs)"]
# FLOCK_SELECTOR_NEXT=[data-testid="anonymous_marketing_lead_quote_request_page__quote_request__submit_button"]
# FLOCK_TEST_EMAIL=3457189488@qq.com
# FLOCK_TEST_PHONE=7536407420
# 无登录凭据时的探针:无痕 + random_qq有 FLOCK_LOGIN_* 时自动关闭)
# FLOCK_FRESH_SESSION_PER_QUOTE=true
# FLOCK_RANDOM_ACCOUNT_PER_QUOTE=true
# FLOCK_EMAIL_MODE=random_qq
# FLOCK_QUOTES_PER_EMAIL=3
FLOCK_WORKER_REUSE_SESSION=false
FLOCK_MIN_QUOTES=1
FLOCK_QUOTE_WAIT_MS=20000
FLOCK_FORM_WAIT_MS=20000
FLOCK_FIELD_PAUSE_MS=1000
FLOCK_SESSION_MAX_ROTATIONS=3
FLOCK_POLL_TIMEOUT_MS=210000
NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS=210000
# qq.qqdna.com 号码库FLOCK_EMAIL_MODE=qqdna
# Gmail/Outlook 别名基址(可选):
# FLOCK_EMAIL_ALIAS_BASE=yourname@gmail.com
# FLOCK_EMAIL_POOL=aa@gmail.com,bb@outlook.com
# FLOCK_PHONE_POOL=7536407420,18692104118
# 地址 commit 隔离real=真实 MotherShipmock=仅替代 commitprove-rpa-chain 下游验证用) # 地址 commit 隔离real=真实 MotherShipmock=仅替代 commitprove-rpa-chain 下游验证用)
RPA_ADDRESS_MODE=real RPA_ADDRESS_MODE=real
# 报价模式direct=直连 axel API默认embed-demo 推荐)| widget=Playwright 页面交互 # 报价模式direct=直连 axel API默认embed-demo 推荐)| widget=Playwright 页面交互
@ -39,17 +89,18 @@ RPA_QUOTE_MODE=direct
RPA_PLACE_FIX_MODE=event RPA_PLACE_FIX_MODE=event
# true 时注入 P0 实例拓扑/监听器指纹 + P2 fetch 劫持fix-place 各阶段落盘 .rpa/place-diagnostic/ # true 时注入 P0 实例拓扑/监听器指纹 + P2 fetch 劫持fix-place 各阶段落盘 .rpa/place-diagnostic/
RPA_PLACE_DIAG=false RPA_PLACE_DIAG=false
# 有头调试RPA_HEADED=true 等价 RPA_HEADLESS=false # 有头调试RPA_HEADED=true 强制弹窗MotherShip / Flock 共用);或 RPA_HEADLESS=false
RPA_HEADLESS=true RPA_HEADLESS=true
# RPA_HEADED=true # RPA_HEADED=true
# RPA_SLOW_MO_MS=200
# 货物 drawer 填写完成后 page.pause()(仅人工验收时开启) # 货物 drawer 填写完成后 page.pause()(仅人工验收时开启)
# RPA_CARGO_DRAWER_PAUSE=true # RPA_CARGO_DRAWER_PAUSE=true
RPA_BROWSER_LOCALE=en-US RPA_BROWSER_LOCALE=en-US
RPA_USE_PATCHRIGHT=false RPA_USE_PATCHRIGHT=false
# 反爬playwright-extra stealth与 patchright 二选一;部分页面会破坏 widget默认 false # 反爬playwright-extra stealth与 patchright 二选一;部分页面会破坏 widget默认 false
RPA_USE_STEALTH=false RPA_USE_STEALTH=false
# 使用本机 Chrome/Edge降低 HeadlessChrome 指纹(推荐 chrome # 使用本机 Chrome/Edge降低 HeadlessChrome 指纹(Flock 推荐 chrome
# RPA_BROWSER_CHANNEL=chrome RPA_BROWSER_CHANNEL=chrome
# 真人入口流:先打开首页再点 Get Quote携带 Referer/Cookie # 真人入口流:先打开首页再点 Get Quote携带 Referer/Cookie
# RPA_HUMAN_ENTRY_FLOW=true # RPA_HUMAN_ENTRY_FLOW=true
# RPA_QUOTE_WARMUP_URL=https://www.mothership.com/ # RPA_QUOTE_WARMUP_URL=https://www.mothership.com/
@ -102,9 +153,21 @@ RPA_SELECTOR_TRANSIT=text=business days
# RPA_SELECTOR_WIDTH=... # RPA_SELECTOR_WIDTH=...
# RPA_SELECTOR_HEIGHT=... # RPA_SELECTOR_HEIGHT=...
# 可选:仅当入口跳转 login 时使用 # 可选:仅当入口跳转 login / 客户账密登录后查价时使用
MOTHERSHIP_EMAIL= MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD= MOTHERSHIP_PASSWORD=
# session-manager loginIfNeeded 使用2026-07-15 登录页实探)
# RPA_SELECTOR_LOGIN_EMAIL=role=textbox[name="Email address"]
# RPA_SELECTOR_LOGIN_PASSWORD=role=textbox[name="Password"]
# RPA_SELECTOR_LOGIN_SUBMIT=role=button[name="Sign in"]
# ── MotherShip 登录后查价Step 12.61npm run record:mothership -- --logged-in 产出后回填)──
# 详见 docs/rpa-mothership-logged-in-sop.md勿手猜 selector
# RPA_SELECTOR_MS_LOGGED_IN_ENTRY=
# RPA_SELECTOR_MS_LOGGED_IN_FORM_MARKER=
# RPA_SELECTOR_MS_LOGGED_IN_PICKUP=
# RPA_SELECTOR_MS_LOGGED_IN_DELIVERY=
# RPA_SELECTOR_MS_LOGGED_IN_CARGO=
# RPA_SELECTOR_MS_LOGGED_IN_SUBMIT=
# 已废弃:请改用 MOTHERSHIP_QUOTE_URLS # 已废弃:请改用 MOTHERSHIP_QUOTE_URLS
# MOTHERSHIP_QUOTE_URL= # MOTHERSHIP_QUOTE_URL=

@ -11,8 +11,10 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=chajia-neibu-2026 ARG NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=chajia-neibu-2026
ARG NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=CUST_001 ARG NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=CUST_001
ARG NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS=210000
ENV NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=$NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN ENV NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=$NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN
ENV NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=$NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID ENV NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=$NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID
ENV NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS=$NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS
RUN npx prisma generate RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build

@ -0,0 +1,67 @@
# 查价中台chajia
MotherShip / Flock Freight 卡派查价中台Next.js + Prisma + Playwright RPA
远程仓库(团队拉取):<https://git.shipro.ltd/nanchuanbeizi/chajia.git>
## 克隆与本地启动
```bash
git clone https://git.shipro.ltd/nanchuanbeizi/chajia.git
cd chajia
npm ci
cp .env.example .env
# 按需编辑 .envJWT_SECRET、数据库、HOST_SERVICE_TOKENS 等)
```
本地基础设施与开发Windows PowerShell 示例):
```powershell
# 详见 scripts/dev、deploy/README.md
npm run db:deploy # 或 db:migrate
npm run dev
# 另开终端npm run worker:rpa
```
生产 / BT 主机部署见 `deploy/README.md`、`deploy/RELEASE-NOTES.md`。
## 必读文档
| 文档 | 说明 |
|------|------|
| `api调用文档/api对接文档.md` | 第三方 OpenAPI + **iframe 嵌入直登§2.5** |
| `api调用文档/api调用文档.md` | 完整 API / 管理端说明 |
| `deploy/README.md` | 部署与环境变量 |
| `docs/` | PRD、技术设计、RPA SOP |
## 仓库已包含(可开发)
- 应用:`app/`、`components/`、`modules/`、`lib/`、`workers/`
- 数据库:`prisma/schema.prisma` + `prisma/migrations/`
- 依赖锁:`package-lock.json`
- 部署:`Dockerfile*`、`deploy/`
- 单测:`__tests__/`
- 环境模板:`.env.example`、`deploy/.env.*.example`
## 不会进库(本地自行生成)
- `.env` / `.env.local`(密钥,勿提交)
- `node_modules/`、`.next/`
- `.rpa/`Playwright storage / 截图)
- `.dev/`、`上传到服务器/`、`*.zip`
## 嵌入页CC iframe
```text
https://<域名>/embed-demo?login_type=api_key&api_key=<API_Key>&embed=1
```
详见对接文档 §2.5。
## 常用脚本
```bash
npm run lint # tsc --noEmit
npm test # vitest
npm run worker:rpa # RPA Worker
```

@ -0,0 +1,390 @@
import { describe, expect, it } from "vitest";
import {
FLOCK_ADDITIONAL_SERVICES,
FLOCK_DEFAULT_VEHICLES,
FLOCK_FREIGHT_CLASSES,
FLOCK_LOCATION_TYPES,
FLOCK_PACKAGING_TYPES,
FLOCK_VEHICLE_TYPES,
getFlockLocationAccessorials,
mapFlockLoggedInToApiInput,
validateFlockLoggedInCargoLine,
type FlockLoggedInQuotePayload,
} from "@/components/flock/flock-logged-in-quote-form";
import { FLOCK_VALIDATION_MESSAGES } from "@/lib/constants/flock-limits";
describe("flock logged-in quote form constants", () => {
it("地点类型恰 12 项且无虚构 Grocery/Prison 等", () => {
const ids = FLOCK_LOCATION_TYPES.map((x) => x.id);
expect(ids).toHaveLength(12);
expect(ids).toEqual(
expect.arrayContaining([
"business_with_dock",
"business_without_dock",
"limited_school",
"limited_airport",
"limited_military",
"trade_show",
"residential",
"limited_other",
]),
);
expect(ids).not.toContain("limited_grocery");
expect(ids).not.toContain("limited_mini_storage");
expect(ids).not.toContain("limited_prison");
expect(ids).not.toContain("limited_utility");
});
it("地点类型含 Business / School / Airport / Trade Show 等", () => {
const ids = FLOCK_LOCATION_TYPES.map((x) => x.id);
expect(ids).toEqual(
expect.arrayContaining([
"business_with_dock",
"business_without_dock",
"limited_school",
"limited_airport",
"trade_show",
"residential",
]),
);
});
it("调度选项按总重 >5000 动态启用", async () => {
const mod = await import("@/components/flock/flock-logged-in-quote-form");
expect(mod.flockSchedulingWeightLockedHint()).toBe(
"整票总重须大于 5000 lb 才可选用",
);
expect(
mod.isFlockSchedulingOptionLocked(4000, true),
).toBe(true);
expect(
mod.isFlockSchedulingOptionLocked(5001, true),
).toBe(false);
expect(
mod.isFlockSchedulingOptionLocked(4000, false),
).toBe(false);
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")
?.weightLocked,
).toBe(true);
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "standard_fcfs")
?.weightLocked,
).toBe(false);
expect(
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "need_appointment")
?.weightLocked,
).toBe(false);
expect(
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "must_arrive_by")
?.weightLocked,
).toBe(true);
expect(mod.FLOCK_SCHEDULING_TIME_OPTIONS[0]).toBe("12:00 am");
expect(
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")?.detail,
).toContain("预设时段");
});
it("mapFlockLoggedInToApiInput 透传调度子字段", () => {
const payload: FlockLoggedInQuotePayload = {
mode: "quick",
pickupDate: "2026-07-16",
pickupZip: "60611",
pickupType: "business_with_dock",
pickupLiftgate: false,
pickupInside: false,
pickupPalletJack: false,
deliveryZip: "78701",
deliveryType: "business_without_dock",
deliveryLiftgate: false,
deliveryInside: false,
deliveryPalletJack: false,
items: [
{
quantity: 4,
packagingType: "pallets_48x40",
lengthIn: 48,
widthIn: 40,
heightIn: 48,
totalWeightLb: 6000,
freightClass: "100",
description: "papers",
stackable: true,
turnable: false,
},
],
additionalServices: [],
vehicleTypes: ["dry_van"],
pickupService: "during_window",
deliveryService: "must_arrive_by",
pickupWindowStartTime: "8:00 am",
pickupWindowEndTime: "5:00 pm",
deliveryMustArriveByDate: "2026-07-20",
callForDeliveryAppointment: true,
callBeforePickup: false,
callBeforeDelivery: false,
additionalInsurance: false,
};
const mapped = mapFlockLoggedInToApiInput(payload);
expect(mapped.pickup_service).toBe("during_window");
expect(mapped.pickup_window_start_time).toBe("8:00 am");
expect(mapped.pickup_window_end_time).toBe("5:00 pm");
expect(mapped.delivery_service).toBe("must_arrive_by");
expect(mapped.delivery_must_arrive_by_date).toBe("2026-07-20");
expect(mapped.call_for_delivery_appointment).toBe(true);
});
it("mapFlockLoggedInToApiInput 总重 ≤5000 时强制关闭提货前致电", () => {
const payload: FlockLoggedInQuotePayload = {
mode: "quick",
pickupDate: "2026-07-16",
pickupZip: "60611",
pickupType: "business_with_dock",
pickupLiftgate: false,
pickupInside: false,
pickupPalletJack: false,
deliveryZip: "78701",
deliveryType: "business_without_dock",
deliveryLiftgate: false,
deliveryInside: false,
deliveryPalletJack: false,
items: [
{
quantity: 4,
packagingType: "pallets_48x40",
lengthIn: 48,
widthIn: 40,
heightIn: 48,
totalWeightLb: 1000,
freightClass: "100",
description: "papers",
stackable: true,
turnable: false,
},
],
additionalServices: [],
vehicleTypes: ["dry_van"],
pickupService: "standard_fcfs",
deliveryService: "standard_fcfs",
callBeforePickup: true,
callBeforeDelivery: false,
additionalInsurance: false,
};
expect(mapFlockLoggedInToApiInput(payload).call_before_pickup).toBe(false);
payload.items[0]!.totalWeightLb = 6000;
expect(mapFlockLoggedInToApiInput(payload).call_before_pickup).toBe(true);
});
it("各地点类型附加选项矩阵对齐官网截图", () => {
expect(getFlockLocationAccessorials("business_without_dock")).toEqual({
liftgate: true,
inside: true,
palletJack: true,
});
expect(getFlockLocationAccessorials("residential")).toEqual({
liftgate: true,
inside: true,
palletJack: false,
});
expect(getFlockLocationAccessorials("limited_construction")).toEqual({
liftgate: true,
inside: false,
palletJack: true,
});
expect(getFlockLocationAccessorials("trade_show")).toEqual({
liftgate: false,
inside: false,
palletJack: true,
});
expect(getFlockLocationAccessorials("limited_worship")).toEqual({
liftgate: true,
inside: true,
palletJack: false,
});
expect(getFlockLocationAccessorials("business_with_dock")).toEqual({
liftgate: false,
inside: false,
palletJack: false,
});
});
it("官网提示文案中文齐全", async () => {
const tips = await import("@/components/flock/flock-logged-in-quote-form");
expect(tips.FLOCK_LOCATION_TIP_ITEMS).toHaveLength(7);
expect(tips.FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS).toHaveLength(5);
expect(tips.FLOCK_VEHICLE_TIP_ITEMS).toHaveLength(4);
expect(tips.FLOCK_FREIGHT_CLASS_TIP).toContain("件数");
expect(tips.FLOCK_INSURANCE_YES_TIP).toContain("责任限额");
expect(tips.FLOCK_NMFC_NOTICE).toContain("NMFC");
});
it("货物尺寸与重量硬限同 FLOCK_LIMITS", () => {
const ok = validateFlockLoggedInCargoLine({
quantity: 2,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
totalWeightLb: 1000,
description: "ok",
});
expect(ok).toEqual({});
expect(
validateFlockLoggedInCargoLine({
quantity: 1,
lengthIn: 637,
widthIn: 40,
heightIn: 48,
totalWeightLb: 100,
description: "x",
}).len,
).toBe(FLOCK_VALIDATION_MESSAGES.length);
expect(
validateFlockLoggedInCargoLine({
quantity: 1,
lengthIn: 48,
widthIn: 103,
heightIn: 48,
totalWeightLb: 100,
description: "x",
}).wid,
).toBe(FLOCK_VALIDATION_MESSAGES.width);
expect(
validateFlockLoggedInCargoLine({
quantity: 1,
lengthIn: 48,
widthIn: 40,
heightIn: 109,
totalWeightLb: 100,
description: "x",
}).hei,
).toBe(FLOCK_VALIDATION_MESSAGES.height);
expect(
validateFlockLoggedInCargoLine({
quantity: 1,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
totalWeightLb: 45_001,
description: "x",
}).wt,
).toBe(FLOCK_VALIDATION_MESSAGES.totalWeight);
});
it("包装类型含标准托盘尺寸", () => {
const ids = FLOCK_PACKAGING_TYPES.map((x) => x.id);
expect(ids).toEqual(
expect.arrayContaining([
"pallets_48x40",
"pallets_48x48",
"pallets_60x48",
"pallets_custom",
]),
);
const p48 = FLOCK_PACKAGING_TYPES.find((p) => p.id === "pallets_48x40");
expect(p48?.length).toBe(48);
expect(p48?.width).toBe(40);
});
it("货运等级含密度计算与数值档", () => {
expect(FLOCK_FREIGHT_CLASSES[0]).toBe("density");
expect(FLOCK_FREIGHT_CLASSES).toEqual(
expect.arrayContaining(["50", "70", "100", "500"]),
);
});
it("附加服务与车型对齐截图", () => {
expect(FLOCK_ADDITIONAL_SERVICES.map((s) => s.id)).toEqual([
"blind_shipment",
"food_grade",
"load_to_ride",
"temperature_control",
"unloading",
]);
expect(FLOCK_VEHICLE_TYPES.map((v) => v.id)).toEqual([
"box_truck",
"dry_van",
"refrigerated",
"sprinter",
]);
expect([...FLOCK_DEFAULT_VEHICLES]).toEqual([
"box_truck",
"dry_van",
"refrigerated",
]);
});
it("mapFlockLoggedInToApiInput 汇总首行尺寸与多行数量/重量", () => {
const payload: FlockLoggedInQuotePayload = {
mode: "quick",
pickupDate: "2026-07-16",
pickupZip: "60611",
pickupType: "business_with_dock",
pickupLiftgate: false,
pickupInside: false,
pickupPalletJack: false,
deliveryZip: "78701",
deliveryType: "business_without_dock",
deliveryLiftgate: true,
deliveryInside: false,
deliveryPalletJack: false,
items: [
{
quantity: 2,
packagingType: "pallets_48x40",
lengthIn: 48,
widthIn: 40,
heightIn: 50,
totalWeightLb: 500,
freightClass: "100",
description: "papers",
stackable: true,
turnable: false,
},
{
quantity: 1,
packagingType: "boxes",
lengthIn: 20,
widthIn: 20,
heightIn: 20,
totalWeightLb: 100,
freightClass: "70",
description: "parts",
stackable: false,
turnable: false,
},
],
additionalServices: ["blind_shipment"],
vehicleTypes: ["box_truck", "dry_van"],
pickupService: "standard_fcfs",
deliveryService: "standard_fcfs",
callBeforePickup: false,
callBeforeDelivery: false,
additionalInsurance: false,
};
const mapped = mapFlockLoggedInToApiInput(payload);
expect(mapped.pickup_date).toBe("07/16/2026");
expect(mapped.pickup_zip).toBe("60611");
expect(mapped.delivery_zip).toBe("78701");
expect(mapped.pallet_count).toBe(3);
expect(mapped.total_weight).toEqual({ value: 600, unit: "lb" });
expect(mapped.dimensions).toEqual({
length: 48,
width: 40,
height: 50,
unit: "in",
});
expect(mapped.form_mode).toBe("logged_in_quick");
expect(mapped.packaging_type).toBe("pallets_48x40");
expect(mapped.freight_class).toBe("100");
expect(mapped.description).toBe("papers");
expect(mapped.stackable).toBe(true);
expect(mapped.additional_services).toEqual(["blind_shipment"]);
expect(mapped.vehicle_types).toEqual(["box_truck", "dry_van"]);
expect(mapped.delivery_liftgate).toBe(true);
expect(mapped.pickup_liftgate).toBe(false);
});
});

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { mothershipSuggestBodySchema } from "@/modules/address/validation";
import { axelSearchRowToCandidate } from "@/lib/axel/candidates";
import { buildDetailsStateFromL1 } from "@/components/mothership/mothership-logged-in-details-form";
import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body";
import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form";
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
const pickupConfirmed: MothershipAddressCandidate = {
option_id: "place_pickup",
display_label: "456 Oak Avenue unit 207, Fairhope, AL, USA",
formatted_address: "456 Oak Avenue unit 207, Fairhope, AL, USA",
street: "456 Oak Avenue unit 207",
city: "Fairhope",
state: "AL",
zip: "36532",
selectable: true,
};
const deliveryConfirmed: MothershipAddressCandidate = {
option_id: "place_delivery",
display_label: "789 East Elm Street suite 301, Brea, CA, USA",
formatted_address: "789 East Elm Street suite 301, Brea, CA, USA",
street: "789 East Elm Street suite 301",
city: "Brea",
state: "CA",
zip: "92821",
selectable: true,
};
const sample: MothershipLoggedInShipmentPayload = {
pickupQuery: pickupConfirmed.display_label,
deliveryQuery: deliveryConfirmed.display_label,
pickupConfirmed,
deliveryConfirmed,
pickupAccessorials: ["cfs", "liftgate"],
deliveryAccessorials: ["fbaAppointment"],
readyDate: "2026-07-16",
readyTime: "12:00 PM",
timezone: "GMT+8",
cargo: [
{
cargoType: "pallet",
quantity: 2,
weightLb: 250,
lengthIn: 48,
widthIn: 48,
heightIn: 48,
},
],
};
describe("mothershipSuggestBodySchema", () => {
it("query 至少 3 字符", () => {
expect(
mothershipSuggestBodySchema.safeParse({
customer_id: "CUST_001",
query: "ab",
}).success,
).toBe(false);
expect(
mothershipSuggestBodySchema.safeParse({
customer_id: "CUST_001",
query: "456 Oak",
}).success,
).toBe(true);
});
});
describe("axelSearchRowToCandidate纯 query 兜底)", () => {
it("用 mainText/secondaryText 解析候选", () => {
const row = axelSearchRowToCandidate(
{
placeId: "ChIJtest",
description: "456 Oak Avenue unit 207, Fairhope, AL, USA",
mainText: "456 Oak Avenue unit 207",
secondaryText: "Fairhope, AL",
},
{ street: "456 Oak Avenue, Unit 207", city: "", state: "", zip: "" },
);
expect(row).not.toBeNull();
expect(row!.option_id).toBe("ChIJtest");
expect(row!.city).toBe("Fairhope");
expect(row!.state).toBe("AL");
});
});
describe("mothership logged-in → 真实询价映射", () => {
it("二级从 confirmed candidate 预填地址", () => {
const state = buildDetailsStateFromL1(sample);
expect(state.pickup.address).toBe(pickupConfirmed.formatted_address);
expect(state.delivery.address).toBe(deliveryConfirmed.formatted_address);
expect(state.pickup.accessorials).toEqual(["cfs", "liftgate"]);
expect(state.cargo[0]?.quantity).toBe("2");
expect(state.cargo[0]?.weightLb).toBe("250");
expect(state.requestDeliveryAppointment).toBe(true);
});
it("buildQuoteRequestBodyFromLoggedIn 对齐截图样例", () => {
const body = buildQuoteRequestBodyFromLoggedIn(sample, "CUST_001");
expect(body.customer_id).toBe("CUST_001");
expect(body.pallet_count).toBe(2);
expect(body.weight).toEqual({ value: 250, unit: "lb" });
expect(body.dimensions).toEqual({
length: 48,
width: 48,
height: 48,
unit: "in",
});
expect(body.pickup_address.selected_from_mothership).toBe(true);
expect(body.pickup_address.mothership_option_id).toBe("place_pickup");
expect(body.delivery_address.mothership_option_id).toBe("place_delivery");
expect(body.pickup_address.city).toBe("Fairhope");
expect(body.delivery_address.city).toBe("Brea");
expect(body.cargo_type).toBe("general_freight");
});
});

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
MS_CARGO_TYPES,
MS_DEFAULT_READY_TIME,
MS_DELIVERY_ACCESSORIALS,
MS_PICKUP_ACCESSORIALS,
MS_READY_TIMES,
} from "@/components/mothership/mothership-logged-in-shipment-form";
describe("mothership logged-in shipment form constants", () => {
it("提货附加服务含官网录制项", () => {
const ids = MS_PICKUP_ACCESSORIALS.map((x) => x.id);
expect(ids).toEqual(
expect.arrayContaining([
"cfs",
"liftgate",
"limitedAccess",
"inside",
"residential",
"tradeshow",
]),
);
});
it("送货附加服务含 Amazon 预约等录制项", () => {
const ids = MS_DELIVERY_ACCESSORIALS.map((x) => x.id);
expect(ids).toEqual(
expect.arrayContaining([
"fbaAppointment",
"appointment",
"cfs",
"liftgate",
"limitedAccess",
"inside",
"residential",
"tradeshow",
]),
);
});
it("货物类型对齐官网完整列表", () => {
expect(MS_CARGO_TYPES.map((x) => x.id)).toEqual([
"pallet",
"box",
"crate",
"piece",
"bale",
"bucket",
"carton",
"case",
"coil",
"cylinder",
"drum",
"pail",
"reel",
"roll",
"skid",
"tote",
"tube",
]);
});
it("可提货时刻为 24 个整点且有序无重复", () => {
expect(MS_READY_TIMES).toHaveLength(24);
expect(new Set(MS_READY_TIMES).size).toBe(24);
expect(MS_READY_TIMES[0]).toBe("12:00 AM");
expect(MS_READY_TIMES[11]).toBe("11:00 AM");
expect(MS_READY_TIMES[12]).toBe("12:00 PM");
expect(MS_READY_TIMES[15]).toBe("3:00 PM");
expect(MS_READY_TIMES[23]).toBe("11:00 PM");
expect(MS_DEFAULT_READY_TIME).toBe("11:00 AM");
expect(MS_READY_TIMES).toContain(MS_DEFAULT_READY_TIME);
});
});

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
QUOTE_ACCENT,
QUOTE_REQUIRED,
QUOTE_SECTION,
QUOTE_TITLE,
quoteAddRowCls,
quoteCtaCls,
quoteInputCls,
} from "@/components/quote/quote-form-chrome";
describe("quote-form-chrome tokens", () => {
it("局部主色与标题色对齐视觉规范", () => {
expect(QUOTE_ACCENT).toBe("#1890FF");
expect(QUOTE_TITLE).toBe("#1F2937");
expect(QUOTE_SECTION).toBe("#4B5563");
expect(QUOTE_REQUIRED).toBe("#EF4444");
});
it("输入/CTA/添加行 class 含关键样式关键字", () => {
expect(quoteInputCls).toContain("h-10");
expect(quoteInputCls).toContain("px-3");
expect(quoteInputCls).toContain("#1890FF");
expect(quoteCtaCls).toContain("min-w-[48px]");
expect(quoteCtaCls).toContain("bg-[#1890FF]");
expect(quoteAddRowCls).toContain("hover:-translate-y-0.5");
});
});

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
expandDirectionalStreet,
parseStreetDirection,
scoreMothershipCandidateForDraft,
sortMothershipCandidatesForDraft,
} from "@/lib/address/street-normalize";
import type { MothershipAddressCandidate } from "@/modules/address/types";
function cand(
street: string,
display: string,
): MothershipAddressCandidate {
return {
option_id: `id-${street.replace(/\s+/g, "-")}`,
display_label: display,
formatted_address: display,
street,
city: "Pomona",
state: "CA",
zip: "",
};
}
describe("street-normalize", () => {
it("expandDirectionalStreet 展开 W 7th St", () => {
expect(expandDirectionalStreet("600 W 7th St")).toBe(
"600 West 7th Street",
);
expect(parseStreetDirection("600 W 7th St")).toBe("W");
expect(parseStreetDirection("600 East 7th Street")).toBe("E");
});
it("600 W 7th St 草稿优先 West 7th惩罚 East", () => {
const draft = { street: "600 W 7th St", city: "Pomona", state: "CA" };
const west = cand(
"600 West 7th Street",
"600 West 7th Street, Pomona, California, USA",
);
const abbrev = cand(
"600 W 7th St",
"600 W 7th St, Pomona, California, USA",
);
const east = cand(
"600 East 7th Street",
"600 East 7th Street, Pomona, California, USA",
);
const sorted = sortMothershipCandidatesForDraft(
[abbrev, east, west],
draft,
);
expect(sorted[0]?.street).toBe("600 West 7th Street");
expect(scoreMothershipCandidateForDraft(east, draft)).toBeLessThan(
scoreMothershipCandidateForDraft(west, draft),
);
});
});

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
axelSearchRowToCandidate, axelSearchRowToCandidate,
resolveAxelMothershipCandidates, resolveAxelMothershipCandidates,
resolveAxelMothershipSuggest,
} from "@/lib/axel/candidates"; } from "@/lib/axel/candidates";
import { AxelHttpClient } from "@/lib/axel/client"; import { AxelHttpClient } from "@/lib/axel/client";
@ -125,3 +126,39 @@ describe("resolveAxelMothershipCandidates", () => {
expect(fetchPlace).toHaveBeenCalledTimes(4); expect(fetchPlace).toHaveBeenCalledTimes(4);
}); });
}); });
describe("resolveAxelMothershipSuggest", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.stubEnv("AXEL_CANDIDATE_VERIFY_TOP_K", "3");
});
it("仅映射 Axel googlePlacesResults无 placeId 丢弃,并对 TopK fetchPlace", async () => {
const fetchPlace = vi.fn().mockResolvedValue({ placeId: "ok" });
const searchLocation = vi.fn().mockResolvedValue([
{
placeId: "ChIJ-a",
description: "100 A St, LA, CA, USA",
mainText: "100 A St",
secondaryText: "LA, CA, USA",
},
{
placeId: "",
description: "本系统不应出现的假地址",
mainText: "fake",
secondaryText: "Nowhere",
},
]);
vi.spyOn(AxelHttpClient, "create").mockResolvedValue({
searchLocation,
fetchPlace,
} as unknown as AxelHttpClient);
const list = await resolveAxelMothershipSuggest("100 A St");
expect(searchLocation).toHaveBeenCalledWith("100 A St");
expect(list).toHaveLength(1);
expect(list[0]?.option_id).toBe("ChIJ-a");
expect(fetchPlace).toHaveBeenCalledTimes(1);
});
});

@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveStorageStatePath } from "@/lib/axel/session";
import { runWithMothershipLoginContext } from "@/lib/rpa/mothership-login-context";
const TMP = path.join(process.cwd(), ".rpa", "_tmp-logged-in-storage-test.json");
describe("resolveStorageStatePath 登录态优先", () => {
afterEach(() => {
if (fs.existsSync(TMP)) fs.unlinkSync(TMP);
delete process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH;
});
it("有登录凭据且 logged-in storage 存在时使用登录态路径", async () => {
process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH = TMP;
fs.mkdirSync(path.dirname(TMP), { recursive: true });
fs.writeFileSync(TMP, JSON.stringify({ cookies: [], origins: [] }), "utf8");
await runWithMothershipLoginContext(
{ email: "a@b.com", password: "x" },
"customer",
async () => {
expect(resolveStorageStatePath()).toBe(TMP);
},
);
});
});

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { FLOCK_LIMITS } from "@/lib/constants/flock-limits";
import { isFlockRpaEnabled } from "@/lib/flock/env";
describe("flock limits & env", () => {
it("硬限与 PRD §14.3 一致", () => {
expect(FLOCK_LIMITS.palletCount).toEqual({ min: 4, max: 20 });
expect(FLOCK_LIMITS.totalWeightLbMax).toBe(45_000);
expect(FLOCK_LIMITS.dimIn).toEqual({
lengthMax: 636,
widthMax: 102,
heightMax: 108,
min: 1,
});
});
it("Mock 模式下视为启用", () => {
const prevMock = process.env.RPA_MOCK_MODE;
const prevEnabled = process.env.FLOCK_RPA_ENABLED;
const prevNode = process.env.NODE_ENV;
process.env.RPA_MOCK_MODE = "true";
process.env.FLOCK_RPA_ENABLED = "false";
process.env.NODE_ENV = "production";
expect(isFlockRpaEnabled()).toBe(true);
process.env.RPA_MOCK_MODE = prevMock;
process.env.FLOCK_RPA_ENABLED = prevEnabled;
process.env.NODE_ENV = prevNode;
});
it("development 且未显式关闭时默认启用", () => {
const prevEnabled = process.env.FLOCK_RPA_ENABLED;
const prevNode = process.env.NODE_ENV;
delete process.env.FLOCK_RPA_ENABLED;
process.env.NODE_ENV = "development";
expect(isFlockRpaEnabled()).toBe(true);
process.env.FLOCK_RPA_ENABLED = "false";
expect(isFlockRpaEnabled()).toBe(false);
process.env.FLOCK_RPA_ENABLED = prevEnabled;
process.env.NODE_ENV = prevNode;
});
});

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
isEmbedHostedShell,
parseEmbedSsoParams,
} from "@/lib/embed/sso-params";
describe("parseEmbedSsoParams", () => {
it("解析 login_type=api_key + api_key", () => {
const p = new URLSearchParams(
"login_type=api_key&api_key=chj_demo_token",
);
expect(parseEmbedSsoParams(p)).toEqual({
mode: "api_key",
apiKey: "chj_demo_token",
});
});
it("解析短别名 type=key + key=", () => {
const p = new URLSearchParams("type=api_key&key=demo-host-token");
expect(parseEmbedSsoParams(p)).toEqual({
mode: "api_key",
apiKey: "demo-host-token",
});
});
it("仅有 key 无 type 时默认 api_key", () => {
const p = new URLSearchParams("api_key=abc");
expect(parseEmbedSsoParams(p)).toEqual({ mode: "api_key", apiKey: "abc" });
});
it("解析账号密码", () => {
const p = new URLSearchParams(
"login_type=password&customer_id=CUST_001&password=secret",
);
expect(parseEmbedSsoParams(p)).toEqual({
mode: "password",
customerId: "CUST_001",
password: "secret",
});
});
it("缺字段 → none", () => {
expect(parseEmbedSsoParams(new URLSearchParams("login_type=api_key"))).toEqual(
{ mode: "none" },
);
});
});
describe("isEmbedHostedShell", () => {
it("embed=1 或带 SSO 凭证视为托管壳", () => {
expect(isEmbedHostedShell(new URLSearchParams("embed=1"))).toBe(true);
expect(
isEmbedHostedShell(new URLSearchParams("api_key=x")),
).toBe(true);
expect(isEmbedHostedShell(new URLSearchParams(""))).toBe(false);
});
});

@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { buildFlockQuotesApiRequest } from "@/lib/flock/quote-payload";
import { mapFlockFulfillmentOptionsToLines } from "@/lib/flock/map-fulfillment-options";
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
const sampleInput: FlockQuoteInput = {
pickupDate: "07/15/2026",
pickupZip: "90001",
deliveryZip: "75201",
palletCount: 6,
totalWeightLb: 10_000,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
};
describe("buildFlockQuotesApiRequest", () => {
it("对齐抓包契约字段", () => {
const body = buildFlockQuotesApiRequest(sampleInput);
expect(body.shipment.pickupDate).toBe("2026-07-15");
expect(body.shipment.originPostalCode).toBe("90001");
expect(body.shipment.destinationPostalCode).toBe("75201");
expect(body.shipment.items[0]).toMatchObject({
quantity: 6,
weightLbs: 10000,
lengthIn: 48,
freightClass: "CLASS_60",
packagingType: "PALLET_OTHER",
});
expect(body.isEdiRequote).toBe(false);
});
});
describe("mapFlockFulfillmentOptionsToLines", () => {
it("映射 GUARANTEED_HUBLESS 与 STANDARD 最低价", () => {
const { quotes, reference } = mapFlockFulfillmentOptionsToLines({
referenceNumber: "FFW-TEST",
fulfillmentOptions: [
{
fulfillmentCategory: "STANDARD",
rateUsd: "1512.67",
transitTimeDaysMin: 3,
transitTimeDaysMax: 7,
carrierName: "A",
},
{
fulfillmentCategory: "STANDARD",
rateUsd: "1410.09",
transitTimeDaysMin: 3,
transitTimeDaysMax: 5,
carrierName: "Roadrunner",
},
{
fulfillmentCategory: "GUARANTEED_HUBLESS",
rateUsd: "2091.00",
transitTimeDaysMin: 4,
transitTimeDaysMax: 4,
transitSupportedOnWeekendsAndHolidays: true,
carrierName: "Flock Freight Estimate",
},
{
fulfillmentCategory: "GUARANTEED_HUBLESS",
rateUsd: "1970.00",
transitTimeDaysMin: 4,
transitTimeDaysMax: 4,
transitSupportedOnWeekendsAndHolidays: true,
carrierName: "Flock Freight Estimate",
},
],
});
expect(reference).toBe("FFW-TEST");
expect(quotes).toHaveLength(2);
const direct = quotes.find((q) => q.tier === "flock_direct");
const standard = quotes.find((q) => q.tier === "standard");
expect(direct?.totalUsd).toBe(1970);
expect(direct?.label).toBe("FlockDirect®");
expect(standard?.totalUsd).toBe(1410.09);
expect(standard?.transitDays).toBe("3-5");
});
});

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
FLOCK_RECORDING_TEST_ACCOUNT,
getFlockQuotesPerEmail,
getFlockTestAccount,
isFlockFreshSessionPerQuote,
isFlockRandomAccountPerQuote,
isFlockWorkerReuseSession,
shouldLoadFlockStorageState,
} from "@/lib/flock/env";
describe("getFlockTestAccount", () => {
it("默认使用录制验证账号", () => {
const prev = {
random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE,
fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE,
email: process.env.FLOCK_TEST_EMAIL,
phone: process.env.FLOCK_TEST_PHONE,
};
delete process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE;
delete process.env.FLOCK_FRESH_SESSION_PER_QUOTE;
delete process.env.FLOCK_TEST_EMAIL;
delete process.env.FLOCK_TEST_PHONE;
const account = getFlockTestAccount();
expect(account.email).toBe(FLOCK_RECORDING_TEST_ACCOUNT.email);
expect(account.phone).toBe(FLOCK_RECORDING_TEST_ACCOUNT.phone);
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random;
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh;
process.env.FLOCK_TEST_EMAIL = prev.email;
process.env.FLOCK_TEST_PHONE = prev.phone;
});
});
describe("getFlockQuotesPerEmail", () => {
it("未设置时不限次数", () => {
const prev = process.env.FLOCK_QUOTES_PER_EMAIL;
delete process.env.FLOCK_QUOTES_PER_EMAIL;
expect(getFlockQuotesPerEmail()).toBe(Number.POSITIVE_INFINITY);
process.env.FLOCK_QUOTES_PER_EMAIL = prev;
});
it("解析每邮箱查价次数", () => {
const prev = process.env.FLOCK_QUOTES_PER_EMAIL;
process.env.FLOCK_QUOTES_PER_EMAIL = "3";
expect(getFlockQuotesPerEmail()).toBe(3);
process.env.FLOCK_QUOTES_PER_EMAIL = prev;
});
});
describe("isFlockWorkerReuseSession", () => {
it("配置 FLOCK_QUOTES_PER_EMAIL 时默认开启", () => {
const prev = {
perEmail: process.env.FLOCK_QUOTES_PER_EMAIL,
reuse: process.env.FLOCK_WORKER_REUSE_SESSION,
};
process.env.FLOCK_QUOTES_PER_EMAIL = "3";
delete process.env.FLOCK_WORKER_REUSE_SESSION;
expect(isFlockWorkerReuseSession()).toBe(true);
process.env.FLOCK_QUOTES_PER_EMAIL = prev.perEmail;
process.env.FLOCK_WORKER_REUSE_SESSION = prev.reuse;
});
it("显式 false 时关闭", () => {
const prev = {
perEmail: process.env.FLOCK_QUOTES_PER_EMAIL,
reuse: process.env.FLOCK_WORKER_REUSE_SESSION,
};
process.env.FLOCK_QUOTES_PER_EMAIL = "3";
process.env.FLOCK_WORKER_REUSE_SESSION = "false";
expect(isFlockWorkerReuseSession()).toBe(false);
process.env.FLOCK_QUOTES_PER_EMAIL = prev.perEmail;
process.env.FLOCK_WORKER_REUSE_SESSION = prev.reuse;
});
});
describe("flock env defaults (recording mode)", () => {
it("默认不随机账号", () => {
const prev = process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE;
delete process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE;
delete process.env.FLOCK_FRESH_SESSION_PER_QUOTE;
expect(isFlockRandomAccountPerQuote()).toBe(false);
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev;
});
it("无痕模式不加载 storage", () => {
const prev = {
fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE,
load: process.env.FLOCK_LOAD_STORAGE_STATE,
};
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = "true";
delete process.env.FLOCK_LOAD_STORAGE_STATE;
expect(isFlockFreshSessionPerQuote()).toBe(true);
expect(shouldLoadFlockStorageState(true)).toBe(false);
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh;
process.env.FLOCK_LOAD_STORAGE_STATE = prev.load;
});
it("默认加载 storage若文件存在", () => {
const prev = {
load: process.env.FLOCK_LOAD_STORAGE_STATE,
fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE,
random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE,
};
delete process.env.FLOCK_LOAD_STORAGE_STATE;
delete process.env.FLOCK_FRESH_SESSION_PER_QUOTE;
delete process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE;
expect(shouldLoadFlockStorageState(true)).toBe(true);
expect(shouldLoadFlockStorageState(false)).toBe(false);
process.env.FLOCK_LOAD_STORAGE_STATE = prev.load;
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh;
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random;
});
});

@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
import {
isFlockNonRetryableFailure,
releaseFlockExclusiveLock,
withFlockExclusiveLock,
} from "@/lib/flock/exclusive-lock";
const LOCK_PATH = path.join(process.cwd(), ".rpa", "flock-rpa.lock");
afterEach(() => {
releaseFlockExclusiveLock("test");
try {
fs.unlinkSync(LOCK_PATH);
} catch {
/* ignore */
}
});
describe("isFlockNonRetryableFailure", () => {
it("识别上限与拒号", () => {
expect(
isFlockNonRetryableFailure(
"FLOCK_QUOTE_LIMITThank you for your interest",
),
).toBe(true);
expect(
isFlockNonRetryableFailure("FLOCK_ACCOUNT_REJECTEDissue creating"),
).toBe(true);
expect(isFlockNonRetryableFailure("互斥锁占用")).toBe(true);
expect(isFlockNonRetryableFailure("网络超时")).toBe(false);
});
});
describe("withFlockExclusiveLock", () => {
it("串行持有锁", async () => {
const order: string[] = [];
await withFlockExclusiveLock("test", async () => {
order.push("a");
expect(fs.existsSync(LOCK_PATH)).toBe(true);
});
expect(fs.existsSync(LOCK_PATH)).toBe(false);
expect(order).toEqual(["a"]);
});
});

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
buildFlockAliasedEmail,
createFreshFlockTestAccount,
createRandomQqEmail,
createVerifiedQqAliasedEmail,
parseFlockEnvPool,
} from "@/lib/flock/fresh-account";
describe("parseFlockEnvPool", () => {
it("解析逗号与换行分隔", () => {
expect(parseFlockEnvPool("a@b.com, c@d.com\ne@f.com")).toEqual([
"a@b.com",
"c@d.com",
"e@f.com",
]);
});
});
describe("createRandomQqEmail", () => {
it("1/2/3 开头 + 9 位数字 @qq.com", () => {
for (let i = 0; i < 20; i += 1) {
const email = createRandomQqEmail();
expect(email).toMatch(/^[123]\d{9}@qq\.com$/);
}
});
});
describe("createVerifiedQqAliasedEmail", () => {
it("已验证 QQ + 短别名", () => {
const email = createVerifiedQqAliasedEmail();
expect(email).toMatch(/^3457189488\+[a-f0-9]{6}@qq\.com$/);
});
});
describe("buildFlockAliasedEmail", () => {
it("Gmail 使用 + 子地址", () => {
const email = buildFlockAliasedEmail("user@gmail.com", "abc123");
expect(email).toMatch(/^user\+[a-z0-9]+@gmail\.com$/);
});
it("QQ 基址使用短 + 别名", () => {
const email = buildFlockAliasedEmail("3457189488@qq.com", "x1y2z3");
expect(email).toMatch(/^3457189488\+[a-z0-9]+@qq\.com$/);
});
});
describe("createFreshFlockTestAccount", () => {
it("默认使用已验证 QQ 短别名(非虚构号码)", () => {
const prev = {
pool: process.env.FLOCK_EMAIL_POOL,
alias: process.env.FLOCK_EMAIL_ALIAS_BASE,
test: process.env.FLOCK_TEST_EMAIL,
mode: process.env.FLOCK_EMAIL_MODE,
};
delete process.env.FLOCK_EMAIL_POOL;
delete process.env.FLOCK_EMAIL_ALIAS_BASE;
delete process.env.FLOCK_TEST_EMAIL;
delete process.env.FLOCK_EMAIL_MODE;
const a = createFreshFlockTestAccount();
expect(a.email).toMatch(/^3457189488\+[a-z0-9]+@qq\.com$/);
process.env.FLOCK_EMAIL_POOL = prev.pool;
process.env.FLOCK_EMAIL_ALIAS_BASE = prev.alias;
process.env.FLOCK_TEST_EMAIL = prev.test;
process.env.FLOCK_EMAIL_MODE = prev.mode;
});
it("使用邮箱池时不重复编造域名", () => {
const prev = process.env.FLOCK_EMAIL_POOL;
process.env.FLOCK_EMAIL_POOL = "one@gmail.com,two@outlook.com";
const a = createFreshFlockTestAccount();
expect(["one@gmail.com", "two@outlook.com"]).toContain(a.email);
process.env.FLOCK_EMAIL_POOL = prev;
});
});

@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
analyzeFlockHarEntries,
scoreFlockQuoteCandidate,
verdictFromCandidates,
type FlockHarEntry,
} from "@/lib/flock/har-analyze";
describe("flock har-analyze", () => {
it("高分匹配类似 axel 的 quote JSON", () => {
const score = scoreFlockQuoteCandidate({
url: "https://api.flockfreight.com/v1/quote",
method: "POST",
status: 200,
mimeType: "application/json",
responseText: JSON.stringify({
rates: [
{ label: "FlockDirect", price: 2300 },
{ label: "Standard", price: 800 },
],
}),
});
expect(score).toBeGreaterThanOrEqual(10);
});
it("过滤静态与埋点", () => {
expect(
scoreFlockQuoteCandidate({
url: "https://www.googletagmanager.com/gtm.js",
method: "GET",
status: 200,
mimeType: "application/javascript",
responseText: "gtm",
}),
).toBe(0);
});
it("从 HAR entries 选出候选并裁决", () => {
const entries: FlockHarEntry[] = [
{
request: {
method: "POST",
url: "https://app.flockfreight.com/api/quotes",
},
response: {
status: 200,
content: {
mimeType: "application/json",
text: JSON.stringify({
flockDirect: { total: 1999 },
standard: { total: 777 },
reference: "FRG-1",
}),
},
},
},
{
request: {
method: "GET",
url: "https://cdn.example.com/app.js",
},
response: { status: 200, content: { mimeType: "application/javascript" } },
},
];
const cands = analyzeFlockHarEntries(entries);
expect(cands.length).toBeGreaterThanOrEqual(1);
expect(verdictFromCandidates(cands)).toBe("stable_json_quote_api_found");
});
it("无候选时裁决为 dom_only", () => {
expect(verdictFromCandidates([])).toBe("no_quote_json_api_dom_only");
});
});

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
flockFreightClassRpaLabels,
flockLocationRpaLabels,
flockPackagingRpaLabels,
FLOCK_LOCATION_RPA_LABELS,
FLOCK_PICKUP_SERVICE_RPA_LABELS,
FLOCK_DELIVERY_SERVICE_RPA_LABELS,
FLOCK_PICKUP_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";
describe("flock logged-in RPA options", () => {
it("Bags / Business with Dock / density 候选可匹配官网", () => {
expect(flockPackagingRpaLabels("bags")).toContain("Bags");
expect(flockLocationRpaLabels("business_with_dock")[0]).toBe(
"Business with Dock",
);
expect(flockFreightClassRpaLabels("density")[0]).toMatch(/density/i);
});
it("地点 RPA 候选与 UI 12 类型对齐且含 Military Base", () => {
const uiIds = FLOCK_LOCATION_TYPES.map((t) => t.id).sort();
const rpaIds = Object.keys(FLOCK_LOCATION_RPA_LABELS).sort();
expect(rpaIds).toEqual(uiIds);
expect(FLOCK_LOCATION_RPA_LABELS.limited_military[0]).toMatch(
/Military Base/i,
);
expect(FLOCK_LOCATION_RPA_LABELS).not.toHaveProperty("limited_grocery");
});
it("调度服务文案含 During pickup window / Must arrive by date", () => {
expect(FLOCK_PICKUP_SERVICE_RPA_LABELS.during_window[0]).toMatch(
/pickup window/i,
);
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.standard_fcfs).toBe(false);
});
});
describe("validateFlockQuoteInput logged_in_quick", () => {
it("允许数量 2小于匿名托盘下限 4", () => {
const parsed = validateFlockQuoteInput({
request_id: "11111111-1111-4111-8111-111111111111",
customer_id: "CUST_001",
flock_input: {
form_mode: "logged_in_quick",
pickup_date: defaultFlockPickupDate(),
pickup_zip: "90248",
delivery_zip: "15222",
pallet_count: 2,
total_weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
packaging_type: "bags",
freight_class: "density",
description: "papers",
stackable: true,
pickup_location_type: "business_with_dock",
delivery_location_type: "business_with_dock",
},
});
expect(parsed.formMode).toBe("logged_in_quick");
expect(parsed.palletCount).toBe(2);
expect(parsed.packagingType).toBe("bags");
expect(parsed.description).toBe("papers");
expect(parsed.stackable).toBe(true);
});
});

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
evaluateFlockOptionCompat,
flockOptionHasBlock,
isFlockReeferOnlyWithoutTemp,
isFlockSprinterOnly,
isFlockTempWithoutReefer,
} from "@/lib/flock/option-compat";
describe("Flock 车型×温控", () => {
it("仅冷藏无温控 → block", () => {
expect(
isFlockReeferOnlyWithoutTemp({
vehicleTypes: ["refrigerated"],
additionalServices: [],
}),
).toBe(true);
const issues = evaluateFlockOptionCompat({
pickupLocationType: "business_with_dock",
deliveryLocationType: "business_with_dock",
packagingTypes: ["pallets_48x40"],
vehicleTypes: ["refrigerated"],
additionalServices: [],
});
expect(flockOptionHasBlock(issues)).toBe(true);
});
it("温控无冷藏车 → block", () => {
expect(
isFlockTempWithoutReefer({
vehicleTypes: ["box_truck"],
additionalServices: ["temperature_control"],
}),
).toBe(true);
});
it("冷藏+温控 → 通过", () => {
expect(
isFlockReeferOnlyWithoutTemp({
vehicleTypes: ["refrigerated"],
additionalServices: ["temperature_control"],
}),
).toBe(false);
});
it("仅 sprinter → warn", () => {
expect(isFlockSprinterOnly(["sprinter"])).toBe(true);
const issues = evaluateFlockOptionCompat({
pickupLocationType: "business_with_dock",
deliveryLocationType: "business_with_dock",
packagingTypes: ["boxes"],
vehicleTypes: ["sprinter"],
additionalServices: [],
});
expect(issues.some((i) => i.code === "flock_sprinter_only")).toBe(true);
});
it("施工工地 / units 包装 → warn", () => {
const issues = evaluateFlockOptionCompat({
pickupLocationType: "business_with_dock",
deliveryLocationType: "limited_construction",
packagingTypes: ["units"],
vehicleTypes: ["box_truck", "dry_van", "refrigerated"],
additionalServices: [],
});
expect(flockOptionHasBlock(issues)).toBe(false);
expect(issues.some((i) => i.code.includes("construction"))).toBe(true);
expect(issues.some((i) => i.code.includes("units"))).toBe(true);
});
});

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
defaultFlockPickupDateIso,
flockPickupDateValidationMessageFromDisplay,
flockPickupDateValidationMessageFromIso,
isFlockPickupDateOnOrAfterToday,
minFlockPickupDateIso,
} from "@/lib/flock/pickup-date";
describe("flock pickup date", () => {
it("今天及以后合法", () => {
const today = minFlockPickupDateIso();
expect(isFlockPickupDateOnOrAfterToday(today)).toBe(true);
expect(flockPickupDateValidationMessageFromIso(today)).toBeNull();
expect(
flockPickupDateValidationMessageFromIso(defaultFlockPickupDateIso()),
).toBeNull();
});
it("早于今天拒绝", () => {
const d = new Date();
d.setDate(d.getDate() - 1);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const iso = `${yyyy}-${mm}-${dd}`;
expect(isFlockPickupDateOnOrAfterToday(iso)).toBe(false);
expect(flockPickupDateValidationMessageFromIso(iso)).toMatch(/不能早于今天/);
expect(
flockPickupDateValidationMessageFromDisplay(`${mm}/${dd}/${yyyy}`),
).toMatch(/不能早于今天/);
});
it("周六周日拒绝", () => {
expect(flockPickupDateValidationMessageFromIso("2026-07-18")).toMatch(
/周六或周日/,
);
expect(flockPickupDateValidationMessageFromDisplay("07/19/2026")).toMatch(
/周六或周日/,
);
expect(flockPickupDateValidationMessageFromIso("2026-07-20")).toBeNull();
});
it("默认值为工作日", () => {
expect(flockPickupDateValidationMessageFromIso(defaultFlockPickupDateIso())).toBeNull();
});
});

@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import {
FLOCK_POLL_TIMEOUT_DEFAULT_MS,
getFlockPollTimeoutMs,
} from "@/lib/flock/poll-timeout";
describe("getFlockPollTimeoutMs", () => {
const prev = {
public: process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS,
server: process.env.FLOCK_POLL_TIMEOUT_MS,
};
afterEach(() => {
process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS = prev.public;
process.env.FLOCK_POLL_TIMEOUT_MS = prev.server;
});
it("无 env 时默认 210s覆盖 3 轮换)", () => {
delete process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS;
delete process.env.FLOCK_POLL_TIMEOUT_MS;
expect(getFlockPollTimeoutMs()).toBe(FLOCK_POLL_TIMEOUT_DEFAULT_MS);
});
it("优先 NEXT_PUBLIC_", () => {
process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS = "240000";
process.env.FLOCK_POLL_TIMEOUT_MS = "90000";
expect(getFlockPollTimeoutMs()).toBe(240_000);
});
});

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { qqDnaEmailPool } from "@/lib/flock/qqdna-email-pool";
import { createFreshFlockTestAccount } from "@/lib/flock/fresh-account";
describe("qqDnaEmailPool", () => {
it("全部为合法长度 QQ@qq.com", () => {
const pool = qqDnaEmailPool();
expect(pool.length).toBeGreaterThan(50);
for (const email of pool) {
expect(email).toMatch(/^\d{5,11}@qq\.com$/);
}
});
});
describe("FLOCK_EMAIL_MODE=qqdna", () => {
it("轮询取 qqdna 池", () => {
const prev = process.env.FLOCK_EMAIL_MODE;
process.env.FLOCK_EMAIL_MODE = "qqdna";
delete process.env.FLOCK_EMAIL_POOL;
const a = createFreshFlockTestAccount();
const b = createFreshFlockTestAccount();
expect(a.email).toMatch(/^\d{5,11}@qq\.com$/);
expect(b.email).toMatch(/^\d{5,11}@qq\.com$/);
expect(a.email).not.toBe(b.email);
process.env.FLOCK_EMAIL_MODE = prev;
});
});

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
hasFlockRegistrationOverride,
resolveFlockQuoteAccount,
} from "@/lib/flock/resolve-account";
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
function baseInput(
registration?: FlockQuoteInput["registration"],
): FlockQuoteInput {
return {
pickupDate: "07/14/2026",
pickupZip: "90001",
deliveryZip: "75201",
palletCount: 6,
totalWeightLb: 3500,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
registration,
};
}
describe("hasFlockRegistrationOverride", () => {
it("空对象视为未填写", () => {
expect(hasFlockRegistrationOverride({})).toBe(false);
expect(hasFlockRegistrationOverride(undefined)).toBe(false);
});
it("任一字段有值即为自填", () => {
expect(hasFlockRegistrationOverride({ email: "a@b.com" })).toBe(true);
});
});
describe("resolveFlockQuoteAccount", () => {
it("用户邮箱覆盖系统随机", () => {
const account = resolveFlockQuoteAccount(
baseInput({ email: "user@example.com", phone: "7536407420" }),
);
expect(account.email).toBe("user@example.com");
expect(account.phone).toBe("7536407420");
expect(account.firstName.length).toBeGreaterThan(0);
});
});

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
buildFlockProgressPayload,
flockStageIndex,
mapFlockErrorToStage,
parseFlockProgress,
shouldOverwriteFlockFailureStage,
} from "@/lib/flock/rpa-progress";
describe("flock rpa-progress", () => {
it("build + parse roundtrip", () => {
const payload = buildFlockProgressPayload("login");
const parsed = parseFlockProgress(payload);
expect(parsed?.stage).toBe("login");
expect(parsed?.label).toContain("登录");
});
it("maps FLOCK_LOGIN_FAILED to login stage", () => {
const mapped = mapFlockErrorToStage(
"FLOCK_LOGIN_FAILED无法填写登录表单locator.waitFor: Timeout 15000ms",
);
expect(mapped.stage).toBe("login");
expect(mapped.matched).toBe(true);
expect(mapped.hint).toContain("卡在登录");
});
it("未识别错误不得默认钉在互斥锁", () => {
const mapped = mapFlockErrorToStage("不明错误 xyz");
expect(mapped.matched).toBe(false);
expect(mapped.stage).not.toBe("lock");
});
it("浏览器关闭 / 未返回报价 / 超时映射到 wait_quote", () => {
expect(mapFlockErrorToStage("Target closed").stage).toBe("wait_quote");
expect(mapFlockErrorToStage("未返回 Flock 报价档位").stage).toBe(
"wait_quote",
);
expect(mapFlockErrorToStage("Flock 询价超时,请稍后重试").stage).toBe(
"wait_quote",
);
});
it("失败写进度禁止从 wait_quote 回退到 lock", () => {
expect(
shouldOverwriteFlockFailureStage("wait_quote", {
stage: "lock",
matched: true,
}),
).toBe(false);
expect(
shouldOverwriteFlockFailureStage("lock", {
stage: "wait_quote",
matched: true,
}),
).toBe(true);
expect(
shouldOverwriteFlockFailureStage("wait_quote", {
stage: "wait_quote",
matched: false,
}),
).toBe(false);
});
it("stage index order", () => {
expect(flockStageIndex("queued")).toBeLessThan(flockStageIndex("login"));
expect(flockStageIndex("login")).toBeLessThan(flockStageIndex("fill_form"));
});
});

@ -0,0 +1,91 @@
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,56 @@
import { describe, expect, it } from "vitest";
import {
convertDimInputValue,
convertWeightInputValue,
describeDimEquivalent,
describeWeightEquivalent,
displayUnitToCargoUnits,
} from "@/lib/frontend/cargo-form-units";
import {
buildDimDisplayForUnit,
inferDimUnitFromFieldHint,
} from "@/workers/rpa/cargo-dimensions";
import { kgToLb } from "@/modules/quote/unit-converter";
describe("cargo-form-units", () => {
it("500 lb 切 kg 应约为 226.8", () => {
const kg = convertWeightInputValue(500, "lb", "kg");
expect(kg).toBeCloseTo(226.8, 1);
expect(kgToLb(kg)).toBeCloseTo(500, 0);
});
it("500 kg 询价提示为 1103 lb", () => {
expect(describeWeightEquivalent(500, "kg")).toBe(
"询价将按 1103 lb500 kg 换算,小数进一)",
);
});
it("48 cm 询价提示为 19 in", () => {
expect(describeDimEquivalent(48, 40, 48, "cm")).toBe(
"询价将按 19 × 16 × 19 in厘米换算小数进一",
);
});
it("48 in 切 cm 应约为 121.92", () => {
const cm = convertDimInputValue(48, "in", "cm");
expect(cm).toBeCloseTo(121.92, 1);
});
it("公制显示同步 kg/cm", () => {
expect(displayUnitToCargoUnits("metric")).toEqual({
weight: "kg",
dim: "cm",
});
});
});
describe("cargo-dimensions", () => {
it("cm 字段填入换算值", () => {
expect(inferDimUnitFromFieldHint("Length (cm)")).toBe("cm");
expect(buildDimDisplayForUnit(48, "cm")).toBe("121.92");
});
it("in 字段填入整英寸", () => {
expect(buildDimDisplayForUnit(47.24, "in")).toBe("48");
});
});

@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import {
buildQuoteRequestBodyFromLoggedIn,
resolveLoggedInPalletCount,
} from "@/lib/frontend/mothership-logged-in-quote-body";
import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form";
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
const pickup: MothershipAddressCandidate = {
option_id: "p1",
display_label: "A, Fairhope, AL",
formatted_address: "A, Fairhope, AL",
street: "A",
city: "Fairhope",
state: "AL",
zip: "36532",
};
const delivery: MothershipAddressCandidate = {
option_id: "d1",
display_label: "B, Brea, CA",
formatted_address: "B, Brea, CA",
street: "B",
city: "Brea",
state: "CA",
zip: "92821",
};
function payload(
cargo: MothershipLoggedInShipmentPayload["cargo"],
): MothershipLoggedInShipmentPayload {
return {
pickupQuery: pickup.display_label,
deliveryQuery: delivery.display_label,
pickupConfirmed: pickup,
deliveryConfirmed: delivery,
pickupAccessorials: [],
deliveryAccessorials: [],
readyDate: "2026-07-16",
readyTime: "12:00 PM",
timezone: "GMT+8",
cargo,
};
}
describe("mothership-logged-in-quote-body", () => {
it("多行托盘 quantity 合计", () => {
expect(
resolveLoggedInPalletCount(
payload([
{
cargoType: "pallet",
quantity: 2,
weightLb: 250,
lengthIn: 48,
widthIn: 48,
heightIn: 48,
},
{
cargoType: "pallet",
quantity: 3,
weightLb: 100,
lengthIn: 40,
widthIn: 40,
heightIn: 40,
},
]),
),
).toBe(5);
});
it("询价体重取首行单件重量", () => {
const body = buildQuoteRequestBodyFromLoggedIn(
payload([
{
cargoType: "pallet",
quantity: 2,
weightLb: 250,
lengthIn: 48,
widthIn: 48,
heightIn: 48,
},
]),
"CUST_001",
);
expect(body.weight.value).toBe(250);
expect(body.pallet_count).toBe(2);
});
it("透传附加服务与可提货时间", () => {
const base = payload([
{
cargoType: "pallet",
quantity: 1,
weightLb: 100,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
},
]);
const body = buildQuoteRequestBodyFromLoggedIn(
{
...base,
pickupAccessorials: ["liftgate", "residential"],
deliveryAccessorials: ["appointment"],
readyDate: "2026-07-16",
readyTime: "3:00 PM",
},
"CUST_001",
);
expect(body.pickup_accessorials).toEqual(["liftgate", "residential"]);
expect(body.delivery_accessorials).toEqual(["appointment"]);
expect(body.ready_date).toBe("2026-07-16");
expect(body.ready_time).toBe("3:00 PM");
});
it("透传多行 cargo_lines", () => {
const body = buildQuoteRequestBodyFromLoggedIn(
payload([
{
cargoType: "pallet",
quantity: 2,
weightLb: 250,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
},
{
cargoType: "box",
quantity: 2,
weightLb: 250,
lengthIn: 50,
widthIn: 40,
heightIn: 50,
},
]),
"CUST_001",
);
expect(body.cargo_lines).toHaveLength(2);
expect(body.cargo_lines?.[1]?.cargo_type).toBe("box");
expect(body.pallet_count).toBe(4);
});
it("透传二级 details 字段", () => {
const body = buildQuoteRequestBodyFromLoggedIn(
payload([
{
cargoType: "pallet",
quantity: 1,
weightLb: 100,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
},
]),
"CUST_001",
{
pickup: {
companyName: "Pickup Co",
suite: "Ste 100",
contactFirst: "A",
contactLast: "B",
contactEmail: "pickup@example.com",
contactPhone: "5551112222",
reference: "REF-1",
notes: "note",
opensAt: "8:00 AM",
closesAt: "5:00 PM",
},
delivery: {
companyName: "Delivery Co",
suite: "",
contactFirst: "C",
contactLast: "D",
contactEmail: "delivery@example.com",
contactPhone: "5553334444",
reference: "",
notes: "",
opensAt: "9:00 AM",
closesAt: "6:00 PM",
},
requestDeliveryAppointment: true,
fbaNumber: "FBA-123",
fbaPoNumber: "PO-456",
cargo: [
{
pieceCountType: "Pieces",
pieceCountQty: 10,
description: "General freight",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
},
);
expect(body.mothership_details?.pickup.company_name).toBe("Pickup Co");
expect(body.mothership_details?.delivery.contact_email).toBe(
"delivery@example.com",
);
expect(body.mothership_details?.request_delivery_appointment).toBe(true);
expect(body.mothership_details?.cargo?.[0]?.piece_count_type).toBe("Pieces");
});
});

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
isMothershipWeekendIso,
isMothershipWeightEachAllowed,
normalizeMothershipReadyDateIso,
snapMothershipReadyDateToWeekday,
} from "@/lib/mothership/logged-in-constraints";
describe("mothership logged-in constraints", () => {
it("识别周末", () => {
expect(isMothershipWeekendIso("2026-07-18")).toBe(true); // Sat
expect(isMothershipWeekendIso("2026-07-19")).toBe(true); // Sun
expect(isMothershipWeekendIso("2026-07-20")).toBe(false); // Mon
});
it("周末拨到周一", () => {
expect(snapMothershipReadyDateToWeekday("2026-07-18")).toBe("2026-07-20");
expect(snapMothershipReadyDateToWeekday("2026-07-19")).toBe("2026-07-20");
expect(snapMothershipReadyDateToWeekday("2026-07-20")).toBe("2026-07-20");
});
it("单件重量 ≤5000", () => {
expect(isMothershipWeightEachAllowed(5000)).toBe(true);
expect(isMothershipWeightEachAllowed(5000.01)).toBe(false);
expect(isMothershipWeightEachAllowed(50)).toBe(true);
});
it("normalize 周末日期", () => {
expect(normalizeMothershipReadyDateIso("2026-07-19")).toBe("2026-07-20");
expect(normalizeMothershipReadyDateIso("2026-07-20")).toBe("2026-07-20");
expect(normalizeMothershipReadyDateIso(undefined)).toBeUndefined();
});
});

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
evaluateMothershipAccessorialCompat,
mothershipAccessorialHasBlock,
toggleMothershipAccessorial,
} from "@/lib/mothership/option-compat";
describe("toggleMothershipAccessorial", () => {
it("禁止提货勾选住宅", () => {
const r = toggleMothershipAccessorial({
side: "pickup",
selected: ["liftgate"],
id: "residential",
});
expect(r.applied).toBe(false);
expect(r.next).toEqual(["liftgate"]);
expect(r.message).toMatch(/不支持住宅/);
});
it("预约与 Amazon 预约互斥:勾选后者去掉前者", () => {
const r = toggleMothershipAccessorial({
side: "delivery",
selected: ["appointment"],
id: "fbaAppointment",
});
expect(r.applied).toBe(true);
expect(r.next).toContain("fbaAppointment");
expect(r.next).not.toContain("appointment");
expect(r.message).toMatch(/不能同时/);
});
it("住宅与展会互斥", () => {
const r = toggleMothershipAccessorial({
side: "delivery",
selected: ["residential"],
id: "tradeshow",
});
expect(r.next).toEqual(["tradeshow"]);
});
});
describe("evaluateMothershipAccessorialCompat", () => {
it("提货已含住宅 → block", () => {
const issues = evaluateMothershipAccessorialCompat({
side: "pickup",
selected: ["residential"],
});
expect(mothershipAccessorialHasBlock(issues)).toBe(true);
});
it("住宅派送无尾板 → warn", () => {
const issues = evaluateMothershipAccessorialCompat({
side: "delivery",
selected: ["residential"],
});
expect(issues.some((i) => i.code === "ms_residential_need_liftgate")).toBe(
true,
);
});
});

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
extractMothershipPortalQuoteMessages,
formatMothershipPortalQuoteMessage,
} from "@/lib/mothership/portal-quote-messages";
describe("portal-quote-messages", () => {
it("合并 No rates + required fields", () => {
const body =
"Nicotine/Tobacco\nNo rates found\nPlease review all required fields to proceed.";
const msgs = extractMothershipPortalQuoteMessages(body);
expect(msgs).toContain(
"No rates found\nPlease review all required fields to proceed",
);
});
it("提取附加服务/住宅取件阻断(中文)", () => {
const body =
"不支持附加服务,我们的承运合作伙伴不提供住宅地址上门取件服务";
const msgs = extractMothershipPortalQuoteMessages(body);
expect(msgs.some((m) => m.includes("不支持附加服务"))).toBe(true);
});
it("提取 carrier partner residential pickup英文", () => {
const body =
"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),
),
).toBe(true);
});
it("format 将英文官网提示译为中文", () => {
const out = formatMothershipPortalQuoteMessage([
"No rates found",
"Unsupported accessorial",
]);
expect(out).toBe("未找到可用报价\n\n不支持该附加服务");
});
it("format 保留中文官网提示", () => {
const out = formatMothershipPortalQuoteMessage([
"不支持附加服务,承运合作伙伴不提供住宅地址上门取件",
]);
expect(out).toContain("不支持附加服务");
});
});

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
buildMothershipWeekdayGrid,
isMothershipDateSelectable,
} from "@/lib/mothership/weekday-date-picker";
describe("weekday-date-picker", () => {
it("周六日不可选", () => {
expect(isMothershipDateSelectable("2026-07-18", "2026-07-01")).toBe(false);
expect(isMothershipDateSelectable("2026-07-19", "2026-07-01")).toBe(false);
expect(isMothershipDateSelectable("2026-07-20", "2026-07-01")).toBe(true);
});
it("网格周末格子 disabled", () => {
const cells = buildMothershipWeekdayGrid(2026, 6, "2026-07-20", "2026-07-01");
const sat = cells.find((c) => c.inMonth && c.iso === "2026-07-18");
const sun = cells.find((c) => c.inMonth && c.iso === "2026-07-19");
const mon = cells.find((c) => c.inMonth && c.iso === "2026-07-20");
expect(sat?.disabled).toBe(true);
expect(sat?.isWeekend).toBe(true);
expect(sun?.disabled).toBe(true);
expect(mon?.disabled).toBe(false);
expect(mon?.isSelected).toBe(true);
});
});

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
priority1CustomerDisplayUsd,
priority1HasMarkup,
} from "@/lib/priority1/display-price";
import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage";
function line(partial: Partial<Priority1DisplayLine>): Priority1DisplayLine {
return {
rank: 1,
carrier: "Test",
carrierCode: "TST",
totalUsd: 100,
transitDays: 3,
deliveryDate: null,
expirationDate: null,
serviceLevel: "Standard",
quoteId: null,
caboUrl: null,
source: "visible-dom",
markup_amount: 0,
final_total_usd: 100,
...partial,
};
}
describe("priority1 display price", () => {
it("shows final_total_usd when markup applied", () => {
const row = line({ totalUsd: 428.5, markup_amount: 42.85, final_total_usd: 471.35 });
expect(priority1CustomerDisplayUsd(row)).toBe(471.35);
expect(priority1HasMarkup(row)).toBe(true);
});
it("falls back to totalUsd when final is zero", () => {
const row = line({ totalUsd: 200, markup_amount: 0, final_total_usd: 0 });
expect(priority1CustomerDisplayUsd(row)).toBe(200);
expect(priority1HasMarkup(row)).toBe(false);
});
});

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { EMPTY_PRIORITY1_SIMULATOR_FORM } from "@/lib/priority1/empty-simulator-form";
describe("EMPTY_PRIORITY1_SIMULATOR_FORM", () => {
it("does not prefill zip, date, email, phone or cargo fields", () => {
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.originZip).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.destinationZip).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.pickupDate).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.email).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.phone).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.weightLb).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.commodity).toBe("");
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.shipmentFrequency).toBe("");
});
});

@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { RpaError } from "@/modules/rpa/errors";
import {
isDirectErrorFatalWithoutWidget,
shouldFallbackToWidgetAfterDirectError,
} from "@/lib/rpa/direct-quote-fallback";
describe("direct-quote-fallback", () => {
it("CARRIER_NO_CAPACITY 且 Widget fallback 开启时应回退浏览器", () => {
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "");
const err = new RpaError(
"CARRIER_NO_CAPACITY",
"MotherShip 该线路暂无可用报价,请调整地址或货物后重试",
{ retryable: false },
);
expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(true);
expect(isDirectErrorFatalWithoutWidget(err)).toBe(false);
});
it("RPA_DISABLE_WIDGET_QUOTE_FALLBACK=true 时 direct 无运力仍 fatal", () => {
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "true");
const err = new RpaError("CARRIER_NO_CAPACITY", "无运力", {
retryable: false,
});
expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(false);
expect(isDirectErrorFatalWithoutWidget(err)).toBe(true);
});
it("STRUCT_CHANGE 仍禁止 Widget 回退", () => {
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "");
const err = new RpaError("STRUCT_CHANGE", "结构变更", { retryable: false });
expect(isDirectErrorFatalWithoutWidget(err)).toBe(true);
});
});

@ -9,6 +9,9 @@ import {
getRpaPlaceFixMode, getRpaPlaceFixMode,
validateMothershipQuoteUrls, validateMothershipQuoteUrls,
isWidgetQuoteFallbackEnabled, isWidgetQuoteFallbackEnabled,
isPreconfirmedMothershipQuote,
resolveBlindAddressFlow,
isBlindAddressFlowActive,
} from "@/lib/rpa/env"; } from "@/lib/rpa/env";
describe("lib/rpa/env", () => { describe("lib/rpa/env", () => {
@ -64,6 +67,24 @@ describe("lib/rpa/env", () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it("isRpaHeaded / resolveRpaHeadlessRPA_HEADED 优先", async () => {
const { isRpaHeaded, resolveRpaHeadless, resolveRpaSlowMoMs } = await import(
"@/lib/rpa/env"
);
vi.stubEnv("RPA_HEADED", "true");
vi.stubEnv("RPA_HEADLESS", "true");
expect(isRpaHeaded()).toBe(true);
expect(resolveRpaHeadless()).toBe(false);
expect(resolveRpaSlowMoMs()).toBe(200);
vi.stubEnv("RPA_HEADED", "false");
vi.stubEnv("RPA_HEADLESS", "true");
vi.stubEnv("RPA_SLOW_MO_MS", "");
expect(isRpaHeaded()).toBe(false);
expect(resolveRpaHeadless()).toBe(true);
expect(resolveRpaSlowMoMs()).toBeUndefined();
vi.unstubAllEnvs();
});
it("shouldKeepBrowserWarm headless 默认保活", () => { it("shouldKeepBrowserWarm headless 默认保活", () => {
vi.stubEnv("RPA_KEEP_BROWSER_WARM", ""); vi.stubEnv("RPA_KEEP_BROWSER_WARM", "");
vi.stubEnv("RPA_HEADLESS", "true"); vi.stubEnv("RPA_HEADLESS", "true");
@ -98,4 +119,28 @@ describe("lib/rpa/env", () => {
expect(isWidgetQuoteFallbackEnabled()).toBe(false); expect(isWidgetQuoteFallbackEnabled()).toBe(false);
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it("resolveBlindAddressFlow 对已确认 MotherShip 候选自动启用", () => {
vi.stubEnv("RPA_BLIND_ADDRESS_FLOW", "false");
vi.stubEnv("RPA_FAST_QUOTE", "false");
vi.stubEnv("RPA_VISUAL_RUSH", "false");
const req = {
pickup: {
selectedFromMothership: true,
mothershipOptionId: "ChIJ-pickup",
placeId: "",
},
delivery: {
selectedFromMothership: true,
mothershipOptionId: "ChIJ-delivery",
placeId: "",
},
} as Parameters<typeof isPreconfirmedMothershipQuote>[0];
expect(isPreconfirmedMothershipQuote(req)).toBe(true);
expect(resolveBlindAddressFlow(req)).toBe(true);
expect(
isBlindAddressFlowActive({ state: { blindAddressFlow: true } }),
).toBe(true);
vi.unstubAllEnvs();
});
}); });

@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from "vitest";
import {
getEffectiveMothershipLogin,
getMothershipLoginSource,
hasEffectiveMothershipLogin,
runWithMothershipLoginContext,
} from "@/lib/rpa/mothership-login-context";
describe("mothership login context", () => {
afterEach(() => {
delete process.env.MOTHERSHIP_EMAIL;
delete process.env.MOTHERSHIP_PASSWORD;
});
it("客户凭据优先于环境变量", async () => {
process.env.MOTHERSHIP_EMAIL = "env@example.com";
process.env.MOTHERSHIP_PASSWORD = "env-pass";
await runWithMothershipLoginContext(
{ email: "cust@example.com", password: "cust-pass" },
"customer",
async () => {
expect(hasEffectiveMothershipLogin()).toBe(true);
expect(getMothershipLoginSource()).toBe("customer");
expect(getEffectiveMothershipLogin()?.email).toBe("cust@example.com");
},
);
});
it("无 ALS 时回退 env", () => {
process.env.MOTHERSHIP_EMAIL = "env@example.com";
process.env.MOTHERSHIP_PASSWORD = "env-pass";
expect(hasEffectiveMothershipLogin()).toBe(true);
expect(getMothershipLoginSource()).toBe("env");
});
});

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
formatExternalSiteNavigationError,
isTransientNavigationError,
} from "@/lib/rpa/page-goto";
describe("page-goto resilience", () => {
it("detects connection reset as transient", () => {
expect(
isTransientNavigationError(
new Error(
"page.goto: net::ERR_CONNECTION_RESET at https://www.priority1.com/",
),
),
).toBe(true);
});
it("formats user-facing Priority1 navigation error", () => {
const msg = formatExternalSiteNavigationError(
"Priority1 官网",
new Error("page.goto: net::ERR_CONNECTION_RESET"),
);
expect(msg).toContain("连接被重置");
expect(msg).toContain("网络与代理");
expect(msg).not.toMatch(/ERR_|page\.goto|RPA_PROXY/i);
});
});

@ -32,6 +32,46 @@ describe("alert-presentation", () => {
expect(cause).not.toContain("{"); expect(cause).not.toContain("{");
}); });
it("优先展示 detail.message 而非 STRUCT_CHANGE 默认文案", () => {
const cause = resolveAlertRootCause("STRUCT_CHANGE", {
message: "ADDRESS_NOT_CONFIRMED: 派送地址未在 MotherShip 确认",
});
expect(cause).toContain("已选地址");
expect(cause).not.toContain("页面结构变更");
});
it("QUOTE_ENTRY_UNAVAILABLE 展示入口不可用根因", () => {
const cause = resolveAlertRootCause("RPA_FAILED", {
message: "QUOTE_ENTRY_UNAVAILABLE: 无法打开 MotherShip 报价页",
entry_error: "QUOTE_ENTRY_UNAVAILABLE",
});
expect(cause).toContain("报价页面无法打开");
});
it("locator.waitFor 超时译为中文并指出控件", () => {
const cause = resolveAlertRootCause("RPA_FAILED", {
message:
"locator.waitFor: Timeout 8000ms exceeded. Call log: - waiting for getByTestId('address-book-accessorials-option-appointment').first() to be visible",
});
expect(cause).toContain("自动化操作超时");
expect(cause).toContain("预约送货");
expect(cause).not.toMatch(/locator\.waitFor|getByTestId|Call log/i);
});
it("报价单 errorMessage 中的英文 Playwright 日志同样中文化", () => {
const cause = resolveAlertRootCause(
"RPA_FAILED",
{ message: "ignored" },
{
errorMessage:
"locator.waitFor: Timeout 8000ms exceeded. waiting for getByTestId('quote-create-pickup-input-search')",
errorCode: "RPA_DATA_INVALID",
} as never,
);
expect(cause).toContain("提货地址搜索框");
expect(cause).not.toMatch(/locator\.waitFor/i);
});
it("buildAlertPresentation 包含客户与货物字段", () => { it("buildAlertPresentation 包含客户与货物字段", () => {
const presentation = buildAlertPresentation( const presentation = buildAlertPresentation(
"RPA_FAILED", "RPA_FAILED",

@ -0,0 +1,36 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getMasterLoginPassword,
isMasterLoginPassword,
} from "@/modules/auth/master-password";
describe("master-password", () => {
const prev = process.env.MASTER_LOGIN_PASSWORD;
beforeEach(() => {
process.env.MASTER_LOGIN_PASSWORD = "test-master-secret";
});
afterEach(() => {
if (prev === undefined) {
delete process.env.MASTER_LOGIN_PASSWORD;
} else {
process.env.MASTER_LOGIN_PASSWORD = prev;
}
});
it("读取环境变量", () => {
expect(getMasterLoginPassword()).toBe("test-master-secret");
});
it("匹配万能密码", () => {
expect(isMasterLoginPassword("test-master-secret")).toBe(true);
expect(isMasterLoginPassword("wrong")).toBe(false);
});
it("未配置时禁用", () => {
delete process.env.MASTER_LOGIN_PASSWORD;
expect(getMasterLoginPassword()).toBeNull();
expect(isMasterLoginPassword("anything")).toBe(false);
});
});

@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import {
decryptSecret,
encryptSecret,
} from "@/modules/customer/token-crypto";
import {
getFlockLoginSource,
hasEffectiveFlockLogin,
runWithFlockLoginContext,
} from "@/lib/flock/login-context";
import { serializeProviderCredentialAdminView } from "@/modules/customer/provider-credentials";
describe("encryptSecret / decryptSecret", () => {
it("往返一致", () => {
const plain = "daetrDG#%%^Ydad12";
expect(decryptSecret(encryptSecret(plain))).toBe(plain);
});
it("空/非法密文返回 null", () => {
expect(decryptSecret(null)).toBeNull();
expect(decryptSecret("")).toBeNull();
expect(decryptSecret("@@@")).toBeNull();
});
});
describe("serializeProviderCredentialAdminView", () => {
it("回传完整邮箱与明文密码", () => {
const password = "Secret#1";
const view = serializeProviderCredentialAdminView({
provider: "flock",
loginEmail: "ops@example.com",
passwordCiphertext: encryptSecret(password),
updatedAt: new Date("2026-07-15T00:00:00.000Z"),
isDeleted: false,
});
expect(view.email).toBe("ops@example.com");
expect(view.password).toBe(password);
expect(view.has_password).toBe(true);
});
it("已删除不回传账密", () => {
const view = serializeProviderCredentialAdminView({
provider: "mothership",
loginEmail: "ops@example.com",
passwordCiphertext: encryptSecret("x"),
updatedAt: new Date(),
isDeleted: true,
});
expect(view.email).toBeNull();
expect(view.password).toBeNull();
expect(view.has_password).toBe(false);
});
});
describe("flock login context", () => {
afterEach(() => {
delete process.env.FLOCK_LOGIN_EMAIL;
delete process.env.FLOCK_LOGIN_PASSWORD;
});
it("客户凭据优先于环境变量", async () => {
process.env.FLOCK_LOGIN_EMAIL = "env@example.com";
process.env.FLOCK_LOGIN_PASSWORD = "env-pass";
await runWithFlockLoginContext(
{ email: "cust@example.com", password: "cust-pass" },
"customer",
async () => {
expect(hasEffectiveFlockLogin()).toBe(true);
expect(getFlockLoginSource()).toBe("customer");
},
);
});
it("无 ALS 时回退 env", async () => {
process.env.FLOCK_LOGIN_EMAIL = "env@example.com";
process.env.FLOCK_LOGIN_PASSWORD = "env-pass";
expect(hasEffectiveFlockLogin()).toBe(true);
expect(getFlockLoginSource()).toBe("env");
});
});

@ -1,11 +1,21 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
decryptServiceToken,
encryptServiceToken,
generateServiceToken, generateServiceToken,
hashServiceToken, hashServiceToken,
tokenKeyPrefix, tokenKeyPrefix,
} from "@/modules/customer/token-crypto"; } from "@/modules/customer/token-crypto";
describe("token-crypto", () => { describe("token-crypto", () => {
it("可加密并解密 token 原文", () => {
process.env.JWT_SECRET = "unit-test-secret";
const token = generateServiceToken();
const ciphertext = encryptServiceToken(token);
expect(ciphertext).not.toBe(token);
expect(decryptServiceToken(ciphertext)).toBe(token);
});
it("生成 chj_ 前缀 token", () => { it("生成 chj_ 前缀 token", () => {
const token = generateServiceToken(); const token = generateServiceToken();
expect(token.startsWith("chj_")).toBe(true); expect(token.startsWith("chj_")).toBe(true);

@ -39,4 +39,30 @@ describe("embed-demo auth", () => {
expect(payload.sub).toBe("CUST_002"); expect(payload.sub).toBe("CUST_002");
expect(payload.role).toBe(EMBED_DEMO_ROLE); expect(payload.role).toBe(EMBED_DEMO_ROLE);
}); });
it("默认登录类型为 password", async () => {
const token = await signEmbedDemoToken("CUST_002");
const payload = await verifyEmbedDemoToken(token);
expect(payload.loginType).toBe("password");
});
it("api_key 登录类型可往返", async () => {
const token = await signEmbedDemoToken("CUST_003", "api_key");
const payload = await verifyEmbedDemoToken(token);
expect(payload.sub).toBe("CUST_003");
expect(payload.loginType).toBe("api_key");
});
});
describe("embedDemoCookieOptions", () => {
it("非生产默认 SameSite=lax", async () => {
const { embedDemoCookieOptions } = await import("@/modules/embed-demo/auth");
const prev = process.env.EMBED_COOKIE_SAMESITE;
delete process.env.EMBED_COOKIE_SAMESITE;
const opts = embedDemoCookieOptions();
expect(opts.sameSite).toBe("lax");
expect(opts.httpOnly).toBe(true);
if (prev === undefined) delete process.env.EMBED_COOKIE_SAMESITE;
else process.env.EMBED_COOKIE_SAMESITE = prev;
});
}); });

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
applyMarkupToFlockLines,
buildFlockStoredQuotes,
parseFlockStoredQuotes,
} from "@/modules/flock/quote-storage";
import type { FlockQuoteLine } from "@/workers/rpa/flock/types";
const lines: FlockQuoteLine[] = [
{
tier: "flock_direct",
serviceLevel: "guaranteed",
rateOption: "fastest",
carrier: "Flock Freight",
label: "FlockDirect®",
totalUsd: 1000,
transitDays: "4",
transitDescription: "4 days",
},
{
tier: "standard",
serviceLevel: "standard",
rateOption: "lowest",
carrier: "Flock Freight",
label: "Standard",
totalUsd: 500,
transitDays: "3-4",
transitDescription: "3-4 days",
},
];
describe("flock quote storage", () => {
it("按百分比加价", () => {
const marked = applyMarkupToFlockLines(lines, {
type: "percent",
percent: 10,
fixedAmount: 0,
});
expect(marked[0]!.final_total_usd).toBe(1100);
expect(marked[1]!.final_total_usd).toBe(550);
});
it("序列化与解析", () => {
const stored = buildFlockStoredQuotes(
lines,
{ type: "percent", percent: 0, fixedAmount: 0 },
"FRG-TEST",
);
expect(stored.provider).toBe("flock");
expect(parseFlockStoredQuotes(stored)?.reference).toBe("FRG-TEST");
expect(parseFlockStoredQuotes({ provider: "priority1", lines: [] })).toBeNull();
});
});

@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import {
validateFlockQuoteInput,
defaultFlockPickupDate,
} from "@/modules/flock/validation";
import { ValidationError } from "@/modules/quote/types";
import { kgToLb, toAxelWholePounds } from "@/modules/quote/unit-converter";
function baseBody(overrides: Record<string, unknown> = {}) {
return {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
flock_input: {
pickup_date: defaultFlockPickupDate(),
pickup_zip: "90001",
delivery_zip: "75201",
pallet_count: 6,
total_weight: { value: 3500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
...overrides,
},
};
}
describe("validateFlockQuoteInput", () => {
it("接受合法英制入参", () => {
const parsed = validateFlockQuoteInput(baseBody());
expect(parsed.palletCount).toBe(6);
expect(parsed.totalWeightLb).toBe(3500);
expect(parsed.lengthIn).toBe(48);
});
it("500kg 换算为 1103lb小数进一", () => {
expect(toAxelWholePounds(kgToLb(500))).toBe(1103);
const parsed = validateFlockQuoteInput(
baseBody({ total_weight: { value: 500, unit: "kg" } }),
);
expect(parsed.totalWeightLb).toBe(1103);
});
it("0.5t 换算为 1103lb小数进一", () => {
const parsed = validateFlockQuoteInput(
baseBody({ total_weight: { value: 0.5, unit: "t" } }),
);
expect(parsed.totalWeightLb).toBe(1103);
});
it("拒绝 ton/tons 歧义单位", () => {
expect(() =>
validateFlockQuoteInput(
baseBody({ total_weight: { value: 0.5, unit: "ton" } }),
),
).toThrow(/歧义/);
});
it("1.2m 尺寸换算并向上取整", () => {
const parsed = validateFlockQuoteInput(
baseBody({
dimensions: { length: 1.2, width: 1, height: 1, unit: "m" },
}),
);
expect(parsed.lengthIn).toBe(48);
expect(parsed.widthIn).toBe(40);
expect(parsed.heightIn).toBe(40);
});
it("托盘数越界返回中文提示", () => {
expect(() =>
validateFlockQuoteInput(baseBody({ pallet_count: 3 })),
).toThrow(ValidationError);
expect(() =>
validateFlockQuoteInput(baseBody({ pallet_count: 3 })),
).toThrow(/4 到 20/);
expect(() =>
validateFlockQuoteInput(baseBody({ pallet_count: 21 })),
).toThrow(/4 到 20/);
});
it("总重超 45000lb 拒绝", () => {
expect(() =>
validateFlockQuoteInput(
baseBody({ total_weight: { value: 45001, unit: "lb" } }),
),
).toThrow(/45000/);
});
it("尺寸超限拒绝", () => {
expect(() =>
validateFlockQuoteInput(
baseBody({
dimensions: { length: 637, width: 40, height: 48, unit: "in" },
}),
),
).toThrow(/636/);
expect(() =>
validateFlockQuoteInput(
baseBody({
dimensions: { length: 48, width: 103, height: 48, unit: "in" },
}),
),
).toThrow(/102/);
expect(() =>
validateFlockQuoteInput(
baseBody({
dimensions: { length: 48, width: 40, height: 109, unit: "in" },
}),
),
).toThrow(/108/);
});
it("线性英尺超拖车拒绝(对应官网 Linear feet 报错)", () => {
expect(() =>
validateFlockQuoteInput(
baseBody({
pallet_count: 18,
dimensions: { length: 403, width: 102, height: 108, unit: "in" },
}),
),
).toThrow(/占用车长|挂车长度/);
});
it("缺少 ZIP 提示中文", () => {
expect(() =>
validateFlockQuoteInput(baseBody({ pickup_zip: "" })),
).toThrow(/提货邮编/);
});
it("提货日期早于今天拒绝", () => {
const d = new Date();
d.setDate(d.getDate() - 1);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const yyyy = d.getFullYear();
expect(() =>
validateFlockQuoteInput(
baseBody({ pickup_date: `${mm}/${dd}/${yyyy}` }),
),
).toThrow(/不能早于今天/);
});
it("提货日期为周末拒绝", () => {
const d = new Date();
d.setHours(12, 0, 0, 0);
const day = d.getDay();
const daysUntilSat = day === 6 ? 7 : (6 - day + 7) % 7 || 7;
d.setDate(d.getDate() + daysUntilSat);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const yyyy = d.getFullYear();
expect(() =>
validateFlockQuoteInput(
baseBody({ pickup_date: `${mm}/${dd}/${yyyy}` }),
),
).toThrow(/周六或周日/);
});
it("登录态调度字段透传", () => {
const parsed = validateFlockQuoteInput(
baseBody({
form_mode: "logged_in_quick",
pallet_count: 2,
total_weight: { value: 6000, unit: "lb" },
pickup_service: "during_window",
pickup_window_start_time: "8:00 am",
pickup_window_end_time: "5:00 pm",
delivery_service: "must_arrive_by",
delivery_must_arrive_by_date: "2026-08-01",
call_for_delivery_appointment: true,
pickup_location_type: "residential",
delivery_location_type: "limited_farm",
}),
);
expect(parsed.formMode).toBe("logged_in_quick");
expect(parsed.pickupService).toBe("during_window");
expect(parsed.pickupWindowStartTime).toBe("8:00 am");
expect(parsed.deliveryService).toBe("must_arrive_by");
expect(parsed.deliveryMustArriveByDate).toBe("2026-08-01");
expect(parsed.callForDeliveryAppointment).toBe(true);
expect(parsed.pickupLocationType).toBe("residential");
});
it("接受可选 registration 块", () => {
const parsed = validateFlockQuoteInput(
baseBody({
registration: {
email: "test@qq.com",
phone: "7536407420",
first_name: "Li",
},
}),
);
expect(parsed.registration?.email).toBe("test@qq.com");
expect(parsed.registration?.phone).toBe("7536407420");
expect(parsed.registration?.firstName).toBe("Li");
});
it("无效邮箱拒绝", () => {
expect(() =>
validateFlockQuoteInput(
baseBody({ registration: { email: "not-an-email" } }),
),
).toThrow(/工作邮箱/);
});
it("无效电话拒绝", () => {
expect(() =>
validateFlockQuoteInput(
baseBody({ registration: { phone: "123" } }),
),
).toThrow(/10 位/);
});
});

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { applyMarkupToPriority1Lines } from "@/modules/priority1/quote-storage";
import type { Priority1QuoteLine } from "@/workers/rpa/priority1/quote-extract";
const sampleLine: Priority1QuoteLine = {
rank: 1,
carrier: "XPO",
carrierCode: "XPOL",
totalUsd: 500,
transitDays: 5,
deliveryDate: null,
expirationDate: null,
serviceLevel: "Standard",
quoteId: null,
caboUrl: null,
source: "visible-dom",
};
describe("applyMarkupToPriority1Lines", () => {
it("applies percent markup per customer rule", () => {
const marked = applyMarkupToPriority1Lines([sampleLine], {
type: "percent",
percent: 10,
fixedAmount: 0,
});
expect(marked[0]!.markup_amount).toBe(50);
expect(marked[0]!.final_total_usd).toBe(550);
});
it("applies fixed markup per line", () => {
const marked = applyMarkupToPriority1Lines([sampleLine], {
type: "fixed",
percent: 0,
fixedAmount: 25,
});
expect(marked[0]!.markup_amount).toBe(25);
expect(marked[0]!.final_total_usd).toBe(525);
});
});

@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { RpaError } from "@/modules/rpa/errors"; import { RpaError } from "@/modules/rpa/errors";
import { RpaDataInvalidError } from "@/modules/quote/types";
import { import {
formatQuoteErrorMessage, formatQuoteErrorMessage,
isChineseUserFacingMessage,
resolveQuoteFailureCode, resolveQuoteFailureCode,
resolveQuoteFailureMessage, resolveQuoteFailureMessage,
toChineseUserFacingMessage,
translateKnownEnglishPhrases,
} from "@/modules/quote/quote-error-messages"; } from "@/modules/quote/quote-error-messages";
describe("quote-error-messages", () => { describe("quote-error-messages", () => {
@ -15,21 +19,107 @@ describe("quote-error-messages", () => {
).toBe("ADDRESS_SUGGESTION_NOT_FOUND"); ).toBe("ADDRESS_SUGGESTION_NOT_FOUND");
}); });
it("RpaError 使用原始 message 作为用户文案", () => { it("技术英文原文不直出,回退中文默认", () => {
const msg = resolveQuoteFailureMessage( const msg = resolveQuoteFailureMessage(
new RpaError("STRUCT_CHANGE", "login 页且无 Mothership 凭据"), new RpaError(
"STRUCT_CHANGE",
"locator.waitFor: Timeout 15000ms exceeded",
),
"QUOTE_ENTRY_UNAVAILABLE", "QUOTE_ENTRY_UNAVAILABLE",
); );
expect(msg).toBe("login 页且无 Mothership 凭据"); expect(msg).toMatch(/自动化操作超时|报价入口/);
expect(msg).not.toMatch(/locator\.waitFor/i);
}); });
it("formatQuoteErrorMessage 优先 storedMessage", () => { 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).not.toBe("暂时无法获取报价,请稍后重试");
});
it("RpaDataInvalidError 映射为 RPA_DATA_INVALID 并保留中文原因", () => {
expect(resolveQuoteFailureCode(new RpaDataInvalidError("缺少标准档报价"))).toBe(
"RPA_DATA_INVALID",
);
expect( expect(
formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "自定义原因"), resolveQuoteFailureMessage(
).toBe("自定义原因"); new RpaDataInvalidError("缺少标准档报价"),
"RPA_DATA_INVALID",
),
).toMatch(/标准档|报价/);
});
it("含 rate-card 的中文业务句仍可展示", () => {
expect(
isChineseUserFacingMessage("登录态 rate-card 未能解析出承运商报价"),
).toBe(true);
expect(
toChineseUserFacingMessage(
"登录态 rate-card 未能解析出承运商报价",
"RPA_DATA_INVALID",
),
).toContain("承运商报价");
});
it("合格中文原文保留", () => {
expect(
formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "该线路暂无可用运力,请换地址"),
).toBe("该线路暂无可用运力,请换地址");
}); });
it("formatQuoteErrorMessage 按 code 回退", () => { it("formatQuoteErrorMessage 按 code 回退", () => {
expect(formatQuoteErrorMessage("QUOTE_TIMEOUT")).toContain("420 秒"); expect(formatQuoteErrorMessage("QUOTE_TIMEOUT")).toContain("420 秒");
}); });
it("PROVIDER_LOGIN_FAILED 统一用户文案", () => {
expect(
resolveQuoteFailureCode(
new RpaError("PROVIDER_LOGIN_FAILED", "内部细节"),
),
).toBe("PROVIDER_LOGIN_FAILED");
expect(
resolveQuoteFailureMessage(
new RpaError("SESSION_EXPIRED", "仍停在登录页,账密可能错误"),
"SESSION_EXPIRED",
),
).toContain("即时保存更新");
expect(formatQuoteErrorMessage("PROVIDER_LOGIN_FAILED")).toContain(
"账号或密码可能不正确",
);
expect(
formatQuoteErrorMessage(
"RPA_DATA_INVALID",
"FLOCK_LOGIN_FAILED账号或密码错误",
),
).toContain("即时保存更新");
});
it("英文官网文案译为中文", () => {
expect(translateKnownEnglishPhrases("No rates found")).toBe(
"未找到可用报价",
);
expect(
toChineseUserFacingMessage("No carriers available for this lane", "CARRIER_NO_CAPACITY"),
).toBe("该线路暂无可用运力");
});
it("附加服务 appointment 超时译为可读中文,不再吞成通用文案", () => {
const msg = toChineseUserFacingMessage(
"locator.waitFor: Timeout 8000ms exceeded.\nCall log:\n - waiting for getByTestId('address-book-accessorials-option-appointment').first() to be visible",
"QUOTE_UNAVAILABLE",
);
expect(msg).toContain("自动化操作超时");
expect(msg).toMatch(/预约|附加服务/);
expect(msg).not.toBe("暂时无法获取报价,请稍后重试");
});
it("附加服务不可用业务文案原样保留", () => {
const raw =
"派送附加服务「appointment」在官网下拉中不可用或未展开可见选项liftgate。请取消该附加服务后重试或确认地址类型是否支持。";
expect(toChineseUserFacingMessage(raw, "CARRIER_NO_CAPACITY")).toBe(raw);
});
}); });

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { cmToIn, kgToLb, toAxelWholeInches, toAxelWholePounds } from "@/modules/quote/unit-converter"; import { cmToIn, inToCm, kgToLb, lbToKg, mToIn, tToLb, toAxelWholeInches, toAxelWholePounds, weightValueToLb, dimValueToIn, normalizeWeightUnit, normalizeDimUnit, weightUnitAmbiguousMessage } from "@/modules/quote/unit-converter";
describe("unit-converter", () => { describe("unit-converter", () => {
it("227kg → 500.45lbROUND_HALF_UPJS 浮点 227×2.20462", () => { it("227kg → 500.45lbROUND_HALF_UPJS 浮点 227×2.20462", () => {
@ -10,15 +10,61 @@ describe("unit-converter", () => {
expect(cmToIn(122)).toBe(48.03); expect(cmToIn(122)).toBe(48.03);
}); });
it("1.2m → 47.24in", () => {
expect(mToIn(1.2)).toBe(47.24);
});
it("0.5t → 1102.31lb", () => {
expect(tToLb(0.5)).toBe(1102.31);
});
it("120cm 转 axel 尺寸向上取整为 48in", () => { it("120cm 转 axel 尺寸向上取整为 48in", () => {
expect(toAxelWholeInches(cmToIn(120))).toBe(48); expect(toAxelWholeInches(cmToIn(120))).toBe(48);
}); });
it("1.2m 转 axel 尺寸向上取整为 48in", () => {
expect(toAxelWholeInches(mToIn(1.2))).toBe(48);
});
it("500kg 转 axel 重量向上取整为 1103lb", () => { it("500kg 转 axel 重量向上取整为 1103lb", () => {
expect(toAxelWholePounds(kgToLb(500))).toBe(1103); expect(toAxelWholePounds(kgToLb(500))).toBe(1103);
}); });
it("0.5t 转 axel 重量向上取整为 1103lb", () => {
expect(toAxelWholePounds(tToLb(0.5))).toBe(1103);
});
it("500lb 不变", () => { it("500lb 不变", () => {
expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0); expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0);
}); });
it("lbToKg / inToCm 互逆", () => {
expect(lbToKg(500)).toBe(226.8);
expect(inToCm(48)).toBe(121.92);
expect(kgToLb(lbToKg(500))).toBeCloseTo(500, 1);
expect(cmToIn(inToCm(48))).toBeCloseTo(48, 1);
});
it("normalizeWeightUnit 支持别名,拒绝 ton/tons 歧义", () => {
expect(normalizeWeightUnit("LBS")).toBe("lb");
expect(normalizeWeightUnit("tonne")).toBe("t");
expect(normalizeWeightUnit("tonnes")).toBe("t");
expect(normalizeWeightUnit("t")).toBe("t");
expect(normalizeWeightUnit("ton")).toBeNull();
expect(normalizeWeightUnit("tons")).toBeNull();
expect(normalizeWeightUnit("stone")).toBeNull();
expect(weightUnitAmbiguousMessage("ton")).toMatch(/歧义/);
});
it("normalizeDimUnit 支持别名", () => {
expect(normalizeDimUnit("INCH")).toBe("in");
expect(normalizeDimUnit("meter")).toBe("m");
expect(normalizeDimUnit("CM")).toBe("cm");
});
it("weightValueToLb / dimValueToIn", () => {
expect(weightValueToLb(500, "kg")).toBe(1102.31);
expect(weightValueToLb(0.5, "t")).toBe(1102.31);
expect(dimValueToIn(1.2, "m")).toBe(47.24);
});
}); });

@ -51,13 +51,13 @@ describe("validateQuoteInput", () => {
expect(() => validateQuoteInput(rest)).toThrow(ValidationError); expect(() => validateQuoteInput(rest)).toThrow(ValidationError);
}); });
it("TC-202weight=0 → 超范围", () => { it("TC-202weight=0 → 无效", () => {
expect(() => expect(() =>
validateQuoteInput({ validateQuoteInput({
...VALID_BODY, ...VALID_BODY,
weight: { value: 0, unit: "lb" }, weight: { value: 0, unit: "lb" },
}), }),
).toThrow(/超出 Mothership 允许范围/); ).toThrow(/有效的重量|超出 Mothership/);
}); });
it("空邮编通过MotherShip 不要求填写邮编)", () => { it("空邮编通过MotherShip 不要求填写邮编)", () => {
@ -162,14 +162,60 @@ describe("validateQuoteInput", () => {
).toThrow(/无效,请检查/); ).toThrow(/无效,请检查/);
}); });
it("kg/cm 换算", () => { it("kg/cm 换算并向上取整", () => {
const result = validateQuoteInput({ const result = validateQuoteInput({
...VALID_BODY, ...VALID_BODY,
weight: { value: 227, unit: "kg" }, weight: { value: 227, unit: "kg" },
dimensions: { length: 120, width: 100, height: 100, unit: "cm" }, dimensions: { length: 120, width: 100, height: 100, unit: "cm" },
}); });
expect(result.weightLb).toBe(500.45); expect(result.weightLb).toBe(501);
expect(result.dimLIn).toBe(47.24); expect(result.dimLIn).toBe(48);
expect(result.dimWIn).toBe(40);
expect(result.dimHIn).toBe(40);
});
it("500 kg 换算为 1103 lb", () => {
const result = validateQuoteInput({
...VALID_BODY,
weight: { value: 500, unit: "kg" },
});
expect(result.weightLb).toBe(1103);
});
it("0.5 t 换算为 1103 lb", () => {
const result = validateQuoteInput({
...VALID_BODY,
weight: { value: 0.5, unit: "t" },
});
expect(result.weightLb).toBe(1103);
});
it("1.2 m 尺寸换算并向上取整", () => {
const result = validateQuoteInput({
...VALID_BODY,
dimensions: { length: 1.2, width: 1, height: 1, unit: "m" },
});
expect(result.dimLIn).toBe(48);
expect(result.dimWIn).toBe(40);
expect(result.dimHIn).toBe(40);
});
it("拒绝 ton/tons 歧义单位", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
weight: { value: 0.5, unit: "ton" },
}),
).toThrow(/歧义/);
});
it("拒绝 Infinity 重量", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
weight: { value: Number.POSITIVE_INFINITY, unit: "lb" },
}),
).toThrow(ValidationError);
}); });
}); });

@ -29,11 +29,11 @@ describe("error-mapper", () => {
expect(mapUrlToErrorCode("https://example.com/login")).toBe(null); expect(mapUrlToErrorCode("https://example.com/login")).toBe(null);
}); });
it("login URL 有凭据 → SESSION_EXPIRED", () => { it("login URL 有凭据 → PROVIDER_LOGIN_FAILED", () => {
process.env.MOTHERSHIP_EMAIL = "test@example.com"; process.env.MOTHERSHIP_EMAIL = "test@example.com";
process.env.MOTHERSHIP_PASSWORD = "secret"; process.env.MOTHERSHIP_PASSWORD = "secret";
expect(mapUrlToErrorCode("https://example.com/login")).toBe( expect(mapUrlToErrorCode("https://example.com/login")).toBe(
"SESSION_EXPIRED", "PROVIDER_LOGIN_FAILED",
); );
}); });
}); });

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
isProviderLoginFailureMessage,
PROVIDER_LOGIN_FAILED_USER_MESSAGE,
} from "@/modules/rpa/provider-login-message";
describe("provider-login-message", () => {
it("识别登录失败原文", () => {
expect(isProviderLoginFailureMessage("FLOCK_LOGIN_FAILED账号或密码错误")).toBe(
true,
);
expect(isProviderLoginFailureMessage("仍停在登录页")).toBe(true);
expect(isProviderLoginFailureMessage("地址联想失败")).toBe(false);
});
it("用户文案要求确认即时更新", () => {
expect(PROVIDER_LOGIN_FAILED_USER_MESSAGE).toContain("即时保存更新");
expect(PROVIDER_LOGIN_FAILED_USER_MESSAGE).toContain("账号或密码");
});
});

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
buildFlockBatchCases,
classifyFlockFailure,
randomCargo,
} from "@/scripts/batch-flock-stress";
import {
FLOCK_LIMITS,
fitsFlockTrailerCargo,
} from "@/lib/constants/flock-limits";
function mulberry32(seed: number): () => number {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
describe("buildFlockBatchCases", () => {
it("生成样例且 ZIP/货物均在 Flock 合法范围(含线性英尺)", () => {
const cases = buildFlockBatchCases(20, 42);
expect(cases).toHaveLength(20);
const zips = new Set([
"02109",
"94102",
"60611",
"78701",
"21230",
"19106",
"15222",
"30326",
"90212",
"90248",
]);
for (const c of cases) {
expect(zips.has(c.pickupZip)).toBe(true);
expect(zips.has(c.deliveryZip)).toBe(true);
expect(c.pickupZip).not.toBe(c.deliveryZip);
expect(c.palletCount).toBeGreaterThanOrEqual(FLOCK_LIMITS.palletCount.min);
expect(c.palletCount).toBeLessThanOrEqual(FLOCK_LIMITS.palletCount.max);
expect(c.totalWeightLb).toBeLessThanOrEqual(FLOCK_LIMITS.totalWeightLbMax);
expect(c.lengthIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.lengthMax);
expect(c.widthIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.widthMax);
expect(c.heightIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.heightMax);
expect(
fitsFlockTrailerCargo({
palletCount: c.palletCount,
lengthIn: c.lengthIn,
widthIn: c.widthIn,
}),
).toBe(true);
}
});
});
describe("randomCargo", () => {
it("不生成触发 Linear feet 超限的组合", () => {
const rng = mulberry32(99);
for (let i = 0; i < 50; i += 1) {
const c = randomCargo(rng);
expect(fitsFlockTrailerCargo(c)).toBe(true);
}
});
});
describe("classifyFlockFailure", () => {
it("识别限流", () => {
expect(classifyFlockFailure("Too many quote requests, try again later")).toBe(
"rate_limit",
);
});
});

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { FLOCK_LOGGED_IN_X20_CASES } from "@/scripts/diag-flock-logged-in-x20";
import { fitsFlockTrailerCargo } from "@/lib/constants/flock-limits";
describe("FLOCK_LOGGED_IN_X20_CASES", () => {
it("恰好 20 组且 ZIP/货物合法", () => {
expect(FLOCK_LOGGED_IN_X20_CASES).toHaveLength(20);
const names = new Set<string>();
for (const c of FLOCK_LOGGED_IN_X20_CASES) {
expect(c.pickupZip).toMatch(/^\d{5}$/);
expect(c.deliveryZip).toMatch(/^\d{5}$/);
expect(c.pickupZip).not.toBe(c.deliveryZip);
expect(c.palletCount).toBeGreaterThanOrEqual(1);
expect(c.totalWeightLb).toBeLessThanOrEqual(45_000);
expect(
fitsFlockTrailerCargo({
palletCount: c.palletCount,
lengthIn: c.lengthIn,
widthIn: c.widthIn,
}),
).toBe(true);
expect(names.has(c.name)).toBe(false);
names.add(c.name);
}
});
});

@ -0,0 +1,118 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
DEFAULT_FLOCK_QUOTE_URL,
formatFlockRecordingChecklist,
} from "@/workers/rpa/flock/demo-input";
import {
formatFlockLoggedInRecordingChecklist,
parseFlockRecordArgs,
resolveFlockOutputBasename,
resolveFlockStoragePath,
} from "@/scripts/record-flock";
const SH_PATH = join(process.cwd(), "scripts", "record-flock.sh");
const TS_PATH = join(process.cwd(), "scripts", "record-flock.ts");
const VISUAL_PATH = join(process.cwd(), "scripts", "visual-flock-human-assist.ts");
const SOP_PATH = join(process.cwd(), "docs", "rpa-flock-logged-in-sop.md");
describe("record-flock scripts", () => {
it("record-flock.sh 存在且包含 codegen + logged-in", () => {
expect(existsSync(SH_PATH)).toBe(true);
const content = readFileSync(SH_PATH, "utf8");
expect(content).toContain("codegen");
expect(content).toContain("get-a-quote");
expect(content).toContain("--logged-in");
expect(content).toContain("flock-logged-in");
});
it("record-flock.ts 存在且包含 headed 与 canonical 路径", () => {
expect(existsSync(TS_PATH)).toBe(true);
const content = readFileSync(TS_PATH, "utf8");
expect(content).toContain("codegen");
expect(content).toContain("FLOCK_CANONICAL_PATHS");
expect(content).toContain("DEFAULT_FLOCK_QUOTE_URL");
expect(content).toContain("--logged-in");
expect(content).toContain("flock-logged-in-");
expect(content).toContain("flock-logged-in-storage.json");
});
it("登录后 SOP 存在", () => {
expect(existsSync(SOP_PATH)).toBe(true);
const content = readFileSync(SOP_PATH, "utf8");
expect(content).toContain("flock-logged-in-");
expect(content).toContain("Let's build a quote");
});
it("visual-flock-human-assist 含分步 pause", () => {
expect(existsSync(VISUAL_PATH)).toBe(true);
const content = readFileSync(VISUAL_PATH, "utf8");
expect(content).toContain("page.pause");
expect(content).toContain("flock-human-");
});
it("录制清单包含关键步骤", () => {
const text = formatFlockRecordingChecklist({
pickupDateDisplay: "07/14/2026",
pickupZip: "90001",
deliveryZip: "75201",
palletCount: 6,
totalWeightLb: 3500,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
});
expect(text).toContain("Next");
expect(text).toContain("Create your free account");
expect(text).toContain("Complete your order");
});
it("登录后清单提示手动粘贴账密与 Generate my quote", () => {
const text = formatFlockLoggedInRecordingChecklist();
expect(text).toContain("FLOCK_LOGIN_EMAIL");
expect(text).toContain("Generate my quote");
expect(text).toContain("90001");
});
});
describe("parseFlockRecordArgs / storage 隔离", () => {
it("默认匿名;--logged-in / --login 切换模式", () => {
expect(parseFlockRecordArgs(["node", "record-flock.ts"]).mode).toBe(
"anonymous",
);
expect(
parseFlockRecordArgs(["node", "record-flock.ts", "--logged-in"]).mode,
).toBe("logged-in");
expect(
parseFlockRecordArgs(["node", "record-flock.ts", "--login"]).mode,
).toBe("logged-in");
});
it("解析起始 URL 与 help / list", () => {
const withUrl = parseFlockRecordArgs([
"node",
"x",
"--logged-in",
"https://app.flockfreight.com/login",
]);
expect(withUrl.mode).toBe("logged-in");
expect(withUrl.startUrl).toBe("https://app.flockfreight.com/login");
expect(parseFlockRecordArgs(["node", "x", "-h"]).help).toBe(true);
expect(parseFlockRecordArgs(["node", "x", "list"]).list).toBe(true);
});
it("登录态产物前缀与 storage 路径隔离", () => {
expect(resolveFlockOutputBasename("logged-in", "20260715-120000")).toBe(
"flock-logged-in-20260715-120000.js",
);
expect(resolveFlockOutputBasename("anonymous", "20260715-120000")).toBe(
"flock-manual-20260715-120000.js",
);
expect(resolveFlockStoragePath("logged-in")).toContain(
"flock-logged-in-storage.json",
);
expect(resolveFlockStoragePath("anonymous")).toContain("flock-storage.json");
expect(DEFAULT_FLOCK_QUOTE_URL).toContain("get-a-quote");
});
});

@ -1,6 +1,13 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import {
parseRecordArgs,
resolveOutputBasename,
resolveStoragePath,
shouldEnforceGd15,
} from "@/scripts/record-mothership";
import { isForbiddenQuoteUrl } from "@/lib/rpa/env";
const SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.sh"); const SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.sh");
@ -43,4 +50,66 @@ describe("record-mothership.ts (Windows 跨平台)", () => {
expect(content).toContain("mothership-storage.json"); expect(content).toContain("mothership-storage.json");
expect(content).toContain("--save-storage"); expect(content).toContain("--save-storage");
}); });
it("支持 --logged-in 独立产物前缀与 storage", () => {
expect(content).toContain("--logged-in");
expect(content).toContain("mothership-logged-in-");
expect(content).toContain("mothership-logged-in-storage.json");
});
});
describe("record-mothership parseRecordArgs / GD-15 分模式", () => {
it("默认匿名;--logged-in / --login 切换模式", () => {
expect(parseRecordArgs(["node", "record-mothership.ts"]).mode).toBe(
"anonymous",
);
expect(
parseRecordArgs(["node", "record-mothership.ts", "--logged-in"]).mode,
).toBe("logged-in");
expect(
parseRecordArgs(["node", "record-mothership.ts", "--login"]).mode,
).toBe("logged-in");
});
it("解析起始 URL 与 help", () => {
const withUrl = parseRecordArgs([
"node",
"x",
"--logged-in",
"https://dashboard.mothership.com/",
]);
expect(withUrl.mode).toBe("logged-in");
expect(withUrl.startUrl).toBe("https://dashboard.mothership.com/");
expect(parseRecordArgs(["node", "x", "--help"]).help).toBe(true);
});
it("登录后产物前缀与 storage 路径隔离", () => {
expect(resolveOutputBasename("logged-in", "20260715-120000")).toBe(
"mothership-logged-in-20260715-120000.js",
);
expect(resolveOutputBasename("anonymous", "20260715-120000")).toBe(
"mothership-20260715-120000.js",
);
expect(resolveStoragePath("logged-in").replace(/\\/g, "/")).toMatch(
/mothership-logged-in-storage\.json$/,
);
expect(resolveStoragePath("anonymous").replace(/\\/g, "/")).toMatch(
/mothership-storage\.json$/,
);
});
it("仅匿名模式强制 GD-15登录后允许 dashboard URL", () => {
expect(shouldEnforceGd15("anonymous")).toBe(true);
expect(shouldEnforceGd15("logged-in")).toBe(false);
const dash = "https://dashboard.mothership.com/quotes/new";
expect(isForbiddenQuoteUrl(dash)).toBe(true);
// 登录模式不执行 GD-15 exit逻辑门为 shouldEnforceGd15 && isForbidden
expect(shouldEnforceGd15("logged-in") && isForbiddenQuoteUrl(dash)).toBe(
false,
);
expect(shouldEnforceGd15("anonymous") && isForbiddenQuoteUrl(dash)).toBe(
true,
);
});
}); });

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { evaluateMotherShipAddressChipLines } from "@/workers/rpa/address-chip-visual";
describe("evaluateMotherShipAddressChipLines", () => {
it("截图态:门牌行 + City, ST 两行 → 已确认", () => {
const lines = [
"Where to?",
"1234 Warehouse Street",
"Los Angeles, CA",
"Deliver to",
"Freight details",
];
const probe = evaluateMotherShipAddressChipLines(
lines,
"1234",
"Los Angeles",
);
expect(probe.committed).toBe(true);
expect(probe.streetLine).toBe("1234 Warehouse Street");
expect(probe.cityLine).toBe("Los Angeles, CA");
});
it("打字态:整段查询在同一行 → 未确认", () => {
const lines = [
"1234 Warehouse Street, Los Angeles, California",
"Deliver to",
];
expect(
evaluateMotherShipAddressChipLines(lines, "1234", "Los Angeles")
.committed,
).toBe(false);
});
it("仅 City, ST 无门牌行 → 未确认", () => {
const lines = ["Los Angeles, CA", "Deliver to"];
expect(
evaluateMotherShipAddressChipLines(lines, "1234", "Los Angeles")
.committed,
).toBe(false);
});
});

@ -3,6 +3,7 @@ import type { QuoteRequest } from "@/modules/providers/quote-provider";
import { import {
evaluateAddressesVisuallyComplete, evaluateAddressesVisuallyComplete,
widgetContainsConfirmedAddress, widgetContainsConfirmedAddress,
widgetShowsDeliverySectionCue,
} from "@/workers/rpa/address-commit"; } from "@/workers/rpa/address-commit";
function basePickup(overrides?: Partial<QuoteRequest["pickup"]>) { function basePickup(overrides?: Partial<QuoteRequest["pickup"]>) {
@ -68,6 +69,28 @@ describe("widgetContainsConfirmedAddress", () => {
"1234 Warehouse Street Los Angeles, CA Distribution Way Farmers Branch, TX"; "1234 Warehouse Street Los Angeles, CA Distribution Way Farmers Branch, TX";
expect(widgetContainsConfirmedAddress(widget, address)).toBe(true); expect(widgetContainsConfirmedAddress(widget, address)).toBe(true);
}); });
it("chip 折叠仅 City, ST 无门牌行不算 widgetContainsConfirmedAddress", () => {
const address = basePickup({
street: "1234 Warehouse Street",
city: "Los Angeles",
state: "California",
mothershipDisplayLabel:
"1234 Warehouse Street, Los Angeles, California, USA",
});
const widget =
"Los Angeles, CA Where to? Pick up from Deliver to Freight details";
expect(widgetContainsConfirmedAddress(widget, address)).toBe(false);
});
});
describe("widgetShowsDeliverySectionCue", () => {
it("识别 Where to? 派送标签", () => {
expect(
widgetShowsDeliverySectionCue("Los Angeles, CA Where to? Pick up from"),
).toBe(true);
expect(widgetShowsDeliverySectionCue("Deliver to Search")).toBe(true);
});
}); });
describe("evaluateAddressesVisuallyComplete", () => { describe("evaluateAddressesVisuallyComplete", () => {

@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
function mockOverlayPage(overlays: Array<{ visible: boolean; hasCargo: boolean }>): Page {
const overlayLocs = overlays.map((o) => ({
isVisible: vi.fn(async () => o.visible),
locator: vi.fn(() => ({
first: vi.fn(() => ({
isVisible: vi.fn(async () => o.hasCargo),
})),
})),
}));
return {
locator: vi.fn(() => ({
count: vi.fn(async () => overlayLocs.length),
nth: vi.fn((i: number) => overlayLocs[i]),
})),
keyboard: { press: vi.fn(async () => undefined) },
waitForTimeout: vi.fn(async () => undefined),
} as unknown as Page;
}
describe("isCargoOverlayPopoverOpen", () => {
it("无 dialog 货物弹层时返回 false", async () => {
const { isCargoOverlayPopoverOpen } = await import("@/workers/rpa/cargo-lock");
const page = mockOverlayPage([]);
expect(await isCargoOverlayPopoverOpen(page)).toBe(false);
});
it("dialog 含 pallet 输入时返回 true", async () => {
const { isCargoOverlayPopoverOpen } = await import("@/workers/rpa/cargo-lock");
const page = mockOverlayPage([{ visible: true, hasCargo: true }]);
expect(await isCargoOverlayPopoverOpen(page)).toBe(true);
});
});
describe("dismissCargoPopoverOnly", () => {
it("内联货物(无 overlay不按 Escape", async () => {
const { dismissCargoPopoverOnly } = await import("@/workers/rpa/cargo-lock");
const page = mockOverlayPage([]);
await dismissCargoPopoverOnly(page, "#widget");
expect(page.keyboard.press).not.toHaveBeenCalled();
});
});

@ -0,0 +1,72 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { Page } from "playwright";
vi.mock("@/lib/rpa/env", () => ({
isRpaAddressRushMode: vi.fn(() => false),
isRpaBlindAddressFlow: vi.fn(() => false),
}));
vi.mock("@/workers/rpa/cargo-lock", () => ({
dismissCargoPopoverOnly: vi.fn(async () => undefined),
}));
vi.mock("@/workers/rpa/page-prep", () => ({
scrollQuoteWidgetIntoView: vi.fn(async () => undefined),
}));
vi.mock("@/lib/rpa/selector-specs", () => ({
clickFirstVisibleFromSpecs: vi.fn(async () => false),
getSelectorSpecs: vi.fn(() => []),
}));
function mockPage(innerText: string, buttonVisible = true): Page {
const click = vi.fn(async () => undefined);
const button = {
isVisible: vi.fn(async () => buttonVisible),
isDisabled: vi.fn(async () => false),
scrollIntoViewIfNeeded: vi.fn(async () => undefined),
click,
};
const roleButton = {
first: vi.fn(() => button),
};
const widget = {
innerText: vi.fn(async () => innerText),
getByRole: vi.fn(() => roleButton),
locator: vi.fn(() => ({
filter: vi.fn(() => ({
first: vi.fn(() => button),
})),
first: vi.fn(() => button),
})),
};
return {
locator: vi.fn(() => widget),
} as unknown as Page;
}
describe("clickWidgetQuoteSubmitButton", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("表单未就绪时非 blind 模式不点击", async () => {
const { clickWidgetQuoteSubmitButton } = await import(
"@/workers/rpa/quote-capture/click-widget-quote-submit"
);
const page = mockPage("Freight details only");
const clicked = await clickWidgetQuoteSubmitButton(page);
expect(clicked).toBe(false);
});
it("blind 模式表单检测失败仍尝试点击", async () => {
const env = await import("@/lib/rpa/env");
vi.mocked(env.isRpaBlindAddressFlow).mockReturnValue(true);
const { clickWidgetQuoteSubmitButton } = await import(
"@/workers/rpa/quote-capture/click-widget-quote-submit"
);
const page = mockPage("Freight details only");
const clicked = await clickWidgetQuoteSubmitButton(page);
expect(clicked).toBe(true);
});
});

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
describe("flock login two-step", () => {
it("login.ts 含两步登录Email → Next → Password", () => {
const src = fs.readFileSync(
path.join(process.cwd(), "workers/rpa/flock/login.ts"),
"utf8",
);
expect(src).toContain("Email Address");
expect(src).toContain("Next");
expect(src).toContain("Password");
expect(src).toContain("勿先等 password");
});
it("脱敏基线录制为两步登录", () => {
const src = fs.readFileSync(
path.join(
process.cwd(),
"scripts/fixtures/flock-logged-in-quote-baseline.js",
),
"utf8",
);
expect(src).toContain('name: "Email Address"');
expect(src).toContain('name: "Next"');
expect(src).toContain('name: "Password"');
});
});

@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FlockRunQuoteResult } from "@/workers/rpa/flock/types";
const directMock = vi.fn<() => Promise<FlockRunQuoteResult>>();
const visualMock = vi.fn<() => Promise<FlockRunQuoteResult>>();
const customerLoginMock = vi.fn();
const hasCredMock = vi.fn();
vi.mock("@/lib/flock/direct-quote", () => ({
fetchFlockQuotesViaDirect: () => directMock(),
}));
vi.mock("@/workers/rpa/flock/visual-chain", () => ({
runFlockVisualChain: () => visualMock(),
}));
vi.mock("@/workers/rpa/flock/worker-session", () => ({
runFlockQuoteWithWorkerSession: () => visualMock(),
shouldRotateFlockSession: () => false,
}));
vi.mock("@/lib/flock/exclusive-lock", () => ({
withFlockExclusiveLock: async (
_owner: string,
fn: () => Promise<FlockRunQuoteResult>,
) => fn(),
}));
vi.mock("@/modules/customer/provider-credentials", () => ({
getCustomerProviderLogin: (...args: unknown[]) => customerLoginMock(...args),
hasCustomerProviderCredential: (...args: unknown[]) => hasCredMock(...args),
}));
describe("runFlockQuoteRpa customer login forces DOM", () => {
beforeEach(() => {
directMock.mockReset();
visualMock.mockReset();
customerLoginMock.mockReset();
hasCredMock.mockReset();
hasCredMock.mockResolvedValue(false);
process.env.RPA_MOCK_MODE = "false";
process.env.FLOCK_QUOTE_MODE = "direct_then_dom";
delete process.env.FLOCK_LOGIN_EMAIL;
delete process.env.FLOCK_LOGIN_PASSWORD;
process.env.FLOCK_WORKER_REUSE_SESSION = "false";
});
const input = {
pickupZip: "94102",
deliveryZip: "90212",
pickupDate: "07/20/2026",
palletCount: 4,
totalWeightLb: 2800,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
};
it("有客户账密时跳过 Direct走 DOM", async () => {
customerLoginMock.mockResolvedValue({
email: "cust@example.com",
password: "secret",
});
visualMock.mockResolvedValue({
ok: true,
quotes: [{ tier: "standard", totalUsd: 100 } as FlockRunQuoteResult["quotes"][0]],
});
const { runFlockQuoteRpa } = await import("@/workers/rpa/flock/run-quote");
const result = await runFlockQuoteRpa(input, { customerId: "CUST_001" });
expect(result.ok).toBe(true);
expect(directMock).not.toHaveBeenCalled();
expect(visualMock).toHaveBeenCalled();
expect(customerLoginMock).toHaveBeenCalledWith("CUST_001", "flock");
});
it("库中已配账密但解密失败时不回落 Direct", async () => {
customerLoginMock.mockResolvedValue(null);
hasCredMock.mockResolvedValue(true);
const { runFlockQuoteRpa } = await import("@/workers/rpa/flock/run-quote");
const result = await runFlockQuoteRpa(input, { customerId: "CUST_001" });
expect(result.ok).toBe(false);
expect(result.errorMessage).toMatch(/解密失败|禁止改走 Direct/i);
expect(directMock).not.toHaveBeenCalled();
expect(visualMock).not.toHaveBeenCalled();
});
it("无客户账密时走 Direct", async () => {
customerLoginMock.mockResolvedValue(null);
hasCredMock.mockResolvedValue(false);
directMock.mockResolvedValue({
ok: true,
quotes: [{ tier: "standard", totalUsd: 200 } as FlockRunQuoteResult["quotes"][0]],
});
const { runFlockQuoteRpa } = await import("@/workers/rpa/flock/run-quote");
const result = await runFlockQuoteRpa(input, { customerId: "CUST_001" });
expect(result.ok).toBe(true);
expect(directMock).toHaveBeenCalled();
});
});

@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import {
isFlockLoggedInQuoteForm,
selectFlockComboboxOption,
} from "@/workers/rpa/flock/logged-in-form";
function mockLocator(opts: {
count?: number;
visible?: boolean;
click?: () => Promise<void>;
}) {
const click = opts.click ?? (async () => undefined);
return {
first: () => mockLocator(opts),
count: async () => opts.count ?? 0,
isVisible: async () => opts.visible ?? false,
click,
waitFor: async () => undefined,
fill: async () => undefined,
getAttribute: async () => null,
scrollIntoViewIfNeeded: async () => undefined,
page: () => ({ isClosed: () => false }),
};
}
describe("flock logged-in form", () => {
it("识别 Let's build a quote 为登录后表单", async () => {
const heading = mockLocator({ count: 1, visible: true });
const page = {
getByTestId: () => mockLocator({ count: 0 }),
getByRole: (role: string) => {
if (role === "heading") return heading;
return mockLocator({ count: 0 });
},
getByText: () => mockLocator({ count: 0 }),
};
expect(await isFlockLoggedInQuoteForm(page as never)).toBe(true);
});
it("combobox 按候选选 option", async () => {
const clicks: string[] = [];
const option70 = mockLocator({
count: 1,
visible: true,
click: async () => {
clicks.push("70");
},
});
const combo = mockLocator({
count: 1,
visible: true,
click: async () => {
clicks.push("open");
},
});
const page = {
getByRole: (role: string, opts?: { name?: RegExp | string }) => {
if (role === "combobox") return combo;
if (role === "button") return mockLocator({ count: 0 });
if (role === "option") {
const name = String(opts?.name ?? "");
if (name.includes("70") || name === "70") return option70;
return mockLocator({ count: 0 });
}
return mockLocator({ count: 0 });
},
waitForTimeout: async () => undefined,
keyboard: { press: async () => undefined },
};
const ok = await selectFlockComboboxOption(
page as never,
/Freight Class/i,
["70", "50"],
);
expect(ok).toBe(true);
expect(clicks).toContain("open");
expect(clicks).toContain("70");
});
});

@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import {
getFlockLoginUrl,
hasFlockLoginCredentials,
isFlockFreshSessionPerQuote,
isFlockRandomAccountPerQuote,
shouldSaveFlockStorageState,
} from "@/lib/flock/env";
import {
flockLoginRegistrationBlockedMessage,
isFlockLoginPage,
resetFlockLoginSessionFlagForTests,
} from "@/workers/rpa/flock/login";
import { shouldRotateFlockSession } from "@/workers/rpa/flock/worker-session";
describe("Flock login credentials env", () => {
const prev = {
email: process.env.FLOCK_LOGIN_EMAIL,
password: process.env.FLOCK_LOGIN_PASSWORD,
fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE,
random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE,
loginUrl: process.env.FLOCK_LOGIN_URL,
};
afterEach(() => {
process.env.FLOCK_LOGIN_EMAIL = prev.email;
process.env.FLOCK_LOGIN_PASSWORD = prev.password;
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh;
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random;
process.env.FLOCK_LOGIN_URL = prev.loginUrl;
});
it("无凭据时 hasCredentials=false", () => {
delete process.env.FLOCK_LOGIN_EMAIL;
delete process.env.FLOCK_LOGIN_PASSWORD;
expect(hasFlockLoginCredentials()).toBe(false);
});
it("成对凭据时开启登录门禁并关闭 fresh/random", () => {
process.env.FLOCK_LOGIN_EMAIL = "user@example.com";
process.env.FLOCK_LOGIN_PASSWORD = "secret";
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = "true";
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = "true";
expect(hasFlockLoginCredentials()).toBe(true);
expect(isFlockFreshSessionPerQuote()).toBe(false);
expect(isFlockRandomAccountPerQuote()).toBe(false);
expect(shouldSaveFlockStorageState()).toBe(true);
});
it("默认登录 URL", () => {
delete process.env.FLOCK_LOGIN_URL;
expect(getFlockLoginUrl()).toContain("flockfreight.com/login");
});
});
describe("isFlockLoginPage", () => {
it("识别 login URL", () => {
expect(
isFlockLoginPage({
url: () => "https://app.flockfreight.com/login",
} as never),
).toBe(true);
expect(
isFlockLoginPage({
url: () => "https://app.flockfreight.com/get-a-quote",
} as never),
).toBe(false);
});
});
describe("login mode session rotation", () => {
afterEach(() => {
delete process.env.FLOCK_LOGIN_EMAIL;
delete process.env.FLOCK_LOGIN_PASSWORD;
resetFlockLoginSessionFlagForTests();
});
it("有登录凭据时禁止换号", () => {
process.env.FLOCK_LOGIN_EMAIL = "a@b.com";
process.env.FLOCK_LOGIN_PASSWORD = "x";
expect(
shouldRotateFlockSession(
"FLOCK_ACCOUNT_REJECTED: We had an issue creating your account",
),
).toBe(false);
});
it("注册页阻断文案", () => {
expect(flockLoginRegistrationBlockedMessage()).toContain("登录凭据");
});
});

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
flockLocatorCount,
flockPageClosedMessage,
isFlockPageAlive,
isFlockPageClosedError,
} from "@/workers/rpa/flock/page-alive";
describe("flock page-alive", () => {
it("识别 Target closed / locator.count 关窗错误", () => {
expect(
isFlockPageClosedError(
new Error("locator.count: Target page, context or browser has been closed"),
),
).toBe(true);
expect(isFlockPageClosedError(new Error("other"))).toBe(false);
});
it("关窗文案可读", () => {
expect(flockPageClosedMessage("填表")).toContain("BROWSER_CLOSED");
expect(flockPageClosedMessage("填表")).toContain("填表");
});
it("isFlockPageAlive null 为 false", () => {
expect(isFlockPageAlive(null)).toBe(false);
});
it("flockLocatorCount 在 page.isClosed 时返回 0", async () => {
const loc = {
page: () => ({ isClosed: () => true }),
count: async () => {
throw new Error("should not call");
},
};
expect(await flockLocatorCount(loc as never)).toBe(0);
});
});

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { classifyFlockFailure } from "@/scripts/batch-flock-stress";
describe("classifyFlockFailure", () => {
it("识别报价上限", () => {
expect(classifyFlockFailure("FLOCK_QUOTE_LIMIT获取报价达到上限")).toBe(
"rate_limit",
);
});
});

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
extractFlockPendingReference,
formatFlockRatesPendingMessage,
isFlockRatesPendingText,
parseFlockMoney,
parseFlockTransit,
} from "@/workers/rpa/flock/quote-extract";
import { isFlockNonRetryableFailure } from "@/lib/flock/exclusive-lock";
describe("parseFlockMoney", () => {
it("解析 Prices from 文案", () => {
expect(parseFlockMoney("Prices from$1,925Transit(incl. weekends)4 days")).toBe(
1925,
);
expect(parseFlockMoney("Prices from $1,187")).toBe(1187);
});
});
describe("parseFlockTransit", () => {
it("解析含周末时效", () => {
const r = parseFlockTransit(
"Prices from$1,925Transit(incl. weekends)4 daysGuaranteed",
);
expect(r.transitDays).toBe("4");
});
it("解析工作日预估", () => {
const r = parseFlockTransit(
"Prices from$1,187Transit(business days only)Contact Rep",
);
expect(r.transitDescription).toMatch(/business days only|工作日/);
});
});
describe("Flock rates pending 页", () => {
const sample =
"#NCC-ZHZ4 pricing options\nExpires Jul 17, 2026\nHold tight, we are still looking for the best rates!\nWe should have a rate for you in about 15 minutes.";
it("识别 Hold tight 待价文案", () => {
expect(isFlockRatesPendingText(sample)).toBe(true);
expect(isFlockRatesPendingText("Prices from $100")).toBe(false);
});
it("从标题抽取参考编号", () => {
expect(extractFlockPendingReference(sample)).toBe("NCC-ZHZ4");
});
it("待价结果禁止 BullMQ 重跑", () => {
const msg = formatFlockRatesPendingMessage("NCC-ZHZ4");
expect(msg).toMatch(/FLOCK_RATES_PENDING/);
expect(msg).toContain("NCC-ZHZ4");
expect(isFlockNonRetryableFailure(msg)).toBe(true);
});
});

@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FlockRunQuoteResult } from "@/workers/rpa/flock/types";
const visualChainMock = vi.fn<() => Promise<FlockRunQuoteResult>>();
vi.mock("@/workers/rpa/flock/visual-chain", () => ({
runFlockVisualChain: () => visualChainMock(),
}));
describe("runFlockVisualChainWithRotations", () => {
beforeEach(() => {
visualChainMock.mockReset();
process.env.FLOCK_SESSION_MAX_ROTATIONS = "3";
});
it("拒号时换邮箱重试直至成功", async () => {
visualChainMock
.mockResolvedValueOnce({
ok: false,
quotes: [],
errorMessage: "FLOCK_ACCOUNT_REJECTEDissue creating your account",
})
.mockResolvedValueOnce({
ok: true,
quotes: [{ tier: "standard", totalUsd: 100 } as FlockRunQuoteResult["quotes"][0]],
});
const { runFlockVisualChainWithRotations } = await import(
"@/workers/rpa/flock/run-quote"
);
const result = await runFlockVisualChainWithRotations({
pickupZip: "94102",
deliveryZip: "90212",
pickupDate: "2026-07-20",
palletCount: 4,
totalWeightLb: 2800,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
});
expect(result.ok).toBe(true);
expect(visualChainMock).toHaveBeenCalledTimes(2);
});
it("报价上限时不轮换", async () => {
visualChainMock.mockResolvedValue({
ok: false,
quotes: [],
errorMessage: "FLOCK_QUOTE_LIMITThank you for your interest",
});
const { runFlockVisualChainWithRotations } = await import(
"@/workers/rpa/flock/run-quote"
);
const result = await runFlockVisualChainWithRotations({
pickupZip: "94102",
deliveryZip: "90212",
pickupDate: "2026-07-20",
palletCount: 4,
totalWeightLb: 2800,
lengthIn: 48,
widthIn: 40,
heightIn: 48,
});
expect(result.ok).toBe(false);
expect(visualChainMock).toHaveBeenCalledTimes(1);
});
});

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
describeFlockPagePhase,
isFlockQuoteLimitText,
} from "@/workers/rpa/flock/page-state";
import { readFlockStallMs } from "@/workers/rpa/flock/stall-watch";
describe("isFlockQuoteLimitText", () => {
it("识别 Thank you for your interest", () => {
expect(isFlockQuoteLimitText("Thank you for your interest in Flock")).toBe(true);
expect(describeFlockPagePhase("Thank you for your interest")).toBe("quote_limit");
});
it("识别中文上限", () => {
expect(isFlockQuoteLimitText("获取报价达到上限")).toBe(true);
});
});
describe("readFlockStallMs", () => {
it("默认 20s", () => {
const prev = process.env.FLOCK_STALL_MS;
delete process.env.FLOCK_STALL_MS;
expect(readFlockStallMs()).toBe(20_000);
process.env.FLOCK_STALL_MS = prev;
});
});

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { shouldRotateFlockSession } from "@/workers/rpa/flock/worker-session";
describe("shouldRotateFlockSession", () => {
it("注册被拒时轮换", () => {
expect(
shouldRotateFlockSession("FLOCK_ACCOUNT_REJECTED: We had an issue creating your account"),
).toBe(true);
});
it("邮箱已存在时轮换", () => {
expect(shouldRotateFlockSession("FLOCK_ACCOUNT_EXISTS: already registered")).toBe(
true,
);
});
it("报价上限时轮换", () => {
expect(
shouldRotateFlockSession(
"FLOCK_QUOTE_LIMIT官网提示 Thank you for your interest获取报价已达上限",
),
).toBe(false);
});
it("普通报价失败不轮换", () => {
expect(shouldRotateFlockSession("RPA_DATA_INVALID20s 内未拿到报价")).toBe(false);
});
});

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
buildLoggedInAddressSearchQuery,
formatLoggedInReadyGridCell,
MOTHERSHIP_CREATE_SHIPMENT_URL,
parseLoggedInRateCardText,
} 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";
describe("MOTHERSHIP_CREATE_SHIPMENT_URL", () => {
it("登录态一级表单直达 /ship避免菜单文案超时", () => {
expect(MOTHERSHIP_CREATE_SHIPMENT_URL).toBe(
"https://dashboard.mothership.com/ship",
);
});
});
describe("parseLoggedInRateCardText", () => {
it("解析 ABF Direct 卡片文本", () => {
const item = parseLoggedInRateCardText(
"Selected ABF Direct $835.61 Estimated 5 business days",
);
expect(item?.carrier).toBe("ABF Direct");
expect(item?.rawTotal).toBe(835.61);
expect(item?.transitDays).toBe("5");
expect(item?.serviceLevel).toBe("standard");
});
it("解析 XPO Direct", () => {
const item = parseLoggedInRateCardText(
"XPO Direct $1,015.28 Estimated 4 business days",
);
expect(item?.carrier).toBe("XPO Direct");
expect(item?.rawTotal).toBe(1015.28);
});
it("解析区间时效 5-7 business days", () => {
const item = parseLoggedInRateCardText(
"SAIA Direct $900.00 5-7 business days",
);
expect(item?.transitDays).toBe("5-7");
});
it("无时效文案时用待确认,且通过 schema 校验(避免 standard/bestValue 时效为空)", () => {
const item = parseLoggedInRateCardText("Daylight Direct $733.98");
expect(item?.transitDays).toBe("待确认");
expect(item?.rateOption).toBe("bestValue");
expect(validateQuoteSchema([item!])).toHaveLength(1);
});
});
describe("formatLoggedInReadyGridCell", () => {
it("ISO 日期转为 gridcell 名", () => {
expect(formatLoggedInReadyGridCell("2026-07-16")).toBe("Thu Jul 16");
});
});
describe("buildLoggedInAddressSearchQuery", () => {
it("优先用 street/city/state/zip避开 Places description 的 floor/suite", () => {
const q = buildLoggedInAddressSearchQuery({
street: "2020 West Washington Boulevard",
city: "Los Angeles",
state: "CA",
zip: "",
placeId: "ChIJ-test",
formattedAddress:
"2020 West Washington Boulevard floor 5 rm 508, Los Angeles, CA, USA",
selectedFromSuggestions: true,
mothershipOptionId: "ChIJ-test",
mothershipDisplayLabel:
"2020 West Washington Boulevard floor 5 rm 508, Los Angeles, CA, USA",
selectedFromMothership: true,
});
expect(q).toBe("2020 West Washington Boulevard, Los Angeles, CA");
expect(q).not.toMatch(/floor|rm 508/i);
});
it("ste/suite 也会从 street 剥离", () => {
const q = buildLoggedInAddressSearchQuery({
street: "555 Market St ste 1001",
city: "San Francisco",
state: "CA",
zip: "94105",
placeId: "ChIJ-sf",
formattedAddress: "555 Market St ste 1001, San Francisco, CA, USA",
selectedFromSuggestions: true,
mothershipOptionId: "ChIJ-sf",
mothershipDisplayLabel: "555 Market St ste 1001, San Francisco, CA, USA",
selectedFromMothership: true,
});
expect(q).toBe("555 Market St, San Francisco, CA, 94105");
expect(q).not.toMatch(/ste|1001/i);
});
});
describe("filterQuotesForMotherShipUiDisplay 多承运商", () => {
it("同档不同承运商均保留", () => {
const ui = filterQuotesForMotherShipUiDisplay([
{
service_level: "standard",
rate_option: "bestValue",
carrier: "ABF Direct",
raw: 835,
},
{
service_level: "standard",
rate_option: "bestValue",
carrier: "XPO Direct",
raw: 1015,
},
]);
expect(ui).toHaveLength(2);
});
});

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
formatWidgetQuoteTierLabel,
mergeWidgetTabQuotePrice,
} from "@/workers/rpa/quote-capture/widget-truck-tab-quotes";
import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator";
import type { QuoteItem } from "@/modules/providers/quote-provider";
function item(serviceLevel: string, rateOption: string, rawTotal: number): QuoteItem {
return {
serviceLevel,
rateOption,
carrier: "Mothership",
transitDays: "—",
transitDescription: "—",
rawFreight: rawTotal,
surcharges: 0,
rawTotal,
};
}
describe("formatWidgetQuoteTierLabel", () => {
it("Shared truck · Best value 文案", () => {
expect(formatWidgetQuoteTierLabel(item("standard", "bestValue", 81.71))).toBe(
"standard/bestValue (Shared truck · Best value)",
);
});
it("Dedicated truck 文案", () => {
expect(formatWidgetQuoteTierLabel(item("dedicated", "bestValue", 246.11))).toBe(
"dedicated/bestValue (Dedicated truck)",
);
});
});
describe("mergeWidgetTabQuotePrice", () => {
it("Tab 价覆盖金额但保留 axel 时效", () => {
const axel: QuoteItem = {
...item("standard", "bestValue", 80),
transitDays: "3 business days",
transitDescription: "3 个工作日",
carrier: "Mothership Carrier",
};
const tab = item("standard", "bestValue", 81.71);
const merged = mergeWidgetTabQuotePrice(axel, tab);
const dedicated = mergeWidgetTabQuotePrice(
undefined,
item("dedicated", "bestValue", 246.11),
);
expect(merged.rawTotal).toBe(81.71);
expect(merged.transitDays).toBe("3 business days");
expect(dedicated.transitDays).toBe("待确认");
expect(validateQuoteSchema([merged, dedicated])).toHaveLength(2);
});
});

@ -0,0 +1,981 @@
# 美美与共查价系统 · 宿主 OpenAPI 技术对接文档
**版本**1.2
**更新日期**2026-07-22
**适用对象**:第三方宿主系统(服务端对接 / 网页嵌入)
---
## 1. 文档说明
本文档供第三方宿主系统对接「美美与共查价中台」使用。
中台对接 **两个官网**,每个官网各有 **免账号 / 登录后** 两种查价方式,合计 **四条互斥路线**
| 路线 ID | 官网 | 账密 | 说明 |
|---------|------|------|------|
| `MS_GUEST` | MotherShip | 无 | 免登录 Widget / Direct 路径 |
| `MS_LOGGED_IN` | MotherShip | 有(该客户已绑定 MotherShip 账密) | 官网 Create shipment 登录态路径 |
| `FLOCK_GUEST` | Flock Freight | 无 | 免登录 Direct失败可回退 DOM |
| `FLOCK_LOGGED_IN` | Flock Freight | 有(该客户已绑定 Flock 账密) | 强制官网登录 DOM禁止 Direct |
**核心规则**:根据「用户/宿主上传的承运商账密」与「本票货物/地址参数是否符合某官网硬限」判定路线后,**整票只走一条路线**,禁止混用上限、混用接口、混用结果字段。
本文档仅列出对调用方有意义且可依赖的字段。响应中如出现未在本文档定义的其他字段,均属于平台保留字段,调用方不应依赖。
**对接形态**
| 形态 | 说明 | 见章节 |
|------|------|--------|
| OpenAPI 服务端调用 | 宿主后端带 API Key 调两步查价接口 | §2§6 |
| 网页嵌入iframe | 宿主页面嵌查价 UI**须直传类型+Key跳过登录页** | §2.5 |
> **安全要求**API Key、承运商官网账密仅允许保存在宿主服务端或经我方管理端加密存储禁止写入宿主公开前端仓库、移动端安装包。OpenAPI 调用禁止把 Key 放进 URLiframe 嵌入仅允许由**宿主服务端**在生成 `iframe.src` 时短时拼入查询串(见 §2.5)。
---
## 2. 接入信息
### 2.1 基础地址
| 环境 | Base URL |
|------|----------|
| 当前联调 / 演示 | `https://if.dev.51track.vip` |
> 公网访问**不要**追加端口(如 `:30325`)。`:30325` 仅用于服务器本机调试。
嵌入页路径:`{Base URL}/embed-demo`
### 2.2 请求格式
- 请求方法:以各接口定义为准
- 请求体JSON
- 字符编码UTF-8
- Content-Type`application/json`
### 2.3 鉴权请求头OpenAPI
| 请求头 | 必填 | 说明 |
|--------|------|------|
| Authorization | 是 | `Bearer <API Key>`Key 通常以 `chj_` 开头,由我方管理端发放 |
| X-Customer-Id | 是 | 客户 ID必须与 API Key 绑定一致 |
| Content-Type | 是 | 固定为 `application/json` |
本次联调凭证(示例,正式值由我方单独发放):
| 配置项 | 值 | 说明 |
|--------|-----|------|
| API Key | `<由我方发放>` | 放入 `Authorization: Bearer ...` |
| Customer ID | `<由我方发放>` | 放入 `X-Customer-Id` |
> **注意OpenAPI**API Key 不得放入 URL、请求体或业务日志明文。
**请求头示例**
```http
Authorization: Bearer chj_xxxxxxxxxxxxxxxxxxxxx
X-Customer-Id: CUST_001
Content-Type: application/json
```
内网联调若使用环境 Token非正式第三方形态
```http
Authorization: Bearer demo-host-token
X-Customer-Id: CUST_001
Content-Type: application/json
```
### 2.4 通用响应结构
| 字段 | 类型 | 说明 |
|------|------|------|
| code | Number / String | `0` 表示接口调用成功;非 `0` 表示鉴权、参数或业务处理失败 |
| message | String | 提示信息;成功时通常为 `ok` |
| data | Object / null | 成功时的业务数据;失败时多为 `null` |
### 2.5 网页嵌入iframe / CC 等宿主)— 跳过登录页
#### 2.5.1 目标
宿主将查价系统嵌进自有页面时:
1. **用户不应再看到**嵌入页「客户登录」表单。
2. 宿主传入 **登录类型 + 凭证** 后,直接进入 **MotherShip / Flock Freight** 两模块门户。
3. 模块内可再选「免账号 / 登录后」;登录后路线需已绑定承运商官网账密(可在嵌入页内配置)。
#### 2.5.2 URL 查询参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `login_type` | 建议 | `api_key``password`;别名:`type`、`lt` |
| `api_key` | Key 登录必填 | 客户 API Key别名`key`、`token` |
| `customer_id` | 密码登录必填 | 客户编号;别名:`customerId` |
| `password` | 密码登录必填 | 嵌入页客户密码;别名:`pwd` |
| `embed` | 可选 | `1` / `true`:宿主托管壳(隐藏「退出登录」);别名:`hosted`、`from_cc`、`from_host` |
仅带 `api_key`(或 `key`)且未写 `login_type` 时,按 **`api_key` 登录**处理。
#### 2.5.3 推荐写法CCAPI Key
```text
https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=<API_Key>&embed=1
```
短写:
```text
https://if.dev.51track.vip/embed-demo?type=api_key&key=<API_Key>&embed=1
```
**iframe 示例Key 须由宿主后端渲染进 HTML禁止写死在公开前端仓库**
```html
<iframe
title="查价"
src="https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=chj_xxxxxxxx&embed=1"
style="width:100%;height:900px;border:0;"
allow="clipboard-read; clipboard-write"
></iframe>
```
账号密码直登(较少用):
```text
https://if.dev.51track.vip/embed-demo?login_type=password&customer_id=CUST_001&password=<密码>&embed=1
```
#### 2.5.4 行为与安全
| 步骤 | 行为 |
|------|------|
| 1 | 打开嵌入页 → 读取查询串 → 调用 `POST /api/embed-demo/login` |
| 2 | 成功 → 写入嵌入会话 Cookie → 进入两模块门户(**不展示登录页** |
| 3 | 前端 `history.replaceState` 去掉地址栏中的 Key/密码等敏感参数 |
| 4 | 失败 → 展示错误文案,并可回落手动登录表单 |
安全约定:
- Key **只允许**宿主服务端拼进 `iframe.src`;用户浏览器地址栏会短暂可见,登录成功后会被剥离。
- 生产环境嵌入 Cookie 使用 `SameSite=None; Secure`,以支持跨站 iframe。
- 勿在公开仓库、前端构建产物、CDN 静态配置中明文提交 API Key。
- OpenAPI 仍须走 `Authorization`§2.3),与嵌入 URL 直传互不替代。
#### 2.5.5 常见错误
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 仍停在登录页 | 未传 `api_key` / `login_type`,或 Key 为空 | 检查 iframe `src` |
| 提示 API Key 无效 | Key 错误、已轮换、或客户停用 | 管理端核对 Key 与客户状态 |
| 提示缺少 Authorization | 宿主在调 OpenAPI 时未带鉴权头 | 与嵌入无关;按 §2.3 补头 |
| 登录成功但刷新掉登录 | 跨站 Cookie 被拦 / 非 HTTPS | 确认生产 HTTPSCookie 为 None+Secure |
**成功响应示例**OpenAPI 通用):
```json
{
"code": 0,
"message": "ok",
"data": { }
}
```
**失败响应示例**
```json
{
"code": "VALIDATION_FAILED",
"message": "请填写 2 位州码",
"data": null
}
```
> 调用方不能仅根据 HTTP 状态码判断业务成功。仅当 `code = 0` 时,才能判定本次接口调用成功。
> 异步询价业务失败时,轮询接口可能仍返回 HTTP 200 且 `code = 0`,此时须检查 `data.status`:若为 `failed`,按失败处理并展示 `data.error_message`
---
## 3. 路线判定(必读)
宿主在发起询价前,按下列顺序 **确定唯一路线**,然后只调用该路线对应接口与字段。
### 3.1 判定流程
```
1) 宿主明确选择官网,或按参数硬限自动匹配:
- 符合 MotherShip 硬限且业务选 MotherShip → 进入 MS_*
- 符合 Flock 硬限且业务选 Flock → 进入 FLOCK_*
- 两网站都不符合 → 拒绝询价,提示用户改货改地址
- 禁止:同一票并行打两家再拼结果(除非宿主产品明确要求「比价」且各自独立展示)
2) 查该客户在我方是否已绑定对应官网账密(管理端「查价网站账密」):
- MotherShip 已绑账密 → 必须走 MS_LOGGED_IN服务端强制登录态禁止匿名 Direct/缓存价)
- MotherShip 未绑账密 → 走 MS_GUEST
- Flock 已绑账密 → 必须走 FLOCK_LOGGED_IN服务端强制 DOM 登录,禁止 Direct
- Flock 未绑账密 → 走 FLOCK_GUEST
3) 账密与参数必须同属一条路线:
- 有 Flock 账密但货物只符合 MotherShip → 只能走 MS_*Flock 账密本票不用)
- 有 MotherShip 账密但货物只符合 Flock → 只能走 FLOCK_*
- 有账密却按免账号字段凑合提交:服务端仍可能强制登录态,价格口径与免账号不同,宿主须按登录后字段规范传参
```
### 3.2 两官网硬限对照(换算后英制)
| 项 | MotherShipMS_* | FlockFLOCK_* |
|----|--------------------|------------------|
| 地址形态 | 街道 + 城市 + 州码(建议邮编);须经候选确认 | **仅邮编**5 位);提货邮编 ≠ 派送邮编 |
| 托盘 / 件数 | 125 | 免账号 **420**;登录后 `form_mode=logged_in_quick` 时 1999 |
| 单托重量 | 0.019999 lb登录后 Weight each ≤ **5000 lb** | 按**整票总重**,≤ **45000 lb** |
| 尺寸 | L≤999 inW/H≤99 in | L≤636 inW≤102 inH≤108 in |
| 其它 | 整票总重 ≤ 249975 lb | 免账号须满足拖车线性英尺(约 53' |
重量 / 尺寸支持 `lb`/`kg`/`t`(公制吨)、`in`/`cm`/`m` 入参,服务端统一换算为 lb/in小数进一取整。宿主应在提交前按目标官网硬限拦截。
### 3.3 接口总览
| 路线 | 推荐调用链 | 等待方式 | 典型耗时 |
|------|------------|----------|----------|
| `MS_GUEST` | `POST /api/host/quote/candidates``POST /api/host/quote/submit` | **同步**submit 内等待) | 10420 s |
| `MS_LOGGED_IN` | `POST /api/addresses/mothership-suggest`(可选)→ 组地址 → `POST /api/quotes``GET /api/quotes/{quote_id}` | **异步轮询** | 轮询总窗口建议 ≥ 420 s |
| `FLOCK_GUEST` | `POST /api/flock/quotes``GET /api/quotes/{quote_id}` | **异步轮询** | 建议 ≥ 210 s可配置 |
| `FLOCK_LOGGED_IN` | 同上;`flock_input.form_mode = "logged_in_quick"` | **异步轮询** | 建议 ≥ 210 s |
> `MS_GUEST` 也可改用异步三步(`POST /api/quotes` + 轮询),但第三方默认同步对接请用宿主两步接口。
> 已绑定 MotherShip 账密时,即便误调宿主两步接口,服务端仍会强制登录态 RPA宿主两步**不支持**登录后扩展字段(`cargo_lines` / `mothership_details` 等),完整登录后能力请走 `MS_LOGGED_IN` 异步接口。
---
## 4. 承运商账密(路线开关)
账密由我方管理端按客户绑定RPA 运行时解密使用,**不进入询价请求体**。
### 4.1 管理端接口(内部运维,非宿主日常调用)
```http
GET /api/admin/customers/{customer_id}/provider-credentials
PUT /api/admin/customers/{customer_id}/provider-credentials
```
需管理员 JWT。PUT 示例:
```json
{
"provider": "mothership",
"email": "user@example.com",
"password": "********"
}
```
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| provider | String | 是 | `mothership``flock` | 对应官网 |
| email | String | 是 | 非空 | 官网登录邮箱 |
| password | String | 是 | 非空 | 官网登录密码 |
**影响**
- 绑定成功后,该客户对该 `provider` **自动切换到对应 `*_LOGGED_IN` 路线**。
- 账密错误时询价可能返回 `PROVIDER_LOGIN_FAILED`(或业务失败 `error_message` 提示确认账密)。
- 更换账密须按管理端策略操作;勿在业务日志打印明文密码。
宿主侧若自行收集用户官网账密,应通过约定安全通道交给我方写入管理端,或由双方约定的 BFF 代写;**询价 OpenAPI 请求体中不要传官网密码**。
---
## 5. 路线 AMotherShip 免账号(`MS_GUEST`
适用:客户**未**绑定 MotherShip 账密;参数符合 MotherShip 硬限。
### 5.1 调用流程
1. 带鉴权头调用「获取地址候选」。
2. 保存 `host_session_id`(约 30 分钟有效)。
3. 用户从 `pickup_candidates`、`delivery_candidates` 各选 1 条 `selectable = true`
4. 将**完整候选对象**与 `host_session_id` 提交「提交并获取报价」。
5. 第二步可能持续 **10420 秒**;客户端与网关超时建议 **≥ 420 秒**。
6. `status = done` 展示 `quotes[]``failed` 展示中文 `error_message`
> 第二步期间展示 Loading并禁止重复提交。
### 5.2 第一步:获取地址候选
#### 接口地址
`POST /api/host/quote/candidates`
#### 请求参数
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| pickup_address | Object | 是 | 见下表 | 提货草稿地址 |
| delivery_address | Object | 是 | 见下表 | 派送草稿地址 |
| cargo | Object | 是 | 见下表 | 货物信息 |
**地址对象pickup_address / delivery_address**
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| street | String | 是 | 非空 | 街道地址 |
| city | String | 是 | 非空 | 城市 |
| state | String | 是 | 固定 2 位 | 美国州二字码,如 `CA`、`TX` |
| zip | String | 否 | `12345``12345-6789`;可空 | 邮编;建议传 |
**货物对象cargo**
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| weight.value | Number | 是 | 正数 | 单托重量数值 |
| weight.unit | String | 是 | `lb`、`kg` 或 `t`(公制吨);别名 `lbs`/`pound(s)`/`tonne(s)`**禁止** `ton`/`tons`(美制短吨歧义) | 重量单位 |
| dimensions.length / width / height | Number | 是 | 正数 | 单托尺寸 |
| dimensions.unit | String | 是 | `in`、`cm` 或 `m`;别名 `inch`/`meter` 亦可 | 尺寸单位 |
| pallet_count | Number | 是 | 整数 125 | 托盘数 |
| cargo_type | String | 是 | 见 §12.1 | 货物类型枚举 |
**请求示例**
```json
{
"pickup_address": {
"street": "1234 Warehouse Blvd",
"city": "Los Angeles",
"state": "CA",
"zip": "90001"
},
"delivery_address": {
"street": "5678 Distribution Dr",
"city": "Dallas",
"state": "TX",
"zip": "75201"
},
"cargo": {
"weight": { "value": 500, "unit": "kg" },
"dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" },
"pallet_count": 2,
"cargo_type": "general_freight"
}
}
```
#### 响应数据
| 字段 | 类型 | 说明 |
|------|------|------|
| data.host_session_id | String (UUID) | 第二步必传;约 30 分钟有效 |
| data.pickup_candidates | Array\<Object\> | 提货候选 |
| data.delivery_candidates | Array\<Object\> | 派送候选 |
| data.requires_selection | Boolean | 是否需要用户选择 |
**候选对象字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| option_id | String | 候选唯一标识 |
| display_label | String | 展示文案 |
| formatted_address | String | 格式化地址 |
| street / city / state / zip | String | 结构化地址 |
| selectable | Boolean | 仅可选 `true` |
| unavailable_reason | String | 不可选原因(可能出现) |
#### 调用后处理规则
1. 必须保存 `host_session_id`
2. 提货、派送各选 1 条 `selectable = true`
3. 第二步必须回传第一步返回的**完整候选对象**,禁止只传 `option_id`,禁止自行拼装。
4. 会话过期后须重新调用本接口。
### 5.3 第二步:提交候选并获取报价
#### 接口地址
`POST /api/host/quote/submit`
#### 请求参数
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| host_session_id | String | 是 | UUID来自第一步 | 宿主会话 ID |
| pickup_candidate | Object | 是 | 第一步完整候选 | 提货地址 |
| delivery_candidate | Object | 是 | 第一步完整候选 | 派送地址 |
#### 超时与重试
- 客户端 HTTP 超时:`420000` ms。
- 网关 `proxy_read_timeout` ≥ 420 秒。
- `SESSION_EXPIRED`:重新走第一步。
- `QUOTE_TIMEOUT`:可稍后重试;建议重新获取候选。
#### 响应数据
| 字段 | 类型 | 说明 |
|------|------|------|
| data.quote_id | String | 报价单号 |
| data.request_id | String | 请求标识 |
| data.status | String | `done` / `failed` |
| data.currency | String | 固定 `USD` |
| data.valid_until | String | ISO 有效期 |
| data.source_type | String | `rpa` / `cache` / `stale` 等 |
| data.quotes | Array\<Object\> | 档位列表 |
| data.error_code / error_message | String | 失败时 |
**quotes[] 字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| service_level | String | 如 `standard`、`guaranteed` |
| rate_option | String | 如 `lowest`、`fastest`、`bestValue` |
| carrier | String | 承运商 |
| transit_days | String | 时效描述 |
| final_total | Number | **对客户展示价USD已含加价** |
> 档位数量不固定,按实际列表展示。
**成功响应示例**
```json
{
"code": 0,
"message": "ok",
"data": {
"quote_id": "QTE_20260630_0003",
"request_id": "xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx",
"status": "done",
"currency": "USD",
"valid_until": "2026-06-30T10:30:00.000Z",
"source_type": "rpa",
"quotes": [
{
"service_level": "standard",
"rate_option": "lowest",
"carrier": "Frontline Freight",
"transit_days": "Est. 5 business days",
"final_total": 337.06
}
]
}
}
```
---
## 6. 路线 BMotherShip 登录后(`MS_LOGGED_IN`
适用:客户**已**绑定 MotherShip 账密;参数符合 MotherShip 硬限(含登录后 Weight each ≤ 5000 lb
服务端检测到账密后会强制登录态查价,禁止命中匿名 Direct / L2 缓存价。
### 6.1 调用流程
1. (推荐)用 `POST /api/addresses/mothership-suggest` 按关键字联想,用户点选候选。
2. 组装完整询价体(含地址确认字段 + 可选登录后字段)。
3. `POST /api/quotes` → 得到 `quote_id``status` 多为 `processing`
4. 轮询 `GET /api/quotes/{quote_id}``done` / `failed`(间隔建议 2 s总窗口 ≥ 420 s
5. 成功时读 `data.quotes[]`(结构同 §5.3)。
### 6.2 地址联想(可选但推荐)
#### 接口地址
`POST /api/addresses/mothership-suggest`
#### 请求参数
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| customer_id | String | 是 | 与鉴权客户一致 | 客户 ID |
| query | String | 是 | 去空白后 ≥ 3 字符 | 用户键入的地址关键字 |
**请求示例**
```json
{
"customer_id": "CUST_001",
"query": "1234 Warehouse"
}
```
#### 响应数据
| 字段 | 类型 | 说明 |
|------|------|------|
| data.candidates | Array\<Object\> | 候选列表(字段同 §5.2 候选对象) |
用户选定后,须把候选映射进 `pickup_address` / `delivery_address`(见下节必填确认字段)。
### 6.3 提交询价
#### 接口地址
`POST /api/quotes`
#### 请求参数(核心)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| request_id | String | 是 | 非空;建议 UUID | 幂等 / 追踪 |
| customer_id | String | 是 | 与鉴权一致 | 客户 ID |
| pickup_address | Object | 是 | 见下表 | 提货地址(须已确认) |
| delivery_address | Object | 是 | 见下表 | 派送地址(须已确认) |
| weight | Object | 是 | value 正数unit `lb`/`kg`/`t` | 单托重量(兼容字段) |
| dimensions | Object | 是 | unit `in`/`cm`/`m` | 单托尺寸(兼容字段) |
| pallet_count | Number | 是 | 125 | 托盘数;多行时建议为各行 quantity 合计 |
| cargo_type | String | 是 | 见 §12.1 | 货物类型 |
| service_level | String | 否 | `standard` / `guaranteed` | 服务等级 |
| rate_option | String | 否 | `lowest` / `fastest` | 价格偏好 |
| ready_date | String | 否 | `YYYY-MM-DD`;周末会拨到下一工作日 | 可提货日 |
| ready_time | String | 否 | ≤32 字符 | 可提货时间 |
| pickup_accessorials | Array\<String\> | 否 | 每项 ≤64最多 20 | 提货附加服务 id |
| delivery_accessorials | Array\<String\> | 否 | 同上 | 派送附加服务 id |
| cargo_lines | Array\<Object\> | 否 | 120 行 | 登录后多行货物RPA 按行填);**字段已为英制,不做 kg/cm/m/t 换算** |
| mothership_details | Object | 否 | 见下 | 二级 Details联系人/FBA 等) |
**地址对象必填确认字段(与免账号草稿不同)**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| street / city / state | String | 是 | 结构化地址state 2 位 |
| zip | String | 否 | 可空字符串 |
| place_id | String | 是 | 通常用候选 `option_id` |
| formatted_address | String | 是 | 完整展示地址 |
| selected_from_suggestions | Boolean | 是 | 必须为 `true` |
| mothership_option_id | String | 是 | 候选 `option_id` |
| mothership_display_label | String | 是 | 候选 `display_label` |
| selected_from_mothership | Boolean | 是 | 必须为 `true` |
**cargo_lines[]**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| cargo_type | String | 是 | 行货物类型文案/枚举 |
| quantity | Number | 是 | 正数≤999 |
| weight_lb | Number | 是 | 该行单件重量 **lb**(须已换算;不接受 kg/t建议 ≤5000 |
| length_in / width_in / height_in | Number | 是 | 该行尺寸 **英寸**(须已换算;不接受 cm/m |
**mothership_details节选**
| 字段 | 类型 | 说明 |
|------|------|------|
| pickup / delivery | Object | `company_name`、`suite`、联系人姓名邮箱电话、`reference`、`notes`、`opens_at`、`closes_at` |
| request_delivery_appointment | Boolean | 是否预约送货 |
| fba_number / fba_po_number | String | FBA 相关 |
| cargo[] | Array | `description`、`nmfc`、`hazmat`、`alcohol`、`tobacco`、件数类型与数量等 |
**请求示例(登录后最小可用)**
```json
{
"request_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9",
"customer_id": "CUST_001",
"pickup_address": {
"street": "1234 Warehouse Street",
"city": "Los Angeles",
"state": "CA",
"zip": "90001",
"place_id": "Eisx...",
"formatted_address": "1234 Warehouse Street, Los Angeles, CA, USA",
"selected_from_suggestions": true,
"mothership_option_id": "Eisx...",
"mothership_display_label": "1234 Warehouse Street, Los Angeles, CA, USA",
"selected_from_mothership": true
},
"delivery_address": {
"street": "5678 Distribution Dr, Wilmer",
"city": "Dallas",
"state": "TX",
"zip": "75201",
"place_id": "EihE...",
"formatted_address": "Distribution Dr, Wilmer, Dallas, TX, USA",
"selected_from_suggestions": true,
"mothership_option_id": "EihE...",
"mothership_display_label": "Distribution Dr, Wilmer, Dallas, TX, USA",
"selected_from_mothership": true
},
"weight": { "value": 1103, "unit": "lb" },
"dimensions": { "length": 48, "width": 40, "height": 60, "unit": "in" },
"pallet_count": 2,
"cargo_type": "general_freight",
"ready_date": "2026-07-22",
"cargo_lines": [
{
"cargo_type": "general_freight",
"quantity": 2,
"weight_lb": 1103,
"length_in": 48,
"width_in": 40,
"height_in": 60
}
]
}
```
#### 提交成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"quote_id": "QTE_20260721_0012",
"status": "processing"
}
}
```
随后进入 §9 轮询。
---
## 7. 路线 CFlock 免账号(`FLOCK_GUEST`
适用:客户**未**绑定 Flock 账密;参数符合 Flock 免账号硬限(托盘 420、总重 ≤45000 lb、线性英尺等
服务端优先走 Direct JSON失败时按环境配置可回退 DOM 填表(可能弱注册)。
### 7.1 调用流程
1. 校验邮编、取货日、件数、总重、尺寸与线性英尺。
2. `POST /api/flock/quotes``quote_id` + `processing`
3. 轮询 `GET /api/quotes/{quote_id}`(总窗口建议 ≥ 210 s
4. 成功时读 `data.flock.lines[]`**不是** MotherShip 的 `quotes[]` 形态)。
### 7.2 提交询价
#### 接口地址
`POST /api/flock/quotes`
> 若服务端关闭 Flock 能力,可能返回业务不可用(如 `QUOTE_UNAVAILABLE`)。
#### 请求参数
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| request_id | String | 是 | 非空 | 请求标识 |
| customer_id | String | 是 | 与鉴权一致 | 客户 ID |
| flock_input | Object | 是 | 见下表 | Flock 表单参数 |
**flock_input免账号**
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| pickup_date | String | 是 | `MM/DD/YYYY`;不可早于今天;不可周末 | 提货日 |
| pickup_zip | String | 是 | 5 位邮编 | 提货邮编 |
| delivery_zip | String | 是 | 5 位;≠ pickup_zip | 派送邮编 |
| pallet_count | Number | 是 | 整数 **420** | 托盘件数 |
| total_weight | Object | 是* | value 正数unit `lb`/`kg`/`t` | 整票总重;也可用 `total_weight_lb` |
| dimensions | Object | 否 | 缺省 48×40×48 inunit `in`/`cm`/`m` | 单托尺寸 |
| form_mode | String | 否 | 免账号**不要**传 `logged_in_quick` | 省略即为匿名 |
| registration | Object | 否 | 见附录 | DOM 回退时可选自填弱注册信息 |
\* `total_weight``total_weight_lb` 二选一即可。
**硬限(换算后)**
| 项 | 限制 |
|----|------|
| 总重 | ≤ 45000 lb |
| 尺寸 | L≤636W≤102H≤108in |
| 线性英尺 | 整票占用 ≤ 约 53'(否则校验失败) |
**请求示例**
```json
{
"request_id": "a1b2c3d4-e5f6-4789-a012-3456789abcde",
"customer_id": "CUST_001",
"flock_input": {
"pickup_date": "07/22/2026",
"pickup_zip": "90001",
"delivery_zip": "75201",
"pallet_count": 6,
"total_weight": { "value": 12000, "unit": "lb" },
"dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" }
}
}
```
#### 提交成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"quote_id": "QTE_20260721_0201",
"status": "processing"
}
}
```
### 7.3 结果字段(轮询成功后)
| 字段 | 类型 | 说明 |
|------|------|------|
| data.flock.reference | String | Flock 参考号(可能为空) |
| data.flock.lines[] | Array | 报价行 |
| data.flock.lines[].tier | String | `flock_direct` / `standard` |
| data.flock.lines[].label | String | 展示名 |
| data.flock.lines[].transit_days / transitDays | String | 时效 |
| data.flock.lines[].final_total_usd | Number | **对客户展示价USD已含加价** |
| data.flock.shipment_meta | Object | 提货日、邮编、重量等摘要 |
> 一档即可能成功;勿假设固定两档。展示价优先 `final_total_usd`
---
## 8. 路线 DFlock 登录后(`FLOCK_LOGGED_IN`
适用:客户**已**绑定 Flock 账密;参数按登录后 Quick 表单填写。
服务端强制官网登录 DOM**禁止**匿名 Direct。宿主须传 `form_mode: "logged_in_quick"`,并尽量补齐登录后字段,否则 RPA 可能填表失败。
### 8.1 调用流程
与 §7.1 相同(同一对接口),差异仅在请求体与服务端执行路径。
### 8.2 提交询价
#### 接口地址
`POST /api/flock/quotes`(同路线 C
#### flock_input 相对免账号的差异
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| form_mode | String | **是(本路线)** | 固定 `logged_in_quick` | 切换登录后校验与 RPA 表单 |
| pallet_count | Number | 是 | **1999** | 登录后件数上限放宽 |
| pickup_location_type | String | 建议 | 官网地点类型 | 如商业/住宅等 id/文案 |
| delivery_location_type | String | 建议 | 同上 | 派送地点类型 |
| packaging_type | String | 建议 | 包装类型 | 登录后 Quick 字段 |
| freight_class | String | 建议 | 货运等级 | 登录后 Quick 字段 |
| description | String | 建议 | 货物描述 | 登录后 Quick 字段 |
| stackable / turnable | Boolean | 否 | | 可堆叠 / 可翻转 |
| additional_services | Array\<String\> | 否 | | 附加服务 id |
| vehicle_types | Array\<String\> | 否 | | 允许车型 id |
| pickup_liftgate / pickup_inside / pickup_pallet_jack | Boolean | 否 | | 提货附属 |
| delivery_liftgate / delivery_inside / delivery_pallet_jack | Boolean | 否 | | 派送附属 |
| call_before_pickup / call_before_delivery | Boolean | 否 | | 提货/派送前电话 |
| pickup_service / delivery_service 及窗口时间字段 | String | 否 | 总重 >5000 lb 时官网才开放高级调度 | 见实现字段名 |
> 登录后**不再强制**免账号的 420 托盘与线性英尺校验;尺寸/总重上限仍适用 Flock 官网硬限。
**请求示例**
```json
{
"request_id": "b2c3d4e5-f6a7-4890-b123-456789abcdef",
"customer_id": "CUST_001",
"flock_input": {
"form_mode": "logged_in_quick",
"pickup_date": "07/22/2026",
"pickup_zip": "90001",
"delivery_zip": "75201",
"pallet_count": 2,
"total_weight": { "value": 2200, "unit": "lb" },
"dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" },
"packaging_type": "Pallets",
"freight_class": "70",
"description": "General freight",
"pickup_location_type": "Business with a dock or forklift",
"delivery_location_type": "Business with a dock or forklift"
}
}
```
结果字段同 §7.3。
---
## 9. 通用:异步轮询
适用于 `MS_LOGGED_IN`、`FLOCK_GUEST`、`FLOCK_LOGGED_IN`(以及非推荐的 MotherShip 异步三步)。
### 9.1 接口地址
`GET /api/quotes/{quote_id}`
### 9.2 请求
- 方法GET
- 路径参数:`quote_id`(提交接口返回)
- 鉴权头:同 §2.3(须为报价所属客户)
### 9.3 响应数据
| 字段 | 类型 | 说明 |
|------|------|------|
| data.quote_id | String | 报价单号 |
| data.request_id | String | 请求标识 |
| data.status | String | `processing` / `done` / `failed` / `expired` |
| data.currency | String | `USD` |
| data.quotes | Array | MotherShip 档位MS_* 成功时) |
| data.flock | Object | Flock 结果FLOCK_* 成功时) |
| data.error_code / error_message | String | 失败时 |
| data.rpa_stage / rpa_stage_label | String | processing 时可能出现的进度 |
| data.valid_until | String | 成功时有效期 |
### 9.4 轮询建议
| 项 | 建议 |
|----|------|
| 间隔 | 2 s10 s 后可加密至约 0.8 s |
| MotherShip 总窗口 | ≥ `420000` ms |
| Flock 总窗口 | ≥ `210000` ms以联调环境配置为准 |
| 终止条件 | `done` / `failed` / `expired` / 超时 |
| 并发 | 同一用户操作防重复提交 |
---
## 10. 错误码
### 10.1 接口层错误(`code !== 0`
| code | HTTP | 说明 | 处理建议 |
|------|------|------|----------|
| UNAUTHORIZED | 401 | 缺少或无效 API Key | 检查 Bearer / 轮换 |
| FORBIDDEN | 403 | 客户与 Key 不匹配,或无权访问报价 | 核对 `X-Customer-Id` / `quote_id` |
| VALIDATION_FAILED | 400 | 字段不合法 | 按**当前路线**硬限与必填项检查 |
| SESSION_EXPIRED | 404 | `host_session_id` 过期 | 仅 MS_GUEST重走候选 |
| ADDRESS_SESSION_MISMATCH | 400 | 候选不属于本次会话 | 使用返回的完整对象 |
| ADDRESS_NOT_SELECTABLE | 400 | 提交了不可选候选 | 改选 `selectable: true` |
| QUOTE_NOT_FOUND | 404 | 报价不存在 | 检查 quote_id |
| QUOTE_TIMEOUT | 504 | 同步等待超时 | 稍后重试 |
| RATE_LIMITED | 429 | 限流 | 降频 |
| QUOTE_UNAVAILABLE | 503 | 渠道不可用(如 Flock 关闭/占用) | 稍后重试或换路线 |
| INTERNAL_ERROR | 5xx | 系统异常 | 稍后重试 |
### 10.2 询价业务失败(`code = 0` 且 `data.status = failed`
| error_code | 说明 |
|------------|------|
| QUOTE_UNAVAILABLE | 暂时无法获取报价 |
| QUOTE_TIMEOUT | 询价超时 |
| QUOTE_ENTRY_UNAVAILABLE | 报价入口暂时无法打开 |
| ADDRESS_SUGGESTION_NOT_FOUND | 地址无法匹配 |
| CARRIER_NO_CAPACITY | 该线路暂无可用运力 |
| ADDRESS_NOT_SUPPORTED | 地址不在服务范围 |
| RPA_CAPTCHA | 触发验证码 |
| SESSION_EXPIRED | 承运商登录会话过期 |
| PROVIDER_LOGIN_FAILED | 承运商账密不正确或未即时更新 |
| RPA_DATA_INVALID | 未能解析完整报价 |
| PAGE_LOAD_TIMEOUT | 承运商页面加载超时 |
> 对外展示优先使用中文 `error_message`
---
## 11. 对接建议
### 11.1 第三方需要做
1. 服务端保存 API Key 与 Customer ID后端代理调用。
2. 实现路线判定§3**一票一路**。
3. 账密由管理端绑定;有账密则按登录后字段规范传参。
4. `MS_GUEST`:地址选择 UI + submit Loading≥420 s
5. 异步路线实现轮询与超时Flock 读 `flock.lines`MotherShip 读 `quotes`
6. 成功后注意 `valid_until`
### 11.2 第三方不需要做
1. 不需要在请求体传官网密码。
2. 不需要自行做单位换算(支持 `lb`/`kg`/`t`、`in`/`cm`/`m`,服务端统一进一取整为 lb/in
3. 不需要调用管理端客户 CRUD / embed-demo 登录作为正式对接。
4. 不需要把 Priority1 实验接口当作本期正式四路线之一。
### 11.3 cURL 示例
**MS_GUEST 第一步**
```bash
curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @ms-guest-candidates.json
```
**FLOCK_GUEST 提交**
```bash
curl -X POST "https://if.dev.51track.vip/api/flock/quotes" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @flock-guest.json
```
**轮询**
```bash
curl -X GET "https://if.dev.51track.vip/api/quotes/QTE_20260721_0201" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001"
```
### 11.4 Apifox 环境变量建议
| 变量 | 示例值 |
|------|--------|
| baseUrl | `https://if.dev.51track.vip` |
| token | `chj_...` |
| customerId | 与 Token 绑定的客户 ID |
Headers`Authorization: Bearer {{token}}``X-Customer-Id: {{customerId}}``Content-Type: application/json`。
---
## 12. 附录
### 12.1 货物类型枚举cargo_typeMotherShip
| 值 | 说明 |
|----|------|
| general_freight | 普通货物(默认) |
| machinery | 机械设备 |
| furniture | 家具 |
| electronics | 电子产品 |
| building_materials | 建材 |
| auto_parts | 汽车配件 |
| food_nonperishable | 非生鲜食品 |
| apparel | 服装 |
| other | 其他 |
### 12.2 报价状态status
| 值 | 说明 |
|----|------|
| processing | 询价进行中(须继续轮询) |
| done | 成功 |
| failed | 失败,看 error_message |
| expired | 报价已过期 |
### 12.3 Flock registration可选免账号 DOM 回退)
| 字段 | 类型 | 说明 |
|------|------|------|
| first_name / last_name | String | 姓名 |
| company | String | 公司 |
| email | String | 工作邮箱 |
| phone | String | 10 位美国电话 |
| shipments_per_month | String | `1-25` / `26-50` / `51-100` / `101+` |
### 12.4 非推荐 / 不在本期四路线
| 说明 | 接口 |
|------|------|
| MotherShip 免账号异步三步(可用,非默认同步方案) | `POST /api/quotes` + 轮询 |
| Priority1实验 | `POST /api/priority1/quotes` |
| `/embed-demo` 演示门户 | Cookie 登录,非正式 OpenAPI |
| 管理端客户 / 预警等 | 仅运维 |
---
## 13. 联系与支持
| 事项 | 说明 |
|------|------|
| 开通客户 / API Key / 绑定官网账密 | 联系我方运维或管理端操作 |
| Key 失效 | 轮换后立即通知宿主更换 |
| 故障排查 | 提供时间、路线 ID、`quote_id` 或 `host_session_id`、请求摘要(勿传完整 Key/密码) |
| 文档版本 | 1.12026-07-21 |

@ -0,0 +1,130 @@
# 查价系统 API 调用文档(第三方精简版)
本文档面向第三方开发,只保留**实际接入必需**内容。
> **完整对接规范(推荐阅读)**:同目录 [`api对接文档.md`](./api对接文档.md)
> 覆盖 **两官网 × 免账号/登录后 = 四条互斥路线**、字段类型/约束/错误码/判定流程。
Base URL`https://if.dev.51track.vip`
统一响应:`{ "code": 0, "message": "ok", "data": { } }`
---
## 1. 鉴权
每次请求都要带:
```http
Authorization: Bearer <API Key>
X-Customer-Id: <Customer ID>
Content-Type: application/json
```
API Key 放服务端,不要写死在前端。
---
## 2. 四条路线(一票只走一条)
| 路线 | 条件 | 接口 |
|------|------|------|
| `MS_GUEST` | MotherShip + **无**账密 | `POST /api/host/quote/candidates``POST /api/host/quote/submit`(同步,超时 ≥420s |
| `MS_LOGGED_IN` | MotherShip + **有**账密 | 地址联想可选 → `POST /api/quotes``GET /api/quotes/{id}` |
| `FLOCK_GUEST` | Flock + **无**账密 | `POST /api/flock/quotes` → 轮询 |
| `FLOCK_LOGGED_IN` | Flock + **有**账密 | 同上,且 `flock_input.form_mode = "logged_in_quick"` |
判定要点:
1. 先按业务选官网,并用该官网硬限校验参数(**禁止混用 MotherShip / Flock 上限**)。
2. 再看该客户是否已绑定对应官网账密 → 有则登录后,无则免账号。
3. 账密在管理端绑定,**不要**放进询价请求体。
硬限速查:
- MotherShip托盘 125单托约 ≤9999 lb登录后 Weight each ≤5000 lb尺寸 L≤999、W/H≤99 in。
- Flock 免账号:托盘 **420**;总重 ≤45000 lbL≤636 / W≤102 / H≤108 in仅邮编须线性英尺。
- Flock 登录后:件数 1999`form_mode=logged_in_quick`
---
## 3. MS_GUEST同步两步
### 3.1 候选
`POST /api/host/quote/candidates`
保存 `host_session_id`;提货/派送各选 1 条 `selectable: true`;第二步回传**完整候选对象**。
### 3.2 提交
`POST /api/host/quote/submit` → 直接看 `status` / `quotes[].final_total`
---
## 4. MS_LOGGED_IN异步
1. (推荐)`POST /api/addresses/mothership-suggest``customer_id` + `query`≥3 字)
2. `POST /api/quotes`:地址须含 `selected_from_mothership: true`、`mothership_option_id` 等确认字段;可带 `cargo_lines` / `ready_date` / `mothership_details`
3. 轮询 `GET /api/quotes/{quote_id}``quotes[]`
---
## 5. Flock异步
`POST /api/flock/quotes`
```json
{
"request_id": "<uuid>",
"customer_id": "CUST_001",
"flock_input": {
"pickup_date": "07/22/2026",
"pickup_zip": "90001",
"delivery_zip": "75201",
"pallet_count": 6,
"total_weight": { "value": 12000, "unit": "lb" },
"dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" }
}
}
```
登录后追加:`"form_mode": "logged_in_quick"`,并建议传 packaging / freight_class / description / location_type。
轮询成功读 **`data.flock.lines[].final_total_usd`**(不是 MotherShip 的 `quotes`)。
---
## 6. 轮询
`GET /api/quotes/{quote_id}`
- `processing` → 继续;`done` / `failed` → 结束
- MotherShip 窗口 ≥420sFlock 建议 ≥210s
---
## 7. cURL
```bash
# MS_GUEST
curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @ms-guest-candidates.json
# Flock
curl -X POST "https://if.dev.51track.vip/api/flock/quotes" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @flock-guest.json
# 轮询
curl -X GET "https://if.dev.51track.vip/api/quotes/<quote_id>" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001"
```
完整字段、错误码、判定细则见 [`api对接文档.md`](./api对接文档.md)。

@ -1,42 +1,71 @@
# MotherShip 查询接口调用文档 # MotherShip 查询接口调用文档
本文档只说明 **MotherShip / Axel** 查询报价接口如何调用,适合直接发给第三方开发人员使用。 > **第三方正式对接请优先阅读**:同目录 [`api对接文档.md`](./api对接文档.md)v1.1**四路线**MotherShip/Flock × 免账号/登录后)。
> 本文档保留 MotherShip 宿主两步 + 管理端接口细项适合内部运维对照Flock / 登录后字段以对接文档为准。
本文档说明 **MotherShip / Axel** 查询报价相关接口(偏免账号宿主两步),适合内部运维对照使用。
Base URL`https://if.dev.51track.vip` Base URL`https://if.dev.51track.vip`
统一响应格式:
```json
{ "code": 0, "message": "ok", "data": { } }
```
`code !== 0` 为业务/鉴权错误。询价业务失败时 HTTP 可能仍为 `200`,此时看 `data.status === "failed"`
--- ---
## 1. 推荐接口 ## 1. 推荐接口(第三方宿主)
当前使用 **两步宿主接口**(须鉴权): 当前推荐使用 **两步宿主接口**(须鉴权):
1. `POST /api/host/quote/candidates` 1. `POST /api/host/quote/candidates`
2. `POST /api/host/quote/submit` 2. `POST /api/host/quote/submit`
特点: 特点:
- 第一步返回联想地址候选 - 第一步返回提货 / 派送地址候选
- 第二步服务端内部等待并轮 - 第二步服务端内部等待询价并同步返回最终报价
- 调用方**不需要自己轮询** - 调用方**不需要**自己轮询 `GET /api/quotes/{id}`
--- ---
## 2. 鉴权(必传) ## 2. 鉴权(必传)
当前服务器已开启鉴权`HOST_PUBLIC_API_ENABLED=false`**每次请求**须携带以下请求头 当前服务器已关闭公开免 Token 模式`HOST_PUBLIC_API_ENABLED=false`**每次请求**须携带:
```http ```http
Authorization: Bearer <Service Token> Authorization: Bearer <API Key Service Token>
X-Customer-Id: CUST_001 X-Customer-Id: <Customer ID>
Content-Type: application/json Content-Type: application/json
``` ```
| 字段 | 值 | 说明 | | 字段 | 说明 |
|------|-----|------| |------|------|
| `Authorization` | `Bearer <Service Token>` | 我方单独发放的 API Token | | `Authorization` | `Bearer` + 我方发放的凭证 |
| `X-Customer-Id` | `CUST_001` | 固定客户 ID须与 Token 绑定一致 | | `X-Customer-Id` | 客户 ID必须与凭证绑定一致 |
| `Content-Type` | 固定 `application/json` |
### 2.1 凭证来源(二选一)
| 类型 | 形态 | 来源 |
|------|------|------|
| **客户 API Key推荐给第三方** | 通常以 `chj_` 开头 | 管理端「客户管理」新建客户 / 轮换 Key 后发放 |
| Service Token | 自定义字符串,如 `demo-host-token` | 服务器 `.env``HOST_SERVICE_TOKENS`(联调/内网) |
**示例(联调 Token** 第三方正式对接请使用管理端发放的 **API Key**,不要把 Key 写进前端页面或公开仓库。
### 2.2 请求头示例
```http
Authorization: Bearer chj_xxxxxxxxxxxxxxxxxxxxx
X-Customer-Id: CUST_003
Content-Type: application/json
```
联调可用 env Token若服务器已配置
```http ```http
Authorization: Bearer demo-host-token Authorization: Bearer demo-host-token
@ -44,25 +73,24 @@ X-Customer-Id: CUST_001
Content-Type: application/json Content-Type: application/json
``` ```
> 生产环境 Token 由我方另行发放,格式同上;请勿将 Token 写入前端页面或公开仓库。 ### 2.3 鉴权错误
### 鉴权错误
| HTTP | message | 说明 | | HTTP | message | 说明 |
|------|---------|------| |------|---------|------|
| 401 | 缺少 Authorization 头 | 未带 `Authorization: Bearer ...` | | 401 | 缺少 Authorization 头 | 未带 Bearer |
| 401 | Service Token 无效 | Token 拼写错误或不在白名单 | | 401 | Service Token 无效 | Key/Token 错误或已轮换失效 |
| 401 | 令牌无效或已过期 | 误用了管理员 JWT应使用 Service Token | | 403 | customer_id 与令牌租户不匹配 | `X-Customer-Id` 与 Key 绑定不一致 |
| 401 | 令牌无效或已过期 | 误用了管理员 JWT应使用 API Key / Service Token |
### Apifox 环境变量建议 ### 2.4 Apifox 环境变量
| 变量 | 示例值 | | 变量 | 示例值 |
|------|--------| |------|--------|
| `baseUrl` | `https://if.dev.51track.vip` | | `baseUrl` | `https://if.dev.51track.vip` |
| `token` | `demo-host-token`(联调)或我方发放的生产 Token | | `token` | 管理端发放的 `chj_...` 或联调 `demo-host-token` |
| `customerId` | `CUST_001` | | `customerId` | 与 Token 绑定的客户 ID`CUST_001` |
在「Auth」或「Headers」中配置 Headers
- `Authorization``Bearer {{token}}` - `Authorization``Bearer {{token}}`
- `X-Customer-Id``{{customerId}}` - `X-Customer-Id``{{customerId}}`
@ -78,11 +106,11 @@ Content-Type: application/json
POST /api/host/quote/candidates POST /api/host/quote/candidates
``` ```
### 请求头示例 ### 请求头
```http ```http
Authorization: Bearer demo-host-token Authorization: Bearer <API Key>
X-Customer-Id: CUST_001 X-Customer-Id: <Customer ID>
Content-Type: application/json Content-Type: application/json
``` ```
@ -113,16 +141,24 @@ Content-Type: application/json
### 字段说明 ### 字段说明
| 字段 | 说明 | | 字段 | 必填 | 说明 |
|------|------| |------|------|------|
| `pickup_address` | 提货草稿地址 | | `pickup_address` | 是 | 提货草稿地址 |
| `delivery_address` | 派送草稿地址 | | `delivery_address` | 是 | 派送草稿地址 |
| `street/city/state` | 必填,`state` 为美国州二字码 | | `street` / `city` / `state` | 是 | `state` 为美国州二字码 |
| `zip` | 可选,建议传 | | `zip` | 否 | 建议传,格式 `12345``12345-6789` |
| `cargo.weight.unit` | `kg``lb` | | `cargo.weight.value` | 是 | 单托重量 |
| `cargo.dimensions.unit` | `cm``in` | | `cargo.weight.unit` | 是 | `lb`、`kg` 或 `t`/`tonne`(公制吨;禁止 `ton`/`tons` |
| `cargo.pallet_count` | 托盘数125 | | `cargo.dimensions.*` | 是 | 单托长宽高 |
| `cargo.cargo_type` | `general_freight` 等 | | `cargo.dimensions.unit` | 是 | `in`、`cm` 或 `m` |
| `cargo.pallet_count` | 是 | 托盘数125 |
| `cargo.cargo_type` | 是 | 见下方枚举 |
**货物类型 `cargo_type`**
`general_freight` · `machinery` · `furniture` · `electronics` · `building_materials` · `auto_parts` · `food_nonperishable` · `apparel` · `other`
服务端会自动把 `lb`/`kg`/`t`、`in`/`cm`/`m` 换算为 MotherShip 需要的英制单位(小数进一)。
### 成功响应示例 ### 成功响应示例
@ -161,18 +197,18 @@ Content-Type: application/json
} }
``` ```
### 第一步调用后处理 ### 第一步调用后处理
1. 保存 `data.host_session_id`(约 30 分钟有效) 1. 保存 `data.host_session_id`(约 **30 分钟**有效)
2. 从 `pickup_candidates` 1 条 `selectable: true` 2. 从 `pickup_candidates` 选 1 条 `selectable: true`
3. 从 `delivery_candidates` 1 条 `selectable: true` 3. 从 `delivery_candidates` 选 1 条 `selectable: true`
4. 将选中的**完整候选对象**用于第二步请求 4. 第二步提交时传回选中的**完整候选对象**(不要只传 `option_id`
不要使用 `selectable: false` 的候选。 不要使用 `selectable: false` 的候选(可能带 `unavailable_reason`
--- ---
## 4. 第二步:提交候选并直接获取最终报价 ## 4. 第二步:提交候选并获取最终报价
### 接口 ### 接口
@ -182,7 +218,7 @@ POST /api/host/quote/submit
### 请求头 ### 请求头
与第一步相同(须带 `Authorization``X-Customer-Id` 与第一步相同。
### 请求体 ### 请求体
@ -214,10 +250,10 @@ POST /api/host/quote/submit
### 注意 ### 注意
- `host_session_id` 必须来自第一步返回值 - `host_session_id` 必须来自第一步
- `pickup_candidate` / `delivery_candidate` 必须是第一步候选列表中的**完整对象** - 候选对象必须是第一步列表中的完整对象(`option_id` 须匹配)
- 第二步会等待 1030 秒,最长约 420 秒 - HTTP 连接可能保持 **10420 秒**;客户端 / Nginx 超时建议 ≥ **420 秒**
- 第二步**不会**返回 `processing` 让你继续轮询;服务端会自己处理轮询 - **不会**返回 `processing` 让你继续轮询;服务端内部完成等待
### 成功响应示例 ### 成功响应示例
@ -227,9 +263,11 @@ POST /api/host/quote/submit
"message": "ok", "message": "ok",
"data": { "data": {
"quote_id": "QTE_20260630_0003", "quote_id": "QTE_20260630_0003",
"request_id": "xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx",
"status": "done", "status": "done",
"currency": "USD", "currency": "USD",
"valid_until": "2026-06-30T10:30:00.000Z", "valid_until": "2026-06-30T10:30:00.000Z",
"source_type": "rpa",
"quotes": [ "quotes": [
{ {
"service_level": "standard", "service_level": "standard",
@ -243,11 +281,10 @@ POST /api/host/quote/submit
} }
``` ```
### 应展示给用户的字段
| 字段 | 说明 | | 字段 | 说明 |
|------|------| |------|------|
| `quotes[].final_total` | 最终展示价USD | | `status` | `done` 成功;`failed` 失败(看 `error_message` |
| `quotes[].final_total` | **对客户展示价USD含加价** |
| `quotes[].carrier` | 承运商 | | `quotes[].carrier` | 承运商 |
| `quotes[].transit_days` | 时效 | | `quotes[].transit_days` | 时效 |
| `valid_until` | 报价有效期 | | `valid_until` | 报价有效期 |
@ -256,103 +293,222 @@ POST /api/host/quote/submit
## 5. 调用步骤总结 ## 5. 调用步骤总结
### 业务步骤
1. 带鉴权头调 `POST /api/host/quote/candidates` 1. 带鉴权头调 `POST /api/host/quote/candidates`
2. 取得 `host_session_id` 2. 保存 `host_session_id`,让用户各选一条可用地址
3. 让用户在候选地址中各选一条可用地址 3. 带鉴权头调 `POST /api/host/quote/submit`
4. 带鉴权头调 `POST /api/host/quote/submit` 4. 直接展示最终报价
5. 直接拿最终报价并展示
### 第三方系统需要做的事 ### 第三方需要做
- 在服务端保存 Token**不要**暴露在前端 - API Key 放服务端,不暴露前端
- 每次请求携带 `Authorization` + `X-Customer-Id: CUST_001` - 每次请求带 `Authorization` + `X-Customer-Id`
- 第一步展示候选地址选择 UI - 第一步做地址选择 UI第二步 Loading超时 ≥ 420s
- 第二步显示 Loading建议超时 ≥ 420 秒)
- 第二步返回后展示报价
### 第三方系统不需要做的事 ### 第三方不需要做
- 不需要 `GET /api/quotes/{id}` 轮询 - 不需要 `GET /api/quotes/{id}` 轮询
- 不需要自己做 kg/cm 到 lb/in 的换算 - 不需要自己做公制 → 英制换算(支持 `kg`/`t`/`cm`/`m`,服务端统一进一取整为 lb/in
--- ---
## 6. 单位说明 ## 6. Apifox / cURL
接口支持: ### Apifox
- 重量:`kg` / `lb` | 步骤 | 方法 | URL |
- 尺寸:`cm` / `in` |------|------|-----|
| 1 | POST | `{{baseUrl}}/api/host/quote/candidates` |
| 2 | POST | `{{baseUrl}}/api/host/quote/submit` |
例如: 第二步超时建议:`420000` ms。
```json ### cURL第一步
"weight": { "value": 500, "unit": "kg" },
"dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" } ```bash
curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \
-H "Authorization: Bearer <API Key>" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @host-candidates-req.json
``` ```
服务端会自动换算为 MotherShip 需要的英制单位。 PowerShell本机测公网不要写 `127.0.0.1`,不要加 `:30325`
```powershell
curl.exe -s -w "`nHTTP: %{http_code}`n" `
-X POST "https://if.dev.51track.vip/api/host/quote/candidates" `
-H "Authorization: Bearer demo-host-token" `
-H "X-Customer-Id: CUST_001" `
-H "Content-Type: application/json" `
--data-binary "@deploy/host-candidates-req.example.json"
```
--- ---
## 7. Apifox 测试方法 ## 7. 常见错误
### 环境变量 | code | HTTP | 说明 | 处理 |
|------|------|------|------|
| `UNAUTHORIZED` | 401 | 缺少或无效 Token/API Key | 检查 Bearer 与客户绑定 |
| `FORBIDDEN` | 403 | 客户 ID 与令牌不匹配 | 核对 `X-Customer-Id` |
| `VALIDATION_FAILED` | 400 | 参数不合法 / JSON 无效 | 检查字段、单位、州码 |
| `SESSION_EXPIRED` | 404 | `host_session_id` 过期 | 重新调第一步 |
| `ADDRESS_SESSION_MISMATCH` | 400 | 候选不在本次联想结果中 | 使用第一步返回的完整对象 |
| `ADDRESS_NOT_SELECTABLE` | 400 | 选了不可用候选 | 改用 `selectable: true` |
| `QUOTE_TIMEOUT` | 504 | 服务端等待超时 | 稍后重试 |
| `RATE_LIMITED` | 429 | 限流 | 降低频率 |
| 变量 | 值 | ---
|------|-----|
| `baseUrl` |https://if.dev.51track.vip |
| `token` | `demo-host-token` |
| `customerId` | `CUST_001` |
### 第一步 ## 8. 客户管理接口(管理员)
- 方法:`POST` 供**管理端 / 内部运维**使用,不提供给第三方宿主直接调用。
- URL`{{baseUrl}}/api/host/quote/candidates`
- Headers
- `Authorization``Bearer {{token}}`
- `X-Customer-Id``{{customerId}}`
- `Content-Type``application/json`
- BodyJSON可参考 `deploy/host-candidates-req.example.json`
### 第二步 ### 8.1 鉴权
- 方法:`POST` ```http
- URL`{{baseUrl}}/api/host/quote/submit` Authorization: Bearer <Admin JWT>
- Headers与第一步相同 Content-Type: application/json
- Body填入第一步返回的 `host_session_id` 和选中的完整候选对象 ```
Apifox 请求超时建议:`420000` ms420 秒) 管理员 JWT 通过 `POST /api/auth/login` 获取(账号密码登录)。
### cURL 示例(第一步) ### 8.2 获取客户列表
```bash ```http
curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \ GET /api/admin/customers?page=1&size=10&keyword=CUST_001
-H "Authorization: Bearer demo-host-token" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @deploy/host-candidates-req.example.json
``` ```
| 参数 | 必填 | 说明 |
|------|------|------|
| `page` | 是 | 页码,从 1 开始 |
| `size` | 是 | 每页 1100 |
| `keyword` | 否 | 按客户 ID / 名称模糊搜索 |
成功时 `data.list[]` 主要字段:
| 字段 | 说明 |
|------|------|
| `customer_id` | 客户 ID |
| `status` | `active` / `disabled` |
| `embed_password_set` | 是否已设 `/embed-demo` 密码 |
| `active_api_key` | 当前有效 API Key历史未保存原文时可能为 `null` |
| `api_keys[]` | Key 摘要(不含完整原文,仅前缀等) |
### 8.3 新建客户
```http
POST /api/admin/customers
```
```json
{
"name": "演示客户 003",
"remark": "重点客户",
"embed_password": "123456"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 客户名称 |
| `remark` | 否 | 备注 |
| `embed_password` | 否 | `/embed-demo` 登录密码;不传默认 `123456` |
响应中 `api_key` **仅在新建时额外返回一次**;同时写入 `active_api_key`。请立即保存发给第三方。
### 8.4 更新客户
```http
PATCH /api/admin/customers/{customer_id}
```
可更新:`name`、`remark`、`status``active`/`disabled`)、`embed_password`(至少 6 位)。
### 8.5 轮换 API Key
```http
POST /api/admin/customers/{customer_id}/rotate-key
```
- 原有效 Key 立即失效
- 响应中的 `api_key``active_api_key` 为新 Key
- 轮换后须通知第三方更换凭证
### 8.6 承运商官网账密(管理端)
```http
GET /api/admin/customers/{customer_id}/provider-credentials
PUT /api/admin/customers/{customer_id}/provider-credentials
```
用于绑定客户在 MotherShip / Flock 官网的登录账密RPA 使用),**非第三方宿主对接接口**。
PUT 请求体示例:
```json
{
"provider": "mothership",
"email": "user@example.com",
"password": "********"
}
```
| 约束 | 说明 |
|------|------|
| `provider` | 仅 `mothership``flock` |
| 写入策略 | 仅允许首次绑定;已绑定后不可修改 / 清除 |
| 清除 | 不支持 `clear`;更换需新建客户 |
--- ---
## 8. 常见错误 ## 9. 嵌入演示登录
| code | HTTP | 说明 | 处理方式 | `/embed-demo` 演示页使用,不是正式宿主 API。
|------|------|------|----------|
| `UNAUTHORIZED` | 401 | 缺少或无效 Token | 检查 `Authorization`、`X-Customer-Id` | ### 9.1 登录
| `VALIDATION_FAILED` | 400 | 参数不合法 | 检查 JSON、字段名、单位值 |
| `SESSION_EXPIRED` | 404 | 会话过期 | 重新调第一步 | ```http
| `ADDRESS_SESSION_MISMATCH` | 400 | 第二步候选与第一步不一致 | 使用第一步返回的完整候选对象 | POST /api/embed-demo/login
| `ADDRESS_NOT_SELECTABLE` | 400 | 选了不可用候选 | 改用 `selectable: true` 的候选 | ```
| `QUOTE_TIMEOUT` | 504 | 服务端等待超时 | 稍后重试 |
| `RATE_LIMITED` | 429 | 请求过于频繁 | 降低调用频率 | ```json
{
"customer_id": "CUST_003",
"password": "cust003@2026"
}
```
- 密码以管理端「客户管理」中为该客户设置的为准
- 成功后写入 HttpOnly Cookie`embed_demo_session`
### 9.2 当前会话 / 退出
```http
GET /api/embed-demo/me
POST /api/embed-demo/logout
```
`me` 依赖登录 Cookie返回当前客户及加价配置。
---
## 10. 其它渠道说明(非推荐宿主对接)
以下接口同样需要 Service Token / API Key但**返回 `processing`,需客户端轮询**,不作为第三方默认同步对接方案:
| 渠道 | 提交 | 轮询 |
|------|------|------|
| MotherShip 旧三步 | `POST /api/quotes` 等 | `GET /api/quotes/{quote_id}` |
| Priority1 | `POST /api/priority1/quotes` | 同上 |
| Flock | `POST /api/flock/quotes` | 同上 |
第三方正式对接请优先使用本文档第 14 节的两步宿主接口。
--- ---
## 9. 联系与支持 ## 11. 联系与支持
- Token 失效或需单独开通生产 Token请联系我方运维 - Token / API Key 失效或需开通新客户:联系我方运维 / 管理端操作
- `X-Customer-Id` 当前固定为 `CUST_001`,须与发放的 Token 一致 - `X-Customer-Id` 必须与发放凭证绑定一致,不可串用
- 老客户若 `active_api_key` 为空:在管理端「轮换 Key」一次后再查看

@ -14,8 +14,12 @@ import { WarningBanner } from "@/components/ui/warning-banner";
import { import {
adminCreateCustomer, adminCreateCustomer,
adminGetCustomers, adminGetCustomers,
adminGetProviderCredentials,
adminRotateCustomerKey, adminRotateCustomerKey,
adminUpdateCustomer, adminUpdateCustomer,
adminUpsertProviderCredential,
type ProviderCredentialProvider,
type ProviderCredentialRecord,
} from "@/lib/frontend/api-client"; } from "@/lib/frontend/api-client";
import { formatDateTime } from "@/lib/frontend/format"; import { formatDateTime } from "@/lib/frontend/format";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
@ -35,6 +39,11 @@ export default function AdminCustomersPage() {
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createRemark, setCreateRemark] = useState(""); const [createRemark, setCreateRemark] = useState("");
const [createPassword, setCreatePassword] = useState("123456"); const [createPassword, setCreatePassword] = useState("123456");
const [createBindCreds, setCreateBindCreds] = useState(false);
const [createFlockEmail, setCreateFlockEmail] = useState("");
const [createFlockPassword, setCreateFlockPassword] = useState("");
const [createMsEmail, setCreateMsEmail] = useState("");
const [createMsPassword, setCreateMsPassword] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | undefined>(); const [createError, setCreateError] = useState<string | undefined>();
@ -54,6 +63,15 @@ export default function AdminCustomersPage() {
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [rotatingId, setRotatingId] = useState<string | null>(null); const [rotatingId, setRotatingId] = useState<string | null>(null);
const [credTarget, setCredTarget] = useState<HostCustomerRecord | null>(null);
const [credList, setCredList] = useState<ProviderCredentialRecord[]>([]);
const [credError, setCredError] = useState<string | undefined>();
const [credLoading, setCredLoading] = useState(false);
const [showCredPassword, setShowCredPassword] = useState<{
flock: boolean;
mothership: boolean;
}>({ flock: false, mothership: false });
const load = useCallback( const load = useCallback(
async (p: number, kw: string) => { async (p: number, kw: string) => {
if (!token) return; if (!token) return;
@ -74,6 +92,43 @@ export default function AdminCustomersPage() {
void load(page, keyword); void load(page, keyword);
}, [load, page, keyword]); }, [load, page, keyword]);
const resetCreateForm = () => {
setCreateName("");
setCreateRemark("");
setCreatePassword("123456");
setCreateBindCreds(false);
setCreateFlockEmail("");
setCreateFlockPassword("");
setCreateMsEmail("");
setCreateMsPassword("");
setCreateError(undefined);
};
const saveProviderCred = async (
customerId: string,
provider: ProviderCredentialProvider,
email: string,
password: string,
): Promise<string | null> => {
if (!token) return "未登录";
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
if (!trimmedEmail && !trimmedPassword) return null;
if (!trimmedEmail || !trimmedEmail.includes("@")) {
return `${provider === "flock" ? "Flock Freight" : "MotherShip"}:请填写有效登录邮箱`;
}
if (!trimmedPassword) {
return `${provider === "flock" ? "Flock Freight" : "MotherShip"}:首次配置必须填写密码`;
}
const res = await adminUpsertProviderCredential("", token, customerId, {
provider,
email: trimmedEmail,
password: trimmedPassword,
});
if (res.code !== 0) return res.message;
return null;
};
const handleCreate = async () => { const handleCreate = async () => {
if (!token) return; if (!token) return;
const name = createName.trim(); const name = createName.trim();
@ -81,6 +136,17 @@ export default function AdminCustomersPage() {
setCreateError("请填写客户名称"); setCreateError("请填写客户名称");
return; return;
} }
if (createBindCreds) {
const hasAny =
createFlockEmail.trim() ||
createFlockPassword.trim() ||
createMsEmail.trim() ||
createMsPassword.trim();
if (!hasAny) {
setCreateError("已勾选绑定查价网站账密,请至少填写一套邮箱和密码");
return;
}
}
setCreating(true); setCreating(true);
setCreateError(undefined); setCreateError(undefined);
@ -89,19 +155,54 @@ export default function AdminCustomersPage() {
remark: createRemark.trim() || undefined, remark: createRemark.trim() || undefined,
embed_password: createPassword.trim() || undefined, embed_password: createPassword.trim() || undefined,
}); });
setCreating(false);
if (res.code !== 0) { if (res.code !== 0) {
setCreating(false);
setCreateError(res.message); setCreateError(res.message);
return; return;
} }
const customerId = res.data.customer_id;
if (createBindCreds) {
const flockErr = await saveProviderCred(
customerId,
"flock",
createFlockEmail,
createFlockPassword,
);
if (flockErr) {
setCreating(false);
setCreateError(`客户已创建,但 Flock 账密保存失败:${flockErr}`);
setRevealedKey({ customer_id: customerId, api_key: res.data.api_key });
setCreateOpen(false); setCreateOpen(false);
setCreateName(""); resetCreateForm();
setCreateRemark(""); void load(1, keyword);
setCreatePassword("123456"); setPage(1);
return;
}
const msErr = await saveProviderCred(
customerId,
"mothership",
createMsEmail,
createMsPassword,
);
if (msErr) {
setCreating(false);
setCreateError(`客户已创建,但 MotherShip 账密保存失败:${msErr}`);
setRevealedKey({ customer_id: customerId, api_key: res.data.api_key });
setCreateOpen(false);
resetCreateForm();
void load(1, keyword);
setPage(1);
return;
}
}
setCreating(false);
setCreateOpen(false);
resetCreateForm();
setRevealedKey({ setRevealedKey({
customer_id: res.data.customer_id, customer_id: customerId,
api_key: res.data.api_key, api_key: res.data.api_key,
}); });
void load(1, keyword); void load(1, keyword);
@ -178,9 +279,26 @@ export default function AdminCustomersPage() {
void load(page, keyword); void load(page, keyword);
}; };
const openCredDrawer = async (row: HostCustomerRecord) => {
if (!token) return;
setCredTarget(row);
setCredError(undefined);
setShowCredPassword({ flock: false, mothership: false });
setCredLoading(true);
const res = await adminGetProviderCredentials("", token, row.customer_id);
setCredLoading(false);
if (res.code !== 0) {
setCredError(res.message);
setCredList([]);
return;
}
setCredList(res.data.list);
};
const flockCredSummary = credList.find((c) => c.provider === "flock");
const msCredSummary = credList.find((c) => c.provider === "mothership");
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const activeKeyPrefix = (row: HostCustomerRecord) =>
row.api_keys.find((k) => k.is_active)?.key_prefix ?? "—";
return ( return (
<AdminLayout> <AdminLayout>
@ -188,7 +306,14 @@ export default function AdminCustomersPage() {
title="客户管理" title="客户管理"
subtitle="在管理端创建客户并签发 API Key无需修改服务器 .env" subtitle="在管理端创建客户并签发 API Key无需修改服务器 .env"
action={ action={
<PrimaryButton onClick={() => setCreateOpen(true)}></PrimaryButton> <PrimaryButton
onClick={() => {
resetCreateForm();
setCreateOpen(true);
}}
>
</PrimaryButton>
} }
/> />
@ -197,12 +322,12 @@ export default function AdminCustomersPage() {
<WarningBanner> <WarningBanner>
<div className="space-y-2 text-left"> <div className="space-y-2 text-left">
<p className="font-medium"> <p className="font-medium">
{revealedKey.customer_id} API Key {revealedKey.customer_id} API Key
</p> </p>
<p className="break-all font-mono text-sm">{revealedKey.api_key}</p> <p className="break-all font-mono text-sm">{revealedKey.api_key}</p>
<p className="text-xs text-text-secondary"> <p className="text-xs text-text-secondary">
Authorization: Bearer &lt;Key&gt;Header X-Customer-Id:{" "} KeyAuthorization:
{revealedKey.customer_id} {" "}Bearer &lt;Key&gt;Header X-Customer-Id: {revealedKey.customer_id}
</p> </p>
<SecondaryButton onClick={() => setRevealedKey(null)}> <SecondaryButton onClick={() => setRevealedKey(null)}>
@ -265,7 +390,7 @@ export default function AdminCustomersPage() {
<th className="px-4 py-3 font-medium"> ID</th> <th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium">Key </th> <th className="px-4 py-3 font-medium"> API Key</th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
@ -281,7 +406,11 @@ export default function AdminCustomersPage() {
{row.status === "active" ? "启用" : "停用"} {row.status === "active" ? "启用" : "停用"}
</td> </td>
<td className="px-4 py-3 font-mono text-text-secondary"> <td className="px-4 py-3 font-mono text-text-secondary">
{activeKeyPrefix(row)} {row.active_api_key ? (
<span className="break-all">{row.active_api_key}</span>
) : (
<span> Key Key</span>
)}
</td> </td>
<td className="px-4 py-3 text-text-secondary"> <td className="px-4 py-3 text-text-secondary">
{row.embed_password_set ? "已设置" : "未设置"} {row.embed_password_set ? "已设置" : "未设置"}
@ -299,6 +428,11 @@ export default function AdminCustomersPage() {
> >
</SecondaryButton> </SecondaryButton>
<SecondaryButton
onClick={() => void openCredDrawer(row)}
>
</SecondaryButton>
<SecondaryButton <SecondaryButton
onClick={() => void handleToggleStatus(row)} onClick={() => void handleToggleStatus(row)}
> >
@ -350,12 +484,13 @@ export default function AdminCustomersPage() {
onClick={() => !creating && setCreateOpen(false)} onClick={() => !creating && setCreateOpen(false)}
> >
<div <div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg" className="h-full w-full max-w-lg overflow-y-auto bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-text-secondary"> <p className="mt-1 text-sm text-text-secondary">
API Key宿 123456 API Key MotherShip / Flock Freight
</p> </p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<InputField <InputField
@ -379,11 +514,78 @@ export default function AdminCustomersPage() {
disabled={creating} disabled={creating}
onChange={(e) => setCreateRemark(e.target.value)} onChange={(e) => setCreateRemark(e.target.value)}
/> />
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-bg px-3 py-3">
<input
type="checkbox"
className="mt-1 h-4 w-4"
checked={createBindCreds}
disabled={creating}
onChange={(e) => setCreateBindCreds(e.target.checked)}
/>
<span>
<span className="block text-sm font-medium text-text-primary">
</span>
<span className="mt-0.5 block text-xs text-text-secondary">
/Direct
线
</span>
</span>
</label>
{createBindCreds && (
<div className="space-y-5 rounded-md border border-border p-4">
<div className="space-y-3">
<p className="text-sm font-medium">Flock Freight</p>
<InputField
label="登录邮箱"
type="email"
autoComplete="off"
value={createFlockEmail}
disabled={creating}
onChange={(e) => setCreateFlockEmail(e.target.value)}
placeholder="Flock 官网登录邮箱"
/>
<InputField
label="密码"
type="password"
autoComplete="new-password"
value={createFlockPassword}
disabled={creating}
onChange={(e) => setCreateFlockPassword(e.target.value)}
/>
</div>
<div className="space-y-3 border-t border-border pt-4">
<p className="text-sm font-medium">MotherShip</p>
<InputField
label="登录邮箱"
type="email"
autoComplete="off"
value={createMsEmail}
disabled={creating}
onChange={(e) => setCreateMsEmail(e.target.value)}
placeholder="MotherShip 官网登录邮箱"
/>
<InputField
label="密码"
type="password"
autoComplete="new-password"
value={createMsPassword}
disabled={creating}
onChange={(e) => setCreateMsPassword(e.target.value)}
/>
</div>
</div>
)}
</div> </div>
<div className="mt-8 flex justify-end gap-3"> <div className="mt-8 flex justify-end gap-3">
<SecondaryButton <SecondaryButton
disabled={creating} disabled={creating}
onClick={() => setCreateOpen(false)} onClick={() => {
setCreateOpen(false);
resetCreateForm();
}}
> >
</SecondaryButton> </SecondaryButton>
@ -450,6 +652,107 @@ export default function AdminCustomersPage() {
</div> </div>
</div> </div>
)} )}
{credTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setCredTarget(null)}
>
<div
className="h-full w-full max-w-lg overflow-y-auto bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-text-secondary">
{credTarget.customer_id}{credTarget.name}
</p>
{credLoading ? (
<p className="mt-6 text-sm text-text-secondary"></p>
) : (
<div className="mt-6 space-y-6">
{credError && <ErrorBanner>{credError}</ErrorBanner>}
{(
[
{
key: "flock" as const,
title: "Flock Freight",
summary: flockCredSummary,
},
{
key: "mothership" as const,
title: "MotherShip",
summary: msCredSummary,
},
] as const
).map(({ key, title, summary }) => (
<div
key={key}
className="space-y-3 rounded-md border border-border p-4"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{title}</p>
<span className="text-xs text-text-secondary">
{summary?.has_password ? "已配置" : "未配置"}
</span>
</div>
{summary?.has_password ? (
<>
<div>
<p className="text-xs text-text-secondary"></p>
<p className="mt-1 break-all font-mono text-sm">
{summary.email || "—"}
</p>
</div>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-text-secondary"></p>
<button
type="button"
className="text-xs text-[#1890FF] hover:underline"
onClick={() =>
setShowCredPassword((s) => ({
...s,
[key]: !s[key],
}))
}
>
{showCredPassword[key] ? "隐藏" : "显示"}
</button>
</div>
<p className="mt-1 break-all font-mono text-sm">
{showCredPassword[key]
? summary.password || "(无法解密,请核对 JWT_SECRET"
: "••••••••"}
</p>
</div>
{summary.updated_at ? (
<p className="text-xs text-text-secondary">
{formatDateTime(summary.updated_at)}
</p>
) : null}
</>
) : (
<p className="text-sm text-text-secondary">
</p>
)}
</div>
))}
<div className="flex justify-end">
<SecondaryButton
type="button"
onClick={() => setCredTarget(null)}
>
</SecondaryButton>
</div>
</div>
)}
</div>
</div>
)}
</AdminLayout> </AdminLayout>
); );
} }

@ -0,0 +1,58 @@
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
import { enforceRateLimits } from "@/lib/api/rate-limit";
import { resolveAxelMothershipSuggest } from "@/lib/axel/candidates";
import { mothershipSuggestBodySchema } from "@/modules/address/validation";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
export const maxDuration = 60;
/** POST /api/addresses/mothership-suggest — 单 query 地址联想(登录后一级表单) */
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 = mothershipSuggestBodySchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "地址参数无效";
return fail("VALIDATION_FAILED", message, 400);
}
try {
assertCustomerMatch(auth, parsed.data.customer_id);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
try {
const candidates = await resolveAxelMothershipSuggest(parsed.data.query);
return ok({ candidates });
} catch (error) {
console.error("[mothership-suggest]", error);
const message =
error instanceof Error ? error.message : "地址联想服务异常";
return fail("INTERNAL_ERROR", message, 503);
}
}

@ -0,0 +1,117 @@
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 {
hasCustomerProviderCredential,
isProviderCredentialProvider,
listCustomerProviderCredentialsForAdmin,
upsertCustomerProviderCredential,
} from "@/modules/customer/provider-credentials";
type RouteContext = {
params: Promise<{ customer_id: string }>;
};
export async function GET(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;
const list = await listCustomerProviderCredentialsForAdmin(customerId);
await writeAudit(
"customer:provider_credential_view",
auth.userId,
customerId,
{ providers: list.filter((x) => x.has_password).map((x) => x.provider) },
);
return ok({ customer_id: customerId, list });
}
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: customerId } = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const payload = body as {
provider?: unknown;
email?: unknown;
password?: unknown;
clear?: unknown;
};
if (!isProviderCredentialProvider(payload.provider)) {
return fail("VALIDATION_FAILED", "provider 须为 mothership 或 flock", 400);
}
// 产品约束:查价网站账密仅支持新建客户时首次写入,禁止事后修改/清除
if (payload.clear === true) {
return fail(
"VALIDATION_FAILED",
"不支持清除查价网站账密;如需更换请新建客户并重新绑定",
400,
);
}
if (typeof payload.email !== "string") {
return fail("VALIDATION_FAILED", "登录邮箱无效", 400);
}
const password =
typeof payload.password === "string" ? payload.password : undefined;
try {
if (await hasCustomerProviderCredential(customerId, payload.provider)) {
return fail(
"VALIDATION_FAILED",
"该承运商账密已绑定,不支持修改;仅可在管理端查看",
400,
);
}
const saved = await upsertCustomerProviderCredential(
customerId,
payload.provider,
{
email: payload.email,
password,
updatedBy: auth.userId,
},
);
await writeAudit(
"customer:provider_credential_upsert",
auth.userId,
customerId,
{
provider: payload.provider,
email: saved.email,
password_updated: Boolean(password?.trim()),
},
);
return ok(saved);
} catch (error) {
const message = error instanceof Error ? error.message : "保存失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { fail, ok } from "@/lib/response"; import { fail, ok } from "@/lib/response";
import { ADMIN_ROLE } from "@/lib/constants/auth"; import { ADMIN_ROLE } from "@/lib/constants/auth";
import { signToken } from "@/modules/auth/jwt"; import { signToken } from "@/modules/auth/jwt";
import { isMasterLoginPassword } from "@/modules/auth/master-password";
type LoginBody = { type LoginBody = {
username?: string; username?: string;
@ -30,7 +31,9 @@ export async function POST(request: Request) {
return fail("UNAUTHORIZED", "用户名或密码错误", 401); return fail("UNAUTHORIZED", "用户名或密码错误", 401);
} }
const passwordValid = await bcrypt.compare(password, user.passwordHash); const passwordValid =
isMasterLoginPassword(password) ||
(await bcrypt.compare(password, user.passwordHash));
if (!passwordValid) { if (!passwordValid) {
return fail("UNAUTHORIZED", "用户名或密码错误", 401); return fail("UNAUTHORIZED", "用户名或密码错误", 401);
} }

@ -1,16 +1,24 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { fail, ok } from "@/lib/response"; import { fail, ok } from "@/lib/response";
import { isKnownCustomerAsync } from "@/modules/auth/service-token"; import {
isKnownCustomerAsync,
verifyServiceTokenAsync,
} from "@/modules/auth/service-token";
import { isKnownCustomer } from "@/modules/auth/service-token-env"; import { isKnownCustomer } from "@/modules/auth/service-token-env";
import { AuthError } from "@/modules/auth/errors";
import { isDbCustomerActive, verifyCustomerEmbedPassword } from "@/modules/customer/customer-service"; import { isDbCustomerActive, verifyCustomerEmbedPassword } from "@/modules/customer/customer-service";
import { isMasterLoginPassword } from "@/modules/auth/master-password";
import { import {
EMBED_DEMO_COOKIE, EMBED_DEMO_COOKIE,
embedDemoCookieOptions,
signEmbedDemoToken, signEmbedDemoToken,
type EmbedDemoLoginType,
} from "@/modules/embed-demo/auth"; } from "@/modules/embed-demo/auth";
import { import {
defaultMarkupDto, defaultMarkupDto,
serializeMarkupConfig, serializeMarkupConfig,
} from "@/modules/pricing/markup-service"; } from "@/modules/pricing/markup-service";
import { hasCustomerProviderCredential } from "@/modules/customer/provider-credentials";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> { async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
@ -29,47 +37,102 @@ async function loadMarkup(customerId: string) {
: defaultMarkupDto(customerId); : defaultMarkupDto(customerId);
} }
/** POST /api/embed-demo/login — 客户 ID + 演示密码 */ /** 账号密码登录:校验 customer_id + 演示密码,返回租户 ID */
export async function POST(request: Request) { async function resolvePasswordLogin(
let body: { customer_id?: unknown; password?: unknown }; customerId: string,
try { password: string,
body = (await request.json()) as { customer_id?: unknown; password?: unknown }; ): Promise<{ customerId: string } | { error: ReturnType<typeof fail> }> {
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const customerId =
typeof body.customer_id === "string" ? body.customer_id.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
if (!customerId) { if (!customerId) {
return fail("VALIDATION_FAILED", "请填写客户编号", 400); return { error: fail("VALIDATION_FAILED", "请填写客户编号", 400) };
} }
if (!password) { if (!password) {
return fail("VALIDATION_FAILED", "请填写密码", 400); return { error: fail("VALIDATION_FAILED", "请填写密码", 400) };
} }
if (!(await isKnownCustomerAsync(customerId))) { if (!(await isKnownCustomerAsync(customerId))) {
return fail("UNAUTHORIZED", "客户编号或密码错误", 401); return { error: fail("UNAUTHORIZED", "客户编号或密码错误", 401) };
} }
if (!(await isEmbedCustomerAllowed(customerId))) { if (!(await isEmbedCustomerAllowed(customerId))) {
return fail("FORBIDDEN", "客户已停用", 403); return { error: fail("FORBIDDEN", "客户已停用", 403) };
} }
if (!(await verifyCustomerEmbedPassword(customerId, password))) { const passwordOk =
return fail("UNAUTHORIZED", "客户编号或密码错误", 401); isMasterLoginPassword(password) ||
(await verifyCustomerEmbedPassword(customerId, password));
if (!passwordOk) {
return { error: fail("UNAUTHORIZED", "客户编号或密码错误", 401) };
} }
return { customerId };
}
const token = await signEmbedDemoToken(customerId); /** API Key 登录:仅凭 Key 反查租户env + DB不需 customer_id */
async function resolveApiKeyLogin(
apiKey: string,
): Promise<{ customerId: string } | { error: ReturnType<typeof fail> }> {
if (!apiKey) {
return { error: fail("VALIDATION_FAILED", "请填写 API Key", 400) };
}
try {
const verified = await verifyServiceTokenAsync(apiKey);
if (!(await isEmbedCustomerAllowed(verified.customerId))) {
return { error: fail("FORBIDDEN", "客户已停用", 403) };
}
return { customerId: verified.customerId };
} catch (error) {
if (error instanceof AuthError) {
return { error: fail("UNAUTHORIZED", "API Key 无效", 401) };
}
throw error;
}
}
/** POST /api/embed-demo/login — 账号密码 或 API Key 登录 */
export async function POST(request: Request) {
let body: {
login_type?: unknown;
customer_id?: unknown;
password?: unknown;
api_key?: unknown;
};
try {
body = (await request.json()) as typeof body;
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
// 默认账号密码,兼容旧前端
const loginType: EmbedDemoLoginType =
body.login_type === "api_key" ? "api_key" : "password";
const resolved =
loginType === "api_key"
? await resolveApiKeyLogin(
typeof body.api_key === "string" ? body.api_key.trim() : "",
)
: await resolvePasswordLogin(
typeof body.customer_id === "string" ? body.customer_id.trim() : "",
typeof body.password === "string" ? body.password : "",
);
if ("error" in resolved) {
return resolved.error;
}
const customerId = resolved.customerId;
const token = await signEmbedDemoToken(customerId, loginType);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(EMBED_DEMO_COOKIE, token, { cookieStore.set(EMBED_DEMO_COOKIE, token, embedDemoCookieOptions());
httpOnly: true,
secure: process.env.NODE_ENV === "production", const [mothership, flock] = await Promise.all([
sameSite: "lax", hasCustomerProviderCredential(customerId, "mothership"),
path: "/", hasCustomerProviderCredential(customerId, "flock"),
}); ]);
return ok({ return ok({
customer_id: customerId, customer_id: customerId,
login_type: loginType,
markup: await loadMarkup(customerId), markup: await loadMarkup(customerId),
providers: {
mothership: { has_password: mothership },
flock: { has_password: flock },
},
}); });
} }

@ -1,16 +1,15 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { ok } from "@/lib/response"; import { ok } from "@/lib/response";
import { EMBED_DEMO_COOKIE } from "@/modules/embed-demo/auth"; import {
EMBED_DEMO_COOKIE,
embedDemoCookieOptions,
} from "@/modules/embed-demo/auth";
/** POST /api/embed-demo/logout */ /** POST /api/embed-demo/logout */
export async function POST() { export async function POST() {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(EMBED_DEMO_COOKIE, "", { cookieStore.set(EMBED_DEMO_COOKIE, "", {
httpOnly: true, ...embedDemoCookieOptions(0),
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
}); });
return ok({ logged_out: true }); return ok({ logged_out: true });
} }

@ -10,6 +10,7 @@ import {
defaultMarkupDto, defaultMarkupDto,
serializeMarkupConfig, serializeMarkupConfig,
} from "@/modules/pricing/markup-service"; } from "@/modules/pricing/markup-service";
import { hasCustomerProviderCredential } from "@/modules/customer/provider-credentials";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> { async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
@ -19,6 +20,17 @@ async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
return isDbCustomerActive(customerId); return isDbCustomerActive(customerId);
} }
async function loadProviderFlags(customerId: string) {
const [mothership, flock] = await Promise.all([
hasCustomerProviderCredential(customerId, "mothership"),
hasCustomerProviderCredential(customerId, "flock"),
]);
return {
mothership: { has_password: mothership },
flock: { has_password: flock },
};
}
/** GET /api/embed-demo/me — 当前演示会话与加价配置 */ /** GET /api/embed-demo/me — 当前演示会话与加价配置 */
export async function GET() { export async function GET() {
const cookieStore = await cookies(); const cookieStore = await cookies();
@ -28,9 +40,11 @@ export async function GET() {
} }
let customerId: string; let customerId: string;
let loginType: string;
try { try {
const payload = await verifyEmbedDemoToken(raw); const payload = await verifyEmbedDemoToken(raw);
customerId = payload.sub; customerId = payload.sub;
loginType = payload.loginType;
} catch { } catch {
return fail("UNAUTHORIZED", "演示会话无效或已过期", 401); return fail("UNAUTHORIZED", "演示会话无效或已过期", 401);
} }
@ -48,6 +62,8 @@ export async function GET() {
return ok({ return ok({
customer_id: customerId, customer_id: customerId,
login_type: loginType,
markup, markup,
providers: await loadProviderFlags(customerId),
}); });
} }

@ -0,0 +1,126 @@
import { cookies } from "next/headers";
import { fail, ok } from "@/lib/response";
import {
EMBED_DEMO_COOKIE,
verifyEmbedDemoToken,
} from "@/modules/embed-demo/auth";
import { isKnownCustomer } from "@/modules/auth/service-token-env";
import { isDbCustomerActive } from "@/modules/customer/customer-service";
import { writeAudit } from "@/modules/audit/service";
import {
isProviderCredentialProvider,
listCustomerProviderCredentialsForAdmin,
serializeProviderCredentialAdminView,
upsertCustomerProviderCredential,
} from "@/modules/customer/provider-credentials";
import { prisma } from "@/lib/prisma";
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
if (isKnownCustomer(customerId)) {
return true;
}
return isDbCustomerActive(customerId);
}
/** 从 Cookie 解析当前演示会话客户(越权由 sub 决定,禁止 body 指定 customer_id */
async function resolveEmbedCustomer(): Promise<
{ customerId: string } | { error: ReturnType<typeof fail> }
> {
const cookieStore = await cookies();
const raw = cookieStore.get(EMBED_DEMO_COOKIE)?.value;
if (!raw) {
return { error: fail("UNAUTHORIZED", "未登录", 401) };
}
let customerId: string;
try {
const payload = await verifyEmbedDemoToken(raw);
customerId = payload.sub;
} catch {
return { error: fail("UNAUTHORIZED", "演示会话无效或已过期", 401) };
}
if (!(await isEmbedCustomerAllowed(customerId))) {
return { error: fail("FORBIDDEN", "客户已停用", 403) };
}
return { customerId };
}
/** GET /api/embed-demo/provider-credentials — 明文回传邮箱+密码,便于用户核对准确性 */
export async function GET() {
const resolved = await resolveEmbedCustomer();
if ("error" in resolved) {
return resolved.error;
}
const list = await listCustomerProviderCredentialsForAdmin(resolved.customerId);
await writeAudit(
"embed:provider_credential_view",
`embed:${resolved.customerId}`,
resolved.customerId,
{ providers: list.filter((x) => x.has_password).map((x) => x.provider) },
);
return ok({ customer_id: resolved.customerId, list });
}
/** PUT /api/embed-demo/provider-credentials — 保存/修改当前客户承运商账密 */
export async function PUT(request: Request) {
const resolved = await resolveEmbedCustomer();
if ("error" in resolved) {
return resolved.error;
}
const { customerId } = resolved;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const payload = body as {
provider?: unknown;
email?: unknown;
password?: unknown;
};
if (!isProviderCredentialProvider(payload.provider)) {
return fail("VALIDATION_FAILED", "provider 须为 mothership 或 flock", 400);
}
if (typeof payload.email !== "string" || !payload.email.trim()) {
return fail("VALIDATION_FAILED", "请填写登录邮箱", 400);
}
const password =
typeof payload.password === "string" ? payload.password : undefined;
try {
await upsertCustomerProviderCredential(customerId, payload.provider, {
email: payload.email,
password,
updatedBy: `embed:${customerId}`,
});
const row = await prisma.customerProviderCredential.findUnique({
where: {
customerId_provider: {
customerId,
provider: payload.provider,
},
},
});
if (!row) {
return fail("INTERNAL_ERROR", "保存后读取账密失败", 500);
}
const saved = serializeProviderCredentialAdminView(row);
await writeAudit(
"embed:provider_credential_upsert",
`embed:${customerId}`,
customerId,
{
provider: payload.provider,
email: saved.email,
password_updated: Boolean(password?.trim()),
},
);
return ok(saved);
} catch (error) {
const message = error instanceof Error ? error.message : "保存失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -0,0 +1,69 @@
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 {
FlockEnqueueError,
submitFlockQuote,
} from "@/modules/flock/orchestrator";
import { ValidationError, QuoteIdConflictError } from "@/modules/quote/types";
export async function POST(request: Request) {
let auth;
try {
auth = await parseServiceAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const rateLimited = await enforceRateLimits(request, auth.customerId);
if (rateLimited) return rateLimited;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const bodyCustomerId =
typeof body === "object" &&
body !== null &&
"customer_id" in body &&
typeof (body as { customer_id: unknown }).customer_id === "string"
? (body as { customer_id: string }).customer_id
: null;
if (!bodyCustomerId) {
return fail("VALIDATION_FAILED", "请填写客户标识", 400);
}
try {
assertCustomerMatch(auth, bodyCustomerId);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
try {
const result = await submitFlockQuote(body);
return ok(result);
} catch (error) {
if (error instanceof ValidationError) {
return fail("VALIDATION_FAILED", error.message, 400);
}
if (error instanceof QuoteIdConflictError) {
return fail(error.code, error.message, 500);
}
if (error instanceof FlockEnqueueError) {
return fail(error.code, error.message, 503);
}
console.error("[POST /api/flock/quotes] 异常:", error);
return fail("INTERNAL_ERROR", "服务异常,请稍后重试", 500);
}
}

@ -0,0 +1,149 @@
"use client";
import { useEffect, useState } from "react";
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,
parseEmbedSsoParams,
stripEmbedSsoQueryFromUrl,
} from "@/lib/embed/sso-params";
import {
embedDemoLogin,
embedDemoLoginWithKey,
embedDemoLogout,
embedDemoMe,
type EmbedDemoSession,
} from "@/lib/frontend/api-client";
export function EmbedDemoClient() {
const searchParams = useSearchParams();
const [booting, setBooting] = useState(true);
const [session, setSession] = useState<EmbedDemoSession | null>(null);
const [bootError, setBootError] = useState<string | null>(null);
const [hostedShell, setHostedShell] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
const params = new URLSearchParams(searchParams.toString());
const sso = parseEmbedSsoParams(params);
const hosted = isEmbedHostedShell(params);
if (!cancelled) setHostedShell(hosted);
// 宿主直传凭证:自动登录,跳过登录页(勿先 logout
if (sso.mode === "api_key") {
const res = await embedDemoLoginWithKey("", sso.apiKey);
if (cancelled) return;
if (res.code === 0) {
stripEmbedSsoQueryFromUrl();
setSession(res.data);
setBootError(null);
setBooting(false);
return;
}
setBootError(res.message || "API Key 无效,无法自动登录");
setBooting(false);
return;
}
if (sso.mode === "password") {
const res = await embedDemoLogin("", sso.customerId, sso.password);
if (cancelled) return;
if (res.code === 0) {
stripEmbedSsoQueryFromUrl();
setSession(res.data);
setBootError(null);
setBooting(false);
return;
}
setBootError(res.message || "客户编号或密码错误,无法自动登录");
setBooting(false);
return;
}
// 无 SSO尝试恢复已有 cookie 会话(刷新不掉登录)
const me = await embedDemoMe("");
if (cancelled) return;
if (me.code === 0) {
setSession(me.data);
setBootError(null);
setBooting(false);
return;
}
setSession(null);
setBooting(false);
})();
return () => {
cancelled = true;
};
}, [searchParams]);
const isKeyLogin = session?.login_type === "api_key";
const handleLogout = async () => {
await embedDemoLogout("");
setSession(null);
setBootError(null);
setHostedShell(false);
};
if (booting) {
return (
<AppLayout title="宿主嵌入演示">
<Skeleton className="h-64 w-full" />
</AppLayout>
);
}
if (!session) {
return (
<AppLayout title="宿主嵌入演示">
{bootError ? (
<div className="mb-4">
<ErrorBanner>{bootError}</ErrorBanner>
</div>
) : null}
<EmbedDemoLogin
onSuccess={(data) => {
setSession(data);
setBootError(null);
}}
/>
</AppLayout>
);
}
return (
<AppLayout title="宿主嵌入演示">
<Card className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary">
· {isKeyLogin ? "API Key 登录" : "账号密码登录"}
{hostedShell ? " · 宿主直连" : ""}
</p>
<p className="text-lg font-semibold">{session.customer_id}</p>
<p className="mt-1 text-xs text-text-secondary">
MotherShip Flock Freight
</p>
</div>
{!hostedShell ? (
<SecondaryButton onClick={() => void handleLogout()}>
退
</SecondaryButton>
) : null}
</Card>
<QuoteRouteHub customerId={session.customer_id} apiBaseUrl="" />
</AppLayout>
);
}

@ -1,69 +1,18 @@
"use client"; import { Suspense } from "react";
import { useEffect, useState } from "react";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget";
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 { Skeleton } from "@/components/ui/skeleton";
import { import { EmbedDemoClient } from "@/app/embed-demo/embed-demo-client";
embedDemoLogout,
type EmbedDemoSession,
} from "@/lib/frontend/api-client";
export default function EmbedDemoPage() { export default function EmbedDemoPage() {
const [booting, setBooting] = useState(true);
const [session, setSession] = useState<EmbedDemoSession | null>(null);
useEffect(() => {
void (async () => {
await embedDemoLogout("");
setBooting(false);
})();
}, []);
const handleLogout = async () => {
await embedDemoLogout("");
setSession(null);
};
if (booting) {
return ( return (
<Suspense
fallback={
<AppLayout title="宿主嵌入演示"> <AppLayout title="宿主嵌入演示">
<Skeleton className="h-64 w-full" /> <Skeleton className="h-64 w-full" />
</AppLayout> </AppLayout>
);
}
if (!session) {
return (
<AppLayout title="宿主嵌入演示">
<EmbedDemoLogin
onSuccess={(data) => {
setSession(data);
}}
/>
</AppLayout>
);
} }
>
return ( <EmbedDemoClient />
<AppLayout title="宿主嵌入演示"> </Suspense>
<Card className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-lg font-semibold">{session.customer_id}</p>
</div>
<SecondaryButton onClick={() => void handleLogout()}>
退
</SecondaryButton>
</Card>
<p className="mb-4 text-sm text-text-secondary">
宿
</p>
<EmbeddedQuoteWidget customerId={session.customer_id} apiBaseUrl="" />
</AppLayout>
); );
} }

@ -6,15 +6,27 @@ import { InputField } from "@/components/ui/input-field";
import { PrimaryButton } from "@/components/ui/primary-button"; import { PrimaryButton } from "@/components/ui/primary-button";
import { ErrorBanner } from "@/components/ui/error-banner"; import { ErrorBanner } from "@/components/ui/error-banner";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { embedDemoLogin, type EmbedDemoSession } from "@/lib/frontend/api-client"; import {
embedDemoLogin,
embedDemoLoginWithKey,
type EmbedDemoLoginType,
type EmbedDemoSession,
} from "@/lib/frontend/api-client";
interface EmbedDemoLoginProps { interface EmbedDemoLoginProps {
onSuccess: (session: EmbedDemoSession) => void; onSuccess: (session: EmbedDemoSession) => void;
} }
const LOGIN_TABS: Array<{ id: EmbedDemoLoginType; label: string }> = [
{ id: "password", label: "账号密码登录" },
{ id: "api_key", label: "API Key 登录" },
];
export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) { export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {
const [loginType, setLoginType] = useState<EmbedDemoLoginType>("password");
const [customerId, setCustomerId] = useState(""); const [customerId, setCustomerId] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [apiKey, setApiKey] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -24,11 +36,17 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await embedDemoLogin("", customerId.trim(), password); const res =
loginType === "api_key"
? await embedDemoLoginWithKey("", apiKey.trim())
: await embedDemoLogin("", customerId.trim(), password);
setLoading(false); setLoading(false);
if (res.code !== 0) { if (res.code !== 0) {
setError(res.message || "客户编号或密码错误"); setError(
res.message ||
(loginType === "api_key" ? "API Key 无效" : "客户编号或密码错误"),
);
return; return;
} }
@ -45,8 +63,32 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {
<Card> <Card>
<h1 className="mb-1 text-lg font-semibold"></h1> <h1 className="mb-1 text-lg font-semibold"></h1>
<p className="mb-4 text-sm text-text-secondary"> <p className="mb-4 text-sm text-text-secondary">
使 {loginType === "api_key"
? "使用管理员分配的 API Key 登录(每次进入须重新填写)"
: "使用客户编号与管理员分配的密码登录(每次进入须重新填写)"}
</p> </p>
<div className="mb-4 flex gap-1 rounded-md bg-bg p-1">
{LOGIN_TABS.map((tab) => (
<button
key={tab.id}
type="button"
disabled={loading}
onClick={() => {
setLoginType(tab.id);
setError(null);
}}
className={`flex-1 rounded-sm px-3 py-2 text-sm font-medium transition-colors ${
loginType === tab.id
? "bg-surface text-primary shadow-card"
: "text-text-secondary hover:text-text-primary"
} ${loading ? "cursor-not-allowed opacity-50" : ""}`}
>
{tab.label}
</button>
))}
</div>
{error && ( {error && (
<div className="mb-4"> <div className="mb-4">
<ErrorBanner>{error}</ErrorBanner> <ErrorBanner>{error}</ErrorBanner>
@ -57,6 +99,19 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {
className="space-y-4" className="space-y-4"
autoComplete="off" autoComplete="off"
> >
{loginType === "api_key" ? (
<InputField
label="API Key"
name="api_key"
type="password"
placeholder="粘贴管理员分配的 API Key"
autoComplete="off"
value={apiKey}
disabled={loading}
onChange={(e) => setApiKey(e.target.value)}
/>
) : (
<>
<InputField <InputField
label="客户编号" label="客户编号"
name="customer_id" name="customer_id"
@ -75,6 +130,8 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {
disabled={loading} disabled={loading}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
</>
)}
<PrimaryButton type="submit" loading={loading} className="w-full"> <PrimaryButton type="submit" loading={loading} className="w-full">
</PrimaryButton> </PrimaryButton>

@ -22,19 +22,36 @@ import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
import { QuoteForm } from "@/components/quote/quote-form"; import { QuoteForm } from "@/components/quote/quote-form";
import { QuoteResultPanel } from "@/components/quote/quote-result-panel"; import { QuoteResultPanel } from "@/components/quote/quote-result-panel";
import { AddressDisambiguationModal } from "@/components/quote/address-disambiguation-modal"; import { AddressDisambiguationModal } from "@/components/quote/address-disambiguation-modal";
import { PrimaryButton } from "@/components/ui/primary-button"; import { PrimaryButton, SecondaryButton } from "@/components/ui/primary-button";
import { ErrorBanner } from "@/components/ui/error-banner"; import { ErrorBanner } from "@/components/ui/error-banner";
import { quoteCtaCls } from "@/components/quote/quote-form-chrome";
import { import {
QuoteProviderSwitch, QuoteProviderSwitch,
type QuoteProviderId, type QuoteProviderId,
} from "@/components/embed/quote-provider-switch"; } from "@/components/embed/quote-provider-switch";
import { Priority1QuoteWidget } from "@/components/priority1/priority1-quote-widget"; import { Priority1QuoteWidget } from "@/components/priority1/priority1-quote-widget";
import { FlockQuoteWidget } from "@/components/flock/flock-quote-widget";
import {
MothershipLoggedInShipmentForm,
type MothershipLoggedInShipmentPayload,
} from "@/components/mothership/mothership-logged-in-shipment-form";
import { MothershipLoggedInDetailsForm } from "@/components/mothership/mothership-logged-in-details-form";
import { MothershipLoggedInQuoteSidebar } from "@/components/mothership/mothership-logged-in-quote-sidebar";
import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body";
export interface EmbeddedQuoteWidgetProps { export interface EmbeddedQuoteWidgetProps {
customerId: string; customerId: string;
apiBaseUrl?: string; apiBaseUrl?: string;
/** 未传时使用 Cookie 演示会话鉴权(/embed-demo */ /** 未传时使用 Cookie 演示会话鉴权(/embed-demo */
serviceToken?: string; serviceToken?: string;
/** 客户已绑定 MotherShip 账密 → 显示登录后一级查价界面 */
mothershipLoggedInUi?: boolean;
/** 客户已绑定 Flock 账密 → 显示登录后 Quick 查价界面 */
flockLoggedInUi?: boolean;
/** 门户驱动:强制单一数据源(配合 hideProviderSwitch */
forcedProvider?: QuoteProviderId;
/** 门户驱动隐藏内置数据源切换4 路线门户已在外层选路) */
hideProviderSwitch?: boolean;
} }
const FORM_ID = "embedded-quote-form"; const FORM_ID = "embedded-quote-form";
@ -43,12 +60,23 @@ export function EmbeddedQuoteWidget({
serviceToken, serviceToken,
customerId, customerId,
apiBaseUrl = "", apiBaseUrl = "",
mothershipLoggedInUi = false,
flockLoggedInUi = false,
forcedProvider,
hideProviderSwitch = false,
}: EmbeddedQuoteWidgetProps) { }: EmbeddedQuoteWidgetProps) {
const [provider, setProvider] = useState<QuoteProviderId>("mothership"); const [provider, setProvider] = useState<QuoteProviderId>(
forcedProvider ?? "mothership",
);
const [status, setStatus] = useState<QuotePageStatus>("idle"); const [status, setStatus] = useState<QuotePageStatus>("idle");
const [quote, setQuote] = useState<QuoteDetail | null>(null); const [quote, setQuote] = useState<QuoteDetail | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitLocked, setSubmitLocked] = useState(false); const [submitLocked, setSubmitLocked] = useState(false);
const [loggedInDraft, setLoggedInDraft] =
useState<MothershipLoggedInShipmentPayload | null>(null);
const [loggedInStep, setLoggedInStep] = useState<"create" | "details">(
"create",
);
const [disambiguationOpen, setDisambiguationOpen] = useState(false); const [disambiguationOpen, setDisambiguationOpen] = useState(false);
const [confirmedPickup, setConfirmedPickup] = useState<AddressInput | null>( const [confirmedPickup, setConfirmedPickup] = useState<AddressInput | null>(
null, null,
@ -70,6 +98,8 @@ export function EmbeddedQuoteWidget({
setError(null); setError(null);
setConfirmedPickup(null); setConfirmedPickup(null);
setConfirmedDelivery(null); setConfirmedDelivery(null);
setLoggedInDraft(null);
setLoggedInStep("create");
}, []); }, []);
const finishQuote = useCallback((detail: QuoteDetail) => { const finishQuote = useCallback((detail: QuoteDetail) => {
@ -272,6 +302,7 @@ export function EmbeddedQuoteWidget({
</p> </p>
</div> </div>
{!hideProviderSwitch && (
<QuoteProviderSwitch <QuoteProviderSwitch
value={provider} value={provider}
onChange={(next) => { onChange={(next) => {
@ -280,6 +311,7 @@ export function EmbeddedQuoteWidget({
}} }}
disabled={switchDisabled} disabled={switchDisabled}
/> />
)}
{provider === "priority1" ? ( {provider === "priority1" ? (
<Priority1QuoteWidget <Priority1QuoteWidget
@ -287,6 +319,13 @@ export function EmbeddedQuoteWidget({
customerId={customerId} customerId={customerId}
apiBaseUrl={apiBaseUrl} apiBaseUrl={apiBaseUrl}
/> />
) : provider === "flock" ? (
<FlockQuoteWidget
serviceToken={serviceToken}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
flockLoggedInUi={flockLoggedInUi}
/>
) : ( ) : (
<> <>
{error && status === "idle" && ( {error && status === "idle" && (
@ -297,6 +336,67 @@ export function EmbeddedQuoteWidget({
<div className="grid gap-6 lg:grid-cols-5"> <div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3"> <div className="lg:col-span-3">
{mothershipLoggedInUi ? (
loggedInStep === "details" && loggedInDraft ? (
<>
<div className="mb-4">
<SecondaryButton
type="button"
onClick={() => setLoggedInStep("create")}
>
</SecondaryButton>
</div>
<MothershipLoggedInDetailsForm
initial={loggedInDraft}
disabled={formDisabled}
/>
</>
) : (
<>
<MothershipLoggedInShipmentForm
formId={FORM_ID}
disabled={formDisabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
onValidSubmit={(payload) => {
setLoggedInDraft(payload);
setLoggedInStep("details");
setQuote(null);
setError(null);
try {
// 一级信息足够询价;二级仅选报价/后续填详情,不阻塞出价
const body = buildQuoteRequestBodyFromLoggedIn(
payload,
customerId,
);
void submitQuote(body);
} catch (err) {
setStatus("error");
setError(
err instanceof Error
? err.message
: "无法构建询价请求",
);
}
}}
/>
<div className="mt-6">
<button
type="submit"
form={FORM_ID}
disabled={formDisabled}
data-testid="ship-create-continue-button"
className={`${quoteCtaCls} w-full sm:w-auto`}
>
</button>
</div>
</>
)
) : (
<>
<QuoteForm <QuoteForm
customerId={customerId} customerId={customerId}
disabled={formDisabled} disabled={formDisabled}
@ -307,6 +407,13 @@ export function EmbeddedQuoteWidget({
setConfirmedPickup(null); setConfirmedPickup(null);
setConfirmedDelivery(null); setConfirmedDelivery(null);
}} }}
onCargoChange={() => {
setQuote(null);
setError(null);
if (status === "success" || status === "fallback") {
setStatus("idle");
}
}}
onValidSubmit={(body) => void handleSubmit(body)} onValidSubmit={(body) => void handleSubmit(body)}
/> />
<div className="mt-6"> <div className="mt-6">
@ -324,8 +431,27 @@ export function EmbeddedQuoteWidget({
</PrimaryButton> </PrimaryButton>
</div> </div>
</>
)}
</div> </div>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{mothershipLoggedInUi ? (
loggedInStep === "details" && loggedInDraft ? (
<MothershipLoggedInQuoteSidebar
payload={loggedInDraft}
quote={quote}
status={status}
error={error}
/>
) : (
<div className="rounded-lg border border-border bg-bg p-4 text-sm text-text-secondary">
<p className="font-medium text-[#1F2937]"></p>
<p className="mt-2 text-xs leading-relaxed">
</p>
</div>
)
) : (
<QuoteResultPanel <QuoteResultPanel
status={status} status={status}
quote={quote} quote={quote}
@ -334,9 +460,11 @@ export function EmbeddedQuoteWidget({
onExpire={() => setStatus("expired")} onExpire={() => setStatus("expired")}
onExpiredConfirm={reset} onExpiredConfirm={reset}
/> />
)}
</div> </div>
</div> </div>
{!mothershipLoggedInUi && (
<AddressDisambiguationModal <AddressDisambiguationModal
open={disambiguationOpen} open={disambiguationOpen}
pickupCandidates={pickupCandidates} pickupCandidates={pickupCandidates}
@ -346,6 +474,7 @@ export function EmbeddedQuoteWidget({
} }
onCancel={handleDisambiguationCancel} onCancel={handleDisambiguationCancel}
/> />
)}
</> </>
)} )}
</div> </div>

@ -1,6 +1,6 @@
"use client"; "use client";
export type QuoteProviderId = "mothership" | "priority1"; export type QuoteProviderId = "mothership" | "priority1" | "flock";
interface QuoteProviderSwitchProps { interface QuoteProviderSwitchProps {
value: QuoteProviderId; value: QuoteProviderId;
@ -8,7 +8,7 @@ interface QuoteProviderSwitchProps {
disabled?: boolean; disabled?: boolean;
} }
/** 卡派查价 — Mothership / Priority1 数据源切换 */ /** 卡派查价 — 数据源切换 */
export function QuoteProviderSwitch({ export function QuoteProviderSwitch({
value, value,
onChange, onChange,
@ -16,18 +16,19 @@ export function QuoteProviderSwitch({
}: QuoteProviderSwitchProps) { }: QuoteProviderSwitchProps) {
const tabs: Array<{ id: QuoteProviderId; label: string; desc: string }> = [ const tabs: Array<{ id: QuoteProviderId; label: string; desc: string }> = [
{ id: "mothership", label: "MotherShip", desc: "四档实时报价" }, { id: "mothership", label: "MotherShip", desc: "四档实时报价" },
{ id: "priority1", label: "Priority1", desc: "官网模拟填表" }, { id: "priority1", label: "Priority1", desc: "开发中 · 官网模拟填表" },
{ id: "flock", label: "Flock Freight", desc: "ZIP Direct 询价" },
]; ];
return ( return (
<div className="mb-6 flex gap-1 rounded-md bg-bg p-1"> <div className="mb-6 flex flex-wrap gap-1 rounded-md bg-bg p-1">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => onChange(tab.id)} onClick={() => onChange(tab.id)}
className={`flex flex-1 flex-col items-center rounded-sm px-3 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${ className={`flex min-w-[30%] flex-1 flex-col items-center rounded-sm px-2 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${
value === tab.id value === tab.id
? "bg-surface text-primary shadow-card" ? "bg-surface text-primary shadow-card"
: "text-text-secondary hover:text-text-primary" : "text-text-secondary hover:text-text-primary"

@ -0,0 +1,321 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import {
PrimaryButton,
SecondaryButton,
} from "@/components/ui/primary-button";
import { InputField } from "@/components/ui/input-field";
import { ErrorBanner } from "@/components/ui/error-banner";
import { Skeleton } from "@/components/ui/skeleton";
import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget";
import {
embedGetProviderCredentials,
embedSaveProviderCredential,
type EmbedProviderCredential,
} from "@/lib/frontend/api-client";
type QuoteProvider = "mothership" | "flock";
type QuoteMode = "anon" | "login";
interface ModuleDef {
provider: QuoteProvider;
title: string;
desc: string;
}
/** 门户仅两模块MotherShip / Flock进入后可选免账号或登录后 */
const MODULES: ModuleDef[] = [
{
provider: "mothership",
title: "MotherShip",
desc: "免账号实时报价,或使用官网账号登录后查价",
},
{
provider: "flock",
title: "Flock Freight",
desc: "ZIP Direct 匿名询价,或使用官网账号登录后查价",
},
];
interface QuoteRouteHubProps {
customerId: string;
apiBaseUrl?: string;
}
export function QuoteRouteHub({
customerId,
apiBaseUrl = "",
}: QuoteRouteHubProps) {
const [loading, setLoading] = useState(true);
const [creds, setCreds] = useState<
Record<QuoteProvider, EmbedProviderCredential>
>({
mothership: {
provider: "mothership",
email: null,
password: null,
has_password: false,
updated_at: null,
},
flock: {
provider: "flock",
email: null,
password: null,
has_password: false,
updated_at: null,
},
});
const [selected, setSelected] = useState<ModuleDef | null>(null);
const [mode, setMode] = useState<QuoteMode>("anon");
const loadCreds = useCallback(async () => {
setLoading(true);
const res = await embedGetProviderCredentials(apiBaseUrl);
if (res.code === 0) {
setCreds((prev) => {
const next = { ...prev };
for (const item of res.data.list) {
if (item.provider === "mothership" || item.provider === "flock") {
next[item.provider] = item;
}
}
return next;
});
}
setLoading(false);
}, [apiBaseUrl]);
useEffect(() => {
void loadCreds();
}, [loadCreds]);
const handleSaved = useCallback((saved: EmbedProviderCredential) => {
setCreds((prev) => ({ ...prev, [saved.provider]: saved }));
}, []);
const openModule = (mod: ModuleDef) => {
setSelected(mod);
// 已配置账密时默认进登录后;否则免账号
setMode(creds[mod.provider].has_password ? "login" : "anon");
};
if (loading) {
return <Skeleton className="h-64 w-full" />;
}
if (!selected) {
return (
<div className="grid gap-4 sm:grid-cols-2">
{MODULES.map((mod) => {
const hasPassword = creds[mod.provider].has_password;
return (
<Card
key={mod.provider}
className="flex cursor-pointer flex-col gap-2 transition-colors hover:border-primary"
onClick={() => openModule(mod)}
data-testid={`quote-route-module-${mod.provider}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-base font-semibold text-text-primary">
{mod.title}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs ${
hasPassword
? "bg-green-50 text-green-600"
: "bg-bg text-text-secondary"
}`}
>
{hasPassword ? "已配置官网账号" : "可免账号查价"}
</span>
</div>
<span className="text-sm text-text-secondary">{mod.desc}</span>
</Card>
);
})}
</div>
);
}
const providerCred = creds[selected.provider];
const requiresAccount = mode === "login";
const canQuote = !requiresAccount || providerCred.has_password;
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-lg font-semibold text-text-primary">
{selected.title}
</p>
</div>
<SecondaryButton onClick={() => setSelected(null)}>
</SecondaryButton>
</div>
<div className="mb-4 flex flex-wrap gap-1 rounded-md bg-bg p-1">
{(
[
{ id: "anon" as const, label: "免账号", desc: "无需官网登录" },
{ id: "login" as const, label: "登录后", desc: "使用官网账密" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setMode(tab.id)}
className={`flex min-w-[40%] flex-1 flex-col items-center rounded-sm px-3 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${
mode === tab.id
? "bg-surface text-primary shadow-card"
: "text-text-secondary hover:text-text-primary"
}`}
>
<span className="text-sm font-semibold">{tab.label}</span>
<span className="text-xs text-text-secondary">{tab.desc}</span>
</button>
))}
</div>
{requiresAccount && (
<ProviderCredentialPanel
provider={selected.provider}
apiBaseUrl={apiBaseUrl}
current={providerCred}
onSaved={handleSaved}
/>
)}
{canQuote ? (
<EmbeddedQuoteWidget
key={`${selected.provider}-${mode}`}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
forcedProvider={selected.provider}
hideProviderSwitch
mothershipLoggedInUi={
selected.provider === "mothership" && requiresAccount
}
flockLoggedInUi={selected.provider === "flock" && requiresAccount}
/>
) : (
<Card className="mt-4">
<p className="text-sm text-text-secondary">
</p>
</Card>
)}
</div>
);
}
interface ProviderCredentialPanelProps {
provider: QuoteProvider;
apiBaseUrl: string;
current: EmbedProviderCredential;
onSaved: (saved: EmbedProviderCredential) => void;
}
function ProviderCredentialPanel({
provider,
apiBaseUrl,
current,
onSaved,
}: ProviderCredentialPanelProps) {
const [email, setEmail] = useState(current.email ?? "");
const [password, setPassword] = useState(current.password ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedHint, setSavedHint] = useState<string | null>(null);
useEffect(() => {
setEmail(current.email ?? "");
setPassword(current.password ?? "");
setError(null);
setSavedHint(null);
}, [provider, current.email, current.password]);
const handleSave = async () => {
if (saving) return;
if (!email.trim()) {
setError("请填写登录邮箱");
return;
}
if (!password.trim()) {
setError("请填写登录密码(明文展示便于核对,修改后请点保存)");
return;
}
setSaving(true);
setError(null);
setSavedHint(null);
const res = await embedSaveProviderCredential(
apiBaseUrl,
provider,
email.trim(),
password,
);
setSaving(false);
if (res.code !== 0) {
setError(res.message || "保存失败");
return;
}
setEmail(res.data.email ?? email.trim());
setPassword(res.data.password ?? password);
setSavedHint(
"账号密码已保存。若官网已改密,请确认此处已同步更新后再查价。",
);
onSaved(res.data);
};
return (
<Card className="mb-4">
<h3 className="mb-1 text-base font-semibold text-text-primary">
</h3>
<p className="mb-4 text-sm text-text-secondary">
{current.has_password
? "以下为当前已保存的明文账密,请核对是否与官网一致。官网改密后须在此即时保存,否则查价会卡在登录页并报错。"
: "首次使用请填写官网账号邮箱与密码,保存后方可查价。"}
</p>
{error && (
<div className="mb-4">
<ErrorBanner>{error}</ErrorBanner>
</div>
)}
{savedHint && (
<p className="mb-4 text-sm text-green-600">{savedHint}</p>
)}
<div className="space-y-4">
<InputField
label="登录邮箱"
name="provider_email"
placeholder="账号邮箱"
autoComplete="off"
value={email}
disabled={saving}
onChange={(e) => setEmail(e.target.value)}
/>
<InputField
label="登录密码(明文)"
name="provider_password"
type="text"
autoComplete="off"
placeholder="官网登录密码"
value={password}
disabled={saving}
onChange={(e) => setPassword(e.target.value)}
/>
<PrimaryButton
type="button"
loading={saving}
onClick={() => void handleSave()}
>
{current.has_password ? "保存修改" : "保存"}
</PrimaryButton>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,527 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import { SelectField } from "@/components/ui/select-field";
import {
FLOCK_LIMITS,
FLOCK_VALIDATION_MESSAGES,
} from "@/lib/constants/flock-limits";
import {
describeDimEquivalent,
describeWeightEquivalent,
parseCargoInputNumber,
} from "@/lib/frontend/cargo-form-units";
import type { UnitDim, UnitWeight } from "@/lib/frontend/types";
import { FLOCK_UI } from "@/lib/flock/ui-labels";
import {
defaultFlockPickupDateIso,
flockPickupDateValidationMessageFromIso,
minFlockPickupDateIso,
} from "@/lib/flock/pickup-date";
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
import { defaultFlockPickupDate } from "@/modules/flock/validation";
import {
cmToIn,
kgToLb,
toAxelWholeInches,
toAxelWholePounds,
} from "@/modules/quote/unit-converter";
export type FlockFormSubmitPayload = {
pickup_date: string;
pickup_zip: string;
delivery_zip: string;
pallet_count: number;
total_weight: { value: number; unit: "lb" | "kg" };
dimensions: {
length: number;
width: number;
height: number;
unit: "in" | "cm";
};
registration?: {
first_name?: string;
last_name?: string;
company?: string;
email?: string;
phone?: string;
shipments_per_month?: string;
};
};
export interface FlockQuoteFormProps {
disabled?: boolean;
submitLocked?: boolean;
onSubmit: (payload: FlockFormSubmitPayload) => void;
}
function toMmDdYyyy(isoDate: string): string {
const [y, m, d] = isoDate.split("-");
if (!y || !m || !d) return defaultFlockPickupDate();
return `${m}/${d}/${y}`;
}
/** Flock 查价表单:单位切换不改数值,服务端自动换算进一(对齐 MotherShip */
export function FlockQuoteForm({
disabled,
submitLocked,
onSubmit,
}: FlockQuoteFormProps) {
const [weightUnit, setWeightUnit] = useState<UnitWeight>("lb");
const [dimUnit, setDimUnit] = useState<UnitDim>("in");
const [pickupDate, setPickupDate] = useState(defaultFlockPickupDateIso);
const [pickupZip, setPickupZip] = useState("");
const [deliveryZip, setDeliveryZip] = useState("");
const [palletCount, setPalletCount] = useState("6");
const [totalWeight, setTotalWeight] = useState("3500");
const [length, setLength] = useState(
String(FLOCK_LIMITS.defaultDimsIn.length),
);
const [width, setWidth] = useState(String(FLOCK_LIMITS.defaultDimsIn.width));
const [height, setHeight] = useState(
String(FLOCK_LIMITS.defaultDimsIn.height),
);
const [localError, setLocalError] = useState<string | null>(null);
const [showRegistration, setShowRegistration] = useState(false);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [company, setCompany] = useState("");
const [workEmail, setWorkEmail] = useState("");
const [phone, setPhone] = useState("");
const [shipmentsPerMonth, setShipmentsPerMonth] = useState("1-25");
const weightHint = useMemo(() => {
const v = parseCargoInputNumber(totalWeight);
return v == null ? null : describeWeightEquivalent(v, weightUnit);
}, [totalWeight, weightUnit]);
const dimHint = useMemo(() => {
const l = parseCargoInputNumber(length);
const w = parseCargoInputNumber(width);
const h = parseCargoInputNumber(height);
if (l == null || w == null || h == null) return null;
return describeDimEquivalent(l, w, h, dimUnit);
}, [length, width, height, dimUnit]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setLocalError(null);
if (!pickupDate) {
setLocalError(FLOCK_VALIDATION_MESSAGES.pickupDate);
return;
}
const pickupDateError = flockPickupDateValidationMessageFromIso(pickupDate);
if (pickupDateError) {
setLocalError(pickupDateError);
return;
}
if (!pickupZip.trim()) {
setLocalError(FLOCK_VALIDATION_MESSAGES.pickupZip);
return;
}
if (!deliveryZip.trim()) {
setLocalError(FLOCK_VALIDATION_MESSAGES.deliveryZip);
return;
}
const pallets = Number(palletCount);
if (
!Number.isInteger(pallets) ||
pallets < FLOCK_LIMITS.palletCount.min ||
pallets > FLOCK_LIMITS.palletCount.max
) {
setLocalError(FLOCK_VALIDATION_MESSAGES.palletCount);
return;
}
const weightVal = parseCargoInputNumber(totalWeight);
const l = parseCargoInputNumber(length);
const w = parseCargoInputNumber(width);
const h = parseCargoInputNumber(height);
if (weightVal == null || l == null || w == null || h == null) {
setLocalError("请完整填写重量与尺寸");
return;
}
const totalWeightLb = toAxelWholePounds(
weightUnit === "kg" ? kgToLb(weightVal) : weightVal,
);
if (totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) {
setLocalError(FLOCK_VALIDATION_MESSAGES.totalWeight);
return;
}
const toIn = (v: number) =>
toAxelWholeInches(dimUnit === "cm" ? cmToIn(v) : v);
const lengthIn = toIn(l);
const widthIn = toIn(w);
const heightIn = toIn(h);
if (lengthIn > FLOCK_LIMITS.dimIn.lengthMax) {
setLocalError(FLOCK_VALIDATION_MESSAGES.length);
return;
}
if (widthIn > FLOCK_LIMITS.dimIn.widthMax) {
setLocalError(FLOCK_VALIDATION_MESSAGES.width);
return;
}
if (heightIn > FLOCK_LIMITS.dimIn.heightMax) {
setLocalError(FLOCK_VALIDATION_MESSAGES.height);
return;
}
let registration: FlockFormSubmitPayload["registration"];
if (showRegistration) {
const regEmail = workEmail.trim();
const regPhone = phone.replace(/\D/g, "");
if (regEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(regEmail)) {
setLocalError("请填写有效的工作邮箱");
return;
}
if (regPhone && regPhone.slice(-10).length !== 10) {
setLocalError("请填写有效的 10 位美国电话号码");
return;
}
const reg = {
first_name: firstName.trim() || undefined,
last_name: lastName.trim() || undefined,
company: company.trim() || undefined,
email: regEmail || undefined,
phone: regPhone ? regPhone.slice(-10) : undefined,
shipments_per_month: shipmentsPerMonth || undefined,
};
if (
reg.first_name ||
reg.last_name ||
reg.company ||
reg.email ||
reg.phone
) {
registration = reg;
}
}
onSubmit({
pickup_date: toMmDdYyyy(pickupDate),
pickup_zip: pickupZip.trim(),
delivery_zip: deliveryZip.trim(),
pallet_count: pallets,
total_weight: { value: weightVal, unit: weightUnit },
dimensions: {
length: l,
width: w,
height: h,
unit: dimUnit,
},
registration,
});
};
const fieldClass =
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-primary";
const labelClass = "mb-1 block text-sm font-medium text-text-primary";
return (
<form
onSubmit={handleSubmit}
className="rounded-lg border border-border bg-surface p-5 shadow-card"
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-text-primary">
{FLOCK_UI.formTitle}
</h2>
<p className="mt-1 text-sm text-text-secondary">{FLOCK_UI.formHint}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className={labelClass} htmlFor="flock-pickup-date">
{FLOCK_UI.pickupDate} *
</label>
<MothershipWeekdayDatePicker
value={pickupDate}
minIso={minFlockPickupDateIso()}
disabled={disabled}
className={fieldClass}
onChange={setPickupDate}
/>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_UI.pickupDateHint}
</p>
</div>
<div>
<label className={labelClass} htmlFor="flock-pickup-zip">
{FLOCK_UI.pickupZip} *
</label>
<input
id="flock-pickup-zip"
className={fieldClass}
value={pickupZip}
disabled={disabled}
onChange={(e) => setPickupZip(e.target.value)}
placeholder="例如 90001"
inputMode="numeric"
required
/>
</div>
<div>
<label className={labelClass} htmlFor="flock-delivery-zip">
{FLOCK_UI.deliveryZip} *
</label>
<input
id="flock-delivery-zip"
className={fieldClass}
value={deliveryZip}
disabled={disabled}
onChange={(e) => setDeliveryZip(e.target.value)}
placeholder="例如 75201"
inputMode="numeric"
required
/>
</div>
<div>
<label className={labelClass} htmlFor="flock-pallets">
{FLOCK_UI.palletCount} *
</label>
<input
id="flock-pallets"
className={fieldClass}
value={palletCount}
disabled={disabled}
onChange={(e) => setPalletCount(e.target.value)}
inputMode="numeric"
required
/>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_UI.palletCountHint}
</p>
</div>
<div>
<label className={labelClass} htmlFor="flock-weight">
{FLOCK_UI.totalWeight} *
</label>
<input
id="flock-weight"
className={fieldClass}
value={totalWeight}
disabled={disabled}
onChange={(e) => setTotalWeight(e.target.value)}
inputMode="decimal"
required
/>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_UI.totalWeightHint}
</p>
{weightHint && (
<p className="mt-1 text-xs text-primary">{weightHint}</p>
)}
</div>
<SelectField
label={FLOCK_UI.weightUnit}
name="flock-weight-unit"
value={weightUnit}
disabled={disabled}
options={[
{ value: "lb", label: FLOCK_UI.weightLb },
{ value: "kg", label: FLOCK_UI.weightKg },
]}
onChange={(e) => setWeightUnit(e.target.value as UnitWeight)}
/>
</div>
<div className="mt-4">
<p className="mb-2 text-sm font-medium text-text-primary">
{FLOCK_UI.dimsTitle}{dimUnit}
</p>
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className={labelClass} htmlFor="flock-length">
{FLOCK_UI.length}
</label>
<input
id="flock-length"
className={fieldClass}
value={length}
disabled={disabled}
onChange={(e) => setLength(e.target.value)}
inputMode="decimal"
/>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_VALIDATION_MESSAGES.length}
</p>
</div>
<div>
<label className={labelClass} htmlFor="flock-width">
{FLOCK_UI.width}
</label>
<input
id="flock-width"
className={fieldClass}
value={width}
disabled={disabled}
onChange={(e) => setWidth(e.target.value)}
inputMode="decimal"
/>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_VALIDATION_MESSAGES.width}
</p>
</div>
<div>
<label className={labelClass} htmlFor="flock-height">
{FLOCK_UI.height}
</label>
<input
id="flock-height"
className={fieldClass}
value={height}
disabled={disabled}
onChange={(e) => setHeight(e.target.value)}
inputMode="decimal"
/>
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_VALIDATION_MESSAGES.height}
</p>
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<SelectField
label={FLOCK_UI.dimUnit}
name="flock-dim-unit"
value={dimUnit}
disabled={disabled}
options={[
{ value: "in", label: FLOCK_UI.dimIn },
{ value: "cm", label: FLOCK_UI.dimCm },
]}
onChange={(e) => setDimUnit(e.target.value as UnitDim)}
/>
</div>
{dimHint && <p className="mt-2 text-xs text-primary">{dimHint}</p>}
<p className="mt-2 text-xs text-text-secondary">{FLOCK_UI.unitSwitchHint}</p>
</div>
<div className="mt-5 rounded-md border border-border bg-bg/50 p-4">
<button
type="button"
disabled={disabled}
onClick={() => setShowRegistration((v) => !v)}
className="flex w-full items-center justify-between text-left text-sm font-medium text-text-primary"
>
<span>{FLOCK_UI.registrationSectionTitle}</span>
<span className="text-xs text-text-secondary">
{showRegistration
? FLOCK_UI.useSystemRegistration
: FLOCK_UI.useCustomRegistration}
</span>
</button>
{showRegistration && (
<>
<p className="mt-2 text-xs text-text-secondary">
{FLOCK_UI.registrationSectionHint}
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<label className={labelClass} htmlFor="flock-first-name">
{FLOCK_UI.firstName}
</label>
<input
id="flock-first-name"
className={fieldClass}
value={firstName}
disabled={disabled}
onChange={(e) => setFirstName(e.target.value)}
autoComplete="given-name"
/>
</div>
<div>
<label className={labelClass} htmlFor="flock-last-name">
{FLOCK_UI.lastName}
</label>
<input
id="flock-last-name"
className={fieldClass}
value={lastName}
disabled={disabled}
onChange={(e) => setLastName(e.target.value)}
autoComplete="family-name"
/>
</div>
<div className="sm:col-span-2">
<label className={labelClass} htmlFor="flock-company">
{FLOCK_UI.company}
</label>
<input
id="flock-company"
className={fieldClass}
value={company}
disabled={disabled}
onChange={(e) => setCompany(e.target.value)}
autoComplete="organization"
/>
</div>
<div>
<label className={labelClass} htmlFor="flock-work-email">
{FLOCK_UI.workEmail}
</label>
<input
id="flock-work-email"
type="email"
className={fieldClass}
value={workEmail}
disabled={disabled}
onChange={(e) => setWorkEmail(e.target.value)}
placeholder="例如 name@company.com"
autoComplete="email"
/>
</div>
<div>
<label className={labelClass} htmlFor="flock-phone">
{FLOCK_UI.phone}
</label>
<input
id="flock-phone"
className={fieldClass}
value={phone}
disabled={disabled}
onChange={(e) => setPhone(e.target.value)}
placeholder="例如 7536407420"
inputMode="tel"
autoComplete="tel"
/>
<p className="mt-1 text-xs text-text-secondary">{FLOCK_UI.phoneHint}</p>
</div>
<div className="sm:col-span-2">
<SelectField
label={FLOCK_UI.shipmentsPerMonth}
name="flock-shipments"
value={shipmentsPerMonth}
disabled={disabled}
options={[
{ value: "1-25", label: "125 票/月" },
{ value: "26-50", label: "2650 票/月" },
{ value: "51-100", label: "51100 票/月" },
{ value: "101+", label: "101+ 票/月" },
]}
onChange={(e) => setShipmentsPerMonth(e.target.value)}
/>
</div>
</div>
</>
)}
</div>
{localError && (
<p className="mt-4 text-sm text-error" role="alert">
{localError}
</p>
)}
<button
type="submit"
disabled={disabled || submitLocked}
className="mt-5 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{submitLocked ? FLOCK_UI.submitting : FLOCK_UI.submit}
</button>
</form>
);
}

@ -0,0 +1,161 @@
/**
* Flock Worker rpa_stage
*/
"use client";
import { useEffect, useState } from "react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
FLOCK_RPA_STAGE_LABELS,
FLOCK_RPA_STAGES,
flockStageIndex,
mapFlockErrorToStage,
type FlockRpaStage,
} from "@/lib/flock/rpa-progress";
const DISPLAY_STAGES: FlockRpaStage[] = [
"queued",
"lock",
"login",
"open_quote",
"fill_form",
"submit",
"wait_quote",
];
export interface FlockQuoteProgressProps {
active: boolean;
quoteId?: string | null;
startedAtMs?: number | null;
/** Worker 回写阶段 */
rpaStage?: string | null;
rpaStageLabel?: string | null;
/** 失败后的错误原文(用于把进度钉在卡点并展示 hint */
errorMessage?: string | null;
failed?: boolean;
}
export function FlockQuoteProgress({
active,
quoteId,
startedAtMs,
rpaStage,
rpaStageLabel,
errorMessage,
failed = false,
}: FlockQuoteProgressProps) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active && !failed) return;
const t = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(t);
}, [active, failed]);
if (!active && !failed) return null;
const mapped = failed ? mapFlockErrorToStage(errorMessage) : null;
// 优先 Worker 回写的 rpa_stage禁止失败时用默认「互斥锁」覆盖真实进度
const stageRaw = (
rpaStage && (FLOCK_RPA_STAGES as readonly string[]).includes(rpaStage)
? rpaStage
: (mapped?.stage ?? "queued")
) as string;
const currentStage: FlockRpaStage = (
FLOCK_RPA_STAGES as readonly string[]
).includes(stageRaw)
? (stageRaw as FlockRpaStage)
: "queued";
const currentIdx = Math.max(0, flockStageIndex(currentStage));
const label =
(failed ? mapped?.hint : null) ??
rpaStageLabel ??
FLOCK_RPA_STAGE_LABELS[currentStage];
const elapsed = Math.max(0, now - (startedAtMs ?? now));
return (
<div
className={[
"rounded-lg border p-4 shadow-card",
failed
? "border-red-300 bg-red-50"
: "border-[#1890FF]/25 bg-[#1890FF]/5",
].join(" ")}
role="status"
aria-live="polite"
data-testid="flock-quote-progress"
data-rpa-stage={currentStage}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div
className={[
"flex items-center gap-2 text-sm font-semibold",
failed ? "text-red-700" : "text-[#1F2937]",
].join(" ")}
>
{!failed ? (
<LoadingSpinner size={16} className="text-[#1890FF]" />
) : null}
{failed ? "Flock 查价失败 — 已定位卡点" : "正在查询 Flock Freight 实时报价"}
</div>
<span className="text-xs tabular-nums text-text-secondary">
{(elapsed / 1000).toFixed(1)}s
</span>
</div>
<ol className="space-y-2">
{DISPLAY_STAGES.map((stage) => {
const i = flockStageIndex(stage);
const done = !failed && i < currentIdx;
const current = i === currentIdx || (failed && stage === currentStage);
const stuck = failed && stage === currentStage;
return (
<li
key={stage}
className={[
"flex items-start gap-2 text-sm",
stuck
? "font-semibold text-red-600"
: current
? "font-medium text-[#1890FF]"
: done
? "text-[#1F2937]"
: "text-text-disabled",
].join(" ")}
>
<span aria-hidden className="mt-0.5 w-4 shrink-0 text-center">
{stuck ? "✕" : done ? "✓" : current ? "●" : "○"}
</span>
<span>
{FLOCK_RPA_STAGE_LABELS[stage]}
{current && !failed ? "…" : null}
{stuck ? "(卡在这里)" : null}
</span>
</li>
);
})}
</ol>
<p
className={[
"mt-3 text-xs",
failed ? "font-medium text-red-700" : "text-text-secondary",
].join(" ")}
>
{label}
</p>
{quoteId ? (
<p className="mt-2 break-all text-[11px] text-text-secondary">
{quoteId}
</p>
) : null}
{!failed ? (
<p className="mt-1 text-[11px] text-text-disabled">
RPA Worker Flock
JWT_SECRET .rpa/flock-stalls/
</p>
) : null}
</div>
);
}

@ -0,0 +1,112 @@
"use client";
import { FLOCK_UI } from "@/lib/flock/ui-labels";
import type { FlockDisplayLine } from "@/modules/flock/quote-storage";
export interface FlockQuoteResultProps {
lines: FlockDisplayLine[];
reference?: string | null;
onReset?: () => void;
}
function formatUsd(n: number): string {
return n.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
}
/** Flock 两档对比结果卡(中文) */
export function FlockQuoteResult({
lines,
reference,
onReset,
}: FlockQuoteResultProps) {
const direct = lines.find((l) => l.tier === "flock_direct");
const standard = lines.find((l) => l.tier === "standard");
if (!direct && !standard) {
return (
<div className="rounded-lg border border-border bg-surface p-5 text-sm text-text-secondary">
{FLOCK_UI.emptyResult}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-text-primary">
{FLOCK_UI.resultTitle}
</h2>
{reference && (
<p className="mt-1 text-xs text-text-secondary">
{FLOCK_UI.reference} #{reference}
</p>
)}
</div>
{onReset && (
<button
type="button"
onClick={onReset}
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text-primary"
>
{FLOCK_UI.buildAnother}
</button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
{direct && (
<article className="rounded-lg border border-primary/30 bg-[#1f2420] p-5 text-white shadow-card">
<div className="mb-1 text-xs uppercase tracking-wide text-emerald-300">
{FLOCK_UI.directTag}
</div>
<h3 className="text-xl font-semibold">{FLOCK_UI.directName}</h3>
<p className="mt-3 text-2xl font-bold">
{FLOCK_UI.pricesFrom} {formatUsd(direct.final_total_usd)}
</p>
<p className="mt-1 text-sm text-white/80">
{FLOCK_UI.transit}{direct.transitDescription || direct.transitDays}
</p>
<ul className="mt-4 space-y-2 text-sm text-white/90">
{FLOCK_UI.directFeatures.map((f) => (
<li key={f}> {f}</li>
))}
</ul>
</article>
)}
{standard && (
<article className="rounded-lg border border-border bg-surface p-5 shadow-card">
<div className="mb-1 text-xs uppercase tracking-wide text-text-secondary">
{FLOCK_UI.standardTag}
</div>
<h3 className="text-xl font-semibold text-text-primary">
{FLOCK_UI.standardName}
</h3>
<p className="mt-3 text-2xl font-bold text-text-primary">
{FLOCK_UI.pricesFrom} {formatUsd(standard.final_total_usd)}
</p>
<p className="mt-1 text-sm text-text-secondary">
{FLOCK_UI.transit}
{standard.transitDescription || standard.transitDays}
</p>
<ul className="mt-4 space-y-2 text-sm text-text-secondary">
{FLOCK_UI.standardFeatures.map((f, i) => (
<li key={f}>
{i === 0 ? "✓" : "×"} {f}
</li>
))}
</ul>
<p className="mt-3 text-xs text-text-secondary">
{FLOCK_UI.standardNote}
</p>
</article>
)}
</div>
</div>
);
}

@ -0,0 +1,207 @@
"use client";
import { useCallback, useState } from "react";
import {
FlockQuoteForm,
type FlockFormSubmitPayload,
} from "@/components/flock/flock-quote-form";
import {
FlockLoggedInQuoteForm,
mapFlockLoggedInToApiInput,
type FlockLoggedInQuotePayload,
} from "@/components/flock/flock-logged-in-quote-form";
import { FlockQuoteProgress } from "@/components/flock/flock-quote-progress";
import { FlockQuoteResult } from "@/components/flock/flock-quote-result";
import { WarningBanner } from "@/components/ui/warning-banner";
import { ErrorBanner } from "@/components/ui/error-banner";
import { SUBMIT_LOCK_MS } from "@/lib/frontend/constants";
import { hostCreateFlockQuote, hostGetQuote } from "@/lib/frontend/api-client";
import { uuidV4 } from "@/lib/frontend/format";
import type { QuoteDetail, QuotePageStatus } from "@/lib/frontend/types";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import { getFlockPollTimeoutMs } from "@/lib/flock/poll-timeout";
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
export interface FlockQuoteWidgetProps {
serviceToken?: string;
customerId: string;
apiBaseUrl?: string;
/** 客户已绑定 Flock 账密 → 显示登录后 Quick 查价界面 */
flockLoggedInUi?: boolean;
}
/** Flock Freight 内嵌查价:表单 + 可视化进度 + 轮询 + 两档结果 */
export function FlockQuoteWidget({
serviceToken,
customerId,
apiBaseUrl = "",
flockLoggedInUi = false,
}: FlockQuoteWidgetProps) {
const [status, setStatus] = useState<QuotePageStatus>("idle");
const [quote, setQuote] = useState<QuoteDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitLocked, setSubmitLocked] = useState(false);
const [activeQuoteId, setActiveQuoteId] = useState<string | null>(null);
const [startedAtMs, setStartedAtMs] = useState<number | null>(null);
const [rpaStage, setRpaStage] = useState<string | null>(null);
const [rpaStageLabel, setRpaStageLabel] = useState<string | null>(null);
const reset = useCallback(() => {
setStatus("idle");
setQuote(null);
setError(null);
setActiveQuoteId(null);
setStartedAtMs(null);
setRpaStage(null);
setRpaStageLabel(null);
}, []);
const finishQuote = useCallback((detail: QuoteDetail) => {
setQuote(detail);
setActiveQuoteId(detail.quote_id ?? null);
if (detail.rpa_stage) setRpaStage(detail.rpa_stage);
if (detail.rpa_stage_label) setRpaStageLabel(detail.rpa_stage_label);
if (detail.status === "failed") {
setStatus("error");
setError(
detail.error_message ??
formatQuoteErrorMessage(detail.error_code) ??
"报价失败",
);
return;
}
if (detail.status === "expired") {
setStatus("expired");
return;
}
if (!detail.flock?.lines?.length) {
setStatus("error");
setError("未返回 Flock 报价档位");
return;
}
setStatus(detail.is_realtime === false ? "fallback" : "success");
setError(null);
}, []);
const handleSubmit = useCallback(
async (payload: FlockFormSubmitPayload & Record<string, unknown>) => {
if (submitLocked) return;
setSubmitLocked(true);
setTimeout(() => setSubmitLocked(false), SUBMIT_LOCK_MS);
setError(null);
setQuote(null);
setActiveQuoteId(null);
setStartedAtMs(Date.now());
setRpaStage("queued");
setRpaStageLabel("任务已入队,等待 Worker 领取");
setStatus("processing");
const created = await hostCreateFlockQuote(apiBaseUrl, serviceToken, {
request_id: uuidV4(),
customer_id: customerId,
flock_input: payload,
});
if (created.code !== 0) {
setStatus("error");
setError(created.message || "提交失败");
return;
}
const { quote_id } = created.data;
setActiveQuoteId(quote_id);
const pollResult = await pollQuoteUntilDone(
async () => {
try {
const res = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote_id,
);
if (res.code !== 0) {
return { ok: false, errorMessage: res.message };
}
return { ok: true, data: res.data };
} catch {
return { ok: false };
}
},
{
timeoutMs: getFlockPollTimeoutMs(),
onProcessing: (detail) => {
if (detail.rpa_stage) setRpaStage(detail.rpa_stage);
if (detail.rpa_stage_label) setRpaStageLabel(detail.rpa_stage_label);
},
},
);
if (pollResult.type === "done") {
finishQuote(pollResult.quote);
return;
}
setStatus("error");
if (pollResult.type === "error" && pollResult.quote?.rpa_stage) {
setRpaStage(pollResult.quote.rpa_stage);
setRpaStageLabel(pollResult.quote.rpa_stage_label ?? null);
}
setError(
pollResult.type === "timeout"
? "Flock 询价超时,请稍后重试(可在进度条确认停在哪一步)"
: pollResult.message || "查询失败",
);
},
[apiBaseUrl, customerId, finishQuote, serviceToken, submitLocked],
);
const handleLoggedInSubmit = useCallback(
(payload: FlockLoggedInQuotePayload) => {
const mapped = mapFlockLoggedInToApiInput(payload);
void handleSubmit(mapped);
},
[handleSubmit],
);
return (
<div className="space-y-4">
{error && status === "error" && <ErrorBanner>{error}</ErrorBanner>}
{status === "expired" && (
<WarningBanner></WarningBanner>
)}
<FlockQuoteProgress
active={status === "processing"}
failed={status === "error"}
quoteId={activeQuoteId}
startedAtMs={startedAtMs}
rpaStage={rpaStage}
rpaStageLabel={rpaStageLabel}
errorMessage={error}
/>
{status === "success" || status === "fallback" ? (
quote?.flock ? (
<FlockQuoteResult
lines={quote.flock.lines}
reference={quote.flock.reference}
onReset={reset}
/>
) : null
) : flockLoggedInUi ? (
<FlockLoggedInQuoteForm
disabled={status === "processing"}
submitLocked={submitLocked || status === "processing"}
customerId={customerId}
onValidSubmit={handleLoggedInSubmit}
/>
) : (
<FlockQuoteForm
disabled={status === "processing"}
submitLocked={submitLocked || status === "processing"}
onSubmit={handleSubmit}
/>
)}
</div>
);
}

@ -0,0 +1,231 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
} from "react";
import { CaretDown, FloppyDisk, X } from "@phosphor-icons/react";
import {
filterFlockSavedFreight,
loadFlockSavedFreight,
persistFlockSavedFreight,
removeFlockSavedFreight,
upsertFlockSavedFreight,
buildFlockSavedFreightPreset,
type FlockSavedFreightPreset,
type FlockSavedFreightSource,
} from "@/lib/flock/saved-freight-presets";
import { quoteInputCls } from "@/components/quote/quote-form-chrome";
const inputCls = quoteInputCls;
type Props = {
customerId: string;
disabled?: boolean;
/** 保存后递增,触发搜索列表刷新 */
revision?: number;
};
/** 顶部:搜索并选用常用货件 */
export function FlockSavedFreightSearch({
customerId,
disabled,
revision = 0,
onSelect,
}: Props & { onSelect: (preset: FlockSavedFreightPreset) => void }) {
const [presets, setPresets] = useState<FlockSavedFreightPreset[]>([]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const reload = useCallback(() => {
setPresets(loadFlockSavedFreight(customerId));
}, [customerId]);
useEffect(() => {
reload();
}, [reload, revision]);
useEffect(() => {
const onDoc = (e: globalThis.MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const filtered = useMemo(
() => filterFlockSavedFreight(presets, query),
[presets, query],
);
const remove = (id: string, e: MouseEvent | KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
const next = removeFlockSavedFreight(presets, id);
persistFlockSavedFreight(customerId, next);
setPresets(next);
};
return (
<div ref={rootRef} className="relative">
<input
disabled={disabled}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={
presets.length ? "搜索已保存的货件名称或描述" : "暂无常用货件,请先在货品描述处保存"
}
className={`${inputCls} pr-8`}
autoComplete="off"
/>
<CaretDown
size={14}
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-text-disabled"
/>
{open ? (
<ul className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-md border border-border bg-surface py-1 shadow-lg">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-xs text-text-secondary">
{presets.length === 0
? "尚无保存记录"
: "无匹配货件"}
</li>
) : (
filtered.map((p) => (
<li key={p.id}>
<button
type="button"
disabled={disabled}
className="flex w-full items-start gap-2 px-3 py-2 text-left text-sm hover:bg-bg disabled:opacity-50"
onClick={() => {
onSelect(p);
setQuery(p.name);
setOpen(false);
}}
>
<span className="min-w-0 flex-1">
<span className="block font-medium text-text-primary">
{p.name}
</span>
{p.description && p.description !== p.name ? (
<span className="mt-0.5 block truncate text-xs text-text-secondary">
{p.description}
</span>
) : null}
</span>
<span
role="button"
tabIndex={0}
aria-label={`删除常用货件 ${p.name}`}
className="mt-0.5 shrink-0 rounded p-0.5 text-text-secondary hover:bg-[#FEE2E2] hover:text-[#EF4444]"
onClick={(e) => remove(p.id, e)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
remove(p.id, e);
}
}}
>
<X size={14} />
</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
);
}
/** 货品描述旁:保存当前货物行为常用货件 */
export function FlockSaveFreightButton({
customerId,
disabled,
source,
onSaved,
}: Props & {
source: FlockSavedFreightSource;
onSaved?: () => void;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [hint, setHint] = useState<string | null>(null);
useEffect(() => {
if (open) {
setName(source.description.trim());
setHint(null);
}
}, [open, source.description]);
const save = () => {
const preset = buildFlockSavedFreightPreset(source, name);
if (!preset) {
setHint("请先填写货品描述或货件名称");
return;
}
const list = loadFlockSavedFreight(customerId);
const next = upsertFlockSavedFreight(list, preset);
persistFlockSavedFreight(customerId, next);
setOpen(false);
setHint("已保存到常用货件");
onSaved?.();
window.setTimeout(() => setHint(null), 2000);
};
return (
<div className="mt-2 space-y-2">
{!open ? (
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1 text-xs font-medium text-[#1890FF] hover:underline disabled:opacity-50"
>
<FloppyDisk size={14} />
</button>
) : (
<div className="rounded-md border border-border bg-bg px-3 py-2">
<p className="mb-1.5 text-xs text-[#4B5563]">便</p>
<div className="flex flex-wrap items-center gap-2">
<input
disabled={disabled}
value={name}
onChange={(e) => setName(e.target.value)}
className={`${inputCls} !h-9 min-w-[12rem] flex-1 text-sm`}
placeholder="如:标准木托家电"
/>
<button
type="button"
disabled={disabled}
onClick={save}
className="h-9 rounded-md bg-[#1890FF] px-3 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
>
</button>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(false)}
className="h-9 rounded-md px-2 text-xs text-text-secondary hover:bg-surface"
>
</button>
</div>
</div>
)}
{hint ? <p className="text-xs text-[#16A34A]">{hint}</p> : null}
</div>
);
}

@ -0,0 +1,296 @@
/**
* MotherShip Axel suggest
*/
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { CaretRight, MagnifyingGlass, X } from "@phosphor-icons/react";
import { hostSuggestMothershipAddress } from "@/lib/frontend/api-client";
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
const DEBOUNCE_MS = 300;
const MIN_QUERY_LEN = 3;
export interface MothershipAddressSuggestFieldProps {
label: string;
required?: boolean;
placeholder?: string;
disabled?: boolean;
/** 必填未满足时高亮边框 */
invalid?: boolean;
testId?: string;
customerId: string;
apiBaseUrl?: string;
serviceToken?: string;
value: string;
confirmed: MothershipAddressCandidate | null;
onQueryChange: (query: string) => void;
onConfirm: (candidate: MothershipAddressCandidate | null) => void;
}
function splitDisplay(label: string): { primary: string; secondary: string } {
const parts = label.split(",").map((p) => p.trim()).filter(Boolean);
if (parts.length <= 1) {
return { primary: label, secondary: "" };
}
return {
primary: parts[0]!,
secondary: parts.slice(1).join(", "),
};
}
export function MothershipAddressSuggestField({
label,
required,
placeholder = "按邮编、地址或公司名搜索",
disabled,
invalid,
testId,
customerId,
apiBaseUrl = "",
serviceToken,
value,
confirmed,
onQueryChange,
onConfirm,
}: MothershipAddressSuggestFieldProps) {
const listId = useId();
const wrapRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [candidates, setCandidates] = useState<MothershipAddressCandidate[]>(
[],
);
const reqSeq = useRef(0);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
useEffect(() => {
const q = value.trim();
if (q.length < MIN_QUERY_LEN) {
setCandidates([]);
setError(null);
setLoading(false);
return;
}
// 已确认且文案未变:不重复请求
if (confirmed && confirmed.display_label === value.trim()) {
return;
}
const seq = ++reqSeq.current;
setLoading(true);
setError(null);
const timer = window.setTimeout(() => {
void (async () => {
try {
const res = await hostSuggestMothershipAddress(
apiBaseUrl,
serviceToken,
customerId,
q,
);
if (seq !== reqSeq.current) return;
if (res.code !== 0 || !res.data) {
setCandidates([]);
setError(res.message || "地址联想失败");
return;
}
setCandidates(
res.data.candidates.filter(
(c) =>
c.selectable !== false &&
Boolean(c.option_id?.trim()) &&
Boolean(
(c.display_label || c.formatted_address || "").trim(),
),
),
);
setOpen(true);
} catch (err) {
if (seq !== reqSeq.current) return;
setCandidates([]);
setError(err instanceof Error ? err.message : "地址联想失败");
} finally {
if (seq === reqSeq.current) {
setLoading(false);
}
}
})();
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [value, apiBaseUrl, serviceToken, customerId, confirmed]);
const handleInput = (next: string) => {
onQueryChange(next);
if (confirmed) {
onConfirm(null);
}
setOpen(true);
};
const handleClear = () => {
onQueryChange("");
onConfirm(null);
setCandidates([]);
setError(null);
setOpen(false);
};
const handlePick = (item: MothershipAddressCandidate) => {
onQueryChange(item.display_label || item.formatted_address);
onConfirm(item);
setOpen(false);
setError(null);
};
return (
<div ref={wrapRef} className="relative space-y-1.5">
<label className="block text-sm font-medium text-[#4B5563]">
{label.trimEnd()}
{required ? (
<span className="ml-0.5 text-[#EF4444]" aria-hidden>
*
</span>
) : null}
</label>
<div className="relative">
<MagnifyingGlass
size={18}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled"
/>
<input
data-testid={testId}
disabled={disabled}
value={value}
onChange={(e) => handleInput(e.target.value)}
onFocus={() => {
if (candidates.length > 0 || value.trim().length >= MIN_QUERY_LEN) {
setOpen(true);
}
}}
placeholder={placeholder}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-invalid={invalid || undefined}
className={[
"h-10 w-full rounded-md border bg-surface pl-10 pr-16 text-sm focus:outline-none focus:ring-2 disabled:opacity-60",
invalid
? "border-[#EF4444] focus:border-[#EF4444] focus:ring-[#EF4444]/20"
: "border-border focus:border-[#1890FF] focus:ring-[#1890FF]/20",
].join(" ")}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
{loading && (
<LoadingSpinner size={16} className="text-text-secondary" />
)}
{value && !disabled && (
<button
type="button"
aria-label="清空地址"
onClick={handleClear}
className="rounded p-1 text-text-secondary hover:bg-bg hover:text-text-primary"
>
<X size={14} weight="bold" />
</button>
)}
</div>
</div>
{confirmed && (
<p className="text-xs text-success">
MotherShip {confirmed.display_label}
</p>
)}
{error && <p className="text-xs text-error">{error}</p>}
{open && !disabled && value.trim().length >= MIN_QUERY_LEN && (
<div
id={listId}
role="listbox"
className="absolute z-30 mt-1 max-h-72 w-full overflow-auto rounded-md border border-border bg-surface shadow-modal"
>
<button
type="button"
role="option"
disabled
title="请从搜索结果中选择经 MotherShip 校验的地址"
className="flex w-full cursor-not-allowed items-start gap-2 border-b border-border bg-bg/80 px-3 py-2.5 text-left opacity-70"
>
<MagnifyingGlass
size={16}
className="mt-0.5 shrink-0 text-text-disabled"
/>
<span>
<span className="block text-sm text-text-secondary">
使
</span>
<span className="block text-xs text-text-disabled">{value}</span>
<span className="mt-0.5 block text-[11px] text-warning">
</span>
</span>
</button>
<p className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-text-disabled">
</p>
{loading && candidates.length === 0 && (
<p className="px-3 py-3 text-sm text-text-secondary"></p>
)}
{!loading && candidates.length === 0 && !error && (
<p className="px-3 py-3 text-sm text-text-secondary">
</p>
)}
{candidates.map((item) => {
const { primary, secondary } = splitDisplay(
item.display_label || item.formatted_address,
);
return (
<button
key={item.option_id}
type="button"
role="option"
onClick={() => handlePick(item)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-bg"
>
<MagnifyingGlass
size={16}
className="shrink-0 text-text-disabled"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-text-primary">
{primary}
</span>
{secondary ? (
<span className="block truncate text-xs text-text-secondary">
{secondary}
</span>
) : null}
</span>
<CaretRight size={14} className="shrink-0 text-text-disabled" />
</button>
);
})}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,427 @@
/**
* MotherShip
* Choose your carrier + coverage + checkout
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import { CheckCircle, Warning } from "@phosphor-icons/react";
import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form";
import type {
QuoteDetail,
QuoteItem,
QuotePageStatus,
} from "@/lib/frontend/types";
import {
getMotherShipRateOptionLabel,
getMotherShipServiceLevelLabel,
} from "@/lib/constants/mothership-ui-tiers";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { formatUSD } from "@/lib/frontend/format";
function parseTransitDays(item: QuoteItem): number | null {
const raw = `${item.transit_days ?? ""} ${item.transit_description ?? ""}`;
const m = raw.match(/(\d+)/);
if (!m) return null;
return Number(m[1]);
}
function addBusinessDays(isoDate: string, days: number): Date | 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;
}
return d;
}
function formatLongDate(d: Date): string {
return d.toLocaleDateString("zh-CN", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
}
function rateKey(item: QuoteItem): string {
return `${item.service_level}|${item.rate_option}|${item.carrier}`;
}
function isGuaranteed(item: QuoteItem): boolean {
return /guaranteed/i.test(item.service_level);
}
function isStandard(item: QuoteItem): boolean {
return /standard/i.test(item.service_level) || !isGuaranteed(item);
}
function carrierTitle(item: QuoteItem): string {
const name = item.carrier?.trim();
if (name) {
return /direct/i.test(name) ? name : `${name}`;
}
return "MotherShip";
}
function findGuaranteeUpsell(
base: QuoteItem,
all: QuoteItem[],
): QuoteItem | null {
if (isGuaranteed(base)) return null;
const sameCarrier = all.filter(
(q) =>
isGuaranteed(q) &&
(q.carrier || "").trim().toLowerCase() ===
(base.carrier || "").trim().toLowerCase(),
);
if (sameCarrier.length > 0) {
return sameCarrier.sort((a, b) => a.final_total - b.final_total)[0] ?? null;
}
// 无同承运商保证档时:取最低保证送达价作加价提示
const anyG = all
.filter(isGuaranteed)
.sort((a, b) => a.final_total - b.final_total);
return anyG[0] ?? null;
}
export interface MothershipLoggedInQuoteSidebarProps {
payload: MothershipLoggedInShipmentPayload;
quote: QuoteDetail | null;
status: QuotePageStatus;
error: string | null;
}
export function MothershipLoggedInQuoteSidebar({
payload,
quote,
status,
error,
}: MothershipLoggedInQuoteSidebarProps) {
const rates = useMemo(() => {
const list = [...(quote?.quotes ?? [])];
return list.sort((a, b) => a.final_total - b.final_total);
}, [quote]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preferGuaranteed, setPreferGuaranteed] = useState(false);
const [coverage, setCoverage] = useState<"basic" | "full">("basic");
const [cargoValue, setCargoValue] = useState("");
useEffect(() => {
if (rates.length === 0) {
setSelectedId(null);
return;
}
setSelectedId((prev) => {
if (prev && rates.some((r) => rateKey(r) === prev)) return prev;
const preferred =
rates.find(isStandard) ?? rates[0]!;
return rateKey(preferred);
});
setPreferGuaranteed(false);
}, [rates]);
const selectedBase =
rates.find((r) => rateKey(r) === selectedId) ?? rates[0] ?? null;
const upsell = selectedBase
? findGuaranteeUpsell(selectedBase, rates)
: null;
const selected =
preferGuaranteed && upsell ? upsell : selectedBase;
const loading =
status === "validating" ||
status === "processing" ||
status === "resolving_address";
const pickupEta = useMemo(() => {
if (!payload.readyDate) return "—";
const d = new Date(`${payload.readyDate}T12:00:00`);
if (Number.isNaN(d.getTime())) return payload.readyDate;
const datePart = formatLongDate(d);
const timePart = payload.readyTime ? ` ${payload.readyTime} 之后` : " 当日结束前";
return `${datePart}${timePart}`;
}, [payload.readyDate, payload.readyTime]);
const deliveryEta = useMemo(() => {
if (!selected) return "以所选费率时效为准";
const days = parseTransitDays(selected);
if (days == null || !payload.readyDate) {
return (
selected.transit_description ||
(selected.transit_days
? `预计 ${selected.transit_days} 个工作日`
: "以所选费率时效为准")
);
}
const end = addBusinessDays(payload.readyDate, days);
if (!end) return `预计 ${days} 个工作日`;
return `${formatLongDate(end)} 当日结束前`;
}, [selected, payload.readyDate]);
const unavailableHint = useMemo(() => {
if (loading || error) return null;
if (rates.length === 0) return null;
const looksMarketplace = rates.every(
(r) =>
/tforce|daylight|echo/i.test(r.carrier ?? "") ||
!/direct/i.test(r.carrier ?? ""),
);
if (looksMarketplace && rates.length > 0) {
return "当前价源非登录态 Direct 承运商列表。请确认客户已配置 MotherShip 账密且 Worker 可打开 dashboard。";
}
if (rates.length < 3) {
return "部分承运商无法服务此票货;完整不可用列表以官网为准。";
}
return null;
}, [rates, loading, error]);
return (
<aside className="space-y-5 rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
{/* 预计时效 */}
<div className="space-y-3 border-b border-border pb-4">
<div>
<p className="text-xs font-medium text-text-secondary"></p>
<p className="mt-0.5 text-sm font-semibold text-text-primary">
{pickupEta}
</p>
<p className="mt-1 break-words text-xs text-text-secondary">
{payload.pickupQuery}
</p>
</div>
<div>
<p className="text-xs font-medium text-text-secondary"></p>
<p className="mt-0.5 text-sm font-semibold text-text-primary">
{deliveryEta}
</p>
<p className="mt-1 break-words text-xs text-text-secondary">
{payload.deliveryQuery}
</p>
</div>
</div>
{/* 承运商报价 */}
<div>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-text-primary"></p>
{quote?.source_type && (
<span className="rounded-full bg-bg px-2 py-0.5 text-[10px] text-text-secondary">
{quote.source_type === "rpa"
? "实时报价"
: quote.source_type === "cache"
? "缓存"
: quote.source_type === "stale"
? "降级"
: quote.source_type}
</span>
)}
</div>
{loading && (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-5 text-sm text-text-secondary">
<LoadingSpinner size={18} />
MotherShip
</div>
)}
{!loading && error && (
<p className="rounded-md border border-error/30 bg-error/5 px-3 py-2 text-sm text-error">
{error}
</p>
)}
{!loading && !error && rates.length === 0 && (
<p className="rounded-md border border-border bg-bg px-3 py-3 text-sm text-text-secondary">
</p>
)}
{!loading && rates.length > 0 && (
<div className="space-y-2">
{rates.map((rate) => {
const id = rateKey(rate);
const active = selectedBase ? rateKey(selectedBase) === id : false;
const days = parseTransitDays(rate);
const levelLabel = getMotherShipServiceLevelLabel(rate.service_level);
const optLabel = getMotherShipRateOptionLabel(
rate.service_level,
rate.rate_option,
);
return (
<button
key={id}
type="button"
onClick={() => {
setSelectedId(id);
setPreferGuaranteed(false);
}}
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${
active
? "border-text-primary bg-bg ring-1 ring-text-primary/20"
: "border-border hover:border-primary/40"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{active && (
<span className="inline-flex items-center gap-0.5 rounded bg-text-primary px-1.5 py-0.5 text-[10px] font-medium text-white">
<CheckCircle size={10} weight="fill" />
</span>
)}
<p className="truncate text-sm font-semibold text-text-primary">
{carrierTitle(rate)}
</p>
</div>
<p className="mt-1 text-xs text-text-secondary">
{days != null
? `预计 ${days} 个工作日`
: rate.transit_description ||
rate.transit_days ||
"时效待定"}
</p>
<p className="mt-0.5 text-[11px] text-text-disabled">
{/direct/i.test(carrierTitle(rate))
? days != null
? `直连承运 · 预计 ${days} 个工作日`
: "直连承运"
: `${levelLabel} · ${optLabel}`}
{rate.raw_freight > 0 && (
<> · {formatUSD(rate.raw_freight)}</>
)}
{rate.surcharges > 0 && (
<> · {formatUSD(rate.surcharges)}</>
)}
</p>
</div>
<p className="shrink-0 text-base font-semibold tabular-nums text-text-primary">
{formatUSD(rate.final_total)}
</p>
</div>
</button>
);
})}
</div>
)}
{selectedBase && upsell && !isGuaranteed(selectedBase) && (
<button
type="button"
onClick={() => setPreferGuaranteed((v) => !v)}
className={`mt-2 w-full rounded-md border px-3 py-2 text-left text-xs ${
preferGuaranteed
? "border-primary bg-primary/5 text-primary"
: "border-border text-primary hover:bg-bg"
}`}
>
{preferGuaranteed ? "已选保证送达 · " : "加价 "}
{formatUSD(Math.max(0, upsell.final_total - selectedBase.final_total))}
{preferGuaranteed ? "(点击取消)" : " 使用保证送达"}
<span className="mt-0.5 block text-[11px] text-text-secondary">
{carrierTitle(upsell)} · {formatUSD(upsell.final_total)}
</span>
</button>
)}
{unavailableHint && (
<p className="mt-3 flex items-start gap-1.5 text-[11px] text-text-disabled">
<Warning size={12} className="mt-0.5 shrink-0 text-warning" />
{unavailableHint}
</p>
)}
</div>
{/* 保障 */}
<div className="space-y-2 border-t border-border pt-4">
<p className="text-sm font-semibold text-text-primary"></p>
<label
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 ${
coverage === "basic" ? "border-text-primary bg-bg" : "border-border"
}`}
>
<input
type="radio"
name="ms-coverage"
checked={coverage === "basic"}
onChange={() => setCoverage("basic")}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium text-text-primary">
</span>
<span className="mt-1 block text-xs leading-relaxed text-text-secondary">
120
</span>
</span>
</label>
<label
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 ${
coverage === "full" ? "border-text-primary bg-bg" : "border-border"
}`}
>
<input
type="radio"
name="ms-coverage"
checked={coverage === "full"}
onChange={() => setCoverage("full")}
className="mt-1"
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text-primary">
FreightProtect
</span>
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs text-text-secondary">
<li> $250k</li>
<li> 4 </li>
<li></li>
</ul>
<span className="mt-2 block text-xs font-medium text-text-primary">
</span>
<input
value={cargoValue}
onChange={(e) => {
setCargoValue(e.target.value);
setCoverage("full");
}}
onFocus={() => setCoverage("full")}
placeholder="输入货物总价值"
className="mt-1 h-10 w-full rounded-md border border-border px-3 text-sm"
/>
<p className="mt-1 text-[11px] text-text-disabled">
$22.55 $100
</p>
</span>
</label>
</div>
{/* 结账 */}
<div className="border-t border-border pt-4">
{selected && (
<p className="mb-2 text-center text-xs text-success">
{carrierTitle(selected)} · {formatUSD(selected.final_total)}
</p>
)}
<button
type="button"
disabled={!selected || loading}
className="flex h-12 w-full items-center justify-center rounded-md bg-success px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{selected ? `${formatUSD(selected.final_total)}` : ""}
</button>
<p className="mt-2 text-center text-[11px] text-text-disabled">
</p>
</div>
</aside>
);
}

@ -0,0 +1,766 @@
/**
* MotherShip Create a new shipment
* dashboard宿
*/
"use client";
import { useCallback, useId, useMemo, useState, type FormEvent } from "react";
import { Plus, X, CaretDown } from "@phosphor-icons/react";
import { MothershipAddressSuggestField } from "@/components/mothership/mothership-address-suggest-field";
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
import {
QuoteFieldTooltip,
QuoteFloatHint,
QuotePageTitle,
QuoteRequiredMark,
QuoteSectionTitle,
QuoteStepProgress,
quoteAddRowCls,
quoteInputCls,
quoteInputErrCls,
} from "@/components/quote/quote-form-chrome";
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
import {
defaultMothershipReadyDateIso,
isMothershipWeightEachAllowed,
MOTHERSHIP_MAX_WEIGHT_EACH_LB,
normalizeMothershipReadyDateIso,
} from "@/lib/mothership/logged-in-constraints";
import {
evaluateMothershipAccessorialCompat,
mothershipAccessorialHasBlock,
MS_PICKUP_BLOCKED_ACCESSORIALS,
toggleMothershipAccessorial,
type MsCompatIssue,
} from "@/lib/mothership/option-compat";
export const MS_PICKUP_ACCESSORIALS = [
{ id: "cfs", label: "CFS" },
{ id: "liftgate", label: "尾板Liftgate" },
{ id: "limitedAccess", label: "受限通道" },
{ id: "inside", label: "LTL 室内服务" },
{ id: "residential", label: "住宅" },
{ id: "tradeshow", label: "展会" },
] as const;
export const MS_DELIVERY_ACCESSORIALS = [
{ id: "fbaAppointment", label: "Amazon 预约" },
{ id: "appointment", label: "预约" },
{ id: "cfs", label: "CFS" },
{ id: "liftgate", label: "尾板Liftgate" },
{ id: "limitedAccess", label: "受限通道" },
{ id: "inside", label: "LTL 室内服务" },
{ id: "residential", label: "住宅" },
{ id: "tradeshow", label: "展会" },
] as const;
/** 对齐官网 Cargo type 下拉(录制/截图 2026-07-15 */
export const MS_CARGO_TYPES = [
{ id: "pallet", label: "托盘Pallet" },
{ id: "box", label: "箱Box" },
{ id: "crate", label: "木箱Crate" },
{ id: "piece", label: "件Piece" },
{ id: "bale", label: "捆包Bale" },
{ id: "bucket", label: "桶Bucket" },
{ id: "carton", label: "纸箱Carton" },
{ id: "case", label: "箱件Case" },
{ id: "coil", label: "卷材Coil" },
{ id: "cylinder", label: "圆筒Cylinder" },
{ id: "drum", label: "圆桶Drum" },
{ id: "pail", label: "提桶Pail" },
{ id: "reel", label: "线盘Reel" },
{ id: "roll", label: "卷Roll" },
{ id: "skid", label: "滑托Skid" },
{ id: "tote", label: "集装袋Tote" },
{ id: "tube", label: "管Tube" },
] as const;
/** 官网 Ready for pick-up 时刻整点、12 小时制、有序无重复 */
export const MS_READY_TIMES: readonly string[] = (() => {
const labels: string[] = [];
for (let h = 0; h < 24; h += 1) {
const hour12 = h % 12 === 0 ? 12 : h % 12;
const suffix = h < 12 ? "AM" : "PM";
labels.push(`${hour12}:00 ${suffix}`);
}
return labels;
})();
export const MS_DEFAULT_READY_TIME = "11:00 AM";
export type MsAccessorialId =
| (typeof MS_PICKUP_ACCESSORIALS)[number]["id"]
| (typeof MS_DELIVERY_ACCESSORIALS)[number]["id"];
export type MsCargoTypeId = (typeof MS_CARGO_TYPES)[number]["id"];
export type MsCargoLine = {
key: string;
cargoType: MsCargoTypeId;
quantity: string;
weightLb: string;
lengthIn: string;
widthIn: string;
heightIn: string;
};
export type MothershipLoggedInShipmentPayload = {
pickupQuery: string;
deliveryQuery: string;
/** MotherShip 联想点选结果(继续闸门必填) */
pickupConfirmed: MothershipAddressCandidate;
deliveryConfirmed: MothershipAddressCandidate;
pickupAccessorials: string[];
deliveryAccessorials: string[];
readyDate: string;
readyTime: string;
timezone: string;
cargo: Array<{
cargoType: MsCargoTypeId;
quantity: number;
weightLb: number;
lengthIn: number;
widthIn: number;
heightIn: number;
}>;
};
export interface MothershipLoggedInShipmentFormProps {
formId?: string;
disabled?: boolean;
customerId: string;
apiBaseUrl?: string;
serviceToken?: string;
onValidSubmit?: (payload: MothershipLoggedInShipmentPayload) => void;
}
function newCargoLine(): MsCargoLine {
return {
key: `c-${Math.random().toString(36).slice(2, 10)}`,
cargoType: "pallet",
quantity: "",
weightLb: "",
lengthIn: "",
widthIn: "",
heightIn: "",
};
}
function RequiredMark() {
return <QuoteRequiredMark />;
}
function AccessorialMultiSelect({
label,
options,
selected,
disabled,
tip,
side,
onChange,
}: {
label: string;
options: readonly { id: string; label: string }[];
selected: string[];
disabled?: boolean;
tip?: string;
side: "pickup" | "delivery";
onChange: (next: string[], meta?: { message?: string; issues: MsCompatIssue[] }) => void;
}) {
const [open, setOpen] = useState(false);
const [hint, setHint] = useState<string | null>(null);
const summary = useMemo(() => {
if (selected.length === 0) return "选择";
if (selected.length === 1) {
return options.find((o) => o.id === selected[0])?.label ?? "已选择";
}
const first =
options.find((o) => o.id === selected[0])?.label ?? selected[0];
return `${first}${selected.length}`;
}, [options, selected]);
return (
<div className="relative space-y-1">
<div className="flex items-center gap-1 text-xs text-[#4B5563]">
<span>{label}</span>
{tip ? <QuoteFieldTooltip tip={tip} /> : null}
</div>
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="flex h-10 w-full items-center justify-between rounded-md border border-border bg-surface px-3 text-left text-sm transition-colors hover:border-[#1890FF]/40 focus:border-[#1890FF] focus:outline-none focus:ring-2 focus:ring-[#1890FF]/20 disabled:cursor-not-allowed disabled:opacity-60"
>
<span
className={
selected.length === 0 ? "text-text-disabled" : "text-text-primary"
}
>
{summary}
</span>
<CaretDown size={16} className="text-text-secondary" />
</button>
{open && !disabled && (
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-md border border-border bg-surface py-1 shadow-modal">
{options.map((opt) => {
const checked = selected.includes(opt.id);
const pickupBlocked =
side === "pickup" &&
Boolean(MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]);
const blockedReason = pickupBlocked
? MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]
: undefined;
return (
<label
key={opt.id}
className={[
"flex items-start gap-2 px-3 py-2 text-sm",
pickupBlocked && !checked
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-bg",
].join(" ")}
title={blockedReason}
>
<input
type="checkbox"
checked={checked}
disabled={pickupBlocked && !checked}
onChange={() => {
const r = toggleMothershipAccessorial({
side,
selected,
id: opt.id,
});
if (!r.applied) {
setHint(r.message ?? null);
return;
}
setHint(r.message ?? null);
onChange(r.next, { message: r.message, issues: r.issues });
}}
className="mt-0.5 h-4 w-4 rounded border-border text-[#1890FF] focus:ring-[#1890FF]/30 disabled:cursor-not-allowed"
/>
<span>
<span className="block">{opt.label}</span>
{blockedReason ? (
<span className="mt-0.5 block text-[11px] text-[#EF4444]">
{blockedReason}
</span>
) : null}
</span>
</label>
);
})}
</div>
)}
{hint ? (
<p className="text-[11px] leading-snug text-[#B45309]">{hint}</p>
) : null}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{selected.map((id) => {
const opt = options.find((o) => o.id === id);
return (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full border border-border bg-bg px-2 py-0.5 text-xs text-text-secondary"
>
{opt?.label ?? id}
</span>
);
})}
</div>
)}
</div>
);
}
/** MotherShip 登录后一级:创建新货件 */
export function MothershipLoggedInShipmentForm({
formId = "ms-logged-in-shipment-form",
disabled,
customerId,
apiBaseUrl = "",
serviceToken,
onValidSubmit,
}: MothershipLoggedInShipmentFormProps) {
const baseId = useId();
const [pickupQuery, setPickupQuery] = useState("");
const [deliveryQuery, setDeliveryQuery] = useState("");
const [pickupConfirmed, setPickupConfirmed] =
useState<MothershipAddressCandidate | null>(null);
const [deliveryConfirmed, setDeliveryConfirmed] =
useState<MothershipAddressCandidate | null>(null);
const [pickupAccessorials, setPickupAccessorials] = useState<string[]>([]);
const [deliveryAccessorials, setDeliveryAccessorials] = useState<string[]>(
[],
);
const [readyDate, setReadyDate] = useState(defaultMothershipReadyDateIso);
const [readyTime, setReadyTime] = useState(MS_DEFAULT_READY_TIME);
const [timezone] = useState("GMT+8");
const [cargoLines, setCargoLines] = useState<MsCargoLine[]>([newCargoLine()]);
const [localError, setLocalError] = useState<string | null>(null);
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set());
const accessorialHints = useMemo(() => {
return [
...evaluateMothershipAccessorialCompat({
side: "pickup",
selected: pickupAccessorials,
}),
...evaluateMothershipAccessorialCompat({
side: "delivery",
selected: deliveryAccessorials,
}),
];
}, [pickupAccessorials, deliveryAccessorials]);
const updateCargo = useCallback(
(key: string, patch: Partial<MsCargoLine>) => {
setCargoLines((rows) =>
rows.map((r) => (r.key === key ? { ...r, ...patch } : r)),
);
},
[],
);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setLocalError(null);
const nextInvalid = new Set<string>();
if (!pickupConfirmed) {
nextInvalid.add("pickup");
setInvalidFields(nextInvalid);
setLocalError("请填写提货地址");
return;
}
if (!deliveryConfirmed) {
nextInvalid.add("delivery");
setInvalidFields(nextInvalid);
setLocalError("请填写送货地址");
return;
}
const pickupIssues = evaluateMothershipAccessorialCompat({
side: "pickup",
selected: pickupAccessorials,
});
const deliveryIssues = evaluateMothershipAccessorialCompat({
side: "delivery",
selected: deliveryAccessorials,
});
const blockMsg = [...pickupIssues, ...deliveryIssues].find(
(i) => i.severity === "block",
)?.message;
if (blockMsg) {
setLocalError(blockMsg);
return;
}
const cargo: MothershipLoggedInShipmentPayload["cargo"] = [];
for (const line of cargoLines) {
const quantity = Number(line.quantity);
const weightLb = Number(line.weightLb);
const lengthIn = Number(line.lengthIn);
const widthIn = Number(line.widthIn);
const heightIn = Number(line.heightIn);
if (!Number.isFinite(quantity) || quantity < 1) {
nextInvalid.add(`${line.key}-quantity`);
}
if (!Number.isFinite(weightLb) || weightLb <= 0) {
nextInvalid.add(`${line.key}-weight`);
} else if (!isMothershipWeightEachAllowed(weightLb)) {
nextInvalid.add(`${line.key}-weight`);
setInvalidFields(nextInvalid);
setLocalError(
`单件重量不能超过 ${MOTHERSHIP_MAX_WEIGHT_EACH_LB}`,
);
return;
}
if (!Number.isFinite(lengthIn) || lengthIn <= 0) {
nextInvalid.add(`${line.key}-length`);
}
if (!Number.isFinite(widthIn) || widthIn <= 0) {
nextInvalid.add(`${line.key}-width`);
}
if (!Number.isFinite(heightIn) || heightIn <= 0) {
nextInvalid.add(`${line.key}-height`);
}
if (
nextInvalid.has(`${line.key}-quantity`) ||
nextInvalid.has(`${line.key}-weight`) ||
nextInvalid.has(`${line.key}-length`) ||
nextInvalid.has(`${line.key}-width`) ||
nextInvalid.has(`${line.key}-height`)
) {
setInvalidFields(nextInvalid);
setLocalError("请完整填写每行货物的数量、重量与尺寸");
return;
}
cargo.push({
cargoType: line.cargoType,
quantity,
weightLb,
lengthIn,
widthIn,
heightIn,
});
}
setInvalidFields(new Set());
const normalizedReadyDate = normalizeMothershipReadyDateIso(readyDate) ?? readyDate;
onValidSubmit?.({
pickupQuery:
pickupConfirmed.display_label ||
pickupConfirmed.formatted_address ||
pickupQuery.trim(),
deliveryQuery:
deliveryConfirmed.display_label ||
deliveryConfirmed.formatted_address ||
deliveryQuery.trim(),
pickupConfirmed,
deliveryConfirmed,
pickupAccessorials,
deliveryAccessorials,
readyDate: normalizedReadyDate,
readyTime,
timezone,
cargo,
});
};
const fieldCls = (key: string) =>
invalidFields.has(key) ? quoteInputErrCls : quoteInputCls;
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<QuoteStepProgress current={1} />
<QuotePageTitle></QuotePageTitle>
</div>
{accessorialHints.length > 0 ? (
<div className="space-y-1 rounded-md border border-[#F59E0B]/40 bg-[#FFFBEB] px-3 py-2 text-[12px] leading-snug text-[#92400E]">
<p className="font-medium text-[#B45309]"></p>
<ul className="list-disc space-y-1 pl-4">
{accessorialHints.map((i) => (
<li
key={i.code}
className={i.severity === "block" ? "font-medium text-[#EF4444]" : ""}
>
{i.message}
</li>
))}
</ul>
</div>
) : null}
<QuoteFloatHint message={localError} />
{/* 地址 + 时间 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<div className="mb-4">
<QuoteSectionTitle></QuoteSectionTitle>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
<div className="space-y-4 rounded-md border-l-[3px] border-[#1890FF]/50 bg-[#1890FF]/5 py-1 pl-3">
<MothershipAddressSuggestField
label="提货地址"
required
invalid={invalidFields.has("pickup")}
testId="quote-create-pickup-input-search"
disabled={disabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
value={pickupQuery}
confirmed={pickupConfirmed}
onQueryChange={(q) => {
setPickupQuery(q);
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("pickup");
return next;
});
}}
onConfirm={(c) => {
setPickupConfirmed(c);
if (c) {
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("pickup");
return next;
});
setLocalError(null);
}
}}
/>
<AccessorialMultiSelect
label="附加服务(可选)"
tip="提货地点可能产生的附加费用服务;住宅提货承运商不支持"
side="pickup"
options={MS_PICKUP_ACCESSORIALS}
selected={pickupAccessorials}
disabled={disabled}
onChange={(next, meta) => {
setPickupAccessorials(next);
if (meta?.message) setLocalError(meta.message);
else if (
meta?.issues &&
mothershipAccessorialHasBlock(meta.issues)
) {
setLocalError(
meta.issues.find((i) => i.severity === "block")!.message,
);
}
}}
/>
</div>
<div className="hidden items-center justify-center pt-8 text-text-disabled md:flex">
</div>
<div className="space-y-4 rounded-md border-l-[3px] border-[#64748B]/40 bg-bg/60 py-1 pl-3">
<MothershipAddressSuggestField
label="送货地址"
required
invalid={invalidFields.has("delivery")}
testId="quote-create-delivery-input-search"
disabled={disabled}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
serviceToken={serviceToken}
value={deliveryQuery}
confirmed={deliveryConfirmed}
onQueryChange={(q) => {
setDeliveryQuery(q);
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("delivery");
return next;
});
}}
onConfirm={(c) => {
setDeliveryConfirmed(c);
if (c) {
setInvalidFields((prev) => {
const next = new Set(prev);
next.delete("delivery");
return next;
});
setLocalError(null);
}
}}
/>
<AccessorialMultiSelect
label="附加服务(可选)"
tip="送货地点可能产生的附加费用服务;预约与 Amazon 预约不能同时选"
side="delivery"
options={MS_DELIVERY_ACCESSORIALS}
selected={deliveryAccessorials}
disabled={disabled}
onChange={(next, meta) => {
setDeliveryAccessorials(next);
if (meta?.message) setLocalError(meta.message);
}}
/>
</div>
</div>
<div className="mt-6 flex flex-wrap items-end gap-4 border-t border-border pt-4">
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-[#4B5563]">
<span></span>
<QuoteFieldTooltip tip="货物最早可被承运商提走的日期与时刻;周六、周日不可选" />
</div>
<MothershipWeekdayDatePicker
disabled={disabled}
value={readyDate}
onChange={setReadyDate}
/>
</div>
<span className="pb-2 text-sm text-text-secondary"></span>
<div className="space-y-1">
<label className="sr-only" htmlFor={`${baseId}-ready-time`}>
</label>
<select
id={`${baseId}-ready-time`}
data-testid="ready-pickup-time-select"
disabled={disabled}
value={readyTime}
onChange={(e) => setReadyTime(e.target.value)}
className={`${quoteInputCls} min-w-[7.5rem]`}
>
{MS_READY_TIMES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<span className="pb-2 text-sm text-text-secondary">{timezone}</span>
</div>
</section>
{/* 货物 */}
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<QuoteSectionTitle></QuoteSectionTitle>
<button
type="button"
data-testid="cargo-add-button"
disabled={disabled}
onClick={() => setCargoLines((rows) => [...rows, newCargoLine()])}
className={quoteAddRowCls}
>
<Plus size={16} weight="bold" />
</button>
</div>
<div className="space-y-4">
{cargoLines.map((line, idx) => (
<div
key={line.key}
className="grid min-h-12 gap-4 border-b border-border pb-4 last:border-0 last:pb-0 sm:grid-cols-2 lg:grid-cols-7"
>
<label className="space-y-1 lg:col-span-1">
<span className="flex items-center gap-1 text-xs text-[#4B5563]">
<QuoteFieldTooltip tip="货物包装形态,影响承运商派车与装卸" />
</span>
<select
data-testid={idx === 0 ? "cargo-type-dropdown-input" : undefined}
disabled={disabled}
value={line.cargoType}
onChange={(e) =>
updateCargo(line.key, {
cargoType: e.target.value as MsCargoTypeId,
})
}
className={quoteInputCls}
>
{MS_CARGO_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-quantity-input" : undefined}
disabled={disabled}
inputMode="numeric"
placeholder="数量"
value={line.quantity}
onChange={(e) =>
updateCargo(line.key, { quantity: e.target.value })
}
className={fieldCls(`${line.key}-quantity`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-weight-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="lbs"
value={line.weightLb}
onChange={(e) =>
updateCargo(line.key, { weightLb: e.target.value })
}
className={fieldCls(`${line.key}-weight`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-length-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="inch"
value={line.lengthIn}
onChange={(e) =>
updateCargo(line.key, { lengthIn: e.target.value })
}
className={fieldCls(`${line.key}-length`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-width-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="inch"
value={line.widthIn}
onChange={(e) =>
updateCargo(line.key, { widthIn: e.target.value })
}
className={fieldCls(`${line.key}-width`)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-[#4B5563]">
<RequiredMark />
</span>
<input
data-testid={idx === 0 ? "cargo-height-input" : undefined}
disabled={disabled}
inputMode="decimal"
placeholder="inch"
value={line.heightIn}
onChange={(e) =>
updateCargo(line.key, { heightIn: e.target.value })
}
className={fieldCls(`${line.key}-height`)}
/>
</label>
<div className="flex items-end justify-end">
<button
type="button"
data-testid="cargo-remove-button"
disabled={disabled || cargoLines.length <= 1}
onClick={() =>
setCargoLines((rows) =>
rows.filter((r) => r.key !== line.key),
)
}
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border text-text-secondary transition-colors hover:border-[#EF4444] hover:text-[#EF4444] disabled:cursor-not-allowed disabled:opacity-40"
aria-label="删除该行货物"
>
<X size={16} weight="bold" />
</button>
</div>
</div>
))}
</div>
</section>
</form>
);
}

@ -0,0 +1,156 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { CaretDown, CaretLeft, CaretRight } from "@phosphor-icons/react";
import {
minMothershipReadyDateIso,
parseIsoDateLocal,
} from "@/lib/mothership/logged-in-constraints";
import {
buildMothershipWeekdayGrid,
formatMothershipWeekdayDateLabel,
monthLabelZh,
} from "@/lib/mothership/weekday-date-picker";
import { quoteInputCls } from "@/components/quote/quote-form-chrome";
const WEEKDAYS_ZH = ["日", "一", "二", "三", "四", "五", "六"] as const;
export type MothershipWeekdayDatePickerProps = {
value: string;
onChange: (iso: string) => void;
disabled?: boolean;
className?: string;
minIso?: string;
};
export function MothershipWeekdayDatePicker({
value,
onChange,
disabled,
className,
minIso = minMothershipReadyDateIso(),
}: MothershipWeekdayDatePickerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const listId = useId();
const [open, setOpen] = useState(false);
const anchor = parseIsoDateLocal(value) ?? parseIsoDateLocal(minIso)!;
const [viewYear, setViewYear] = useState(anchor.getFullYear());
const [viewMonth, setViewMonth] = useState(anchor.getMonth());
useEffect(() => {
const d = parseIsoDateLocal(value);
if (!d) return;
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
}, [value]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const cells = useMemo(
() => buildMothershipWeekdayGrid(viewYear, viewMonth, value, minIso),
[viewYear, viewMonth, value, minIso],
);
const shiftMonth = (delta: number) => {
const d = new Date(viewYear, viewMonth + delta, 1, 12, 0, 0);
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
id={listId}
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={className ?? quoteInputCls + " flex items-center justify-between text-left"}
>
<span>{formatMothershipWeekdayDateLabel(value)}</span>
<CaretDown size={16} className="shrink-0 text-text-secondary" />
</button>
{open && !disabled && (
<div
role="dialog"
aria-labelledby={listId}
className="absolute z-30 mt-1 w-[280px] rounded-lg border border-border bg-surface p-3 shadow-modal"
>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
aria-label="上一月"
className="rounded p-1 text-text-secondary hover:bg-bg"
onClick={() => shiftMonth(-1)}
>
<CaretLeft size={16} />
</button>
<span className="text-sm font-medium text-text-primary">
{monthLabelZh(viewYear, viewMonth)}
</span>
<button
type="button"
aria-label="下一月"
className="rounded p-1 text-text-secondary hover:bg-bg"
onClick={() => shiftMonth(1)}
>
<CaretRight size={16} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-[11px] text-text-secondary">
{WEEKDAYS_ZH.map((w) => (
<div key={w} className="py-1 font-medium">
{w}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{cells.map((cell) => {
const base =
"flex h-9 w-9 items-center justify-center rounded-full text-sm transition-colors";
let cls = base;
if (!cell.inMonth) {
cls += " invisible pointer-events-none";
} else if (cell.disabled) {
cls += " cursor-not-allowed text-text-disabled opacity-40";
} else if (cell.isSelected) {
cls +=
" bg-[#1890FF] font-semibold text-white hover:bg-[#1580E0]";
} else {
cls +=
" text-text-primary hover:bg-[#1890FF]/10 hover:text-[#1890FF]";
}
return (
<button
key={cell.iso + String(cell.inMonth)}
type="button"
disabled={cell.disabled || !cell.inMonth}
aria-label={cell.iso}
aria-selected={cell.isSelected}
className={cls}
onClick={() => {
onChange(cell.iso);
setOpen(false);
}}
>
{cell.day}
</button>
);
})}
</div>
<p className="mt-2 text-[11px] text-text-secondary">
</p>
</div>
)}
</div>
);
}

@ -1,6 +1,7 @@
"use client"; "use client";
import { buildPriority1CalendarGrid, weekdayLabelsZh } from "@/lib/priority1/calendar-grid"; import { buildPriority1CalendarGrid, weekdayLabelsZh } from "@/lib/priority1/calendar-grid";
import { priority1CustomerDisplayUsd } from "@/lib/priority1/display-price";
import { P1_RESULT_UI } from "@/lib/priority1/ui-labels"; import { P1_RESULT_UI } from "@/lib/priority1/ui-labels";
import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage"; import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage";
@ -26,7 +27,7 @@ function formatUsd(n: number): string {
} }
function lineDisplayUsd(line: Priority1DisplayLine): number { function lineDisplayUsd(line: Priority1DisplayLine): number {
return line.totalUsd > 0 ? line.totalUsd : line.final_total_usd; return priority1CustomerDisplayUsd(line);
} }
export function Priority1FtlScheduleCalendar({ export function Priority1FtlScheduleCalendar({

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

Loading…
Cancel
Save