From 4ef21f41db3def698b9c0108057b1a4515b1f333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84GitHub=E7=94=A8=E6=88=B7=E5=90=8D?= <3457189498@qq.com> Date: Fri, 3 Jul 2026 18:20:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=9A=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=8A=A5=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 21 +- .gitattributes | 1 + .gitignore | 4 + Dockerfile | 4 + Dockerfile.worker | 5 +- __tests__/api/admin-markup-routes.test.ts | 4 + __tests__/lib/axel/candidates.test.ts | 107 +- __tests__/lib/axel/quote.test.ts | 51 +- .../lib/constants/host-public-api.test.ts | 27 + __tests__/lib/constants/host-service.test.ts | 28 + .../lib/constants/mothership-ui-tiers.test.ts | 59 + __tests__/lib/frontend/us-states.test.ts | 38 + __tests__/lib/math.test.ts | 10 +- __tests__/lib/priority1/calendar-grid.test.ts | 50 + .../priority1/date-format-and-errors.test.ts | 32 + .../lib/rpa/ensure-storage-state.test.ts | 90 + __tests__/middleware/host-auth-gate.test.ts | 34 + .../modules/address/candidates-cache.test.ts | 33 + .../address/mothership-candidates.test.ts | 50 +- .../modules/alert/alert-presentation.test.ts | 68 + .../modules/auth/service-token-async.test.ts | 81 + __tests__/modules/cache/cache.test.ts | 13 + .../modules/customer/embed-password.test.ts | 26 + .../modules/customer/token-crypto.test.ts | 25 + __tests__/modules/embed-demo/auth.test.ts | 42 + .../modules/host/public-orchestrator.test.ts | 220 +++ __tests__/modules/host/wait-for-quote.test.ts | 26 + __tests__/modules/pricing/engine.test.ts | 6 + .../modules/pricing/markup-validation.test.ts | 18 + .../modules/priority1/orchestrator.test.ts | 87 + .../modules/priority1/quote-storage.test.ts | 114 ++ .../modules/priority1/validation.test.ts | 44 + .../quote/fallback-orchestrator.test.ts | 16 + __tests__/modules/quote/orchestrator.test.ts | 68 +- .../modules/quote/quote-completeness.test.ts | 30 +- .../quote/quote-serializer-priority1.test.ts | 158 ++ .../modules/quote/unit-converter.test.ts | 10 +- __tests__/scripts/record-priority1.test.ts | 84 + .../workers/rpa/mothership.validate.test.ts | 10 +- .../workers/rpa/priority1-demo-input.test.ts | 67 + .../rpa/priority1-form-constraints.test.ts | 45 + .../rpa/priority1-form-interactions.test.ts | 48 + .../rpa/priority1-form-logic-map.test.ts | 82 + .../workers/rpa/priority1-job-handler.test.ts | 102 ++ .../rpa/priority1-quote-extract-dom.test.ts | 87 + .../rpa/priority1-quote-extract.test.ts | 123 ++ __tests__/workers/rpa/quote-capture.test.ts | 81 +- api调用文档/api调用文档.md | 358 ++++ app/admin/alerts/page.tsx | 103 +- app/admin/customers/page.tsx | 455 +++++ app/admin/markup/page.tsx | 29 +- app/admin/queues/page.tsx | 247 ++- .../mothership-candidates/confirm/route.ts | 2 +- .../mothership-candidates/preheat/route.ts | 2 +- .../mothership-candidates/release/route.ts | 2 +- .../addresses/mothership-candidates/route.ts | 2 +- .../[customer_id]/rotate-key/route.ts | 34 + .../admin/customers/[customer_id]/route.ts | 87 + app/api/admin/customers/route.ts | 109 ++ .../markup-configs/[customer_id]/route.ts | 4 +- app/api/admin/query-logs/route.ts | 79 + app/api/alerts/route.ts | 6 +- app/api/embed-demo/login/route.ts | 75 + app/api/embed-demo/logout/route.ts | 16 + app/api/embed-demo/me/route.ts | 53 + app/api/host/quote/candidates/route.ts | 57 + app/api/host/quote/submit/route.ts | 68 + app/api/markup-configs/[customer_id]/route.ts | 6 +- app/api/markup-configs/route.ts | 2 +- app/api/priority1/quotes/route.ts | 71 + app/api/quotes/[quote_id]/route.ts | 2 +- app/api/quotes/history/route.ts | 2 +- app/api/quotes/manual-fallback/route.ts | 9 +- app/api/quotes/route.ts | 2 +- app/dashboard/page.tsx | 37 +- app/embed-demo/page.tsx | 66 +- app/login/login-client.tsx | 17 +- components/embed/embed-demo-login.tsx | 86 + components/embed/embedded-quote-widget.tsx | 143 +- components/embed/quote-provider-switch.tsx | 42 + components/layout/admin-layout.tsx | 5 +- components/priority1/feathery-card.tsx | 282 +++ components/priority1/p1-date-field.tsx | 55 + components/priority1/p1-phone-field.tsx | 155 ++ .../priority1-ftl-schedule-calendar.tsx | 153 ++ .../priority1/priority1-ltl-quote-options.tsx | 113 ++ .../priority1-quote-result-panel.tsx | 180 ++ .../priority1/priority1-quote-widget.tsx | 232 +++ components/priority1/priority1-simulator.tsx | 833 +++++++++ components/quote/quote-card.tsx | 99 +- components/quote/quote-form.tsx | 20 +- components/quote/quote-result-panel.tsx | 26 +- components/ui/state-combobox-field.tsx | 143 ++ components/ui/warning-banner.tsx | 8 +- cursor_project_rules/implementation-plan.mdc | 322 ++++ deploy/.env.bt-host-mysql.example | 80 + deploy/.env.production.example | 72 + deploy/.gitignore | 2 + deploy/API.md | 167 ++ deploy/README.md | 175 ++ deploy/RELEASE-NOTES.md | 221 +++ deploy/database/README.md | 55 + deploy/database/chajia.sql | 248 +++ deploy/database/chajia.sql.gz | Bin 0 -> 2194 bytes deploy/database/export-local.sh | 60 + deploy/docker-compose.bt-host-mysql.yml | 110 ++ deploy/docker-compose.yml | 123 ++ deploy/host-candidates-req.example.json | 20 + deploy/install-bt-host-mysql.sh | 240 +++ deploy/install.sh | 185 ++ deploy/nginx/chajia.conf | 58 + deploy/nginx/chajia.http.conf | 35 + deploy/nginx/chajia.ssl.conf | 52 + deploy/pack-readme-template.md | 106 ++ deploy/pack-server-upload.ps1 | 199 +++ deploy/setup-firewall.sh | 23 + deploy/test-scripts/test-denver-boston.sh | 155 ++ deploy/test-scripts/test-peachtree-tremont.sh | 151 ++ deploy/update.sh | 6 + deploy/宝塔部署说明.md | 125 ++ deploy/服务器更新说明.md | 369 ++++ deploy/第三方对接指南.md | 406 +++++ docs/报价提速与稳定性-改动清单.md | 186 ++ docs/查价项目.md | 151 +- docs/真人录证-commit时刻.md | 161 -- ...州货物运输报价汇总_edited3.docx | Bin 10574 -> 0 bytes hooks/use-auth.tsx | 16 +- hooks/use-quote-polling.ts | 25 +- lib/api/auth-context.ts | 60 +- lib/axel/candidates.ts | 17 +- lib/axel/client.ts | 8 +- lib/axel/constants.ts | 15 + lib/axel/quote-from-request.ts | 37 +- lib/axel/session.ts | 13 +- lib/axel/shipment.ts | 9 +- lib/constants/cache.ts | 3 + lib/constants/host-public-api.ts | 33 + lib/constants/host-service.ts | 32 + lib/constants/mothership-ui-tiers.ts | 265 +++ lib/constants/quote.ts | 27 +- lib/frontend/api-client.ts | 245 ++- lib/frontend/constants.ts | 23 +- lib/frontend/format.ts | 4 +- lib/frontend/types.ts | 84 +- lib/frontend/us-states.ts | 95 + lib/math.ts | 6 + lib/priority1/api-errors.ts | 37 + lib/priority1/calendar-grid.ts | 136 ++ lib/priority1/date-format.ts | 13 + lib/priority1/phone-countries.ts | 112 ++ lib/priority1/pickup-date.ts | 34 + lib/priority1/ui-labels.ts | 315 ++++ lib/rpa/ensure-storage-state.ts | 104 ++ lib/rpa/env.ts | 6 + middleware.ts | 115 +- modules/address/candidates-cache.ts | 63 + modules/address/mothership-candidates.ts | 22 + modules/alert/alert-presentation.ts | 199 +++ modules/alert/serializer.ts | 22 + modules/auth/service-token-env.ts | 120 ++ modules/auth/service-token.ts | 126 +- modules/cache/redis-cache.ts | 25 + modules/customer/customer-service.ts | 353 ++++ modules/customer/embed-password.ts | 29 + modules/customer/token-crypto.ts | 15 + modules/customer/types.ts | 27 + modules/embed-demo/auth.ts | 53 + modules/host/address-mapper.ts | 44 + modules/host/public-orchestrator.ts | 164 ++ modules/host/public-session.ts | 41 + modules/host/types.ts | 39 + modules/host/validation.ts | 54 + modules/host/wait-for-quote.ts | 68 + modules/pricing/engine.ts | 4 +- modules/pricing/markup-service.ts | 4 +- modules/pricing/markup-validation.ts | 7 +- modules/priority1/constants.ts | 3 + modules/priority1/hash.ts | 8 + modules/priority1/orchestrator.ts | 144 ++ modules/priority1/quote-mapper.ts | 46 + modules/priority1/quote-outcome.ts | 12 + modules/priority1/quote-storage.ts | 179 ++ modules/priority1/rpa-queue.ts | 23 + modules/priority1/validation.ts | 105 ++ modules/providers/quote-provider.ts | 4 +- modules/quote/fallback-orchestrator.ts | 31 +- modules/quote/orchestrator.ts | 122 +- modules/quote/query-log.ts | 200 +++ modules/quote/quote-completeness.ts | 47 +- modules/quote/quote-serializer.ts | 93 +- modules/quote/unit-converter.ts | 12 +- modules/rpa/quote-mapper.ts | 9 +- package.json | 18 + .../migration.sql | 4 + .../migration.sql | 37 + .../migration.sql | 22 + .../migration.sql | 8 + prisma/schema.prisma | 57 +- prisma/seed.ts | 6 +- scripts/benchmark-priority1-10cases.ts | 165 ++ .../benchmark-priority1-canonical-matrix.ts | 229 +++ scripts/benchmark-priority1-ftl-expedited.ts | 123 ++ .../export-priority1-canonical-test-matrix.ts | 119 ++ scripts/export-priority1-form-reference.ts | 15 + scripts/probe-priority1-ltl.ts | 50 + scripts/probe-priority1-quote-extract.ts | 118 ++ scripts/probe-priority1-shipment-types.ts | 296 ++++ scripts/probe-priority1-step1-submit.ts | 74 + scripts/probe-priority1-submit-errors.ts | 59 + scripts/probe-priority1-zip.ts | 76 + scripts/record-priority1-ftl-path.ts | 58 + scripts/record-priority1-p08-accessorials.ts | 25 + scripts/record-priority1-path.ts | 113 ++ scripts/record-priority1.ps1 | 25 + scripts/record-priority1.sh | 86 + scripts/record-priority1.ts | 132 ++ scripts/test-host-api.ps1 | 135 ++ scripts/test-host-api.sh | 140 ++ scripts/visual-priority1-canonical-inspect.ts | 133 ++ scripts/visual-priority1-full-chain.ts | 33 + scripts/visual-priority1-human-assist.ts | 35 + .../visual-priority1-inspect-ftl-trailers.ts | 85 + scripts/visual-priority1-inspect-types.ts | 72 + tsconfig.json | 2 +- workers/rpa/address-side.ts | 19 +- workers/rpa/cargo-field-fill.ts | 2 +- workers/rpa/fill-cargo-in-drawer.ts | 23 +- workers/rpa/fix-place/google-event-commit.ts | 4 +- workers/rpa/index.ts | 27 + workers/rpa/job-handler.ts | 14 +- workers/rpa/kernel/context-validator.ts | 36 +- workers/rpa/kernel/step-guard.ts | 5 +- workers/rpa/mothership.ts | 17 +- workers/rpa/page-prep.ts | 3 +- workers/rpa/priority1-job-handler.ts | 150 ++ workers/rpa/priority1/FORM-LOGIC-REFERENCE.md | 1046 +++++++++++ workers/rpa/priority1/demo-input.ts | 752 ++++++++ workers/rpa/priority1/form-constraints.ts | 260 +++ workers/rpa/priority1/form-interactions.ts | 540 ++++++ workers/rpa/priority1/form-logic-map.ts | 909 ++++++++++ .../human-assist-recorder.browser.js | 98 ++ .../rpa/priority1/human-assist-recorder.ts | 131 ++ workers/rpa/priority1/quote-extract.ts | 594 +++++++ workers/rpa/priority1/run-manual-recording.ts | 209 +++ workers/rpa/priority1/run-quote.ts | 117 ++ workers/rpa/priority1/shipment-types.ts | 126 ++ workers/rpa/priority1/visual-chain.ts | 1535 +++++++++++++++++ workers/rpa/queue.ts | 11 + workers/rpa/quote-capture/payload-decode.ts | 227 +-- workers/rpa/quote-capture/quote-normalize.ts | 12 +- .../quote-capture/quote-schema-validator.ts | 8 - workers/scheduler/timeout-sweeper.ts | 20 +- 252 files changed, 24138 insertions(+), 982 deletions(-) create mode 100644 .gitattributes create mode 100644 __tests__/lib/constants/host-public-api.test.ts create mode 100644 __tests__/lib/constants/host-service.test.ts create mode 100644 __tests__/lib/constants/mothership-ui-tiers.test.ts create mode 100644 __tests__/lib/frontend/us-states.test.ts create mode 100644 __tests__/lib/priority1/calendar-grid.test.ts create mode 100644 __tests__/lib/priority1/date-format-and-errors.test.ts create mode 100644 __tests__/lib/rpa/ensure-storage-state.test.ts create mode 100644 __tests__/middleware/host-auth-gate.test.ts create mode 100644 __tests__/modules/address/candidates-cache.test.ts create mode 100644 __tests__/modules/alert/alert-presentation.test.ts create mode 100644 __tests__/modules/auth/service-token-async.test.ts create mode 100644 __tests__/modules/customer/embed-password.test.ts create mode 100644 __tests__/modules/customer/token-crypto.test.ts create mode 100644 __tests__/modules/embed-demo/auth.test.ts create mode 100644 __tests__/modules/host/public-orchestrator.test.ts create mode 100644 __tests__/modules/host/wait-for-quote.test.ts create mode 100644 __tests__/modules/priority1/orchestrator.test.ts create mode 100644 __tests__/modules/priority1/quote-storage.test.ts create mode 100644 __tests__/modules/priority1/validation.test.ts create mode 100644 __tests__/modules/quote/quote-serializer-priority1.test.ts create mode 100644 __tests__/scripts/record-priority1.test.ts create mode 100644 __tests__/workers/rpa/priority1-demo-input.test.ts create mode 100644 __tests__/workers/rpa/priority1-form-constraints.test.ts create mode 100644 __tests__/workers/rpa/priority1-form-interactions.test.ts create mode 100644 __tests__/workers/rpa/priority1-form-logic-map.test.ts create mode 100644 __tests__/workers/rpa/priority1-job-handler.test.ts create mode 100644 __tests__/workers/rpa/priority1-quote-extract-dom.test.ts create mode 100644 __tests__/workers/rpa/priority1-quote-extract.test.ts create mode 100644 api调用文档/api调用文档.md create mode 100644 app/admin/customers/page.tsx create mode 100644 app/api/admin/customers/[customer_id]/rotate-key/route.ts create mode 100644 app/api/admin/customers/[customer_id]/route.ts create mode 100644 app/api/admin/customers/route.ts create mode 100644 app/api/admin/query-logs/route.ts create mode 100644 app/api/embed-demo/login/route.ts create mode 100644 app/api/embed-demo/logout/route.ts create mode 100644 app/api/embed-demo/me/route.ts create mode 100644 app/api/host/quote/candidates/route.ts create mode 100644 app/api/host/quote/submit/route.ts create mode 100644 app/api/priority1/quotes/route.ts create mode 100644 components/embed/embed-demo-login.tsx create mode 100644 components/embed/quote-provider-switch.tsx create mode 100644 components/priority1/feathery-card.tsx create mode 100644 components/priority1/p1-date-field.tsx create mode 100644 components/priority1/p1-phone-field.tsx create mode 100644 components/priority1/priority1-ftl-schedule-calendar.tsx create mode 100644 components/priority1/priority1-ltl-quote-options.tsx create mode 100644 components/priority1/priority1-quote-result-panel.tsx create mode 100644 components/priority1/priority1-quote-widget.tsx create mode 100644 components/priority1/priority1-simulator.tsx create mode 100644 components/ui/state-combobox-field.tsx create mode 100644 deploy/.env.bt-host-mysql.example create mode 100644 deploy/.env.production.example create mode 100644 deploy/.gitignore create mode 100644 deploy/API.md create mode 100644 deploy/README.md create mode 100644 deploy/RELEASE-NOTES.md create mode 100644 deploy/database/README.md create mode 100644 deploy/database/chajia.sql create mode 100644 deploy/database/chajia.sql.gz create mode 100644 deploy/database/export-local.sh create mode 100644 deploy/docker-compose.bt-host-mysql.yml create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/host-candidates-req.example.json create mode 100644 deploy/install-bt-host-mysql.sh create mode 100644 deploy/install.sh create mode 100644 deploy/nginx/chajia.conf create mode 100644 deploy/nginx/chajia.http.conf create mode 100644 deploy/nginx/chajia.ssl.conf create mode 100644 deploy/pack-readme-template.md create mode 100644 deploy/pack-server-upload.ps1 create mode 100644 deploy/setup-firewall.sh create mode 100644 deploy/test-scripts/test-denver-boston.sh create mode 100644 deploy/test-scripts/test-peachtree-tremont.sh create mode 100644 deploy/update.sh create mode 100644 deploy/宝塔部署说明.md create mode 100644 deploy/服务器更新说明.md create mode 100644 deploy/第三方对接指南.md create mode 100644 docs/报价提速与稳定性-改动清单.md delete mode 100644 docs/真人录证-commit时刻.md delete mode 100644 docs/美国跨州货物运输报价汇总_edited3.docx create mode 100644 lib/constants/host-public-api.ts create mode 100644 lib/constants/host-service.ts create mode 100644 lib/constants/mothership-ui-tiers.ts create mode 100644 lib/frontend/us-states.ts create mode 100644 lib/priority1/api-errors.ts create mode 100644 lib/priority1/calendar-grid.ts create mode 100644 lib/priority1/date-format.ts create mode 100644 lib/priority1/phone-countries.ts create mode 100644 lib/priority1/pickup-date.ts create mode 100644 lib/priority1/ui-labels.ts create mode 100644 lib/rpa/ensure-storage-state.ts create mode 100644 modules/address/candidates-cache.ts create mode 100644 modules/alert/alert-presentation.ts create mode 100644 modules/auth/service-token-env.ts create mode 100644 modules/customer/customer-service.ts create mode 100644 modules/customer/embed-password.ts create mode 100644 modules/customer/token-crypto.ts create mode 100644 modules/customer/types.ts create mode 100644 modules/embed-demo/auth.ts create mode 100644 modules/host/address-mapper.ts create mode 100644 modules/host/public-orchestrator.ts create mode 100644 modules/host/public-session.ts create mode 100644 modules/host/types.ts create mode 100644 modules/host/validation.ts create mode 100644 modules/host/wait-for-quote.ts create mode 100644 modules/priority1/constants.ts create mode 100644 modules/priority1/hash.ts create mode 100644 modules/priority1/orchestrator.ts create mode 100644 modules/priority1/quote-mapper.ts create mode 100644 modules/priority1/quote-outcome.ts create mode 100644 modules/priority1/quote-storage.ts create mode 100644 modules/priority1/rpa-queue.ts create mode 100644 modules/priority1/validation.ts create mode 100644 modules/quote/query-log.ts create mode 100644 prisma/migrations/20260703100000_markup_percent_two_decimals/migration.sql create mode 100644 prisma/migrations/20260703120000_host_customer_api_keys/migration.sql create mode 100644 prisma/migrations/20260703140000_quote_query_log/migration.sql create mode 100644 prisma/migrations/20260703160000_host_customer_embed_password/migration.sql create mode 100644 scripts/benchmark-priority1-10cases.ts create mode 100644 scripts/benchmark-priority1-canonical-matrix.ts create mode 100644 scripts/benchmark-priority1-ftl-expedited.ts create mode 100644 scripts/export-priority1-canonical-test-matrix.ts create mode 100644 scripts/export-priority1-form-reference.ts create mode 100644 scripts/probe-priority1-ltl.ts create mode 100644 scripts/probe-priority1-quote-extract.ts create mode 100644 scripts/probe-priority1-shipment-types.ts create mode 100644 scripts/probe-priority1-step1-submit.ts create mode 100644 scripts/probe-priority1-submit-errors.ts create mode 100644 scripts/probe-priority1-zip.ts create mode 100644 scripts/record-priority1-ftl-path.ts create mode 100644 scripts/record-priority1-p08-accessorials.ts create mode 100644 scripts/record-priority1-path.ts create mode 100644 scripts/record-priority1.ps1 create mode 100644 scripts/record-priority1.sh create mode 100644 scripts/record-priority1.ts create mode 100644 scripts/test-host-api.ps1 create mode 100644 scripts/test-host-api.sh create mode 100644 scripts/visual-priority1-canonical-inspect.ts create mode 100644 scripts/visual-priority1-full-chain.ts create mode 100644 scripts/visual-priority1-human-assist.ts create mode 100644 scripts/visual-priority1-inspect-ftl-trailers.ts create mode 100644 scripts/visual-priority1-inspect-types.ts create mode 100644 workers/rpa/priority1-job-handler.ts create mode 100644 workers/rpa/priority1/FORM-LOGIC-REFERENCE.md create mode 100644 workers/rpa/priority1/demo-input.ts create mode 100644 workers/rpa/priority1/form-constraints.ts create mode 100644 workers/rpa/priority1/form-interactions.ts create mode 100644 workers/rpa/priority1/form-logic-map.ts create mode 100644 workers/rpa/priority1/human-assist-recorder.browser.js create mode 100644 workers/rpa/priority1/human-assist-recorder.ts create mode 100644 workers/rpa/priority1/quote-extract.ts create mode 100644 workers/rpa/priority1/run-manual-recording.ts create mode 100644 workers/rpa/priority1/run-quote.ts create mode 100644 workers/rpa/priority1/shipment-types.ts create mode 100644 workers/rpa/priority1/visual-chain.ts diff --git a/.env.example b/.env.example index de5b116..4fcd8e6 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,24 @@ -# 宿主机 MySQL 端口(docker-compose 映射,避开本机已占用的 3306) +# 查价中台环境变量模板(本地开发) +# 生产部署请使用:cp deploy/.env.production.example .env && bash deploy/install.sh +# 详见 deploy/README.md + MYSQL_HOST_PORT=3307 MYSQL_ROOT_PASSWORD=changeme JWT_SECRET=your-256-bit-secret-change-in-production # 宿主 Service Token 映射(JSON):token → { customerId, permissions[] } HOST_SERVICE_TOKENS={"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}} +# 本地 development 默认跳过宿主 Token 鉴权(npm run dev);生产环境无效 +# 若要在本地也校验 Token,设为 false +# HOST_SERVICE_AUTH_DISABLED=false # 管理端可见客户列表(逗号分隔,与 HOST_SERVICE_TOKENS 合并) CUSTOMER_REGISTRY=CUST_001,CUST_002,CUST_003 +# 宿主两步公开接口(美美与共等):true=免 Token;后期改 false 并走 Service Token +HOST_PUBLIC_API_ENABLED=false +HOST_PUBLIC_DEFAULT_CUSTOMER_ID=CUST_001 + # ── RPA(PRD v0.6 GD-15,headed 录制后填入)──────────────────────── # 逗号分隔、按序探测;禁止 dashboard.mothership.com / www.mothership.com/quote # 英文 locale 避免中文精简 widget(无 cargo editor);首页 + en-US 为推荐组合 @@ -16,6 +26,8 @@ MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/ # 留空则 Playwright 默认;en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容 # RPA_BROWSER_LOCALE=en-US RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json +# 缺会话文件时 headless 自动访问 MOTHERSHIP_QUOTE_URLS 获取 cookies(默认 true;设 false 则须手动 record:mothership) +# RPA_AUTO_BOOTSTRAP_STORAGE=true RPA_MOCK_MODE=false # 地址 commit 隔离:real=真实 MotherShip;mock=仅替代 commit(prove-rpa-chain 下游验证用) RPA_ADDRESS_MODE=real @@ -75,6 +87,13 @@ RPA_QUOTE_NETWORK_VISIBILITY=false RPA_QUOTE_DEBUG_RAW=false # 设为 true 时日志输出 JSON 候选 URL(校准 HAR pinned 用) RPA_QUOTE_NETWORK_SNIFF=false + +# Priority1 worker 填表提速(生产建议) +# RPA_DWELL_MS=0 +# RPA_SLOW_MO_MS=0 +# RPA_QUOTE_POLL_MS=600 +# RPA_QUOTE_WAIT_MS=90000 +# RPA_PRIORITY1_WORKER_FAST=true RPA_SELECTOR_TRANSIT=text=business days # 货物字段已改为 fillCargoInDrawer 动态 placeholder 定位,下列 env 仅兼容旧脚本,runtime 不再读取 # RPA_SELECTOR_PALLET_QTY=... diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 2496ac9..fdd71ff 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ next-env.d.ts # test artifacts playwright-report/ test-results/ + +# local upload bundles +*.zip +上传到服务器/ diff --git a/Dockerfile b/Dockerfile index ef90be3..1c4313e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,10 @@ FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=chajia-neibu-2026 +ARG NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=CUST_001 +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 RUN npx prisma generate ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/Dockerfile.worker b/Dockerfile.worker index 14d2302..9775071 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -1,4 +1,5 @@ -FROM node:20-alpine +# Playwright 官方镜像(含 Chromium 与系统依赖),RPA Worker 生产环境必需 +FROM mcr.microsoft.com/playwright:v1.61.0-jammy WORKDIR /app @@ -9,5 +10,7 @@ COPY . . RUN npx prisma generate ENV NODE_ENV=production +# 镜像已预装浏览器,禁止重复下载 +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 CMD ["npm", "run", "worker:rpa"] diff --git a/__tests__/api/admin-markup-routes.test.ts b/__tests__/api/admin-markup-routes.test.ts index 1d11a24..9763d0b 100644 --- a/__tests__/api/admin-markup-routes.test.ts +++ b/__tests__/api/admin-markup-routes.test.ts @@ -9,6 +9,10 @@ vi.mock("@/lib/prisma", () => ({ findMany: vi.fn(), upsert: vi.fn(), }, + hostCustomer: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, auditLog: { create: vi.fn(), }, diff --git a/__tests__/lib/axel/candidates.test.ts b/__tests__/lib/axel/candidates.test.ts index 01e47de..2bb6543 100644 --- a/__tests__/lib/axel/candidates.test.ts +++ b/__tests__/lib/axel/candidates.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { axelSearchRowToCandidate } from "@/lib/axel/candidates"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + axelSearchRowToCandidate, + resolveAxelMothershipCandidates, +} from "@/lib/axel/candidates"; +import { AxelHttpClient } from "@/lib/axel/client"; describe("axelSearchRowToCandidate", () => { it("display_label 与 axel description 一致,option_id 保留原始 placeId", () => { @@ -22,3 +26,102 @@ describe("axelSearchRowToCandidate", () => { expect(candidate?.street).toContain("123"); }); }); + +describe("resolveAxelMothershipCandidates", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubEnv("AXEL_CANDIDATE_VERIFY_TOP_K", "3"); + }); + + function makeSearchRow(suffix: string) { + return { + placeId: `ChIJ-${suffix}`, + description: `${suffix} Main Street, Dallas, TX, USA`, + mainText: `${suffix} Main Street`, + secondaryText: "Dallas, TX, USA", + }; + } + + it("仅对前 3 条候选 fetchPlace 校验,其余默认可选", async () => { + const fetchPlace = vi.fn().mockResolvedValue({ placeId: "ok" }); + const searchLocation = vi + .fn() + .mockResolvedValueOnce([ + makeSearchRow("1"), + makeSearchRow("2"), + makeSearchRow("3"), + makeSearchRow("4"), + makeSearchRow("5"), + ]) + .mockResolvedValueOnce([makeSearchRow("d1")]); + + vi.spyOn(AxelHttpClient, "create").mockResolvedValue({ + searchLocation, + fetchPlace, + } as unknown as AxelHttpClient); + + const result = await resolveAxelMothershipCandidates({ + pickup: { + street: "1 Main St", + city: "Dallas", + state: "TX", + zip: "75201", + }, + delivery: { + street: "2 Oak Ave", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + }); + + expect(result.pickup_candidates).toHaveLength(5); + expect(result.delivery_candidates).toHaveLength(1); + expect(fetchPlace).toHaveBeenCalledTimes(4); + expect(result.pickup_candidates[0]?.selectable).toBe(true); + expect(result.pickup_candidates[3]?.selectable).toBe(true); + expect(result.pickup_candidates[4]?.selectable).toBe(true); + }); + + it("前 K 条均失败时,未校验候选仍可使 assert 通过", async () => { + const fetchPlace = vi + .fn() + .mockRejectedValueOnce(new Error("place 404")) + .mockRejectedValueOnce(new Error("place 404")) + .mockRejectedValueOnce(new Error("place 404")) + .mockResolvedValueOnce({ placeId: "ChIJ-d1" }); + const searchLocation = vi + .fn() + .mockResolvedValueOnce([ + makeSearchRow("bad1"), + makeSearchRow("bad2"), + makeSearchRow("bad3"), + makeSearchRow("good-tail"), + ]) + .mockResolvedValueOnce([makeSearchRow("d1")]); + + vi.spyOn(AxelHttpClient, "create").mockResolvedValue({ + searchLocation, + fetchPlace, + } as unknown as AxelHttpClient); + + const result = await resolveAxelMothershipCandidates({ + pickup: { + street: "99 Main St", + city: "Dallas", + state: "TX", + zip: "75201", + }, + delivery: { + street: "1 Oak", + city: "LA", + state: "CA", + zip: "90001", + }, + }); + + expect(result.pickup_candidates[0]?.selectable).toBe(false); + expect(result.pickup_candidates[3]?.selectable).toBe(true); + expect(fetchPlace).toHaveBeenCalledTimes(4); + }); +}); diff --git a/__tests__/lib/axel/quote.test.ts b/__tests__/lib/axel/quote.test.ts index ba74ae6..e20edcb 100644 --- a/__tests__/lib/axel/quote.test.ts +++ b/__tests__/lib/axel/quote.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request"; +import { AxelHttpClient } from "@/lib/axel/client"; import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate"; import { buildAxelShipmentPayload } from "@/lib/axel/shipment"; import { parseQuoteCliArgs } from "@/lib/axel/parse-cli-args"; +import { AXEL_QUOTE_BODY } from "__tests__/fixtures/axel-quote"; describe("parseQuoteCliArgs", () => { it("解析必填参数", () => { @@ -86,3 +89,49 @@ describe("buildAxelShipmentPayload", () => { expect(Object.keys(shipment.cargo as object)).toHaveLength(1); }); }); + +describe("fetchAxelQuoteItems", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("已知 placeId 时每侧只 fetchPlace 一次", async () => { + const client = { + fetchPlace: vi + .fn() + .mockResolvedValueOnce({ placeId: "ChIJ-pickup" }) + .mockResolvedValueOnce({ placeId: "ChIJ-delivery" }), + searchLocation: vi.fn(), + postQuote: vi.fn().mockResolvedValue(AXEL_QUOTE_BODY), + }; + + vi.spyOn(AxelHttpClient, "create").mockResolvedValue( + client as unknown as AxelHttpClient, + ); + + const items = await fetchAxelQuoteItems({ + pickup: { + street: "1 Main St", + city: "Seattle", + state: "WA", + zip: "98101", + mothershipOptionId: "ChIJ-pickup", + }, + delivery: { + street: "2 Oak Ave", + city: "Miami", + state: "FL", + zip: "33101", + mothershipOptionId: "ChIJ-delivery", + }, + palletCount: 2, + weightLb: 500, + dimsIn: { l: 48, w: 40, h: 45 }, + }); + + expect(items).toHaveLength(4); + expect(client.fetchPlace).toHaveBeenCalledTimes(2); + expect(client.searchLocation).not.toHaveBeenCalled(); + expect(client.postQuote).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/lib/constants/host-public-api.test.ts b/__tests__/lib/constants/host-public-api.test.ts new file mode 100644 index 0000000..fcb2040 --- /dev/null +++ b/__tests__/lib/constants/host-public-api.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { + hostPublicDefaultCustomerId, + isHostPublicApiEnabled, +} from "@/lib/constants/host-public-api"; + +describe("host public api env", () => { + const prevEnabled = process.env.HOST_PUBLIC_API_ENABLED; + const prevCustomer = process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID; + + afterEach(() => { + process.env.HOST_PUBLIC_API_ENABLED = prevEnabled; + process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID = prevCustomer; + }); + + it("is disabled by default", () => { + delete process.env.HOST_PUBLIC_API_ENABLED; + expect(isHostPublicApiEnabled()).toBe(false); + }); + + it("reads enabled flag and default customer", () => { + process.env.HOST_PUBLIC_API_ENABLED = "true"; + process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID = "CUST_MEIMEI"; + expect(isHostPublicApiEnabled()).toBe(true); + expect(hostPublicDefaultCustomerId()).toBe("CUST_MEIMEI"); + }); +}); diff --git a/__tests__/lib/constants/host-service.test.ts b/__tests__/lib/constants/host-service.test.ts new file mode 100644 index 0000000..253186b --- /dev/null +++ b/__tests__/lib/constants/host-service.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isHostServiceAuthDisabled } from "@/lib/constants/host-service"; + +describe("isHostServiceAuthDisabled", () => { + const env = process.env; + + afterEach(() => { + process.env = { ...env }; + }); + + it("production 始终需鉴权", () => { + process.env.NODE_ENV = "production"; + process.env.HOST_SERVICE_AUTH_DISABLED = "true"; + expect(isHostServiceAuthDisabled()).toBe(false); + }); + + it("development 默认跳过宿主 Token", () => { + process.env.NODE_ENV = "development"; + delete process.env.HOST_SERVICE_AUTH_DISABLED; + expect(isHostServiceAuthDisabled()).toBe(true); + }); + + it("development 可显式开启鉴权", () => { + process.env.NODE_ENV = "development"; + process.env.HOST_SERVICE_AUTH_DISABLED = "false"; + expect(isHostServiceAuthDisabled()).toBe(false); + }); +}); diff --git a/__tests__/lib/constants/mothership-ui-tiers.test.ts b/__tests__/lib/constants/mothership-ui-tiers.test.ts new file mode 100644 index 0000000..165bf0d --- /dev/null +++ b/__tests__/lib/constants/mothership-ui-tiers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + filterQuotesForMotherShipUiDisplay, + getMotherShipRateOptionLabel, + getMotherShipServiceLevelLabel, + listMotherShipUiTabsFromQuotes, + listVisibleRateOptionsForTab, + normalizeMotherShipServiceLevel, +} from "@/lib/constants/mothership-ui-tiers"; + +const SAMPLE = [ + { service_level: "standard", rate_option: "lowest", raw: 235 }, + { service_level: "standard", rate_option: "bestValue", raw: 301 }, + { service_level: "standard", rate_option: "fastest", raw: 689 }, + { service_level: "guaranteed", rate_option: "lowest", raw: 408 }, + { service_level: "guaranteed", rate_option: "fastest", raw: 830 }, + { service_level: "dedicated", rate_option: "bestValue", raw: 4989 }, +] as const; + +describe("mothership-ui-tiers", () => { + it("normalizeMotherShipServiceLevel 识别专属卡车别名", () => { + expect(normalizeMotherShipServiceLevel("专属卡车")).toBe("dedicated"); + expect(normalizeMotherShipServiceLevel("FTL")).toBe("dedicated"); + }); + + it("Standard 档 lowest 与 bestValue 并存时均保留(与官网三档一致)", () => { + const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]); + expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "lowest")).toBe(true); + expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "bestValue")).toBe(true); + expect(ui.find((q) => q.service_level === "standard" && q.rate_option === "lowest")?.raw).toBe(235); + }); + + it("listMotherShipUiTabsFromQuotes 含 dedicated 第三 Tab", () => { + const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]); + expect(listMotherShipUiTabsFromQuotes(ui)).toEqual([ + "standard", + "guaranteed", + "dedicated", + ]); + }); + + it("dedicated Tab 展示 API 实际返回的子档(不白名单裁剪)", () => { + const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]); + expect(listVisibleRateOptionsForTab(ui, "dedicated")).toEqual(["bestValue"]); + const withExtra = filterQuotesForMotherShipUiDisplay([ + ...SAMPLE, + { service_level: "dedicated", rate_option: "lowest", raw: 4000 }, + ]); + expect(listVisibleRateOptionsForTab(withExtra, "dedicated")).toEqual([ + "bestValue", + "lowest", + ]); + }); + + it("bestValue 显示为最优性价比", () => { + expect(getMotherShipRateOptionLabel("standard", "bestValue")).toBe("最优性价比"); + expect(getMotherShipRateOptionLabel("standard", "lowest")).toBe("最低价格"); + }); +}); diff --git a/__tests__/lib/frontend/us-states.test.ts b/__tests__/lib/frontend/us-states.test.ts new file mode 100644 index 0000000..20ce4b4 --- /dev/null +++ b/__tests__/lib/frontend/us-states.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + US_STATES, + filterUsStateOptions, + isValidUsStateCode, + normalizeUsStateCode, +} from "@/lib/frontend/us-states"; + +describe("us-states", () => { + it("包含全部 50 个州码", () => { + expect(US_STATES).toHaveLength(50); + expect(new Set(US_STATES).size).toBe(50); + }); + + it("normalizeUsStateCode 支持州码与州名", () => { + expect(normalizeUsStateCode("ca")).toBe("CA"); + expect(normalizeUsStateCode("California")).toBe("CA"); + expect(normalizeUsStateCode("new york")).toBe("NY"); + expect(normalizeUsStateCode("XX")).toBeNull(); + expect(normalizeUsStateCode("")).toBeNull(); + }); + + it("filterUsStateOptions 可按州码或州名过滤", () => { + const byCode = filterUsStateOptions("tx"); + expect(byCode.some((s) => s.code === "TX")).toBe(true); + + const byName = filterUsStateOptions("carolina"); + expect(byName.map((s) => s.code).sort()).toEqual(["NC", "SC"]); + + expect(filterUsStateOptions("")).toHaveLength(50); + }); + + it("isValidUsStateCode 校验 2 位州码", () => { + expect(isValidUsStateCode("WY")).toBe(true); + expect(isValidUsStateCode("wy")).toBe(true); + expect(isValidUsStateCode("ZZ")).toBe(false); + }); +}); diff --git a/__tests__/lib/math.test.ts b/__tests__/lib/math.test.ts index 2c8a849..c3ea4bc 100644 --- a/__tests__/lib/math.test.ts +++ b/__tests__/lib/math.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { roundHalfUp } from "@/lib/math"; +import { formatPercentDisplay, roundHalfUp } from "@/lib/math"; describe("roundHalfUp", () => { it("0.105×2 → 0.21(不是 0.20)", () => { @@ -20,3 +20,11 @@ describe("roundHalfUp", () => { expect(roundHalfUp(100.004, 2)).toBe(100); }); }); + +describe("formatPercentDisplay", () => { + it("支持 0.01% 展示", () => { + expect(formatPercentDisplay(0.01)).toBe("0.01%"); + expect(formatPercentDisplay(10)).toBe("10%"); + expect(formatPercentDisplay(10.5)).toBe("10.5%"); + }); +}); diff --git a/__tests__/lib/priority1/calendar-grid.test.ts b/__tests__/lib/priority1/calendar-grid.test.ts new file mode 100644 index 0000000..f984f93 --- /dev/null +++ b/__tests__/lib/priority1/calendar-grid.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { buildPriority1CalendarGrid } from "@/lib/priority1/calendar-grid"; + +describe("buildPriority1CalendarGrid", () => { + it("接受 MM/DD/YYYY 提货日并生成当月日历", () => { + const { monthLabel, cells } = buildPriority1CalendarGrid("07/15/2026", []); + expect(monthLabel).toBe("Jul"); + expect(cells.length).toBeGreaterThan(28); + }); + + it("优先按 FTL 日历行里的实际日期落格", () => { + const { cells } = buildPriority1CalendarGrid("07/15/2026", [ + { + rank: 1, + carrier: "FTL 日历报价", + carrierCode: "", + totalUsd: 1652.16, + markup_amount: 0, + final_total_usd: 1652.16, + transitDays: 0, + deliveryDate: "DAY:1", + expirationDate: null, + serviceLevel: "FTL Calendar", + quoteId: null, + caboUrl: null, + source: "ftl-calendar", + }, + { + rank: 2, + carrier: "FTL 日历报价", + carrierCode: "", + totalUsd: 2178.47, + markup_amount: 0, + final_total_usd: 2178.47, + transitDays: 0, + deliveryDate: "DAY:6", + expirationDate: null, + serviceLevel: "FTL Calendar", + quoteId: null, + caboUrl: null, + source: "ftl-calendar", + }, + ]); + + const day1 = cells.find((cell) => cell.inMonth && cell.date.getDate() === 1); + const day6 = cells.find((cell) => cell.inMonth && cell.date.getDate() === 6); + expect(day1?.priceUsd).toBe(1652.16); + expect(day6?.priceUsd).toBe(2178.47); + }); +}); diff --git a/__tests__/lib/priority1/date-format-and-errors.test.ts b/__tests__/lib/priority1/date-format-and-errors.test.ts new file mode 100644 index 0000000..4368d5d --- /dev/null +++ b/__tests__/lib/priority1/date-format-and-errors.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { p1DateFromIso, p1DateToIso } from "@/lib/priority1/date-format"; +import { resolvePriority1UserMessage } from "@/lib/priority1/api-errors"; + +describe("p1 date format", () => { + it("converts MM/DD/YYYY to ISO and back", () => { + expect(p1DateToIso("07/15/2026")).toBe("2026-07-15"); + expect(p1DateFromIso("2026-07-15")).toBe("07/15/2026"); + }); +}); + +describe("resolvePriority1UserMessage", () => { + it("keeps Chinese validation messages", () => { + expect(resolvePriority1UserMessage("VALIDATION_FAILED", "提货日须晚于今天")).toBe( + "提货日须晚于今天", + ); + }); + + it("masks English framework errors", () => { + const msg = resolvePriority1UserMessage( + "INTERNAL_ERROR", + "Attempted to call p1DateToIso() from the server but p1DateToIso is on the client", + ); + expect(msg).toContain("服务器处理失败"); + expect(msg).toContain("服务配置异常"); + expect(msg).not.toContain("client"); + }); + + it("maps known error codes", () => { + expect(resolvePriority1UserMessage("UNAUTHORIZED", "")).toContain("未授权"); + }); +}); diff --git a/__tests__/lib/rpa/ensure-storage-state.test.ts b/__tests__/lib/rpa/ensure-storage-state.test.ts new file mode 100644 index 0000000..069d1a8 --- /dev/null +++ b/__tests__/lib/rpa/ensure-storage-state.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("ensure-storage-state", () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + vi.resetModules(); + vi.restoreAllMocks(); + delete process.env.RPA_STORAGE_STATE_PATH; + delete process.env.RPA_AUTO_BOOTSTRAP_STORAGE; + delete process.env.RPA_MOCK_MODE; + }); + + it("会话文件已存在时跳过 bootstrap", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-")); + const stateFile = path.join(tmpDir, "state.json"); + fs.writeFileSync(stateFile, '{"cookies":[]}'); + process.env.RPA_STORAGE_STATE_PATH = stateFile; + + const launch = vi.fn(); + vi.doMock("@/lib/rpa/browser-launch", () => ({ launchRpaBrowser: launch })); + + const { ensureMothershipStorageState } = await import( + "@/lib/rpa/ensure-storage-state" + ); + await ensureMothershipStorageState(); + expect(launch).not.toHaveBeenCalled(); + }); + + it("缺文件且关闭自动 bootstrap 时抛错", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-")); + const stateFile = path.join(tmpDir, "missing.json"); + process.env.RPA_STORAGE_STATE_PATH = stateFile; + process.env.RPA_AUTO_BOOTSTRAP_STORAGE = "false"; + + const { ensureMothershipStorageState } = await import( + "@/lib/rpa/ensure-storage-state" + ); + await expect(ensureMothershipStorageState()).rejects.toThrow(/record:mothership/); + }); + + it("缺文件时自动 bootstrap 并写入 storageState", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-")); + const stateFile = path.join(tmpDir, "state.json"); + process.env.RPA_STORAGE_STATE_PATH = stateFile; + process.env.MOTHERSHIP_QUOTE_URLS = "https://www.mothership.com/"; + + const persistContext = vi.fn(async () => { + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync(stateFile, '{"cookies":[{"name":"s","value":"1"}]}'); + }); + + vi.doMock("@/lib/rpa/browser-launch", () => ({ + launchRpaBrowser: vi.fn(async () => ({ + newContext: vi.fn(async () => ({ + newPage: vi.fn(async () => ({ + close: vi.fn(async () => undefined), + })), + close: vi.fn(async () => undefined), + })), + close: vi.fn(async () => undefined), + })), + })); + vi.doMock("@/lib/rpa/storage-state", async () => { + const actual = await vi.importActual( + "@/lib/rpa/storage-state", + ); + return { ...actual, persistContext }; + }); + vi.doMock("@/workers/rpa/quote-page-adapter", () => ({ + quotePageAdapter: { + resolveQuoteEntry: vi.fn(async () => ({ activeUrl: "https://x" })), + preCheck: vi.fn(async () => true), + }, + })); + + const { ensureMothershipStorageState } = await import( + "@/lib/rpa/ensure-storage-state" + ); + await ensureMothershipStorageState(); + expect(fs.existsSync(stateFile)).toBe(true); + expect(persistContext).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/middleware/host-auth-gate.test.ts b/__tests__/middleware/host-auth-gate.test.ts new file mode 100644 index 0000000..87a951d --- /dev/null +++ b/__tests__/middleware/host-auth-gate.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { forbidden } from "@/modules/auth/errors"; +import { resolveEnvServiceToken } from "@/modules/auth/service-token-env"; + +const DEMO_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, +}); + +describe("middleware host auth gate", () => { + it("env token 带 X-Customer-Id 时命中快路径", () => { + process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS; + const verified = resolveEnvServiceToken("demo-host-token", "CUST_001"); + expect(verified?.customerId).toBe("CUST_001"); + }); + + it("DB token 带 X-Customer-Id 时不应抛错,应返回 null 交给路由层 DB 校验", () => { + process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS; + const verified = resolveEnvServiceToken( + "chj_QUNFn-Le9biXM4UTFv5L4z9qouAKmBu7", + "CUST_002", + ); + expect(verified).toBeNull(); + }); + + it("env token 与客户 ID 不匹配时仍抛 forbidden", () => { + process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS; + expect(() => + resolveEnvServiceToken("demo-host-token", "CUST_002"), + ).toThrow(forbidden("customer_id 与令牌租户不匹配")); + }); +}); diff --git a/__tests__/modules/address/candidates-cache.test.ts b/__tests__/modules/address/candidates-cache.test.ts new file mode 100644 index 0000000..4e011b3 --- /dev/null +++ b/__tests__/modules/address/candidates-cache.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildAddressCandidatesCacheHash } from "@/modules/address/candidates-cache"; + +describe("buildAddressCandidatesCacheHash", () => { + const pickup = { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }; + const delivery = { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }; + + it("相同地址生成相同 hash", () => { + const a = buildAddressCandidatesCacheHash({ pickup, delivery }); + const b = buildAddressCandidatesCacheHash({ pickup, delivery }); + expect(a).toBe(b); + expect(a).toMatch(/^[a-f0-9]{32}$/); + }); + + it("地址变化生成不同 hash", () => { + const a = buildAddressCandidatesCacheHash({ pickup, delivery }); + const b = buildAddressCandidatesCacheHash({ + pickup: { ...pickup, street: "999 Other St" }, + delivery, + }); + expect(a).not.toBe(b); + }); +}); diff --git a/__tests__/modules/address/mothership-candidates.test.ts b/__tests__/modules/address/mothership-candidates.test.ts index e9ca3c6..c480def 100644 --- a/__tests__/modules/address/mothership-candidates.test.ts +++ b/__tests__/modules/address/mothership-candidates.test.ts @@ -1,13 +1,27 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/axel/candidates", () => ({ resolveAxelMothershipCandidates: vi.fn(), })); +vi.mock("@/modules/address/candidates-cache", () => ({ + loadAddressCandidatesCache: vi.fn(), + saveAddressCandidatesCache: vi.fn(), +})); + import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates"; import { resolveAxelMothershipCandidates } from "@/lib/axel/candidates"; +import { + loadAddressCandidatesCache, + saveAddressCandidatesCache, +} from "@/modules/address/candidates-cache"; describe("resolveMothershipCandidates", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadAddressCandidatesCache).mockResolvedValue(null); + vi.mocked(saveAddressCandidatesCache).mockResolvedValue(undefined); + }); const input = { pickup: { street: "5678 Distribution Dr", @@ -52,6 +66,40 @@ describe("resolveMothershipCandidates", () => { const result = await resolveMothershipCandidates(input); expect(resolveAxelMothershipCandidates).toHaveBeenCalledWith(input); + expect(saveAddressCandidatesCache).toHaveBeenCalled(); expect(result.pickup_candidates[0].option_id).toBe("ChIJpickup"); }); + + it("命中缓存时不调用 axel", async () => { + process.env.RPA_MOCK_MODE = "false"; + vi.mocked(loadAddressCandidatesCache).mockResolvedValue({ + pickup_candidates: [ + { + option_id: "ChIJcached-pickup", + display_label: "cached pickup", + formatted_address: "cached pickup", + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + ], + delivery_candidates: [ + { + option_id: "ChIJcached-delivery", + display_label: "cached delivery", + formatted_address: "cached delivery", + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + ], + }); + + const result = await resolveMothershipCandidates(input); + expect(resolveAxelMothershipCandidates).not.toHaveBeenCalled(); + expect(saveAddressCandidatesCache).not.toHaveBeenCalled(); + expect(result.pickup_candidates[0].option_id).toBe("ChIJcached-pickup"); + }); }); diff --git a/__tests__/modules/alert/alert-presentation.test.ts b/__tests__/modules/alert/alert-presentation.test.ts new file mode 100644 index 0000000..281f9c0 --- /dev/null +++ b/__tests__/modules/alert/alert-presentation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + buildAlertPresentation, + formatCustomerAddressForAdmin, + formatSelectedAddressForAdmin, + resolveAlertRootCause, +} from "@/modules/alert/alert-presentation"; + +describe("alert-presentation", () => { + it("区分客户填写地址与联想确认地址", () => { + const addr = { + street: "123 Main St", + city: "Denver", + state: "CO", + zip: "80202", + formatted_address: "123 Main St, Denver, CO", + mothership_display_label: "123 Main St, Denver, CO 80202, USA", + }; + expect(formatCustomerAddressForAdmin(addr)).toBe( + "123 Main St, Denver, CO, 80202", + ); + expect(formatSelectedAddressForAdmin(addr)).toBe( + "123 Main St, Denver, CO 80202, USA", + ); + }); + + it("将 browserType.launch 转为可读根因", () => { + const cause = resolveAlertRootCause("RPA_FAILED", { + reason: "browserType.launch: Executable doesn't exist", + }); + expect(cause).toContain("无法启动浏览器"); + expect(cause).not.toContain("{"); + }); + + it("buildAlertPresentation 包含客户与货物字段", () => { + const presentation = buildAlertPresentation( + "RPA_FAILED", + { reason: "test", customer_id: "CUST_001" }, + { + customerId: "CUST_001", + pickupJson: { + street: "A", + city: "B", + state: "CO", + zip: "80000", + mothership_display_label: "A, B, CO", + }, + deliveryJson: { + street: "C", + city: "D", + state: "MA", + zip: "02101", + mothership_display_label: "C, D, MA", + }, + weightLb: 500, + palletCount: 2, + cargoType: "general_freight", + dimLIn: 48, + dimWIn: 40, + dimHIn: 48, + } as never, + ); + expect(presentation.customer_id).toBe("CUST_001"); + expect(presentation.pickup_customer).toContain("A"); + expect(presentation.pickup_selected).toBe("A, B, CO"); + expect(presentation.cargo_summary).toContain("2 托"); + }); +}); diff --git a/__tests__/modules/auth/service-token-async.test.ts b/__tests__/modules/auth/service-token-async.test.ts new file mode 100644 index 0000000..76ed05a --- /dev/null +++ b/__tests__/modules/auth/service-token-async.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + generateServiceToken, + hashServiceToken, +} from "@/modules/customer/token-crypto"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + hostCustomer: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + serviceApiKey: { + findFirst: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/prisma"; +import { resolveDbServiceToken } from "@/modules/customer/customer-service"; +import { + resetServiceTokenCache, + verifyServiceTokenAsync, +} from "@/modules/auth/service-token"; + +const DEMO_TOKENS = JSON.stringify({ + "demo-host-token": { + customerId: "CUST_001", + permissions: ["pricing:markup:write"], + }, +}); + +describe("verifyServiceTokenAsync", () => { + beforeEach(() => { + process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS; + resetServiceTokenCache(); + vi.clearAllMocks(); + }); + + it("env token 仍可用", async () => { + const result = await verifyServiceTokenAsync("demo-host-token", "CUST_001"); + expect(result.customerId).toBe("CUST_001"); + }); + + it("DB token 校验通过", async () => { + const raw = generateServiceToken(); + vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue({ + customerId: "CUST_004", + permissions: ["pricing:markup:write"], + isActive: true, + tokenHash: hashServiceToken(raw), + customer: { status: "active", isDeleted: false }, + } as never); + + const result = await verifyServiceTokenAsync(raw, "CUST_004"); + expect(result.customerId).toBe("CUST_004"); + expect(prisma.serviceApiKey.findFirst).toHaveBeenCalled(); + }); + + it("无效 token 返回 401", async () => { + vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue(null); + await expect( + verifyServiceTokenAsync("bad-token", "CUST_004"), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); +}); + +describe("resolveDbServiceToken", () => { + it("按 hash 解析客户", async () => { + const raw = generateServiceToken(); + vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue({ + customerId: "CUST_002", + permissions: ["pricing:markup:write"], + } as never); + + const resolved = await resolveDbServiceToken(raw); + expect(resolved?.customerId).toBe("CUST_002"); + }); +}); diff --git a/__tests__/modules/cache/cache.test.ts b/__tests__/modules/cache/cache.test.ts index 24048ce..7b9a911 100644 --- a/__tests__/modules/cache/cache.test.ts +++ b/__tests__/modules/cache/cache.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + ADDRESS_CANDIDATES_CACHE_TTL_SECONDS, L1_TTL_SECONDS, L2_TTL_BASE_SECONDS, L2_TTL_JITTER_MAX_SECONDS, @@ -152,6 +153,18 @@ describe("modules/cache/redis-cache", () => { const [, , , ttl] = redisMock.set.mock.calls[0]; expect(ttl).toBe(LOCK_TTL_SECONDS); }); + + it("addr-cand 缓存 TTL 120s", async () => { + const cache = await loadCache(); + await cache.setAddressCandidatesCache("hash-addr", { + pickup_candidates: [], + delivery_candidates: [], + }); + + const [key, , , ttl] = redisMock.set.mock.calls[0]; + expect(key).toBe("addr-cand:hash-addr"); + expect(ttl).toBe(ADDRESS_CANDIDATES_CACHE_TTL_SECONDS); + }); }); describe("modules/cache/rate-limiter", () => { diff --git a/__tests__/modules/customer/embed-password.test.ts b/__tests__/modules/customer/embed-password.test.ts new file mode 100644 index 0000000..c45d673 --- /dev/null +++ b/__tests__/modules/customer/embed-password.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_EMBED_PASSWORD, + hashEmbedPassword, + validateEmbedPassword, + compareEmbedPassword, +} from "@/modules/customer/embed-password"; + +describe("embed-password", () => { + it("默认演示密码为 123456", () => { + expect(DEFAULT_EMBED_PASSWORD).toBe("123456"); + }); + + it("校验密码长度", () => { + expect(validateEmbedPassword("")).toMatch(/不能为空/); + expect(validateEmbedPassword("12345")).toMatch(/至少/); + expect(validateEmbedPassword("a".repeat(65))).toMatch(/不能超过/); + expect(validateEmbedPassword("123456")).toBeNull(); + }); + + it("哈希后可验证", async () => { + const hash = await hashEmbedPassword("my-secret"); + expect(await compareEmbedPassword("my-secret", hash)).toBe(true); + expect(await compareEmbedPassword("wrong", hash)).toBe(false); + }); +}); diff --git a/__tests__/modules/customer/token-crypto.test.ts b/__tests__/modules/customer/token-crypto.test.ts new file mode 100644 index 0000000..f854d17 --- /dev/null +++ b/__tests__/modules/customer/token-crypto.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + generateServiceToken, + hashServiceToken, + tokenKeyPrefix, +} from "@/modules/customer/token-crypto"; + +describe("token-crypto", () => { + it("生成 chj_ 前缀 token", () => { + const token = generateServiceToken(); + expect(token.startsWith("chj_")).toBe(true); + expect(token.length).toBeGreaterThan(20); + }); + + it("hash 稳定", () => { + const token = "chj_test_token"; + expect(hashServiceToken(token)).toHaveLength(64); + expect(hashServiceToken(token)).toBe(hashServiceToken(token)); + }); + + it("key prefix 用于列表展示", () => { + const token = generateServiceToken(); + expect(tokenKeyPrefix(token).length).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/modules/embed-demo/auth.test.ts b/__tests__/modules/embed-demo/auth.test.ts new file mode 100644 index 0000000..4c7bb1c --- /dev/null +++ b/__tests__/modules/embed-demo/auth.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { + EMBED_DEMO_DEFAULT_PASSWORD, + EMBED_DEMO_ROLE, + getEmbedDemoPassword, + signEmbedDemoToken, + verifyEmbedDemoToken, +} from "@/modules/embed-demo/auth"; + +describe("embed-demo auth", () => { + const prevSecret = process.env.JWT_SECRET; + const prevPassword = process.env.EMBED_DEMO_PASSWORD; + + beforeEach(() => { + process.env.JWT_SECRET = "test-jwt-secret-for-embed-demo"; + delete process.env.EMBED_DEMO_PASSWORD; + }); + + afterEach(() => { + if (prevSecret === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = prevSecret; + } + if (prevPassword === undefined) { + delete process.env.EMBED_DEMO_PASSWORD; + } else { + process.env.EMBED_DEMO_PASSWORD = prevPassword; + } + }); + + it("默认演示密码为 123456", () => { + expect(getEmbedDemoPassword()).toBe(EMBED_DEMO_DEFAULT_PASSWORD); + }); + + it("签发与校验演示会话 JWT", async () => { + const token = await signEmbedDemoToken("CUST_002"); + const payload = await verifyEmbedDemoToken(token); + expect(payload.sub).toBe("CUST_002"); + expect(payload.role).toBe(EMBED_DEMO_ROLE); + }); +}); diff --git a/__tests__/modules/host/public-orchestrator.test.ts b/__tests__/modules/host/public-orchestrator.test.ts new file mode 100644 index 0000000..a0be89f --- /dev/null +++ b/__tests__/modules/host/public-orchestrator.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/modules/address/mothership-candidates", () => ({ + resolveMothershipCandidates: vi.fn(), +})); + +vi.mock("@/modules/host/public-session", () => ({ + saveHostPublicSession: vi.fn(), + loadHostPublicSession: vi.fn(), + deleteHostPublicSession: vi.fn(), +})); + +vi.mock("@/modules/quote/orchestrator", () => ({ + submitQuote: vi.fn(), +})); + +vi.mock("@/modules/host/wait-for-quote", () => ({ + waitForQuoteDetail: vi.fn(), + HostQuoteWaitError: class HostQuoteWaitError extends Error { + code = "QUOTE_FAILED"; + detail = undefined; + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { + findFirst: vi.fn(), + }, + }, +})); + +import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates"; +import { + deleteHostPublicSession, + loadHostPublicSession, + saveHostPublicSession, +} from "@/modules/host/public-session"; +import { + hostPublicResolveCandidates, + hostPublicSubmitAndWait, +} from "@/modules/host/public-orchestrator"; +import { waitForQuoteDetail } from "@/modules/host/wait-for-quote"; +import { submitQuote } from "@/modules/quote/orchestrator"; +import { prisma } from "@/lib/prisma"; + +const pickupCandidate = { + option_id: "pickup-opt-1", + display_label: "1234 Warehouse St, Los Angeles, CA, USA", + formatted_address: "1234 Warehouse St, Los Angeles, CA, USA", + street: "1234 Warehouse St", + city: "Los Angeles", + state: "CA", + zip: "90001", + selectable: true, +}; + +const deliveryCandidate = { + option_id: "delivery-opt-1", + display_label: "5678 Distribution Dr, Dallas, TX, USA", + formatted_address: "5678 Distribution Dr, Dallas, TX, USA", + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + selectable: true, +}; + +describe("hostPublicResolveCandidates", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(saveHostPublicSession).mockResolvedValue("session-uuid"); + }); + + it("returns host_session_id and candidates", async () => { + vi.mocked(resolveMothershipCandidates).mockResolvedValue({ + pickup_candidates: [pickupCandidate], + delivery_candidates: [deliveryCandidate], + requires_selection: false, + quote_session_id: "quote-session-1", + }); + + const result = await hostPublicResolveCandidates("CUST_002", { + 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", + }, + }); + + expect(result.host_session_id).toBe("session-uuid"); + expect(result.pickup_candidates).toHaveLength(1); + expect(saveHostPublicSession).toHaveBeenCalledWith( + expect.objectContaining({ customer_id: "CUST_002" }), + ); + }); +}); + +describe("hostPublicSubmitAndWait", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadHostPublicSession).mockResolvedValue({ + customer_id: "CUST_001", + cargo: { + weight: { value: 500, unit: "kg" }, + dimensions: { length: 120, width: 100, height: 150, unit: "cm" }, + pallet_count: 2, + cargo_type: "general_freight", + }, + pickup_draft: { + street: "1234 Warehouse Blvd", + city: "Los Angeles", + state: "CA", + zip: "90001", + }, + delivery_draft: { + street: "5678 Distribution Dr", + city: "Dallas", + state: "TX", + zip: "75201", + }, + quote_session_id: "quote-session-1", + pickup_candidates: [pickupCandidate], + delivery_candidates: [deliveryCandidate], + created_at: new Date().toISOString(), + }); + vi.mocked(submitQuote).mockResolvedValue({ + quote_id: "QTE_test_001", + status: "processing", + }); + vi.mocked(waitForQuoteDetail).mockResolvedValue({ + quote_id: "QTE_test_001", + request_id: "req-1", + status: "done", + currency: "USD", + quotes: [{ final_total: 337.06 }], + }); + vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue({ + quoteId: "QTE_test_001", + requestId: "req-1", + status: "done", + sourceType: "rpa", + isRealtime: true, + currency: "USD", + confidenceScore: 0.95, + quotesJson: [{ final_total: 337.06 }], + validUntil: new Date("2026-06-30T10:00:00.000Z"), + createdAt: new Date("2026-06-30T09:55:00.000Z"), + palletCount: 2, + cargoType: "general_freight", + errorCode: null, + errorMessage: null, + isDeleted: false, + } as never); + }); + + it("submits quote and waits for final detail", async () => { + const detail = await hostPublicSubmitAndWait({ + host_session_id: "session-uuid", + pickup_candidate: pickupCandidate, + delivery_candidate: deliveryCandidate, + }); + + expect(submitQuote).toHaveBeenCalledOnce(); + expect(waitForQuoteDetail).toHaveBeenCalledWith( + "QTE_test_001", + "CUST_001", + ); + expect(deleteHostPublicSession).toHaveBeenCalledWith("session-uuid"); + expect(detail.status).toBe("done"); + }); + + it("rejects candidate not in session", async () => { + await expect( + hostPublicSubmitAndWait({ + host_session_id: "session-uuid", + pickup_candidate: { ...pickupCandidate, option_id: "unknown" }, + delivery_candidate: deliveryCandidate, + }), + ).rejects.toMatchObject({ code: "ADDRESS_SESSION_MISMATCH" }); + }); + + it("submitQuote 已 done 时直接查一次 DB,不走 waitForQuoteDetail", async () => { + vi.mocked(submitQuote).mockResolvedValue({ + quote_id: "QTE_test_001", + status: "done", + source_type: "rpa", + is_realtime: true, + }); + + const detail = await hostPublicSubmitAndWait({ + host_session_id: "session-uuid", + pickup_candidate: pickupCandidate, + delivery_candidate: deliveryCandidate, + }); + + expect(prisma.quoteRecord.findFirst).toHaveBeenCalledWith({ + where: { + quoteId: "QTE_test_001", + customerId: "CUST_001", + isDeleted: false, + }, + }); + expect(waitForQuoteDetail).not.toHaveBeenCalled(); + expect(detail.status).toBe("done"); + }); +}); diff --git a/__tests__/modules/host/wait-for-quote.test.ts b/__tests__/modules/host/wait-for-quote.test.ts new file mode 100644 index 0000000..1637fee --- /dev/null +++ b/__tests__/modules/host/wait-for-quote.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getHostQuotePollIntervalMs, + HOST_QUOTE_POLL_INTERVAL_MS_DEFAULT, +} from "@/lib/constants/host-public-api"; + +describe("getHostQuotePollIntervalMs", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it("默认 500ms", () => { + expect(HOST_QUOTE_POLL_INTERVAL_MS_DEFAULT).toBe(500); + expect(getHostQuotePollIntervalMs()).toBe(500); + }); + + it("支持 HOST_QUOTE_POLL_INTERVAL_MS 覆盖", () => { + vi.stubEnv("HOST_QUOTE_POLL_INTERVAL_MS", "800"); + expect(getHostQuotePollIntervalMs()).toBe(800); + }); + + it("非法值回退默认", () => { + vi.stubEnv("HOST_QUOTE_POLL_INTERVAL_MS", "0"); + expect(getHostQuotePollIntervalMs()).toBe(500); + }); +}); diff --git a/__tests__/modules/pricing/engine.test.ts b/__tests__/modules/pricing/engine.test.ts index ae921f5..834a934 100644 --- a/__tests__/modules/pricing/engine.test.ts +++ b/__tests__/modules/pricing/engine.test.ts @@ -49,6 +49,12 @@ describe("applyMarkup", () => { expect(result.finalTotal).toBe(130); }); + it("0.01% 细粒度加价", () => { + const result = applyMarkup(1000, 1000, 0.01); + expect(result.markupAmount).toBe(0.1); + expect(result.finalTotal).toBe(1000.1); + }); + it("固定金额加价", () => { const result = applyMarkup(320, 335, { type: "fixed", diff --git a/__tests__/modules/pricing/markup-validation.test.ts b/__tests__/modules/pricing/markup-validation.test.ts index d62f57a..b02841f 100644 --- a/__tests__/modules/pricing/markup-validation.test.ts +++ b/__tests__/modules/pricing/markup-validation.test.ts @@ -30,4 +30,22 @@ describe("parseMarkupInput", () => { message: "加价比例不能超过 30%", }); }); + + it("支持 0.01% 步长", () => { + const result = parseMarkupInput({ markup_percent: 0.01 }); + expect(result).toEqual({ + markupType: "percent", + markupPercent: 0.01, + markupFixedAmount: null, + }); + }); + + it("百分比四舍五入到两位小数", () => { + const result = parseMarkupInput({ markup_percent: 1.235 }); + expect(result).toEqual({ + markupType: "percent", + markupPercent: 1.24, + markupFixedAmount: null, + }); + }); }); diff --git a/__tests__/modules/priority1/orchestrator.test.ts b/__tests__/modules/priority1/orchestrator.test.ts new file mode 100644 index 0000000..afa88f8 --- /dev/null +++ b/__tests__/modules/priority1/orchestrator.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockCreate = vi.fn(); +const mockUpdate = vi.fn(); +const mockGenerateQuoteId = vi.fn(); +const mockEnqueue = vi.fn(); +const mockHandleConflict = vi.fn(); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { + create: (...args: unknown[]) => mockCreate(...args), + update: (...args: unknown[]) => mockUpdate(...args), + }, + }, +})); + +vi.mock("@/modules/quote/quote-id", () => ({ + generateQuoteId: () => mockGenerateQuoteId(), + isQuoteIdUniqueConflict: (error: unknown) => + typeof error === "object" && + error !== null && + (error as { code?: string }).code === "P2002", + handleQuoteIdConflict: (...args: unknown[]) => mockHandleConflict(...args), +})); + +vi.mock("@/modules/priority1/rpa-queue", () => ({ + enqueuePriority1QuoteJob: (...args: unknown[]) => mockEnqueue(...args), +})); + +vi.mock("@/modules/metrics/collector", () => ({ + safeRecord: (task: () => Promise) => { + void task(); + }, + markQuoteStarted: vi.fn(), +})); + +import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input"; +import { + Priority1EnqueueError, + submitPriority1Quote, +} from "@/modules/priority1/orchestrator"; + +describe("submitPriority1Quote", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateQuoteId.mockResolvedValue("QTE_20260701_0001"); + mockCreate.mockResolvedValue({}); + mockUpdate.mockResolvedValue({}); + mockEnqueue.mockResolvedValue(undefined); + }); + + it("入队成功返回 processing", async () => { + const result = await submitPriority1Quote({ + request_id: "req-1", + customer_id: "CUST_001", + priority1_input: DEFAULT_PRIORITY1_DEMO, + }); + expect(result).toEqual({ + quote_id: "QTE_20260701_0001", + status: "processing", + }); + expect(mockEnqueue).toHaveBeenCalledOnce(); + }); + + it("Redis 入队失败时抛出可读错误并标记 quote 失败", async () => { + mockEnqueue.mockRejectedValue(new Error("REDIS_URL 未配置")); + + await expect( + submitPriority1Quote({ + request_id: "req-2", + customer_id: "CUST_001", + priority1_input: DEFAULT_PRIORITY1_DEMO, + }), + ).rejects.toBeInstanceOf(Priority1EnqueueError); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { quoteId: "QTE_20260701_0001" }, + data: expect.objectContaining({ + status: "failed", + errorCode: "QUOTE_UNAVAILABLE", + }), + }), + ); + }); +}); diff --git a/__tests__/modules/priority1/quote-storage.test.ts b/__tests__/modules/priority1/quote-storage.test.ts new file mode 100644 index 0000000..a85aa4e --- /dev/null +++ b/__tests__/modules/priority1/quote-storage.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + buildPriority1StoredQuotes, + inferPriority1DisplayMode, + parsePriority1StoredQuotes, + resolvePriority1StoredQuotes, +} from "@/modules/priority1/quote-storage"; +import type { Priority1QuoteLine } from "@/workers/rpa/priority1/quote-extract"; +import { buildPriority1CalendarGrid } from "@/lib/priority1/calendar-grid"; + +const ltlLine = (overrides: Partial = {}): Priority1QuoteLine => ({ + rank: 1, + carrier: "Old Dominion Freight Line", + totalUsd: 557.04, + transitDays: 2, + serviceLevel: "STANDARD LTL", + source: "visible-dom", + ...overrides, +}); + +describe("priority1 quote storage", () => { + it("infers ltl_cards for standard LTL lines", () => { + expect(inferPriority1DisplayMode([ltlLine()])).toBe("ltl_cards"); + expect(inferPriority1DisplayMode([ltlLine()], "ltl")).toBe("ltl_cards"); + }); + + it("infers ftl_calendar for FTL shipment or calendar source", () => { + expect(inferPriority1DisplayMode([ltlLine()], "ftl")).toBe("ftl_calendar"); + expect( + inferPriority1DisplayMode([ + ltlLine({ source: "ftl-calendar", carrier: "FTL 日历报价" }), + ]), + ).toBe("ftl_calendar"); + }); + + it("builds stored quotes with markup applied", () => { + const stored = buildPriority1StoredQuotes( + [ltlLine({ totalUsd: 100 })], + { type: "percent", percent: 10, fixedAmount: 0 }, + "ltl", + ); + expect(stored.provider).toBe("priority1"); + expect(stored.display_mode).toBe("ltl_cards"); + expect(stored.lines[0]!.final_total_usd).toBe(110); + expect(stored.lines[0]!.markup_amount).toBe(10); + }); + + it("round-trips parsePriority1StoredQuotes", () => { + const stored = buildPriority1StoredQuotes( + [ltlLine()], + { type: "fixed", percent: 0, fixedAmount: 5 }, + "ltl", + ); + expect(parsePriority1StoredQuotes(stored)).toEqual(stored); + expect(parsePriority1StoredQuotes([{ rank: 1 }])).toBeNull(); + }); + + it("converts legacy MotherShip tier array for display", () => { + const resolved = resolvePriority1StoredQuotes( + [ + { + service_level: "standard", + rate_option: "lowest", + carrier: "Old Dominion", + transit_days: "2", + raw_total: 557.04, + final_total: 557.04, + markup_amount: 0, + }, + ], + { priority1_shipment_type: "ltl" }, + ); + expect(resolved?.display_mode).toBe("ltl_cards"); + expect(resolved?.lines[0]?.final_total_usd).toBe(557.04); + expect(resolved?.lines[0]?.source).toBe("legacy-tier"); + }); + + it("does not treat MotherShip tier array as Priority1 without shipment meta", () => { + const resolved = resolvePriority1StoredQuotes( + [ + { + service_level: "standard", + rate_option: "lowest", + carrier: "Frontline Freight", + transit_days: "3", + raw_total: 332.6, + final_total: 332.6, + markup_amount: 0, + }, + ], + { zip: "90001" }, + ); + expect(resolved).toBeNull(); + }); +}); + +describe("priority1 calendar grid", () => { + it("places prices on consecutive days from pickup date", () => { + const lines = buildPriority1StoredQuotes( + [ + ltlLine({ rank: 1, totalUsd: 1652.16 }), + ltlLine({ rank: 2, totalUsd: 1650.78 }), + ], + { type: "percent", percent: 0, fixedAmount: 0 }, + "ftl", + ).lines; + const { cells } = buildPriority1CalendarGrid("2026-07-01", lines); + const inMonth = cells.filter((c) => c.inMonth && c.priceUsd != null); + expect(inMonth.length).toBe(2); + expect(inMonth[0]!.date.getDate()).toBe(1); + expect(inMonth[0]!.priceUsd).toBe(1652.16); + expect(inMonth[1]!.date.getDate()).toBe(2); + }); +}); diff --git a/__tests__/modules/priority1/validation.test.ts b/__tests__/modules/priority1/validation.test.ts new file mode 100644 index 0000000..af30dbd --- /dev/null +++ b/__tests__/modules/priority1/validation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input"; +import { hashPriority1Input } from "@/modules/priority1/hash"; +import { priority1LinesToQuoteItems } from "@/modules/priority1/quote-mapper"; +import { validatePriority1QuoteInput } from "@/modules/priority1/validation"; + +describe("priority1 validation", () => { + it("校验合法 LTL 提交", () => { + const parsed = validatePriority1QuoteInput({ + request_id: "req-1", + customer_id: "CUST_001", + priority1_input: DEFAULT_PRIORITY1_DEMO, + }); + expect(parsed.originZip).toBe("10118"); + expect(parsed.phone).toHaveLength(10); + }); + + it("相同输入生成相同哈希", () => { + const a = hashPriority1Input(DEFAULT_PRIORITY1_DEMO); + const b = hashPriority1Input({ ...DEFAULT_PRIORITY1_DEMO }); + expect(a).toBe(b); + expect(a).toHaveLength(32); + }); + + it("承运商列表映射为报价项", () => { + const items = priority1LinesToQuoteItems([ + { + rank: 1, + carrier: "XPO", + carrierCode: "XPO", + totalUsd: 400, + transitDays: 3, + deliveryDate: null, + expirationDate: null, + serviceLevel: "Standard", + quoteId: 1, + caboUrl: null, + source: "api-all", + }, + ]); + expect(items[0]?.carrier).toBe("XPO"); + expect(items[0]?.rawTotal).toBe(400); + }); +}); diff --git a/__tests__/modules/quote/fallback-orchestrator.test.ts b/__tests__/modules/quote/fallback-orchestrator.test.ts index 230812a..275f5f0 100644 --- a/__tests__/modules/quote/fallback-orchestrator.test.ts +++ b/__tests__/modules/quote/fallback-orchestrator.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/prisma", () => ({ vi.mock("@/modules/pricing/engine", () => ({ getMarkupPercent: vi.fn().mockResolvedValue(10), + getMarkupRule: vi.fn().mockResolvedValue({ type: "percent", percent: 10 }), applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes.map((q) => ({ ...(q as object), markup_percent: 10 })), ), @@ -83,4 +84,19 @@ describe("handleRpaFailure", () => { }), ); }); + + it("skipStaleFallback 时不使用 L3 缓存", async () => { + mockedGetL3.mockResolvedValue({ quotes: FOUR_TIERS }); + + const result = await handleRpaFailure( + "QTE_20260616_0001", + "abc123", + "CUST_001", + new Error("priority1 timeout"), + { skipStaleFallback: true }, + ); + + expect(result.status).toBe("failed"); + expect(mockedGetL3).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/modules/quote/orchestrator.test.ts b/__tests__/modules/quote/orchestrator.test.ts index 4faa498..f89de09 100644 --- a/__tests__/modules/quote/orchestrator.test.ts +++ b/__tests__/modules/quote/orchestrator.test.ts @@ -3,6 +3,8 @@ import { submitQuote } from "@/modules/quote/orchestrator"; vi.mock("@/modules/cache/redis-cache", () => ({ getL2: vi.fn(), + setL2: vi.fn(), + setL3: vi.fn(), })); vi.mock("@/modules/quote/idempotency", () => ({ @@ -21,10 +23,19 @@ vi.mock("@/modules/quote/rpa-queue", () => ({ })); vi.mock("@/modules/pricing/engine", () => ({ - getMarkupPercent: vi.fn().mockResolvedValue(0), + getMarkupRule: vi.fn().mockResolvedValue({ type: "percent", percent: 0, fixedAmount: 0 }), applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes), })); +vi.mock("@/lib/axel/quote-from-request", () => ({ + fetchAxelQuoteItems: vi.fn(), +})); + +vi.mock("@/lib/rpa/env", () => ({ + isAxelDirectQuoteMode: vi.fn(() => true), + isInlineDirectQuoteEnabled: vi.fn(() => true), +})); + vi.mock("@/modules/metrics/collector", () => ({ safeRecord: vi.fn(), recordPostTotal: vi.fn().mockResolvedValue(undefined), @@ -41,6 +52,7 @@ vi.mock("@/lib/prisma", () => ({ })); import { getL2 } from "@/modules/cache/redis-cache"; +import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request"; import { checkIdempotency, saveIdempotency } from "@/modules/quote/idempotency"; import { generateQuoteId } from "@/modules/quote/quote-id"; import { enqueueQuoteJob } from "@/modules/quote/rpa-queue"; @@ -88,6 +100,7 @@ const FOUR_TIERS = [ const mockedCheckIdem = vi.mocked(checkIdempotency); const mockedGetL2 = vi.mocked(getL2); +const mockedFetchInline = vi.mocked(fetchAxelQuoteItems); const mockedGenerateId = vi.mocked(generateQuoteId); const mockedCreate = vi.mocked(prisma.quoteRecord.create); const mockedSaveIdem = vi.mocked(saveIdempotency); @@ -102,6 +115,7 @@ describe("submitQuote", () => { mockedCreate.mockResolvedValue({} as never); mockedSaveIdem.mockResolvedValue(undefined); mockedEnqueue.mockResolvedValue(undefined); + mockedFetchInline.mockRejectedValue(new Error("inline disabled for test")); }); it("TC-105:L1 命中 → 返回原 quote_id", async () => { @@ -126,7 +140,57 @@ describe("submitQuote", () => { expect(mockedEnqueue).not.toHaveBeenCalled(); }); - it("首次询价 → processing + 入队", async () => { + it("inline direct 成功 → done + 不入队", async () => { + mockedFetchInline.mockResolvedValue([ + { + serviceLevel: "standard", + rateOption: "lowest", + carrier: "M", + transitDays: "5", + transitDescription: "x", + rawFreight: 320, + surcharges: 0, + rawTotal: 320, + }, + { + serviceLevel: "standard", + rateOption: "fastest", + carrier: "M", + transitDays: "3", + transitDescription: "x", + rawFreight: 365, + surcharges: 0, + rawTotal: 365, + }, + { + serviceLevel: "guaranteed", + rateOption: "lowest", + carrier: "M", + transitDays: "4", + transitDescription: "x", + rawFreight: 380, + surcharges: 0, + rawTotal: 380, + }, + { + serviceLevel: "guaranteed", + rateOption: "fastest", + carrier: "M", + transitDays: "3", + transitDescription: "x", + rawFreight: 410, + surcharges: 0, + rawTotal: 410, + }, + ] as never); + + const result = await submitQuote(VALID_BODY); + expect(result.status).toBe("done"); + expect(result.source_type).toBe("rpa"); + expect(mockedEnqueue).not.toHaveBeenCalled(); + }); + + it("inline direct 失败 → processing + 入队", async () => { const result = await submitQuote(VALID_BODY); expect(result.status).toBe("processing"); expect(mockedEnqueue).toHaveBeenCalled(); diff --git a/__tests__/modules/quote/quote-completeness.test.ts b/__tests__/modules/quote/quote-completeness.test.ts index 39d6830..4104b50 100644 --- a/__tests__/modules/quote/quote-completeness.test.ts +++ b/__tests__/modules/quote/quote-completeness.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { assertFourTiers, normalizeToFourTiers, + prepareMotherShipStorageQuotes, } from "@/modules/quote/quote-completeness"; const FOUR_TIERS = [ @@ -25,7 +26,7 @@ describe("assertFourTiers", () => { ).not.toThrow(); }); - it("缺 guaranteed 整档 → 仅含 standard/lowest 仍通过", () => { + it("缺 guaranteed 整档 → 仍通过", () => { expect(() => assertFourTiers( FOUR_TIERS.filter((t) => t.service_level !== "guaranteed"), @@ -40,6 +41,17 @@ describe("assertFourTiers", () => { expect(() => assertFourTiers(incomplete)).not.toThrow(); }); + it("仅 bestValue 三 Tab(Denver→Boston 类路线)→ 通过", () => { + const tiers = [ + { service_level: "standard", rate_option: "bestValue" }, + { service_level: "guaranteed", rate_option: "bestValue" }, + { service_level: "dedicated", rate_option: "bestValue" }, + ]; + expect(() => assertFourTiers(tiers)).not.toThrow(); + const ui = prepareMotherShipStorageQuotes(tiers); + expect(ui).toHaveLength(3); + }); + it("含 bestValue 扩展档 → 保留 API 返回的全部档位", () => { const six = [ ...FOUR_TIERS, @@ -72,3 +84,19 @@ describe("assertFourTiers", () => { expect(normalizeToFourTiers(tiers)).toHaveLength(6); }); }); + +describe("prepareMotherShipStorageQuotes", () => { + it("含 dedicated 且 Standard 保留 absolute lowest", () => { + const ui = prepareMotherShipStorageQuotes([ + { service_level: "standard", rate_option: "lowest" }, + { service_level: "standard", rate_option: "bestValue" }, + { service_level: "standard", rate_option: "fastest" }, + { service_level: "guaranteed", rate_option: "lowest" }, + { service_level: "guaranteed", rate_option: "fastest" }, + { service_level: "dedicated", rate_option: "bestValue" }, + ]); + expect(ui.some((q) => q.service_level === "dedicated")).toBe(true); + expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "lowest")).toBe(true); + expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "bestValue")).toBe(true); + }); +}); diff --git a/__tests__/modules/quote/quote-serializer-priority1.test.ts b/__tests__/modules/quote/quote-serializer-priority1.test.ts new file mode 100644 index 0000000..0d97626 --- /dev/null +++ b/__tests__/modules/quote/quote-serializer-priority1.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import type { QuoteRecord } from "@prisma/client"; +import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants"; +import { isPriority1ManualFollowupDetail } from "@/modules/priority1/quote-outcome"; +import { serializeQuoteDetail } from "@/modules/quote/quote-serializer"; +import { PRIORITY1_MANUAL_FOLLOWUP_MESSAGE } from "@/workers/rpa/priority1/quote-extract"; + +function baseRecord(overrides: Partial = {}): QuoteRecord { + return { + id: 1, + quoteId: "Q-TEST-001", + requestId: "req-1", + customerId: "cust-1", + cargoHash: "hash", + status: "done", + sourceType: "rpa", + isRealtime: true, + confidenceScore: 0.95, + currency: "USD", + pickupJson: {}, + deliveryJson: {}, + weightLb: 100, + dimLIn: 48, + dimWIn: 40, + dimHIn: 48, + palletCount: 1, + cargoType: "general_freight", + quotesJson: null, + markupPercent: 0, + validUntil: null, + errorCode: PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE, + errorMessage: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE, + isDeleted: false, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + ...overrides, + } as QuoteRecord; +} + +describe("Priority1 manual_followup quote detail", () => { + it("serializes done + PRIORITY1_MANUAL_FOLLOWUP as informational outcome", () => { + const detail = serializeQuoteDetail(baseRecord()); + expect(detail.status).toBe("done"); + expect(detail.error_code).toBe(PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE); + expect(detail.error_message).toBe(PRIORITY1_MANUAL_FOLLOWUP_MESSAGE); + expect(detail.quotes).toEqual([]); + expect(isPriority1ManualFollowupDetail(detail)).toBe(true); + }); + + it("serializes priority1 stored quotes without mothership tiers", () => { + const detail = serializeQuoteDetail( + baseRecord({ + errorCode: null, + errorMessage: null, + quotesJson: { + provider: "priority1", + display_mode: "ltl_cards", + lines: [ + { + rank: 1, + carrier: "Forward Air", + totalUsd: 557, + markup_amount: 0, + final_total_usd: 557, + serviceLevel: "STANDARD LTL", + source: "visible-dom", + }, + ], + }, + pickupJson: { + zip: "32801", + priority1_shipment_type: "ltl", + priority1_pickup_date: "2026-07-14", + }, + deliveryJson: { zip: "28202" }, + }), + ); + expect(detail.priority1?.display_mode).toBe("ltl_cards"); + expect(detail.priority1?.lines).toHaveLength(1); + expect(detail.priority1?.shipment_meta?.origin_zip).toBe("32801"); + expect(detail.quotes).toBeUndefined(); + }); + + it("serializes legacy tier quotesJson as priority1 payload", () => { + const detail = serializeQuoteDetail( + baseRecord({ + errorCode: null, + errorMessage: null, + quotesJson: [ + { + service_level: "standard", + rate_option: "lowest", + carrier: "Forward Air", + transit_days: "3", + raw_total: 428.5, + final_total: 428.5, + markup_amount: 0, + }, + ], + pickupJson: { zip: "32801", priority1_shipment_type: "ltl" }, + deliveryJson: { zip: "28202" }, + }), + ); + expect(detail.priority1?.lines).toHaveLength(1); + expect(detail.priority1?.lines[0]?.carrier).toBe("Forward Air"); + }); + + it("MotherShip tier 数组在无 Priority1 上下文时序列化为 quotes 而非 priority1", () => { + const detail = serializeQuoteDetail( + baseRecord({ + errorCode: null, + errorMessage: null, + quotesJson: [ + { + service_level: "standard", + rate_option: "lowest", + carrier: "Frontline Freight", + transit_days: "3", + raw_freight: 300, + surcharges: 32.6, + raw_total: 332.6, + markup_percent: 0, + markup_amount: 0, + final_total: 332.6, + breakdown: [ + { item: "运费", amount: 300 }, + { item: "附加费", amount: 32.6 }, + ], + }, + ], + pickupJson: { zip: "90001" }, + deliveryJson: { zip: "75201" }, + }), + ); + expect(detail.priority1).toBeUndefined(); + expect(detail.quotes).toHaveLength(1); + expect(detail.quotes?.[0]).toMatchObject({ + service_level: "standard", + rate_option: "lowest", + carrier: "Frontline Freight", + }); + }); + + it("Priority1 询价无 priority1 数据时返回 failed 而非空结果", () => { + const detail = serializeQuoteDetail( + baseRecord({ + errorCode: null, + errorMessage: null, + quotesJson: null, + pickupJson: { zip: "32801", priority1_shipment_type: "ltl" }, + deliveryJson: { zip: "28202" }, + }), + ); + expect(detail.status).toBe("failed"); + expect(detail.priority1).toBeUndefined(); + expect(detail.quotes).toBeUndefined(); + }); +}); diff --git a/__tests__/modules/quote/unit-converter.test.ts b/__tests__/modules/quote/unit-converter.test.ts index 787f70c..76bf152 100644 --- a/__tests__/modules/quote/unit-converter.test.ts +++ b/__tests__/modules/quote/unit-converter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { cmToIn, kgToLb } from "@/modules/quote/unit-converter"; +import { cmToIn, kgToLb, toAxelWholeInches, toAxelWholePounds } from "@/modules/quote/unit-converter"; describe("unit-converter", () => { it("227kg → 500.45lb(ROUND_HALF_UP,JS 浮点 227×2.20462)", () => { @@ -10,6 +10,14 @@ describe("unit-converter", () => { expect(cmToIn(122)).toBe(48.03); }); + it("120cm 转 axel 尺寸向上取整为 48in", () => { + expect(toAxelWholeInches(cmToIn(120))).toBe(48); + }); + + it("500kg 转 axel 重量向上取整为 1103lb", () => { + expect(toAxelWholePounds(kgToLb(500))).toBe(1103); + }); + it("500lb 不变", () => { expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0); }); diff --git a/__tests__/scripts/record-priority1.test.ts b/__tests__/scripts/record-priority1.test.ts new file mode 100644 index 0000000..d90b0c1 --- /dev/null +++ b/__tests__/scripts/record-priority1.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = join(process.cwd(), "scripts", "record-priority1.sh"); +const TS_SCRIPT_PATH = join(process.cwd(), "scripts", "record-priority1.ts"); + +const FORBIDDEN_PATTERNS = [ + /dashboard\.priority1\.com/i, + /api\.priority1\.com/i, +]; + +function isForbiddenPriority1QuoteUrl(url: string): boolean { + return FORBIDDEN_PATTERNS.some((pattern) => pattern.test(url)); +} + +describe("record-priority1.sh", () => { + const content = readFileSync(SCRIPT_PATH, "utf8"); + + it("存在且包含 headed 与 codegen 启动", () => { + expect(content).toContain("RPA_HEADLESS=false"); + expect(content).toContain("playwright"); + expect(content).toContain("codegen"); + }); + + it("禁止 Cabo 后台与 API 门户 URL", () => { + expect(content).toContain("dashboard.priority1.com"); + expect(content).toContain("api.priority1.com"); + expect(content).toContain("check_url"); + }); + + it("产出录制脚本与 storageState 路径", () => { + expect(content).toContain(".rpa/recordings"); + expect(content).toContain("priority1-storage.json"); + expect(content).toContain("--save-storage"); + }); +}); + +describe("record-priority1.ts (Windows 跨平台)", () => { + const content = readFileSync(TS_SCRIPT_PATH, "utf8"); + + it("存在且包含 headed codegen 与入口校验", () => { + expect(content).toContain('RPA_HEADLESS: "false"'); + expect(content).toContain("playwright"); + expect(content).toContain("codegen"); + expect(content).toContain("isForbiddenPriority1QuoteUrl"); + }); + + it("产出录制脚本与 storageState 路径", () => { + expect(content).toContain(".rpa/recordings"); + expect(content).toContain("priority1-storage.json"); + expect(content).toContain("--save-storage"); + }); + + it("默认打开公开询价页", () => { + expect(content).toContain( + "https://www.priority1.com/instant-freight-quotes/", + ); + }); +}); + +describe("isForbiddenPriority1QuoteUrl", () => { + it("拒绝 Cabo 后台与 API 门户", () => { + expect( + isForbiddenPriority1QuoteUrl( + "https://dashboard.priority1.com/account/login", + ), + ).toBe(true); + expect(isForbiddenPriority1QuoteUrl("https://api.priority1.com/docs")).toBe( + true, + ); + }); + + it("允许公开询价页与首页", () => { + expect( + isForbiddenPriority1QuoteUrl( + "https://www.priority1.com/instant-freight-quotes/", + ), + ).toBe(false); + expect(isForbiddenPriority1QuoteUrl("https://www.priority1.com/")).toBe( + false, + ); + }); +}); diff --git a/__tests__/workers/rpa/mothership.validate.test.ts b/__tests__/workers/rpa/mothership.validate.test.ts index d635442..0b5fb0c 100644 --- a/__tests__/workers/rpa/mothership.validate.test.ts +++ b/__tests__/workers/rpa/mothership.validate.test.ts @@ -39,17 +39,17 @@ describe("validateResult", () => { expect(() => validateResult(items)).not.toThrow(); }); - it("仅 1 档 → RPA_DATA_INVALID", () => { - const items = [buildTier("standard", "lowest")]; - expect(() => validateResult(items)).toThrow(RpaError); + it("仅 1 档 → 通过(MotherShip 返回什么展示什么)", () => { + const items = [buildTier("standard", "bestValue")]; + expect(() => validateResult(items)).not.toThrow(); }); - it("缺 guaranteed 整档 → RPA_DATA_INVALID", () => { + it("缺 guaranteed 整档 → 通过", () => { const items = [ buildTier("standard", "lowest"), buildTier("standard", "fastest"), ]; - expect(() => validateResult(items)).toThrow(RpaError); + expect(() => validateResult(items)).not.toThrow(); }); it("含 bestValue 扩展档 → 通过", () => { diff --git a/__tests__/workers/rpa/priority1-demo-input.test.ts b/__tests__/workers/rpa/priority1-demo-input.test.ts new file mode 100644 index 0000000..9f06782 --- /dev/null +++ b/__tests__/workers/rpa/priority1-demo-input.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + buildPriority1CanonicalTestCases, + generateRandomExpeditedCases, + generateRandomFtlCases, + generateRandomPriority1Cases, + parsePriority1DemoFromEnv, + PRIORITY1_CANONICAL_PATHS, +} from "@/workers/rpa/priority1/demo-input"; + +describe("priority1 demo-input", () => { + it("canonical 全矩阵 24 条", () => { + expect(PRIORITY1_CANONICAL_PATHS).toHaveLength(24); + const ltl = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("P")); + const ftl = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("F")); + const exp = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("E")); + expect(ltl).toHaveLength(9); + expect(ftl).toHaveLength(11); + expect(exp).toHaveLength(4); + }); + + it("导出系统联调用例含 API 载荷", () => { + const cases = buildPriority1CanonicalTestCases({ + customerId: "CUST_X", + requestIdPrefix: "req", + }); + expect(cases).toHaveLength(24); + expect(cases[0]?.apiPayload.request_id).toBe("req-P01-ltl-residential"); + expect(cases[0]?.apiPayload.customer_id).toBe("CUST_X"); + expect(cases.find((c) => c.id === "E03-expedited-large")?.apiPayload.priority1_input.weightLb).toBe( + "10000", + ); + }); + it("相同 seed 生成相同用例", () => { + const a = generateRandomPriority1Cases(10, 42); + const b = generateRandomPriority1Cases(10, 42); + expect(a).toEqual(b); + expect(a).toHaveLength(10); + expect(a[0]?.id).toBe("P01"); + }); + + it("起运与目的 ZIP 不相同", () => { + const cases = generateRandomPriority1Cases(10, 99); + for (const c of cases) { + expect(c.input.originZip).not.toBe(c.input.destinationZip); + expect(c.input.originZip).toMatch(/^\d{5}$/); + } + }); + + it("生成 FTL / Expedited 用例", () => { + const ftl = generateRandomFtlCases(3, 42); + const exp = generateRandomExpeditedCases(3, 42); + expect(ftl[0]?.input.shipmentType).toBe("ftl"); + expect(exp[0]?.input.shipmentType).toBe("expedited"); + expect(ftl[0]?.id).toBe("F01"); + expect(exp[0]?.id).toBe("E01"); + }); + + it("解析环境 JSON 覆盖默认字段", () => { + const demo = parsePriority1DemoFromEnv( + JSON.stringify({ originZip: "30303", weightLb: "999" }), + ); + expect(demo.originZip).toBe("30303"); + expect(demo.weightLb).toBe("999"); + expect(demo.destinationZip).toBe("90017"); + }); +}); diff --git a/__tests__/workers/rpa/priority1-form-constraints.test.ts b/__tests__/workers/rpa/priority1-form-constraints.test.ts new file mode 100644 index 0000000..df9c7a7 --- /dev/null +++ b/__tests__/workers/rpa/priority1-form-constraints.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_EXPEDITED_DEMO, + DEFAULT_FTL_FTL_EXPEDITED_DEMO, + PRIORITY1_CANONICAL_PATHS, +} from "@/workers/rpa/priority1/demo-input"; +import { + validatePriority1FormConstraints, +} from "@/workers/rpa/priority1/form-constraints"; + +describe("priority1 form-constraints", () => { + it("24 条 canonical 无硬限制违规", () => { + const errors = PRIORITY1_CANONICAL_PATHS.flatMap((p) => + validatePriority1FormConstraints(p.input).filter((v) => v.severity === "error"), + ); + expect(errors).toEqual([]); + }); + + it("Expedited Large 超过 10000 lb 报错", () => { + const v = validatePriority1FormConstraints({ + ...DEFAULT_EXPEDITED_DEMO, + expeditedTrailer: "large_straight", + weightLb: "11000", + }); + expect(v.some((x) => x.code === "WEIGHT_MAX")).toBe(true); + }); + + it("非 Large Straight 不可勾选 Liftgate", () => { + const v = validatePriority1FormConstraints({ + ...DEFAULT_EXPEDITED_DEMO, + expeditedTrailer: "sprinter_van", + expeditedLiftgate: true, + }); + expect(v.some((x) => x.code === "LIFTGATE_TRAILER")).toBe(true); + }); + + it("FTL 内嵌 Expedited Large 重量上限 10000", () => { + const v = validatePriority1FormConstraints({ + ...DEFAULT_FTL_FTL_EXPEDITED_DEMO, + ftlExpeditedTrailer: "large_straight", + weightLb: "12000", + }); + expect(v.some((x) => x.code === "WEIGHT_MAX")).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/priority1-form-interactions.test.ts b/__tests__/workers/rpa/priority1-form-interactions.test.ts new file mode 100644 index 0000000..39fcee6 --- /dev/null +++ b/__tests__/workers/rpa/priority1-form-interactions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + freightClassOptionMatches, + ltlAccessorialDisplayText, + ltlAccessorialIsPickupSection, + ltlAccessorialLabelMatches, + normalizeOpenDeckSubtypeQuery, + OPEN_DECK_TYPE_SELECT_SELECTOR, + openDeckSelectionOk, +} from "@/workers/rpa/priority1/form-interactions"; + +describe("priority1 form-interactions", () => { + it("Freight Class 选项匹配含 Not Sure", () => { + expect(freightClassOptionMatches("70", "70")).toBe(true); + expect(freightClassOptionMatches("I'm Not Sure", "Not Sure")).toBe(true); + expect(freightClassOptionMatches("400", "70")).toBe(false); + }); + + it("Open Deck 子类型别名归一", () => { + expect(normalizeOpenDeckSubtypeQuery("Flatbed")).toBe("Foot Flatbed"); + expect(normalizeOpenDeckSubtypeQuery("48 Foot Flatbed")).toBe("48 Foot Flatbed"); + expect(normalizeOpenDeckSubtypeQuery("Goose Neck RGN")).toBe("Removable Gooseneck"); + }); + + it("Open Deck 选中判定", () => { + expect(openDeckSelectionOk("48 Foot Flatbed", "Flatbed")).toBe(true); + expect(openDeckSelectionOk("Removable Gooseneck", "Removable Gooseneck")).toBe(true); + expect(openDeckSelectionOk("", "Flatbed")).toBe(false); + }); + + it("Open Deck Type 录制 selector", () => { + expect(OPEN_DECK_TYPE_SELECT_SELECTOR).toBe("#PickupLocation3"); + }); + + it("LTL 附加服务 label 匹配", () => { + expect(ltlAccessorialLabelMatches("Liftgate at Pickup", "Liftgate at Pickup")).toBe(true); + expect(ltlAccessorialLabelMatches(" Inside Pickup ", "Inside Pickup")).toBe(true); + expect(ltlAccessorialLabelMatches("Inside Delivery", "Inside Pickup")).toBe(false); + expect(ltlAccessorialLabelMatches("Liftgate at Delivery", "Liftgate at Pickup")).toBe(false); + }); + + it("LTL 附加 canonical → 页面文案", () => { + expect(ltlAccessorialDisplayText("Limited Access Pickup")).toBe("Limited Access"); + expect(ltlAccessorialDisplayText("Inside Pickup")).toBe("Inside Pickup"); + expect(ltlAccessorialIsPickupSection("Liftgate at Pickup")).toBe(true); + expect(ltlAccessorialIsPickupSection("Hazardous Material")).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/priority1-form-logic-map.test.ts b/__tests__/workers/rpa/priority1-form-logic-map.test.ts new file mode 100644 index 0000000..ff5d5e8 --- /dev/null +++ b/__tests__/workers/rpa/priority1-form-logic-map.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_FTL_DEMO, + DEFAULT_FTL_FTL_EXPEDITED_DEMO, + DEFAULT_PRIORITY1_DEMO, + PRIORITY1_CANONICAL_PATHS, +} from "@/workers/rpa/priority1/demo-input"; +import { + buildPriority1FormPlan, + collectFieldOptionsCatalog, + formatCompleteFormLogicReference, + formatFormLogicReference, + getNextFields, + SCENARIO_CHEATSHEET, + STEP2_HASH_FALLBACK, +} from "@/workers/rpa/priority1/form-logic-map"; + +describe("priority1 form-logic-map", () => { + it("LTL 计划包含包装 Pallet 与 Step1 邮编", () => { + const plan = buildPriority1FormPlan(DEFAULT_PRIORITY1_DEMO); + const keys = plan.map((p) => p.field.key); + expect(keys).toContain("packaging"); + expect(plan.find((p) => p.field.key === "packaging")?.value).toBe("Pallet"); + expect(plan.find((p) => p.field.key === "originZip")?.value).toBe("10118"); + }); + + it("FTL Dry Van 分支含拖车、货描、重量、托盘", () => { + const plan = buildPriority1FormPlan(DEFAULT_FTL_DEMO); + const labels = plan.map((p) => p.field.label); + expect(labels.some((l) => /Dry Van/i.test(l))).toBe(true); + expect(plan.find((p) => p.field.key === "commodity")?.value).toBe("Electronics"); + expect(plan.find((p) => p.field.key === "weightLb")?.value).toBe("15000"); + expect(plan.find((p) => p.field.key === "palletCount")?.value).toBe("18"); + }); + + it("getNextFields FTL 未选拖车时只返回拖车卡", () => { + const next = getNextFields({ shipmentType: "ftl", step: 2 }); + expect(next.every((f) => f.key.startsWith("ftlTrailer_"))).toBe(true); + }); + + it("Open Deck 分支追加子类型字段", () => { + const next = getNextFields({ + shipmentType: "ftl", + step: 2, + ftlTrailer: "open_deck", + }); + expect(next.some((f) => f.key === "openDeckSubtype")).toBe(true); + }); + + it("场景速查与哈希回退齐全", () => { + expect(SCENARIO_CHEATSHEET.length).toBeGreaterThanOrEqual(6); + expect(STEP2_HASH_FALLBACK.ftl).toContain("Truckload"); + expect(formatFormLogicReference(DEFAULT_FTL_DEMO)).toContain("Dry Van"); + }); + + it("FTL 内嵌 Expedited 计划不含托盘字段", () => { + const plan = buildPriority1FormPlan(DEFAULT_FTL_FTL_EXPEDITED_DEMO); + expect(plan.some((p) => p.field.key === "palletCount")).toBe(false); + }); + + it("canonical 路径覆盖三种主类型与全部 FTL 拖车", () => { + expect(PRIORITY1_CANONICAL_PATHS.length).toBeGreaterThanOrEqual(20); + const types = new Set(PRIORITY1_CANONICAL_PATHS.map((p) => p.input.shipmentType)); + expect(types).toEqual(new Set(["ltl", "ftl", "expedited"])); + const ftlTrailers = PRIORITY1_CANONICAL_PATHS.filter( + (p) => p.input.shipmentType === "ftl", + ).map((p) => p.input.ftlTrailer); + expect(new Set(ftlTrailers)).toEqual( + new Set(["dry_van", "open_deck", "temperature_controlled", "ftl_expedited"]), + ); + }); + + it("完整参考含路径矩阵与字段目录", () => { + const md = formatCompleteFormLogicReference(PRIORITY1_CANONICAL_PATHS); + expect(md).toContain("路径矩阵"); + expect(md).toContain("全字段可选值"); + expect(md).toContain("P01-ltl-residential"); + expect(collectFieldOptionsCatalog().some((r) => r.key === "packaging")).toBe( + true, + ); + }); +}); diff --git a/__tests__/workers/rpa/priority1-job-handler.test.ts b/__tests__/workers/rpa/priority1-job-handler.test.ts new file mode 100644 index 0000000..9fec43c --- /dev/null +++ b/__tests__/workers/rpa/priority1-job-handler.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants"; +import { PRIORITY1_MANUAL_FOLLOWUP_MESSAGE } from "@/workers/rpa/priority1/quote-extract"; + +const { prismaUpdate, runRpa, recordSuccess, closeCircuit } = vi.hoisted( + () => ({ + prismaUpdate: vi.fn(), + runRpa: vi.fn(), + recordSuccess: vi.fn(), + closeCircuit: vi.fn(), + }), +); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + quoteRecord: { update: prismaUpdate }, + }, +})); + +vi.mock("@/workers/rpa/priority1/run-quote", () => ({ + runPriority1QuoteRpa: runRpa, +})); + +vi.mock("@/modules/cache/circuit-breaker", () => ({ + isCircuitOpen: vi.fn().mockResolvedValue(false), + recordRpaFailure: vi.fn(), + recordRpaSuccess: recordSuccess, + closeCircuit: closeCircuit, +})); + +vi.mock("@/workers/rpa/worker-state", () => ({ + heartbeat: vi.fn(), + isWorkerPaused: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/modules/metrics/collector", () => ({ + recordPostDone: vi.fn(), + safeRecord: (fn: () => void) => fn(), +})); + +vi.mock("@/modules/pricing/engine", () => ({ + getMarkupRule: vi.fn(), + applyMarkupToQuotes: vi.fn(), +})); + +vi.mock("@/modules/quote/fallback-orchestrator", () => ({ + handleRpaFailure: vi.fn(), +})); + +import { processPriority1QuoteJob } from "@/workers/rpa/priority1-job-handler"; + +const jobData = { + quoteId: "Q-P1-001", + requestId: "req-p1", + customerId: "cust-1", + cargoHash: "hash-p1", + input: { + shipmentType: "ftl" as const, + originZip: "98101", + destinationZip: "33101", + pickupDate: "07/01/2026", + phoneCountry: "US", + phone: "2065550100", + email: "test@example.com", + commodity: "General Freight", + weightLb: "5000", + ftlTrailer: "temperature_controlled" as const, + tempMinF: "32", + tempMaxF: "40", + palletCount: "1", + }, +}; + +describe("processPriority1QuoteJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("persists manual_followup as done with PRIORITY1_MANUAL_FOLLOWUP", async () => { + runRpa.mockResolvedValue({ + ok: true, + quotes: [], + quoteOutcome: "manual_followup", + message: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE, + }); + + await processPriority1QuoteJob(jobData, "worker-1"); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { quoteId: "Q-P1-001" }, + data: expect.objectContaining({ + status: "done", + errorCode: PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE, + errorMessage: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE, + }), + }), + ); + expect(recordSuccess).toHaveBeenCalled(); + expect(closeCircuit).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/workers/rpa/priority1-quote-extract-dom.test.ts b/__tests__/workers/rpa/priority1-quote-extract-dom.test.ts new file mode 100644 index 0000000..8fa30c1 --- /dev/null +++ b/__tests__/workers/rpa/priority1-quote-extract-dom.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + isPickupDateAfterToday, + minPickupDateDisplay, + pickupDateValidationMessage, +} from "@/lib/priority1/pickup-date"; +import { extractPriority1VisibleQuotes } from "@/workers/rpa/priority1/quote-extract"; + +const SAMPLE_SUMMARY = JSON.stringify({ + quote_id: 42830001, + LTL_best_price_quote_carrier: "XPO", + LTL_best_price_quote_cost: "481.32", + LTL_best_price_quote_transit: 8, + LTL_second_rate_quote_carrier: "Estes", + LTL_second_rate_quote_cost: "503.68", + LTL_second_rate_quote_transit: 5, +}); + +describe("pickup date rules", () => { + it("requires date strictly after today", () => { + const min = minPickupDateDisplay(); + expect(min).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + expect(pickupDateValidationMessage(min)).toBeNull(); + expect(isPickupDateAfterToday(min)).toBe(true); + }); + + it("rejects today or past dates", () => { + const today = new Date(); + const mm = String(today.getMonth() + 1).padStart(2, "0"); + const dd = String(today.getDate()).padStart(2, "0"); + const yyyy = today.getFullYear(); + const todayDisplay = `${mm}/${dd}/${yyyy}`; + expect(pickupDateValidationMessage(todayDisplay)).toBe("提货日须晚于今天"); + }); +}); + +describe("parseVisibleQuoteCardsDom via extractPriority1VisibleQuotes", () => { + const sampleDom = ` +YOUR INSTANT QUOTE OPTIONS +QUOTE 1 +STANDARD LTL +Estes +Transit: 1 Business Days +Quote Expiration Date: 07-09-2026 +Estimated Delivery Date: 07-15-2026 +$426.19 +QUOTE 2 +STANDARD LTL +TForce Freight +Transit: 2 Business Days +Quote Expiration Date: 07-09-2026 +Estimated Delivery Date: 07-16-2026 +$1703.90 +`; + + it("无网络摘要时以可见 DOM 卡片为准", () => { + const lines = extractPriority1VisibleQuotes([], sampleDom); + expect(lines).toHaveLength(2); + expect(lines[0]!.carrier).toBe("Estes"); + expect(lines[0]!.totalUsd).toBe(426.19); + expect(lines[0]!.expirationDate).toBe("07/09/2026"); + expect(lines[0]!.deliveryDate).toBe("07/15/2026"); + expect(lines[1]!.totalUsd).toBe(1703.9); + }); + + it("DOM 无承运商时从 Feathery 摘要按 QUOTE 序号补全承运商与金额", () => { + const dom = ` +QUOTE 1 +STANDARD LTL +Transit: 1 Business Days +Quote Expiration Date: 07-09-2026 +Estimated Delivery Date: 07-15-2026 +$999.99 +QUOTE 2 +STANDARD LTL +Transit: 2 Business Days +$888.88 +`; + const lines = extractPriority1VisibleQuotes([SAMPLE_SUMMARY], dom); + expect(lines).toHaveLength(2); + expect(lines[0]!.carrier).toBe("XPO"); + expect(lines[0]!.totalUsd).toBe(481.32); + expect(lines[0]!.expirationDate).toBe("07/09/2026"); + expect(lines[1]!.carrier).toBe("Estes"); + expect(lines[1]!.totalUsd).toBe(503.68); + }); +}); diff --git a/__tests__/workers/rpa/priority1-quote-extract.test.ts b/__tests__/workers/rpa/priority1-quote-extract.test.ts new file mode 100644 index 0000000..74ce9f3 --- /dev/null +++ b/__tests__/workers/rpa/priority1-quote-extract.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + extractPriority1AllApiQuotes, + extractPriority1QuotesFromDomText, + extractPriority1QuotesFromNetworkBodies, + extractPriority1VisibleQuotes, + isPriority1ManualFollowupPage, + isPriority1QuotePageReady, + parseFtlCalendarQuotes, + PRIORITY1_MANUAL_FOLLOWUP_MESSAGE, + resolvePriority1SubmitOutcome, +} from "@/workers/rpa/priority1/quote-extract"; + +const SAMPLE_API = JSON.stringify({ + data: { + id: 42830001, + rateQuotes: [ + { + id: 1, + carrierName: "XPO", + carrierCode: "XPOL", + transitDays: 8, + deliveryDate: "2026-07-13T00:00:00", + expirationDate: "2026-06-27T07:00:30Z", + serviceLevelDescription: "Standard Rate", + rateQuoteDetail: { total: 481.32 }, + }, + { + id: 2, + carrierName: "Estes", + carrierCode: "EXLA", + transitDays: 5, + rateQuoteDetail: { total: 503.68 }, + }, + { + id: 3, + carrierName: "Hidden Carrier", + carrierCode: "HIDN", + transitDays: 4, + rateQuoteDetail: { total: 999.99 }, + }, + ], + }, +}); + +const SAMPLE_SUMMARY = JSON.stringify({ + quote_id: 42830001, + cabo_url: "https://dashboard.priority1.com/ltl/quotes/details/42830001", + LTL_best_price_quote_carrier: "XPO", + LTL_best_price_quote_cost: "481.32", + LTL_best_price_quote_transit: 8, + LTL_second_rate_quote_carrier: "Estes", + LTL_second_rate_quote_cost: "503.68", + LTL_second_rate_quote_transit: 5, +}); + +const SAMPLE_DOM = ` +QUOTE 1 Forward $560.27 STANDARD LTL Forward Quote Expiration Date 07-03-2026 +2 Business Days +QUOTE 2 Go2 Logistics $2052.67 STANDARD LTL Go2 Logistics Quote Expiration Date +3 Business Days +`; + +describe("priority1 quote-extract", () => { + it("全量 API 解析 rateQuotes", () => { + const lines = extractPriority1AllApiQuotes([SAMPLE_API]); + expect(lines.length).toBe(3); + expect(lines[0].carrier).toBe("XPO"); + expect(lines[0].source).toBe("api-all"); + }); + + it("同时有 API 与摘要时优先页面可见摘要", () => { + const lines = extractPriority1VisibleQuotes([SAMPLE_API, SAMPLE_SUMMARY]); + expect(lines.length).toBe(2); + expect(lines[0].carrier).toBe("XPO"); + expect(lines[0].source).toBe("feathery-summary"); + }); + + it("无摘要时回退 feathery 摘要字段", () => { + const lines = extractPriority1QuotesFromNetworkBodies([SAMPLE_SUMMARY]); + expect(lines.length).toBe(2); + expect(lines[1].carrier).toBe("Estes"); + }); + + it("解析 QUOTE 卡片 DOM", () => { + const lines = extractPriority1QuotesFromDomText(SAMPLE_DOM); + expect(lines.length).toBe(2); + expect(lines[0].carrier).toBe("Forward"); + expect(lines[0].totalUsd).toBe(560.27); + expect(lines[0].source).toBe("visible-dom"); + }); + + it("解析 FTL 日历表格报价(无 $ 前缀)", () => { + const dom = ` +YOUR INSTANT QUOTE OPTIONS +SHIPPING DETAILS From 32801 To 28202 Weight 15000 lb +Wed 1 1652.16 Thu 2 1652.16 Fri 3 1650.78 +Mon 6 2178.47 Tue 7 1652.16 +REQUEST A NEW QUOTE BOOK NOW IN CABO TMS +`; + expect(isPriority1QuotePageReady(dom)).toBe(true); + const lines = parseFtlCalendarQuotes(dom); + expect(lines.length).toBe(5); + expect(lines[0].source).toBe("ftl-calendar"); + expect(lines[0].deliveryDate).toBe("DAY:1"); + expect(lines[3].deliveryDate).toBe("DAY:6"); + expect(lines.some((q) => q.totalUsd === 2178.47)).toBe(true); + }); + + it("识别 MORE INFORMATION NEEDED 人工跟进页", () => { + const dom = ` +MORE INFORMATION NEEDED +A Priority1 freight specialist will reach out to confirm the missing details +Confirm Details & Lock In Your Rate +REQUEST NEW QUOTE +`; + expect(isPriority1ManualFollowupPage(dom)).toBe(true); + const resolved = resolvePriority1SubmitOutcome([], dom); + expect(resolved.outcome).toBe("manual_followup"); + expect(resolved.message).toBe(PRIORITY1_MANUAL_FOLLOWUP_MESSAGE); + expect(resolved.quotes).toHaveLength(0); + }); +}); diff --git a/__tests__/workers/rpa/quote-capture.test.ts b/__tests__/workers/rpa/quote-capture.test.ts index 925ad90..5fa1c93 100644 --- a/__tests__/workers/rpa/quote-capture.test.ts +++ b/__tests__/workers/rpa/quote-capture.test.ts @@ -449,7 +449,7 @@ describe("decodeQuotePayloads", () => { ).toBe(768.88); }); - it("standard 缺 fastest 键时从 availableRates 显式 serviceType 补档", () => { + it("rates 未声明 fastest 槽位时不从 availableRates 补档", () => { const body = { data: { rates: { @@ -475,8 +475,8 @@ describe("decodeQuotePayloads", () => { expect( items.find( (i) => i.serviceLevel === "standard" && i.rateOption === "fastest", - )?.rawFreight, - ).toBe(768.88); + ), + ).toBeUndefined(); }); it("无契约响应返回 null", () => { @@ -495,7 +495,7 @@ describe("decodeQuotePayloads", () => { ).toBeNull(); }); - it("sign-up-quote 页 axel/quote 契约不全时走 lenient", () => { + it("rates 空块 + customRate 时不推断伪档位", () => { const body = { data: { rates: { standard: {}, guaranteed: {}, dedicated: {} }, @@ -525,12 +525,7 @@ describe("decodeQuotePayloads", () => { ], }), ); - expect(decoded?.source).toBe("lenient"); - expect( - decoded?.items.find( - (i) => i.serviceLevel === "standard" && i.rateOption === "lowest", - )?.rawFreight, - ).toBe(612.72); + expect(decoded).toBeNull(); }); }); @@ -541,7 +536,7 @@ describe("decodeEmbeddedJsonBodies", () => { }); describe("decodeAxelQuoteBodyLenient", () => { - it("rates 缺 standard/lowest 时从 availableRates customRate 推断", () => { + it("rates 空块时不从 availableRates customRate 推断", () => { const body = { data: { rates: { @@ -569,15 +564,65 @@ describe("decodeAxelQuoteBodyLenient", () => { }, }, }; - const items = decodeAxelQuoteBodyLenient(body); - expect(items).not.toBeNull(); + expect(decodeAxelQuoteBodyLenient(body)).toBeNull(); + }); + + it("解析 rates.dedicated.bestValue 专属卡车档", () => { + const body = { + data: { + rates: { + standard: { + lowest: { finalPrice: 299.62, days: 3, serviceType: "lowest" }, + fastest: { finalPrice: 682.15, days: 2, serviceType: "fastest" }, + }, + guaranteed: { + lowest: { finalPrice: 411.98, days: 3, serviceType: "lowest" }, + fastest: { finalPrice: 823.52, days: 2, serviceType: "fastest" }, + }, + dedicated: { + bestValue: { + finalPrice: 4989.2, + days: 2, + serviceType: "bestValue", + serviceLevel: "dedicated", + carrierInfo: { name: "Dedicated Carrier" }, + }, + }, + }, + availableRates: {}, + }, + }; + const items = decodeAxelQuoteBodyLenient(body)!; expect( - items!.find((i) => i.serviceLevel === "standard" && i.rateOption === "lowest") + items.find((i) => i.serviceLevel === "dedicated" && i.rateOption === "bestValue") ?.rawTotal, - ).toBe(927.48); + ).toBe(4989.2); + expect(items.filter((i) => i.serviceLevel === "dedicated")).toHaveLength(1); + }); + + it("各 service_level 仅 bestValue 时仍可解码(Denver→Boston)", () => { + const body = { + data: { + rates: { + standard: { + bestValue: { finalPrice: 398.77, days: 4, serviceType: "bestValue" }, + }, + guaranteed: { + bestValue: { finalPrice: 511.12, days: 3, serviceType: "bestValue" }, + }, + dedicated: { + bestValue: { finalPrice: 5989.2, days: 2, serviceType: "bestValue" }, + }, + }, + availableRates: {}, + }, + }; + const items = validateQuoteSchema(decodeAxelQuoteBodyLenient(body)!); + expect(items).toHaveLength(3); expect( - items!.find((i) => i.serviceLevel === "standard" && i.rateOption === "fastest") - ?.rawTotal, - ).toBe(1100.12); + items.find( + (i) => i.serviceLevel === "standard" && i.rateOption === "bestValue", + )?.rawFreight, + ).toBe(398.77); }); }); diff --git a/api调用文档/api调用文档.md b/api调用文档/api调用文档.md new file mode 100644 index 0000000..2721afd --- /dev/null +++ b/api调用文档/api调用文档.md @@ -0,0 +1,358 @@ +# MotherShip 查询接口调用文档 + +本文档只说明 **MotherShip / Axel** 查询报价接口如何调用,适合直接发给第三方开发人员使用。 + +Base URL:`https://if.dev.51track.vip` + +--- + +## 1. 推荐接口 + +当前使用 **两步宿主接口**(须鉴权): + +1. `POST /api/host/quote/candidates` +2. `POST /api/host/quote/submit` + +特点: + +- 第一步返回联想地址候选 +- 第二步服务端内部等待并轮询 +- 调用方**不需要自己轮询** + +--- + +## 2. 鉴权(必传) + +当前服务器已开启鉴权(`HOST_PUBLIC_API_ENABLED=false`),**每次请求**须携带以下请求头: + +```http +Authorization: Bearer +X-Customer-Id: CUST_001 +Content-Type: application/json +``` + +| 字段 | 值 | 说明 | +|------|-----|------| +| `Authorization` | `Bearer ` | 我方单独发放的 API Token | +| `X-Customer-Id` | `CUST_001` | 固定客户 ID,须与 Token 绑定一致 | + +**示例(联调 Token):** + +```http +Authorization: Bearer demo-host-token +X-Customer-Id: CUST_001 +Content-Type: application/json +``` + +> 生产环境 Token 由我方另行发放,格式同上;请勿将 Token 写入前端页面或公开仓库。 + +### 鉴权错误 + +| HTTP | message | 说明 | +|------|---------|------| +| 401 | 缺少 Authorization 头 | 未带 `Authorization: Bearer ...` | +| 401 | Service Token 无效 | Token 拼写错误或不在白名单 | +| 401 | 令牌无效或已过期 | 误用了管理员 JWT,应使用 Service Token | + +### Apifox 环境变量建议 + +| 变量 | 示例值 | +|------|--------| +| `baseUrl` | `https://if.dev.51track.vip` | +| `token` | `demo-host-token`(联调)或我方发放的生产 Token | +| `customerId` | `CUST_001` | + +在「Auth」或「Headers」中配置: + +- `Authorization`:`Bearer {{token}}` +- `X-Customer-Id`:`{{customerId}}` +- `Content-Type`:`application/json` + +--- + +## 3. 第一步:获取联想地址候选 + +### 接口 + +```http +POST /api/host/quote/candidates +``` + +### 请求头示例 + +```http +Authorization: Bearer demo-host-token +X-Customer-Id: CUST_001 +Content-Type: application/json +``` + +### 请求体 + +```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" + } +} +``` + +### 字段说明 + +| 字段 | 说明 | +|------|------| +| `pickup_address` | 提货草稿地址 | +| `delivery_address` | 派送草稿地址 | +| `street/city/state` | 必填,`state` 为美国州二字码 | +| `zip` | 可选,建议传 | +| `cargo.weight.unit` | `kg` 或 `lb` | +| `cargo.dimensions.unit` | `cm` 或 `in` | +| `cargo.pallet_count` | 托盘数,1–25 | +| `cargo.cargo_type` | `general_freight` 等 | + +### 成功响应示例 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "host_session_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9", + "pickup_candidates": [ + { + "option_id": "Eisx...", + "display_label": "1234 Warehouse Street, Los Angeles, CA, USA", + "formatted_address": "1234 Warehouse Street, Los Angeles, CA, USA", + "street": "1234 Warehouse Street", + "city": "Los Angeles", + "state": "CA", + "zip": "90001", + "selectable": true + } + ], + "delivery_candidates": [ + { + "option_id": "EihE...", + "display_label": "Distribution Dr, Wilmer, Dallas, TX, USA", + "formatted_address": "Distribution Dr, Wilmer, Dallas, TX, USA", + "street": "5678 Distribution Dr, Wilmer", + "city": "Dallas", + "state": "TX", + "zip": "75201", + "selectable": true + } + ], + "requires_selection": true + } +} +``` + +### 第一步调用后的处理 + +1. 保存 `data.host_session_id`(约 30 分钟有效) +2. 从 `pickup_candidates` 中选择 1 条 `selectable: true` +3. 从 `delivery_candidates` 中选择 1 条 `selectable: true` +4. 将选中的**完整候选对象**用于第二步请求 + +不要使用 `selectable: false` 的候选。 + +--- + +## 4. 第二步:提交候选并直接获取最终报价 + +### 接口 + +```http +POST /api/host/quote/submit +``` + +### 请求头 + +与第一步相同(须带 `Authorization` 与 `X-Customer-Id`)。 + +### 请求体 + +```json +{ + "host_session_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9", + "pickup_candidate": { + "option_id": "Eisx...", + "display_label": "1234 Warehouse Street, Los Angeles, CA, USA", + "formatted_address": "1234 Warehouse Street, Los Angeles, CA, USA", + "street": "1234 Warehouse Street", + "city": "Los Angeles", + "state": "CA", + "zip": "90001", + "selectable": true + }, + "delivery_candidate": { + "option_id": "EihE...", + "display_label": "Distribution Dr, Wilmer, Dallas, TX, USA", + "formatted_address": "Distribution Dr, Wilmer, Dallas, TX, USA", + "street": "5678 Distribution Dr, Wilmer", + "city": "Dallas", + "state": "TX", + "zip": "75201", + "selectable": true + } +} +``` + +### 注意 + +- `host_session_id` 必须来自第一步返回值 +- `pickup_candidate` / `delivery_candidate` 必须是第一步候选列表中的**完整对象** +- 第二步会等待 10–30 秒,最长约 420 秒 +- 第二步**不会**返回 `processing` 让你继续轮询;服务端会自己处理轮询 + +### 成功响应示例 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "quote_id": "QTE_20260630_0003", + "status": "done", + "currency": "USD", + "valid_until": "2026-06-30T10:30:00.000Z", + "quotes": [ + { + "service_level": "standard", + "rate_option": "lowest", + "carrier": "Frontline Freight", + "transit_days": "Est. 5 business days", + "final_total": 337.06 + } + ] + } +} +``` + +### 应展示给用户的字段 + +| 字段 | 说明 | +|------|------| +| `quotes[].final_total` | 最终展示价(USD) | +| `quotes[].carrier` | 承运商 | +| `quotes[].transit_days` | 时效 | +| `valid_until` | 报价有效期 | + +--- + +## 5. 调用步骤总结 + +### 业务步骤 + +1. 带鉴权头调 `POST /api/host/quote/candidates` +2. 取得 `host_session_id` +3. 让用户在候选地址中各选一条可用地址 +4. 带鉴权头调 `POST /api/host/quote/submit` +5. 直接拿最终报价并展示 + +### 第三方系统需要做的事 + +- 在服务端保存 Token,**不要**暴露在前端 +- 每次请求携带 `Authorization` + `X-Customer-Id: CUST_001` +- 第一步展示候选地址选择 UI +- 第二步显示 Loading(建议超时 ≥ 420 秒) +- 第二步返回后展示报价 + +### 第三方系统不需要做的事 + +- 不需要 `GET /api/quotes/{id}` 轮询 +- 不需要自己做 kg/cm 到 lb/in 的换算 + +--- + +## 6. 单位说明 + +接口支持: + +- 重量:`kg` / `lb` +- 尺寸:`cm` / `in` + +例如: + +```json +"weight": { "value": 500, "unit": "kg" }, +"dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" } +``` + +服务端会自动换算为 MotherShip 需要的英制单位。 + +--- + +## 7. Apifox 测试方法 + +### 环境变量 + +| 变量 | 值 | +|------|-----| +| `baseUrl` |https://if.dev.51track.vip | +| `token` | `demo-host-token` | +| `customerId` | `CUST_001` | + +### 第一步 + +- 方法:`POST` +- URL:`{{baseUrl}}/api/host/quote/candidates` +- Headers: + - `Authorization`:`Bearer {{token}}` + - `X-Customer-Id`:`{{customerId}}` + - `Content-Type`:`application/json` +- Body:JSON(可参考 `deploy/host-candidates-req.example.json`) + +### 第二步 + +- 方法:`POST` +- URL:`{{baseUrl}}/api/host/quote/submit` +- Headers:与第一步相同 +- Body:填入第一步返回的 `host_session_id` 和选中的完整候选对象 + +Apifox 请求超时建议:`420000` ms(420 秒) + +### cURL 示例(第一步) + +```bash +curl -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 +``` + +--- + +## 8. 常见错误 + +| code | HTTP | 说明 | 处理方式 | +|------|------|------|----------| +| `UNAUTHORIZED` | 401 | 缺少或无效 Token | 检查 `Authorization`、`X-Customer-Id` | +| `VALIDATION_FAILED` | 400 | 参数不合法 | 检查 JSON、字段名、单位值 | +| `SESSION_EXPIRED` | 404 | 会话过期 | 重新调第一步 | +| `ADDRESS_SESSION_MISMATCH` | 400 | 第二步候选与第一步不一致 | 使用第一步返回的完整候选对象 | +| `ADDRESS_NOT_SELECTABLE` | 400 | 选了不可用候选 | 改用 `selectable: true` 的候选 | +| `QUOTE_TIMEOUT` | 504 | 服务端等待超时 | 稍后重试 | +| `RATE_LIMITED` | 429 | 请求过于频繁 | 降低调用频率 | + +--- + +## 9. 联系与支持 + +- Token 失效或需单独开通生产 Token,请联系我方运维 +- `X-Customer-Id` 当前固定为 `CUST_001`,须与发放的 Token 一致 diff --git a/app/admin/alerts/page.tsx b/app/admin/alerts/page.tsx index ea3ffb8..ef4fc66 100644 --- a/app/admin/alerts/page.tsx +++ b/app/admin/alerts/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import Link from "next/link"; import { AdminLayout } from "@/components/layout/admin-layout"; import { PageHeader } from "@/components/layout/page-header"; import { Skeleton } from "@/components/ui/skeleton"; @@ -43,6 +42,25 @@ function toneClass(tone: "warning" | "error" | "neutral"): string { return "bg-slate-100 text-text-secondary"; } +function DetailRow({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + export default function AlertsPage() { const { token, user } = useAuth(); const [status, setStatus] = useState("loading"); @@ -96,7 +114,7 @@ export default function AlertsPage() {
@@ -161,6 +179,7 @@ export default function AlertsPage() { 时间 类型 + 客户 报价单号 状态 @@ -182,6 +201,9 @@ export default function AlertsPage() { {ALERT_TYPE_LABEL[row.alert_type]} + + {row.customer_id ?? "—"} + {row.quote_id ?? "—"} @@ -226,42 +248,67 @@ export default function AlertsPage() { onClick={() => !resolving && setDetail(null)} >
e.stopPropagation()} >

预警详情

-
-
-
预警编号
-
{detail.id}
-
-
-
报价单号
-
{detail.quote_id ?? "—"}
-
-
-
触发时间
-
{formatDateTime(detail.created_at)}
-
+
+ + + + +
-
-

- 详情数据 + +

+

根本原因

+

+ {detail.root_cause ?? "暂无详细说明"}

-
-                {JSON.stringify(detail.detail_json, null, 2)}
-              
+ +
+

地址与货物

+ + + + + +
+
setDetail(null)}> 关闭 - {(detail.alert_type === "RPA_FAILED" || - detail.alert_type === "RPA_CAPTCHA") && ( - - 跳转 RPA 暂停 - - )} {detail.status === "open" && ( ("loading"); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [keyword, setKeyword] = useState(""); + + const [createOpen, setCreateOpen] = useState(false); + const [createName, setCreateName] = useState(""); + const [createRemark, setCreateRemark] = useState(""); + const [createPassword, setCreatePassword] = useState("123456"); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(); + + const [passwordTarget, setPasswordTarget] = useState( + null, + ); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(); + const [savingPassword, setSavingPassword] = useState(false); + + const [revealedKey, setRevealedKey] = useState<{ + customer_id: string; + api_key: string; + } | null>(null); + + const [actionError, setActionError] = useState(null); + const [rotatingId, setRotatingId] = useState(null); + + const load = useCallback( + async (p: number, kw: string) => { + if (!token) return; + setStatus("loading"); + const res = await adminGetCustomers("", token, p, PAGE_SIZE, kw); + if (res.code !== 0) { + setStatus("error"); + return; + } + setRows(res.data.list); + setTotal(res.data.total); + setStatus(res.data.total === 0 ? "empty" : "success"); + }, + [token], + ); + + useEffect(() => { + void load(page, keyword); + }, [load, page, keyword]); + + const handleCreate = async () => { + if (!token) return; + const name = createName.trim(); + if (!name) { + setCreateError("请填写客户名称"); + return; + } + + setCreating(true); + setCreateError(undefined); + const res = await adminCreateCustomer("", token, { + name, + remark: createRemark.trim() || undefined, + embed_password: createPassword.trim() || undefined, + }); + setCreating(false); + + if (res.code !== 0) { + setCreateError(res.message); + return; + } + + setCreateOpen(false); + setCreateName(""); + setCreateRemark(""); + setCreatePassword("123456"); + setRevealedKey({ + customer_id: res.data.customer_id, + api_key: res.data.api_key, + }); + void load(1, keyword); + setPage(1); + }; + + const handleToggleStatus = async (row: HostCustomerRecord) => { + if (!token) return; + setActionError(null); + const nextStatus = row.status === "active" ? "disabled" : "active"; + const res = await adminUpdateCustomer("", token, row.customer_id, { + status: nextStatus, + }); + if (res.code !== 0) { + setActionError(res.message); + return; + } + setRows((list) => + list.map((item) => + item.customer_id === row.customer_id ? res.data : item, + ), + ); + }; + + const openPasswordDrawer = (row: HostCustomerRecord) => { + setPasswordTarget(row); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(undefined); + }; + + const handleSavePassword = async () => { + if (!token || !passwordTarget) return; + if (newPassword.length < 6) { + setPasswordError("密码至少 6 位"); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError("两次输入的密码不一致"); + return; + } + + setSavingPassword(true); + setPasswordError(undefined); + const res = await adminUpdateCustomer("", token, passwordTarget.customer_id, { + embed_password: newPassword, + }); + setSavingPassword(false); + + if (res.code !== 0) { + setPasswordError(res.message); + return; + } + + setRows((list) => + list.map((item) => + item.customer_id === passwordTarget.customer_id ? res.data : item, + ), + ); + setPasswordTarget(null); + }; + + const handleRotateKey = async (customerId: string) => { + if (!token) return; + setRotatingId(customerId); + setActionError(null); + const res = await adminRotateCustomerKey("", token, customerId); + setRotatingId(null); + if (res.code !== 0) { + setActionError(res.message); + return; + } + setRevealedKey({ customer_id: customerId, api_key: res.data.api_key }); + void load(page, keyword); + }; + + 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 ( + + setCreateOpen(true)}>新建客户 + } + /> + + {revealedKey && ( +
+ +
+

+ 客户 {revealedKey.customer_id} 的 API Key(仅显示一次,请立即复制保存) +

+

{revealedKey.api_key}

+

+ 调用方式:Authorization: Bearer <Key>;Header X-Customer-Id:{" "} + {revealedKey.customer_id} +

+ setRevealedKey(null)}> + 我已保存 + +
+
+
+ )} + + {actionError && ( +
+ {actionError} +
+ )} + +
+ { + setPage(1); + setKeyword(e.target.value); + }} + /> +
+ + {status === "loading" && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {status === "error" && ( + void load(page, keyword)}> + 重试 + + } + > + 加载客户列表失败 + + )} + + {status === "empty" && ( + + 暂无客户,点击「新建客户」创建第一个租户 + + )} + + {status === "success" && ( +
+
+ + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + + ))} + +
客户 ID名称状态Key 前缀演示密码备注创建时间操作
{row.customer_id}{row.name} + {row.status === "active" ? "启用" : "停用"} + + {activeKeyPrefix(row)}… + + {row.embed_password_set ? "已设置" : "未设置"} + + {row.remark || "—"} + + {formatDateTime(row.created_at)} + +
+ openPasswordDrawer(row)} + > + 设置密码 + + void handleToggleStatus(row)} + > + {row.status === "active" ? "停用" : "启用"} + + void handleRotateKey(row.customer_id)} + > + 轮换 Key + + + 加价配置 + +
+
+
+ +
+ + 共 {total} 条,第 {page} / {totalPages} 页 + +
+ setPage((p) => Math.max(1, p - 1))} + > + 上一页 + + = totalPages} + onClick={() => setPage((p) => p + 1)} + > + 下一页 + +
+
+
+ )} + + {createOpen && ( +
!creating && setCreateOpen(false)} + > +
e.stopPropagation()} + > +

新建客户

+

+ 创建后将自动生成 API Key;可设置宿主嵌入演示登录密码(默认 123456)。 +

+
+ setCreateName(e.target.value)} + /> + setCreatePassword(e.target.value)} + /> + setCreateRemark(e.target.value)} + /> +
+
+ setCreateOpen(false)} + > + 取消 + + void handleCreate()}> + 创建并生成 Key + +
+
+
+ )} + + {passwordTarget && ( +
!savingPassword && setPasswordTarget(null)} + > +
e.stopPropagation()} + > +

设置嵌入演示密码

+

+ 客户 {passwordTarget.customer_id}({passwordTarget.name} + )在 /embed-demo 登录时使用此密码 +

+
{ + e.preventDefault(); + void handleSavePassword(); + }} + > + setNewPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> +
+ setPasswordTarget(null)} + > + 取消 + + + 保存 + +
+ +
+
+ )} + + ); +} diff --git a/app/admin/markup/page.tsx b/app/admin/markup/page.tsx index c5d7047..67dd59c 100644 --- a/app/admin/markup/page.tsx +++ b/app/admin/markup/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { AdminLayout } from "@/components/layout/admin-layout"; import { PageHeader } from "@/components/layout/page-header"; import { Skeleton } from "@/components/ui/skeleton"; @@ -28,13 +29,16 @@ function formatMarkupSummary(row: MarkupConfig): string { return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%"; } -export default function AdminMarkupPage() { +function AdminMarkupPageContent() { const { token, user } = useAuth(); + const searchParams = useSearchParams(); const [status, setStatus] = useState("loading"); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [keyword, setKeyword] = useState(""); + const [keyword, setKeyword] = useState( + () => searchParams.get("keyword") ?? "", + ); const [editing, setEditing] = useState(null); const [markupType, setMarkupType] = useState("percent"); @@ -244,7 +248,7 @@ export default function AdminMarkupPage() { 编辑加价 · {editing.customer_id}

- 百分比仅对运费加价(上限 30%);固定金额为每档报价叠加固定金额。 + 百分比仅对运费加价(上限 30%,步长 0.01%);固定金额为每档报价叠加固定金额。

); } + +export default function AdminMarkupPage() { + return ( + + + + + } + > + + + ); +} diff --git a/app/admin/queues/page.tsx b/app/admin/queues/page.tsx index 1031482..bf76f6a 100644 --- a/app/admin/queues/page.tsx +++ b/app/admin/queues/page.tsx @@ -3,117 +3,194 @@ import { useCallback, useEffect, useState } from "react"; import { AdminLayout } from "@/components/layout/admin-layout"; import { PageHeader } from "@/components/layout/page-header"; -import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { ErrorBanner } from "@/components/ui/error-banner"; import { SecondaryButton } from "@/components/ui/primary-button"; -import { adminGetQueueStats } from "@/lib/frontend/api-client"; +import { SelectField } from "@/components/ui/select-field"; +import { Card } from "@/components/ui/card"; +import { formatDateTime } from "@/lib/frontend/format"; +import { adminGetQueryLogs } from "@/lib/frontend/api-client"; import { useAuth } from "@/hooks/use-auth"; -import type { DataPageStatus, QueueStats } from "@/lib/frontend/types"; +import type { + DataPageStatus, + QuoteQueryLogRecord, + QuoteQueryOutcome, +} from "@/lib/frontend/types"; + +const PAGE_SIZE = 20; -const BULL_BOARD_PATH = "/api/admin/queues"; +const OUTCOME_OPTIONS = [ + { value: "", label: "全部结果" }, + { value: "success", label: "成功" }, + { value: "failed", label: "失败" }, + { value: "stale", label: "降级缓存" }, + { value: "processing", label: "进行中" }, +]; + +const OUTCOME_LABEL: Record = { + success: "成功", + failed: "失败", + stale: "降级缓存", + processing: "进行中", +}; + +function outcomeClass(outcome: QuoteQueryOutcome): string { + if (outcome === "success") return "text-success"; + if (outcome === "failed") return "text-error"; + if (outcome === "stale") return "text-warning"; + return "text-text-secondary"; +} -export default function QueuesAdminPage() { +export default function QueryLogsPage() { const { token } = useAuth(); const [status, setStatus] = useState("loading"); - const [stats, setStats] = useState(null); - const [boardReady, setBoardReady] = useState(false); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [outcomeFilter, setOutcomeFilter] = useState(""); - const load = useCallback(async () => { - if (!token) return; - setStatus("loading"); - const res = await adminGetQueueStats("", token); - if (res.code !== 0) { - setStatus("error"); - return; - } - setStats(res.data); - setStatus("success"); - }, [token]); + const load = useCallback( + async (p: number, outcome: string) => { + if (!token) return; + setStatus("loading"); + const res = await adminGetQueryLogs( + "", + token, + p, + PAGE_SIZE, + undefined, + outcome || undefined, + ); + if (res.code !== 0) { + setStatus("error"); + return; + } + setRows(res.data.list); + setTotal(res.data.total); + setStatus(res.data.total === 0 ? "empty" : "success"); + }, + [token], + ); useEffect(() => { - void load(); - const id = setInterval(() => void load(), 10_000); - return () => clearInterval(id); - }, [load]); + void load(page, outcomeFilter); + }, [load, page, outcomeFilter]); - useEffect(() => { - if (!token) { - setBoardReady(false); - return; - } - const encoded = encodeURIComponent(token); - document.cookie = `bull_board_auth=${encoded}; path=${BULL_BOARD_PATH}; max-age=3600; SameSite=Lax`; - setBoardReady(true); - return () => { - document.cookie = `bull_board_auth=; path=${BULL_BOARD_PATH}; max-age=0`; - setBoardReady(false); - }; - }, [token]); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); return ( + { + setPage(1); + setOutcomeFilter(e.target.value); + }} + /> +
+ } /> - {status === "loading" && } + {status === "loading" && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} {status === "error" && ( void load()}>重试} + action={ void load(page, outcomeFilter)}>重试} > - 加载队列统计失败 + 加载查价记录失败 )} + {status === "empty" && ( + +

暂无查价记录

+
+ )} - {status === "success" && stats && ( -
- -

队列深度

-

{stats.depth}

-
- -

等待中

-

{stats.waiting}

-
- -

执行中

-

{stats.active}

-
- -

延迟

-

0 ? "text-warning" : "" - }`} - > - {stats.delayed} -

-
- -

失败

-

0 ? "text-error" : "" - }`} - > - {stats.failed} -

-
+ {status === "success" && ( +
+
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
查询时间客户提货(联想)派送(联想)货物结果完成时间
+ {formatDateTime(row.created_at)} + {row.customer_id} + {row.pickup_selected} + + {row.delivery_selected} + {row.cargo_summary} + + {OUTCOME_LABEL[row.outcome]} + + {row.failure_reason && row.outcome !== "success" && ( +

+ {row.failure_reason} +

+ )} +
+ {row.completed_at ? formatDateTime(row.completed_at) : "—"} +
+
+
+ + 共 {total} 条(系统最多保留 1000 条) + +
+ setPage((p) => p - 1)} + > + 上一页 + + + {page} / {totalPages} + + = totalPages} + onClick={() => setPage((p) => p + 1)} + > + 下一页 + +
+
)} - - - {!boardReady && } - {boardReady && ( -