From ced49c80e4f822a53f9199a6e4f0c2a982039dab 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: Wed, 22 Jul 2026 10:34:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20MotherShip/Flock=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=80=81=E3=80=81=E5=B5=8C=E5=85=A5=20SSO=20?= =?UTF-8?q?=E4=B8=8E=E5=AF=B9=E6=8E=A5=E6=96=87=E6=A1=A3=EF=BC=8C=E4=BE=BF?= =?UTF-8?q?=E4=BA=8E=E5=9B=A2=E9=98=9F=E4=BB=8E=20Gitea=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=8B=89=E5=8F=96=E5=BC=80=E5=8F=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 71 +- Dockerfile | 2 + README.md | 67 + .../flock-logged-in-quote-form.test.ts | 390 +++ .../mothership-logged-in-details.test.ts | 117 + ...mothership-logged-in-shipment-form.test.ts | 74 + .../components/quote-form-chrome.test.ts | 28 + .../lib/address/street-normalize.test.ts | 58 + __tests__/lib/axel/candidates.test.ts | 37 + .../axel/session-logged-in-storage.test.ts | 28 + __tests__/lib/constants/flock-limits.test.ts | 41 + __tests__/lib/embed/sso-params.test.ts | 57 + __tests__/lib/flock/direct-quote-map.test.ts | 80 + .../lib/flock/env-random-account.test.ts | 115 + __tests__/lib/flock/exclusive-lock.test.ts | 46 + __tests__/lib/flock/fresh-account.test.ts | 77 + __tests__/lib/flock/har-analyze.test.ts | 73 + .../lib/flock/logged-in-rpa-options.test.ts | 72 + __tests__/lib/flock/option-compat.test.ts | 70 + __tests__/lib/flock/pickup-date.test.ts | 47 + __tests__/lib/flock/poll-timeout.test.ts | 29 + __tests__/lib/flock/qqdna-email-pool.test.ts | 27 + __tests__/lib/flock/resolve-account.test.ts | 44 + __tests__/lib/flock/rpa-progress.test.ts | 68 + .../lib/flock/saved-freight-presets.test.ts | 91 + .../lib/frontend/cargo-form-units.test.ts | 56 + .../mothership-logged-in-quote-body.test.ts | 205 ++ .../mothership/logged-in-constraints.test.ts | 33 + .../lib/mothership/option-compat.test.ts | 60 + .../mothership/portal-quote-messages.test.ts | 49 + .../mothership/weekday-date-picker.test.ts | 25 + __tests__/lib/priority1/display-price.test.ts | 39 + .../priority1/empty-simulator-form.test.ts | 15 + .../lib/rpa/direct-quote-fallback.test.ts | 34 + __tests__/lib/rpa/env.test.ts | 45 + .../lib/rpa/mothership-login-context.test.ts | 35 + __tests__/lib/rpa/page-goto.test.ts | 27 + .../modules/alert/alert-presentation.test.ts | 40 + .../modules/auth/master-password.test.ts | 36 + .../customer/provider-credentials.test.ts | 80 + .../modules/customer/token-crypto.test.ts | 10 + __tests__/modules/embed-demo/auth.test.ts | 26 + __tests__/modules/flock/quote-storage.test.ts | 53 + __tests__/modules/flock/validation.test.ts | 212 ++ .../priority1/quote-storage-markup.test.ts | 39 + .../quote/quote-error-messages.test.ts | 102 +- .../modules/quote/unit-converter.test.ts | 48 +- __tests__/modules/quote/validation.test.ts | 56 +- __tests__/modules/rpa/error-mapper.test.ts | 4 +- .../rpa/provider-login-message.test.ts | 20 + __tests__/scripts/batch-flock-stress.test.ts | 74 + .../scripts/diag-flock-logged-in-x20.test.ts | 26 + __tests__/scripts/record-flock.test.ts | 118 + __tests__/scripts/record-mothership.test.ts | 69 + .../workers/rpa/address-chip-visual.test.ts | 41 + __tests__/workers/rpa/address-commit.test.ts | 23 + .../workers/rpa/cargo-overlay-dismiss.test.ts | 44 + .../rpa/click-widget-quote-submit.test.ts | 72 + .../workers/rpa/flock-login-two-step.test.ts | 29 + .../flock/customer-login-force-dom.test.ts | 105 + .../workers/rpa/flock/logged-in-form.test.ts | 80 + __tests__/workers/rpa/flock/login.test.ts | 91 + .../workers/rpa/flock/page-alive.test.ts | 37 + .../workers/rpa/flock/page-state.test.ts | 10 + .../workers/rpa/flock/quote-extract.test.ts | 55 + __tests__/workers/rpa/flock/run-quote.test.ts | 70 + .../workers/rpa/flock/stall-watch.test.ts | 26 + .../workers/rpa/flock/worker-session.test.ts | 28 + .../rpa/mothership-logged-in-quote.test.ts | 115 + .../rpa/widget-truck-tab-quotes.test.ts | 55 + api调用文档/api对接文档.md | 981 ++++++++ .../api调用文档-第三方精简版.md | 130 + api调用文档/api调用文档.md | 388 ++- app/admin/customers/page.tsx | 335 ++- app/api/addresses/mothership-suggest/route.ts | 58 + .../provider-credentials/route.ts | 117 + app/api/auth/login/route.ts | 5 +- app/api/embed-demo/login/route.ts | 119 +- app/api/embed-demo/logout/route.ts | 11 +- app/api/embed-demo/me/route.ts | 16 + .../embed-demo/provider-credentials/route.ts | 126 + app/api/flock/quotes/route.ts | 69 + app/embed-demo/embed-demo-client.tsx | 149 ++ app/embed-demo/page.tsx | 73 +- components/embed/embed-demo-login.tsx | 101 +- components/embed/embedded-quote-widget.tsx | 237 +- components/embed/quote-provider-switch.tsx | 11 +- components/embed/quote-route-hub.tsx | 321 +++ .../flock/flock-logged-in-quote-form.tsx | 2174 +++++++++++++++++ components/flock/flock-quote-form.tsx | 527 ++++ components/flock/flock-quote-progress.tsx | 161 ++ components/flock/flock-quote-result.tsx | 112 + components/flock/flock-quote-widget.tsx | 207 ++ .../flock/flock-saved-freight-controls.tsx | 231 ++ .../mothership-address-suggest-field.tsx | 296 +++ .../mothership-logged-in-details-form.tsx | 1031 ++++++++ .../mothership-logged-in-quote-sidebar.tsx | 427 ++++ .../mothership-logged-in-shipment-form.tsx | 766 ++++++ .../mothership-weekday-date-picker.tsx | 156 ++ .../priority1-ftl-schedule-calendar.tsx | 3 +- .../priority1/priority1-ltl-quote-options.tsx | 17 +- .../priority1/priority1-quote-widget.tsx | 8 +- components/priority1/priority1-simulator.tsx | 67 +- components/quote/quote-form-chrome.tsx | 217 ++ components/quote/quote-form.tsx | 93 +- cursor_project_rules/domain-rules.mdc | 8 +- cursor_project_rules/global-rules.mdc | 13 +- cursor_project_rules/implementation-plan.mdc | 1200 ++++++++- deploy/.env.bt-host-mysql.example | 29 + deploy/RELEASE-NOTES.md | 446 +++- deploy/datagrip-manual-migrate-20260716.sql | 32 + deploy/docker-compose.bt-host-mysql.yml | 1 + deploy/pack-readme-template.md | 17 +- deploy/pack-server-upload.ps1 | 21 +- deploy/update-code-only-bt-host-mysql.sh | 155 ++ deploy/verify-upload-bt-host-mysql.sh | 77 + deploy/服务器更新说明.md | 376 +-- docs/rpa-flock-logged-in-sop.md | 94 + docs/rpa-mothership-logged-in-sop.md | 89 + docs/报价提速与稳定性-改动清单.md | 186 -- docs/查价系统-PRD.md | 116 +- docs/查价系统-技术设计.md | 126 +- hooks/use-quote-polling.ts | 6 +- lib/address/street-normalize.ts | 128 + lib/axel/candidates.ts | 109 +- lib/axel/quote-from-request.ts | 18 +- lib/axel/session.ts | 15 +- lib/constants/flock-limits.ts | 62 + lib/constants/mothership-ui-tiers.ts | 17 +- lib/embed/sso-params.ts | 96 + lib/flock/constants.ts | 10 + lib/flock/direct-quote.ts | 162 ++ lib/flock/env.ts | 264 ++ lib/flock/exclusive-lock.ts | 202 ++ lib/flock/fresh-account.ts | 210 ++ lib/flock/har-analyze.ts | 244 ++ lib/flock/logged-in-rpa-options.ts | 156 ++ lib/flock/login-context.ts | 57 + lib/flock/map-fulfillment-options.ts | 134 + lib/flock/option-compat.ts | 151 ++ lib/flock/pickup-date.ts | 79 + lib/flock/poll-timeout.ts | 15 + lib/flock/qqdna-email-pool.ts | 112 + lib/flock/quote-payload.ts | 100 + lib/flock/resolve-account.ts | 46 + lib/flock/rpa-progress.ts | 202 ++ lib/flock/saved-freight-presets.ts | 145 ++ lib/flock/ui-labels.ts | 63 + lib/frontend/api-client.ts | 182 +- lib/frontend/cargo-form-units.ts | 112 + .../mothership-logged-in-quote-body.ts | 128 + lib/frontend/types.ts | 81 + lib/mothership/logged-in-constraints.ts | 68 + lib/mothership/option-compat.ts | 163 ++ lib/mothership/portal-quote-messages.ts | 132 + lib/mothership/weekday-date-picker.ts | 102 + lib/priority1/calendar-grid.ts | 7 +- lib/priority1/display-price.ts | 13 + lib/priority1/empty-simulator-form.ts | 34 + lib/priority1/ui-labels.ts | 10 +- lib/rpa/browser-context.ts | 40 + lib/rpa/browser-launch.ts | 3 + lib/rpa/direct-quote-fallback.ts | 26 + lib/rpa/ensure-storage-state.ts | 20 + lib/rpa/env.ts | 147 +- lib/rpa/flock-selector-specs.ts | 86 + lib/rpa/mothership-login-context.ts | 55 + lib/rpa/page-goto.ts | 94 + lib/rpa/selector-specs.ts | 2 + lib/rpa/storage-state.ts | 4 +- middleware.ts | 1 + modules/address/validation.ts | 9 + modules/alert/alert-presentation.ts | 59 +- modules/auth/master-password.ts | 20 + modules/customer/customer-service.ts | 29 +- modules/customer/provider-credentials.ts | 246 ++ modules/customer/token-crypto.ts | 50 +- modules/customer/types.ts | 1 + modules/embed-demo/auth.ts | 46 +- modules/flock/hash.ts | 9 + modules/flock/orchestrator.ts | 135 + modules/flock/quote-storage.ts | 72 + modules/flock/rpa-queue.ts | 21 + modules/flock/validation.ts | 381 +++ modules/host/types.ts | 7 +- modules/host/validation.ts | 53 +- modules/providers/quote-provider.ts | 51 + modules/quote/cargo-hash.ts | 57 +- modules/quote/fallback-orchestrator.ts | 5 +- modules/quote/orchestrator.ts | 36 +- modules/quote/quote-completeness.ts | 10 +- modules/quote/quote-error-messages.ts | 314 ++- modules/quote/quote-serializer.ts | 67 +- modules/quote/types.ts | 74 +- modules/quote/unit-converter.ts | 97 + modules/quote/validation.ts | 184 +- modules/rpa/error-mapper.ts | 9 +- modules/rpa/errors.ts | 2 + modules/rpa/provider-login-message.ts | 11 + modules/rpa/quote-mapper.ts | 6 + package.json | 15 + .../migration.sql | 3 + .../migration.sql | 16 + prisma/schema.prisma | 18 + scripts/_dump-flock-pickup-types.ts | 93 + scripts/analyze-flock-har.ts | 80 + scripts/batch-flock-direct-10.ts | 216 ++ scripts/batch-flock-stress.ts | 597 +++++ scripts/capture-flock-network-har.ts | 367 +++ scripts/capture-flock-quote-body.ts | 176 ++ scripts/dev/config.ps1 | 2 +- scripts/dev/ensure-infra.ps1 | 109 +- scripts/diag-flock-logged-in-x20.ts | 609 +++++ scripts/diag-mothership-albany-sf-x10.ts | 222 ++ scripts/diag-mothership-logged-in-la-sf.ts | 136 ++ scripts/diag-mothership-logged-in-multi.ts | 127 + scripts/diag-mothership-logged-in-x20.ts | 509 ++++ scripts/diag-mothership-rules-x10.ts | 300 +++ scripts/diag-mothership-timing-5cases.ts | 318 +++ .../diag-mothership-timing-brea-fairhope.ts | 122 + scripts/finalize-flock-logged-in-recording.ts | 76 + ...finalize-mothership-logged-in-recording.ts | 75 + .../flock-logged-in-quote-baseline.js | 45 + .../mothership-logged-in-login-baseline.js | 28 + scripts/lib/pick-mothership-candidate.ts | 42 +- scripts/probe-flock-logged-in-form-matrix.ts | 518 ++++ scripts/probe-flock-login.ts | 67 + scripts/probe-flock-rpa.ts | 51 + scripts/probe-mothership-open-ship.ts | 103 + scripts/probe-ms-logged-in-salem-sancarlos.ts | 116 + scripts/probe-suggestion-dom-compare.ts | 255 ++ scripts/probe-widget-la-pomona-quote.ts | 210 ++ scripts/record-flock.ps1 | 31 + scripts/record-flock.sh | 90 + scripts/record-flock.ts | 358 +++ scripts/record-mothership.ps1 | 16 +- scripts/record-mothership.ts | 177 +- scripts/smoke-flock-embed-direct.ts | 103 + scripts/visual-flock-human-assist.ts | 124 + scripts/visual-flock-logged-in-replay.ts | 96 + .../mothership-address-adapter.ts | 257 +- workers/rpa/address-chip-visual.ts | 118 + workers/rpa/address-commit.ts | 178 +- workers/rpa/address-side.ts | 21 +- workers/rpa/address-suggestion-mouse.ts | 319 +++ workers/rpa/address-suggestions.ts | 49 +- workers/rpa/cargo-dimensions.ts | 33 + workers/rpa/cargo-lock.ts | 56 +- workers/rpa/fill-cargo-in-drawer.ts | 39 +- workers/rpa/flock-freight.ts | 10 + workers/rpa/flock-job-handler.ts | 170 ++ workers/rpa/flock/demo-input.ts | 77 + workers/rpa/flock/form-interactions.ts | 293 +++ workers/rpa/flock/form-logic-map.ts | 45 + workers/rpa/flock/logged-in-form-verify.ts | 259 ++ workers/rpa/flock/logged-in-form.ts | 1324 ++++++++++ workers/rpa/flock/login.ts | 266 ++ workers/rpa/flock/page-alive.ts | 44 + workers/rpa/flock/page-state.ts | 69 + workers/rpa/flock/quote-extract.ts | 229 ++ workers/rpa/flock/register-account.ts | 194 ++ workers/rpa/flock/run-quote.ts | 221 ++ workers/rpa/flock/stall-watch.ts | 193 ++ workers/rpa/flock/types.ts | 80 + workers/rpa/flock/visual-chain.ts | 422 ++++ workers/rpa/flock/worker-session.ts | 298 +++ workers/rpa/index.ts | 28 + workers/rpa/inject/dual-address-patch-init.ts | 2 +- workers/rpa/job-handler.ts | 50 +- workers/rpa/kernel/browser-scripts.ts | 102 +- workers/rpa/kernel/orchestrator.ts | 5 +- workers/rpa/kernel/state-engine.ts | 2 + workers/rpa/kernel/steps/address-step.ts | 106 +- workers/rpa/kernel/steps/cargo-step.ts | 5 +- workers/rpa/mothership-logged-in-quote.ts | 1918 +++++++++++++++ .../rpa/mothership-styled-suggestion-click.ts | 4 +- workers/rpa/mothership.ts | 28 +- workers/rpa/priority1/run-quote.ts | 9 +- workers/rpa/priority1/visual-chain.ts | 22 +- workers/rpa/queue.ts | 9 + .../click-widget-quote-submit.ts | 129 + workers/rpa/quote-capture/payload-decode.ts | 2 + workers/rpa/quote-capture/pipeline.ts | 14 +- .../quote-capture/post-submit-navigation.ts | 15 + .../quote-capture/quote-network-capture.ts | 14 + .../quote-capture/quote-schema-validator.ts | 19 +- workers/rpa/quote-capture/quote-strategy.ts | 48 +- .../rpa/quote-capture/thin-browser-driver.ts | 64 +- workers/rpa/quote-capture/types.ts | 2 + .../quote-capture/widget-truck-tab-quotes.ts | 478 ++++ workers/rpa/session-manager.ts | 47 +- 291 files changed, 36885 insertions(+), 1543 deletions(-) create mode 100644 README.md create mode 100644 __tests__/components/flock-logged-in-quote-form.test.ts create mode 100644 __tests__/components/mothership-logged-in-details.test.ts create mode 100644 __tests__/components/mothership-logged-in-shipment-form.test.ts create mode 100644 __tests__/components/quote-form-chrome.test.ts create mode 100644 __tests__/lib/address/street-normalize.test.ts create mode 100644 __tests__/lib/axel/session-logged-in-storage.test.ts create mode 100644 __tests__/lib/constants/flock-limits.test.ts create mode 100644 __tests__/lib/embed/sso-params.test.ts create mode 100644 __tests__/lib/flock/direct-quote-map.test.ts create mode 100644 __tests__/lib/flock/env-random-account.test.ts create mode 100644 __tests__/lib/flock/exclusive-lock.test.ts create mode 100644 __tests__/lib/flock/fresh-account.test.ts create mode 100644 __tests__/lib/flock/har-analyze.test.ts create mode 100644 __tests__/lib/flock/logged-in-rpa-options.test.ts create mode 100644 __tests__/lib/flock/option-compat.test.ts create mode 100644 __tests__/lib/flock/pickup-date.test.ts create mode 100644 __tests__/lib/flock/poll-timeout.test.ts create mode 100644 __tests__/lib/flock/qqdna-email-pool.test.ts create mode 100644 __tests__/lib/flock/resolve-account.test.ts create mode 100644 __tests__/lib/flock/rpa-progress.test.ts create mode 100644 __tests__/lib/flock/saved-freight-presets.test.ts create mode 100644 __tests__/lib/frontend/cargo-form-units.test.ts create mode 100644 __tests__/lib/frontend/mothership-logged-in-quote-body.test.ts create mode 100644 __tests__/lib/mothership/logged-in-constraints.test.ts create mode 100644 __tests__/lib/mothership/option-compat.test.ts create mode 100644 __tests__/lib/mothership/portal-quote-messages.test.ts create mode 100644 __tests__/lib/mothership/weekday-date-picker.test.ts create mode 100644 __tests__/lib/priority1/display-price.test.ts create mode 100644 __tests__/lib/priority1/empty-simulator-form.test.ts create mode 100644 __tests__/lib/rpa/direct-quote-fallback.test.ts create mode 100644 __tests__/lib/rpa/mothership-login-context.test.ts create mode 100644 __tests__/lib/rpa/page-goto.test.ts create mode 100644 __tests__/modules/auth/master-password.test.ts create mode 100644 __tests__/modules/customer/provider-credentials.test.ts create mode 100644 __tests__/modules/flock/quote-storage.test.ts create mode 100644 __tests__/modules/flock/validation.test.ts create mode 100644 __tests__/modules/priority1/quote-storage-markup.test.ts create mode 100644 __tests__/modules/rpa/provider-login-message.test.ts create mode 100644 __tests__/scripts/batch-flock-stress.test.ts create mode 100644 __tests__/scripts/diag-flock-logged-in-x20.test.ts create mode 100644 __tests__/scripts/record-flock.test.ts create mode 100644 __tests__/workers/rpa/address-chip-visual.test.ts create mode 100644 __tests__/workers/rpa/cargo-overlay-dismiss.test.ts create mode 100644 __tests__/workers/rpa/click-widget-quote-submit.test.ts create mode 100644 __tests__/workers/rpa/flock-login-two-step.test.ts create mode 100644 __tests__/workers/rpa/flock/customer-login-force-dom.test.ts create mode 100644 __tests__/workers/rpa/flock/logged-in-form.test.ts create mode 100644 __tests__/workers/rpa/flock/login.test.ts create mode 100644 __tests__/workers/rpa/flock/page-alive.test.ts create mode 100644 __tests__/workers/rpa/flock/page-state.test.ts create mode 100644 __tests__/workers/rpa/flock/quote-extract.test.ts create mode 100644 __tests__/workers/rpa/flock/run-quote.test.ts create mode 100644 __tests__/workers/rpa/flock/stall-watch.test.ts create mode 100644 __tests__/workers/rpa/flock/worker-session.test.ts create mode 100644 __tests__/workers/rpa/mothership-logged-in-quote.test.ts create mode 100644 __tests__/workers/rpa/widget-truck-tab-quotes.test.ts create mode 100644 api调用文档/api对接文档.md create mode 100644 api调用文档/api调用文档-第三方精简版.md create mode 100644 app/api/addresses/mothership-suggest/route.ts create mode 100644 app/api/admin/customers/[customer_id]/provider-credentials/route.ts create mode 100644 app/api/embed-demo/provider-credentials/route.ts create mode 100644 app/api/flock/quotes/route.ts create mode 100644 app/embed-demo/embed-demo-client.tsx create mode 100644 components/embed/quote-route-hub.tsx create mode 100644 components/flock/flock-logged-in-quote-form.tsx create mode 100644 components/flock/flock-quote-form.tsx create mode 100644 components/flock/flock-quote-progress.tsx create mode 100644 components/flock/flock-quote-result.tsx create mode 100644 components/flock/flock-quote-widget.tsx create mode 100644 components/flock/flock-saved-freight-controls.tsx create mode 100644 components/mothership/mothership-address-suggest-field.tsx create mode 100644 components/mothership/mothership-logged-in-details-form.tsx create mode 100644 components/mothership/mothership-logged-in-quote-sidebar.tsx create mode 100644 components/mothership/mothership-logged-in-shipment-form.tsx create mode 100644 components/mothership/mothership-weekday-date-picker.tsx create mode 100644 components/quote/quote-form-chrome.tsx create mode 100644 deploy/datagrip-manual-migrate-20260716.sql create mode 100644 deploy/update-code-only-bt-host-mysql.sh create mode 100644 deploy/verify-upload-bt-host-mysql.sh create mode 100644 docs/rpa-flock-logged-in-sop.md create mode 100644 docs/rpa-mothership-logged-in-sop.md delete mode 100644 docs/报价提速与稳定性-改动清单.md create mode 100644 lib/address/street-normalize.ts create mode 100644 lib/constants/flock-limits.ts create mode 100644 lib/embed/sso-params.ts create mode 100644 lib/flock/constants.ts create mode 100644 lib/flock/direct-quote.ts create mode 100644 lib/flock/env.ts create mode 100644 lib/flock/exclusive-lock.ts create mode 100644 lib/flock/fresh-account.ts create mode 100644 lib/flock/har-analyze.ts create mode 100644 lib/flock/logged-in-rpa-options.ts create mode 100644 lib/flock/login-context.ts create mode 100644 lib/flock/map-fulfillment-options.ts create mode 100644 lib/flock/option-compat.ts create mode 100644 lib/flock/pickup-date.ts create mode 100644 lib/flock/poll-timeout.ts create mode 100644 lib/flock/qqdna-email-pool.ts create mode 100644 lib/flock/quote-payload.ts create mode 100644 lib/flock/resolve-account.ts create mode 100644 lib/flock/rpa-progress.ts create mode 100644 lib/flock/saved-freight-presets.ts create mode 100644 lib/flock/ui-labels.ts create mode 100644 lib/frontend/cargo-form-units.ts create mode 100644 lib/frontend/mothership-logged-in-quote-body.ts create mode 100644 lib/mothership/logged-in-constraints.ts create mode 100644 lib/mothership/option-compat.ts create mode 100644 lib/mothership/portal-quote-messages.ts create mode 100644 lib/mothership/weekday-date-picker.ts create mode 100644 lib/priority1/display-price.ts create mode 100644 lib/priority1/empty-simulator-form.ts create mode 100644 lib/rpa/browser-context.ts create mode 100644 lib/rpa/direct-quote-fallback.ts create mode 100644 lib/rpa/flock-selector-specs.ts create mode 100644 lib/rpa/mothership-login-context.ts create mode 100644 lib/rpa/page-goto.ts create mode 100644 modules/auth/master-password.ts create mode 100644 modules/customer/provider-credentials.ts create mode 100644 modules/flock/hash.ts create mode 100644 modules/flock/orchestrator.ts create mode 100644 modules/flock/quote-storage.ts create mode 100644 modules/flock/rpa-queue.ts create mode 100644 modules/flock/validation.ts create mode 100644 modules/rpa/provider-login-message.ts create mode 100644 prisma/migrations/20260709175000_service_api_key_ciphertext/migration.sql create mode 100644 prisma/migrations/20260715094000_customer_provider_credential/migration.sql create mode 100644 scripts/_dump-flock-pickup-types.ts create mode 100644 scripts/analyze-flock-har.ts create mode 100644 scripts/batch-flock-direct-10.ts create mode 100644 scripts/batch-flock-stress.ts create mode 100644 scripts/capture-flock-network-har.ts create mode 100644 scripts/capture-flock-quote-body.ts create mode 100644 scripts/diag-flock-logged-in-x20.ts create mode 100644 scripts/diag-mothership-albany-sf-x10.ts create mode 100644 scripts/diag-mothership-logged-in-la-sf.ts create mode 100644 scripts/diag-mothership-logged-in-multi.ts create mode 100644 scripts/diag-mothership-logged-in-x20.ts create mode 100644 scripts/diag-mothership-rules-x10.ts create mode 100644 scripts/diag-mothership-timing-5cases.ts create mode 100644 scripts/diag-mothership-timing-brea-fairhope.ts create mode 100644 scripts/finalize-flock-logged-in-recording.ts create mode 100644 scripts/finalize-mothership-logged-in-recording.ts create mode 100644 scripts/fixtures/flock-logged-in-quote-baseline.js create mode 100644 scripts/fixtures/mothership-logged-in-login-baseline.js create mode 100644 scripts/probe-flock-logged-in-form-matrix.ts create mode 100644 scripts/probe-flock-login.ts create mode 100644 scripts/probe-flock-rpa.ts create mode 100644 scripts/probe-mothership-open-ship.ts create mode 100644 scripts/probe-ms-logged-in-salem-sancarlos.ts create mode 100644 scripts/probe-suggestion-dom-compare.ts create mode 100644 scripts/probe-widget-la-pomona-quote.ts create mode 100644 scripts/record-flock.ps1 create mode 100644 scripts/record-flock.sh create mode 100644 scripts/record-flock.ts create mode 100644 scripts/smoke-flock-embed-direct.ts create mode 100644 scripts/visual-flock-human-assist.ts create mode 100644 scripts/visual-flock-logged-in-replay.ts create mode 100644 workers/rpa/address-chip-visual.ts create mode 100644 workers/rpa/address-suggestion-mouse.ts create mode 100644 workers/rpa/cargo-dimensions.ts create mode 100644 workers/rpa/flock-freight.ts create mode 100644 workers/rpa/flock-job-handler.ts create mode 100644 workers/rpa/flock/demo-input.ts create mode 100644 workers/rpa/flock/form-interactions.ts create mode 100644 workers/rpa/flock/form-logic-map.ts create mode 100644 workers/rpa/flock/logged-in-form-verify.ts create mode 100644 workers/rpa/flock/logged-in-form.ts create mode 100644 workers/rpa/flock/login.ts create mode 100644 workers/rpa/flock/page-alive.ts create mode 100644 workers/rpa/flock/page-state.ts create mode 100644 workers/rpa/flock/quote-extract.ts create mode 100644 workers/rpa/flock/register-account.ts create mode 100644 workers/rpa/flock/run-quote.ts create mode 100644 workers/rpa/flock/stall-watch.ts create mode 100644 workers/rpa/flock/types.ts create mode 100644 workers/rpa/flock/visual-chain.ts create mode 100644 workers/rpa/flock/worker-session.ts create mode 100644 workers/rpa/mothership-logged-in-quote.ts create mode 100644 workers/rpa/quote-capture/click-widget-quote-submit.ts create mode 100644 workers/rpa/quote-capture/widget-truck-tab-quotes.ts diff --git a/.env.example b/.env.example index 4fcd8e6..2d0aaee 100644 --- a/.env.example +++ b/.env.example @@ -26,9 +26,59 @@ 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 +# 登录后查价录制专用 storage(与匿名隔离;npm run record:mothership -- --logged-in) +# RPA_LOGGED_IN_STORAGE_STATE_PATH=.rpa/mothership-logged-in-storage.json # 缺会话文件时 headless 自动访问 MOTHERSHIP_QUOTE_URLS 获取 cookies(默认 true;设 false 则须手动 record:mothership) # RPA_AUTO_BOOTSTRAP_STORAGE=true RPA_MOCK_MODE=false +# Flock Freight(PRD §14 / 技术设计 §7.6;默认关闭,Mock 或显式 true 可联调) +# Flock Freight(本地开发默认开启;生产请显式 FLOCK_RPA_ENABLED=true) +FLOCK_RPA_ENABLED=true +FLOCK_QUOTE_URL=https://app.flockfreight.com/get-a-quote +# direct | dom | direct_then_dom(默认 Direct 优先,失败回退 DOM 填表) +FLOCK_QUOTE_MODE=direct_then_dom +FLOCK_STORAGE_STATE_PATH=.rpa/flock-storage.json +# 登录后查价录制产物(与匿名 flock-storage.json 隔离;npm run record:flock -- --logged-in) +# FLOCK_LOGGED_IN_STORAGE_STATE_PATH=.rpa/flock-logged-in-storage.json +# DOM/RPA 保障路径:账号密码登录(配置后禁用 random_qq / 无痕注册;勿提交真实密码) +# FLOCK_LOGIN_EMAIL= +# FLOCK_LOGIN_PASSWORD= +# FLOCK_LOGIN_URL=https://app.flockfreight.com/login +# FLOCK_SELECTOR_LOGIN_EMAIL=input[type="email"], input[name="email"] +# FLOCK_SELECTOR_LOGIN_PASSWORD=input[type="password"], input[name="password"] +# FLOCK_SELECTOR_LOGIN_SUBMIT=button[type="submit"] +# 默认加载 .rpa/flock-storage.json(登录成功后回写;可跳过重复登录) +# FLOCK_LOAD_STORAGE_STATE=false +# 登录模式默认写回 storage;显式 false 可关闭 +# FLOCK_SAVE_STORAGE_AFTER_QUOTE=false +# 卡顿 ≥20s 自动截图目录 .rpa/flock-stalls/(识别是否报价上限) +# FLOCK_STALL_MS=20000 +# Flock selector(npm run record:flock 产出;未配时使用录制内置回退) +# FLOCK_SELECTOR_PICKUP_ZIP=role=textbox[name="Pickup ZIP Code"] +# FLOCK_SELECTOR_DELIVERY_ZIP=role=textbox[name="Delivery ZIP Code"] +# FLOCK_SELECTOR_PALLET_COUNT=role=textbox[name="Number of pallets"] +# FLOCK_SELECTOR_TOTAL_WEIGHT=role=textbox[name="Total shipment weight (lbs)"] +# FLOCK_SELECTOR_NEXT=[data-testid="anonymous_marketing_lead_quote_request_page__quote_request__submit_button"] +# FLOCK_TEST_EMAIL=3457189488@qq.com +# FLOCK_TEST_PHONE=7536407420 +# 无登录凭据时的探针:无痕 + random_qq(有 FLOCK_LOGIN_* 时自动关闭) +# FLOCK_FRESH_SESSION_PER_QUOTE=true +# FLOCK_RANDOM_ACCOUNT_PER_QUOTE=true +# FLOCK_EMAIL_MODE=random_qq +# FLOCK_QUOTES_PER_EMAIL=3 +FLOCK_WORKER_REUSE_SESSION=false +FLOCK_MIN_QUOTES=1 +FLOCK_QUOTE_WAIT_MS=20000 +FLOCK_FORM_WAIT_MS=20000 +FLOCK_FIELD_PAUSE_MS=1000 +FLOCK_SESSION_MAX_ROTATIONS=3 +FLOCK_POLL_TIMEOUT_MS=210000 +NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS=210000 +# qq.qqdna.com 号码库:FLOCK_EMAIL_MODE=qqdna +# Gmail/Outlook 别名基址(可选): +# FLOCK_EMAIL_ALIAS_BASE=yourname@gmail.com +# FLOCK_EMAIL_POOL=aa@gmail.com,bb@outlook.com +# FLOCK_PHONE_POOL=7536407420,18692104118 # 地址 commit 隔离:real=真实 MotherShip;mock=仅替代 commit(prove-rpa-chain 下游验证用) RPA_ADDRESS_MODE=real # 报价模式:direct=直连 axel API(默认,embed-demo 推荐)| widget=Playwright 页面交互 @@ -39,17 +89,18 @@ RPA_QUOTE_MODE=direct RPA_PLACE_FIX_MODE=event # true 时注入 P0 实例拓扑/监听器指纹 + P2 fetch 劫持,fix-place 各阶段落盘 .rpa/place-diagnostic/ RPA_PLACE_DIAG=false -# 有头调试:RPA_HEADED=true 等价 RPA_HEADLESS=false +# 有头调试:RPA_HEADED=true 强制弹窗(MotherShip / Flock 共用);或 RPA_HEADLESS=false RPA_HEADLESS=true # RPA_HEADED=true +# RPA_SLOW_MO_MS=200 # 货物 drawer 填写完成后 page.pause()(仅人工验收时开启) # RPA_CARGO_DRAWER_PAUSE=true RPA_BROWSER_LOCALE=en-US RPA_USE_PATCHRIGHT=false # 反爬:playwright-extra stealth(与 patchright 二选一;部分页面会破坏 widget,默认 false) RPA_USE_STEALTH=false -# 使用本机 Chrome/Edge,降低 HeadlessChrome 指纹(推荐 chrome) -# RPA_BROWSER_CHANNEL=chrome +# 使用本机 Chrome/Edge,降低 HeadlessChrome 指纹(Flock 推荐 chrome) +RPA_BROWSER_CHANNEL=chrome # 真人入口流:先打开首页再点 Get Quote(携带 Referer/Cookie) # RPA_HUMAN_ENTRY_FLOW=true # RPA_QUOTE_WARMUP_URL=https://www.mothership.com/ @@ -102,9 +153,21 @@ RPA_SELECTOR_TRANSIT=text=business days # RPA_SELECTOR_WIDTH=... # RPA_SELECTOR_HEIGHT=... -# 可选:仅当入口跳转 login 时使用 +# 可选:仅当入口跳转 login / 客户账密登录后查价时使用 MOTHERSHIP_EMAIL= MOTHERSHIP_PASSWORD= +# session-manager loginIfNeeded 使用(2026-07-15 登录页实探) +# RPA_SELECTOR_LOGIN_EMAIL=role=textbox[name="Email address"] +# RPA_SELECTOR_LOGIN_PASSWORD=role=textbox[name="Password"] +# RPA_SELECTOR_LOGIN_SUBMIT=role=button[name="Sign in"] +# ── MotherShip 登录后查价(Step 12.61;npm run record:mothership -- --logged-in 产出后回填)── +# 详见 docs/rpa-mothership-logged-in-sop.md;勿手猜 selector +# RPA_SELECTOR_MS_LOGGED_IN_ENTRY= +# RPA_SELECTOR_MS_LOGGED_IN_FORM_MARKER= +# RPA_SELECTOR_MS_LOGGED_IN_PICKUP= +# RPA_SELECTOR_MS_LOGGED_IN_DELIVERY= +# RPA_SELECTOR_MS_LOGGED_IN_CARGO= +# RPA_SELECTOR_MS_LOGGED_IN_SUBMIT= # 已废弃:请改用 MOTHERSHIP_QUOTE_URLS # MOTHERSHIP_QUOTE_URL= diff --git a/Dockerfile b/Dockerfile index 1c4313e..9595e3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,10 @@ 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 +ARG NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS=210000 ENV NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=$NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN ENV NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=$NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID +ENV NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS=$NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS RUN npx prisma generate ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bc7829 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# 查价中台(chajia) + +MotherShip / Flock Freight 卡派查价中台(Next.js + Prisma + Playwright RPA)。 + +远程仓库(团队拉取): + +## 克隆与本地启动 + +```bash +git clone https://git.shipro.ltd/nanchuanbeizi/chajia.git +cd chajia +npm ci +cp .env.example .env +# 按需编辑 .env(JWT_SECRET、数据库、HOST_SERVICE_TOKENS 等) +``` + +本地基础设施与开发(Windows PowerShell 示例): + +```powershell +# 详见 scripts/dev、deploy/README.md +npm run db:deploy # 或 db:migrate +npm run dev +# 另开终端:npm run worker:rpa +``` + +生产 / BT 主机部署见 `deploy/README.md`、`deploy/RELEASE-NOTES.md`。 + +## 必读文档 + +| 文档 | 说明 | +|------|------| +| `api调用文档/api对接文档.md` | 第三方 OpenAPI + **iframe 嵌入直登(§2.5)** | +| `api调用文档/api调用文档.md` | 完整 API / 管理端说明 | +| `deploy/README.md` | 部署与环境变量 | +| `docs/` | PRD、技术设计、RPA SOP | + +## 仓库已包含(可开发) + +- 应用:`app/`、`components/`、`modules/`、`lib/`、`workers/` +- 数据库:`prisma/schema.prisma` + `prisma/migrations/` +- 依赖锁:`package-lock.json` +- 部署:`Dockerfile*`、`deploy/` +- 单测:`__tests__/` +- 环境模板:`.env.example`、`deploy/.env.*.example` + +## 不会进库(本地自行生成) + +- `.env` / `.env.local`(密钥,勿提交) +- `node_modules/`、`.next/` +- `.rpa/`(Playwright storage / 截图) +- `.dev/`、`上传到服务器/`、`*.zip` + +## 嵌入页(CC iframe) + +```text +https://<域名>/embed-demo?login_type=api_key&api_key=&embed=1 +``` + +详见对接文档 §2.5。 + +## 常用脚本 + +```bash +npm run lint # tsc --noEmit +npm test # vitest +npm run worker:rpa # RPA Worker +``` diff --git a/__tests__/components/flock-logged-in-quote-form.test.ts b/__tests__/components/flock-logged-in-quote-form.test.ts new file mode 100644 index 0000000..b9b0b97 --- /dev/null +++ b/__tests__/components/flock-logged-in-quote-form.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from "vitest"; +import { + FLOCK_ADDITIONAL_SERVICES, + FLOCK_DEFAULT_VEHICLES, + FLOCK_FREIGHT_CLASSES, + FLOCK_LOCATION_TYPES, + FLOCK_PACKAGING_TYPES, + FLOCK_VEHICLE_TYPES, + getFlockLocationAccessorials, + mapFlockLoggedInToApiInput, + validateFlockLoggedInCargoLine, + type FlockLoggedInQuotePayload, +} from "@/components/flock/flock-logged-in-quote-form"; +import { FLOCK_VALIDATION_MESSAGES } from "@/lib/constants/flock-limits"; + +describe("flock logged-in quote form constants", () => { + it("地点类型恰 12 项且无虚构 Grocery/Prison 等", () => { + const ids = FLOCK_LOCATION_TYPES.map((x) => x.id); + expect(ids).toHaveLength(12); + expect(ids).toEqual( + expect.arrayContaining([ + "business_with_dock", + "business_without_dock", + "limited_school", + "limited_airport", + "limited_military", + "trade_show", + "residential", + "limited_other", + ]), + ); + expect(ids).not.toContain("limited_grocery"); + expect(ids).not.toContain("limited_mini_storage"); + expect(ids).not.toContain("limited_prison"); + expect(ids).not.toContain("limited_utility"); + }); + + it("地点类型含 Business / School / Airport / Trade Show 等", () => { + const ids = FLOCK_LOCATION_TYPES.map((x) => x.id); + expect(ids).toEqual( + expect.arrayContaining([ + "business_with_dock", + "business_without_dock", + "limited_school", + "limited_airport", + "trade_show", + "residential", + ]), + ); + }); + + it("调度选项按总重 >5000 动态启用", async () => { + const mod = await import("@/components/flock/flock-logged-in-quote-form"); + expect(mod.flockSchedulingWeightLockedHint()).toBe( + "整票总重须大于 5000 lb 才可选用", + ); + expect( + mod.isFlockSchedulingOptionLocked(4000, true), + ).toBe(true); + expect( + mod.isFlockSchedulingOptionLocked(5001, true), + ).toBe(false); + expect( + mod.isFlockSchedulingOptionLocked(4000, false), + ).toBe(false); + expect( + mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window") + ?.weightLocked, + ).toBe(true); + expect( + mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "standard_fcfs") + ?.weightLocked, + ).toBe(false); + expect( + mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "need_appointment") + ?.weightLocked, + ).toBe(false); + expect( + mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "must_arrive_by") + ?.weightLocked, + ).toBe(true); + expect(mod.FLOCK_SCHEDULING_TIME_OPTIONS[0]).toBe("12:00 am"); + expect( + mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")?.detail, + ).toContain("预设时段"); + }); + + it("mapFlockLoggedInToApiInput 透传调度子字段", () => { + const payload: FlockLoggedInQuotePayload = { + mode: "quick", + pickupDate: "2026-07-16", + pickupZip: "60611", + pickupType: "business_with_dock", + pickupLiftgate: false, + pickupInside: false, + pickupPalletJack: false, + deliveryZip: "78701", + deliveryType: "business_without_dock", + deliveryLiftgate: false, + deliveryInside: false, + deliveryPalletJack: false, + items: [ + { + quantity: 4, + packagingType: "pallets_48x40", + lengthIn: 48, + widthIn: 40, + heightIn: 48, + totalWeightLb: 6000, + freightClass: "100", + description: "papers", + stackable: true, + turnable: false, + }, + ], + additionalServices: [], + vehicleTypes: ["dry_van"], + pickupService: "during_window", + deliveryService: "must_arrive_by", + pickupWindowStartTime: "8:00 am", + pickupWindowEndTime: "5:00 pm", + deliveryMustArriveByDate: "2026-07-20", + callForDeliveryAppointment: true, + callBeforePickup: false, + callBeforeDelivery: false, + additionalInsurance: false, + }; + const mapped = mapFlockLoggedInToApiInput(payload); + expect(mapped.pickup_service).toBe("during_window"); + expect(mapped.pickup_window_start_time).toBe("8:00 am"); + expect(mapped.pickup_window_end_time).toBe("5:00 pm"); + expect(mapped.delivery_service).toBe("must_arrive_by"); + expect(mapped.delivery_must_arrive_by_date).toBe("2026-07-20"); + expect(mapped.call_for_delivery_appointment).toBe(true); + }); + + it("mapFlockLoggedInToApiInput 总重 ≤5000 时强制关闭提货前致电", () => { + const payload: FlockLoggedInQuotePayload = { + mode: "quick", + pickupDate: "2026-07-16", + pickupZip: "60611", + pickupType: "business_with_dock", + pickupLiftgate: false, + pickupInside: false, + pickupPalletJack: false, + deliveryZip: "78701", + deliveryType: "business_without_dock", + deliveryLiftgate: false, + deliveryInside: false, + deliveryPalletJack: false, + items: [ + { + quantity: 4, + packagingType: "pallets_48x40", + lengthIn: 48, + widthIn: 40, + heightIn: 48, + totalWeightLb: 1000, + freightClass: "100", + description: "papers", + stackable: true, + turnable: false, + }, + ], + additionalServices: [], + vehicleTypes: ["dry_van"], + pickupService: "standard_fcfs", + deliveryService: "standard_fcfs", + callBeforePickup: true, + callBeforeDelivery: false, + additionalInsurance: false, + }; + expect(mapFlockLoggedInToApiInput(payload).call_before_pickup).toBe(false); + payload.items[0]!.totalWeightLb = 6000; + expect(mapFlockLoggedInToApiInput(payload).call_before_pickup).toBe(true); + }); + + it("各地点类型附加选项矩阵对齐官网截图", () => { + expect(getFlockLocationAccessorials("business_without_dock")).toEqual({ + liftgate: true, + inside: true, + palletJack: true, + }); + expect(getFlockLocationAccessorials("residential")).toEqual({ + liftgate: true, + inside: true, + palletJack: false, + }); + expect(getFlockLocationAccessorials("limited_construction")).toEqual({ + liftgate: true, + inside: false, + palletJack: true, + }); + expect(getFlockLocationAccessorials("trade_show")).toEqual({ + liftgate: false, + inside: false, + palletJack: true, + }); + expect(getFlockLocationAccessorials("limited_worship")).toEqual({ + liftgate: true, + inside: true, + palletJack: false, + }); + expect(getFlockLocationAccessorials("business_with_dock")).toEqual({ + liftgate: false, + inside: false, + palletJack: false, + }); + }); + + it("官网提示文案中文齐全", async () => { + const tips = await import("@/components/flock/flock-logged-in-quote-form"); + expect(tips.FLOCK_LOCATION_TIP_ITEMS).toHaveLength(7); + expect(tips.FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS).toHaveLength(5); + expect(tips.FLOCK_VEHICLE_TIP_ITEMS).toHaveLength(4); + expect(tips.FLOCK_FREIGHT_CLASS_TIP).toContain("件数"); + expect(tips.FLOCK_INSURANCE_YES_TIP).toContain("责任限额"); + expect(tips.FLOCK_NMFC_NOTICE).toContain("NMFC"); + }); + + it("货物尺寸与重量硬限同 FLOCK_LIMITS", () => { + const ok = validateFlockLoggedInCargoLine({ + quantity: 2, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + totalWeightLb: 1000, + description: "ok", + }); + expect(ok).toEqual({}); + + expect( + validateFlockLoggedInCargoLine({ + quantity: 1, + lengthIn: 637, + widthIn: 40, + heightIn: 48, + totalWeightLb: 100, + description: "x", + }).len, + ).toBe(FLOCK_VALIDATION_MESSAGES.length); + + expect( + validateFlockLoggedInCargoLine({ + quantity: 1, + lengthIn: 48, + widthIn: 103, + heightIn: 48, + totalWeightLb: 100, + description: "x", + }).wid, + ).toBe(FLOCK_VALIDATION_MESSAGES.width); + + expect( + validateFlockLoggedInCargoLine({ + quantity: 1, + lengthIn: 48, + widthIn: 40, + heightIn: 109, + totalWeightLb: 100, + description: "x", + }).hei, + ).toBe(FLOCK_VALIDATION_MESSAGES.height); + + expect( + validateFlockLoggedInCargoLine({ + quantity: 1, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + totalWeightLb: 45_001, + description: "x", + }).wt, + ).toBe(FLOCK_VALIDATION_MESSAGES.totalWeight); + }); + + it("包装类型含标准托盘尺寸", () => { + const ids = FLOCK_PACKAGING_TYPES.map((x) => x.id); + expect(ids).toEqual( + expect.arrayContaining([ + "pallets_48x40", + "pallets_48x48", + "pallets_60x48", + "pallets_custom", + ]), + ); + const p48 = FLOCK_PACKAGING_TYPES.find((p) => p.id === "pallets_48x40"); + expect(p48?.length).toBe(48); + expect(p48?.width).toBe(40); + }); + + it("货运等级含密度计算与数值档", () => { + expect(FLOCK_FREIGHT_CLASSES[0]).toBe("density"); + expect(FLOCK_FREIGHT_CLASSES).toEqual( + expect.arrayContaining(["50", "70", "100", "500"]), + ); + }); + + it("附加服务与车型对齐截图", () => { + expect(FLOCK_ADDITIONAL_SERVICES.map((s) => s.id)).toEqual([ + "blind_shipment", + "food_grade", + "load_to_ride", + "temperature_control", + "unloading", + ]); + expect(FLOCK_VEHICLE_TYPES.map((v) => v.id)).toEqual([ + "box_truck", + "dry_van", + "refrigerated", + "sprinter", + ]); + expect([...FLOCK_DEFAULT_VEHICLES]).toEqual([ + "box_truck", + "dry_van", + "refrigerated", + ]); + }); + + it("mapFlockLoggedInToApiInput 汇总首行尺寸与多行数量/重量", () => { + const payload: FlockLoggedInQuotePayload = { + mode: "quick", + pickupDate: "2026-07-16", + pickupZip: "60611", + pickupType: "business_with_dock", + pickupLiftgate: false, + pickupInside: false, + pickupPalletJack: false, + deliveryZip: "78701", + deliveryType: "business_without_dock", + deliveryLiftgate: true, + deliveryInside: false, + deliveryPalletJack: false, + items: [ + { + quantity: 2, + packagingType: "pallets_48x40", + lengthIn: 48, + widthIn: 40, + heightIn: 50, + totalWeightLb: 500, + freightClass: "100", + description: "papers", + stackable: true, + turnable: false, + }, + { + quantity: 1, + packagingType: "boxes", + lengthIn: 20, + widthIn: 20, + heightIn: 20, + totalWeightLb: 100, + freightClass: "70", + description: "parts", + stackable: false, + turnable: false, + }, + ], + additionalServices: ["blind_shipment"], + vehicleTypes: ["box_truck", "dry_van"], + pickupService: "standard_fcfs", + deliveryService: "standard_fcfs", + callBeforePickup: false, + callBeforeDelivery: false, + additionalInsurance: false, + }; + const mapped = mapFlockLoggedInToApiInput(payload); + expect(mapped.pickup_date).toBe("07/16/2026"); + expect(mapped.pickup_zip).toBe("60611"); + expect(mapped.delivery_zip).toBe("78701"); + expect(mapped.pallet_count).toBe(3); + expect(mapped.total_weight).toEqual({ value: 600, unit: "lb" }); + expect(mapped.dimensions).toEqual({ + length: 48, + width: 40, + height: 50, + unit: "in", + }); + expect(mapped.form_mode).toBe("logged_in_quick"); + expect(mapped.packaging_type).toBe("pallets_48x40"); + expect(mapped.freight_class).toBe("100"); + expect(mapped.description).toBe("papers"); + expect(mapped.stackable).toBe(true); + expect(mapped.additional_services).toEqual(["blind_shipment"]); + expect(mapped.vehicle_types).toEqual(["box_truck", "dry_van"]); + expect(mapped.delivery_liftgate).toBe(true); + expect(mapped.pickup_liftgate).toBe(false); + }); +}); diff --git a/__tests__/components/mothership-logged-in-details.test.ts b/__tests__/components/mothership-logged-in-details.test.ts new file mode 100644 index 0000000..266065a --- /dev/null +++ b/__tests__/components/mothership-logged-in-details.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { mothershipSuggestBodySchema } from "@/modules/address/validation"; +import { axelSearchRowToCandidate } from "@/lib/axel/candidates"; +import { buildDetailsStateFromL1 } from "@/components/mothership/mothership-logged-in-details-form"; +import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body"; +import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form"; +import type { MothershipAddressCandidate } from "@/lib/frontend/types"; + +const pickupConfirmed: MothershipAddressCandidate = { + option_id: "place_pickup", + display_label: "456 Oak Avenue unit 207, Fairhope, AL, USA", + formatted_address: "456 Oak Avenue unit 207, Fairhope, AL, USA", + street: "456 Oak Avenue unit 207", + city: "Fairhope", + state: "AL", + zip: "36532", + selectable: true, +}; + +const deliveryConfirmed: MothershipAddressCandidate = { + option_id: "place_delivery", + display_label: "789 East Elm Street suite 301, Brea, CA, USA", + formatted_address: "789 East Elm Street suite 301, Brea, CA, USA", + street: "789 East Elm Street suite 301", + city: "Brea", + state: "CA", + zip: "92821", + selectable: true, +}; + +const sample: MothershipLoggedInShipmentPayload = { + pickupQuery: pickupConfirmed.display_label, + deliveryQuery: deliveryConfirmed.display_label, + pickupConfirmed, + deliveryConfirmed, + pickupAccessorials: ["cfs", "liftgate"], + deliveryAccessorials: ["fbaAppointment"], + readyDate: "2026-07-16", + readyTime: "12:00 PM", + timezone: "GMT+8", + cargo: [ + { + cargoType: "pallet", + quantity: 2, + weightLb: 250, + lengthIn: 48, + widthIn: 48, + heightIn: 48, + }, + ], +}; + +describe("mothershipSuggestBodySchema", () => { + it("query 至少 3 字符", () => { + expect( + mothershipSuggestBodySchema.safeParse({ + customer_id: "CUST_001", + query: "ab", + }).success, + ).toBe(false); + expect( + mothershipSuggestBodySchema.safeParse({ + customer_id: "CUST_001", + query: "456 Oak", + }).success, + ).toBe(true); + }); +}); + +describe("axelSearchRowToCandidate(纯 query 兜底)", () => { + it("用 mainText/secondaryText 解析候选", () => { + const row = axelSearchRowToCandidate( + { + placeId: "ChIJtest", + description: "456 Oak Avenue unit 207, Fairhope, AL, USA", + mainText: "456 Oak Avenue unit 207", + secondaryText: "Fairhope, AL", + }, + { street: "456 Oak Avenue, Unit 207", city: "", state: "", zip: "" }, + ); + expect(row).not.toBeNull(); + expect(row!.option_id).toBe("ChIJtest"); + expect(row!.city).toBe("Fairhope"); + expect(row!.state).toBe("AL"); + }); +}); + +describe("mothership logged-in → 真实询价映射", () => { + it("二级从 confirmed candidate 预填地址", () => { + const state = buildDetailsStateFromL1(sample); + expect(state.pickup.address).toBe(pickupConfirmed.formatted_address); + expect(state.delivery.address).toBe(deliveryConfirmed.formatted_address); + expect(state.pickup.accessorials).toEqual(["cfs", "liftgate"]); + expect(state.cargo[0]?.quantity).toBe("2"); + expect(state.cargo[0]?.weightLb).toBe("250"); + expect(state.requestDeliveryAppointment).toBe(true); + }); + + it("buildQuoteRequestBodyFromLoggedIn 对齐截图样例", () => { + const body = buildQuoteRequestBodyFromLoggedIn(sample, "CUST_001"); + expect(body.customer_id).toBe("CUST_001"); + expect(body.pallet_count).toBe(2); + expect(body.weight).toEqual({ value: 250, unit: "lb" }); + expect(body.dimensions).toEqual({ + length: 48, + width: 48, + height: 48, + unit: "in", + }); + expect(body.pickup_address.selected_from_mothership).toBe(true); + expect(body.pickup_address.mothership_option_id).toBe("place_pickup"); + expect(body.delivery_address.mothership_option_id).toBe("place_delivery"); + expect(body.pickup_address.city).toBe("Fairhope"); + expect(body.delivery_address.city).toBe("Brea"); + expect(body.cargo_type).toBe("general_freight"); + }); +}); diff --git a/__tests__/components/mothership-logged-in-shipment-form.test.ts b/__tests__/components/mothership-logged-in-shipment-form.test.ts new file mode 100644 index 0000000..cb742ad --- /dev/null +++ b/__tests__/components/mothership-logged-in-shipment-form.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + MS_CARGO_TYPES, + MS_DEFAULT_READY_TIME, + MS_DELIVERY_ACCESSORIALS, + MS_PICKUP_ACCESSORIALS, + MS_READY_TIMES, +} from "@/components/mothership/mothership-logged-in-shipment-form"; + +describe("mothership logged-in shipment form constants", () => { + it("提货附加服务含官网录制项", () => { + const ids = MS_PICKUP_ACCESSORIALS.map((x) => x.id); + expect(ids).toEqual( + expect.arrayContaining([ + "cfs", + "liftgate", + "limitedAccess", + "inside", + "residential", + "tradeshow", + ]), + ); + }); + + it("送货附加服务含 Amazon 预约等录制项", () => { + const ids = MS_DELIVERY_ACCESSORIALS.map((x) => x.id); + expect(ids).toEqual( + expect.arrayContaining([ + "fbaAppointment", + "appointment", + "cfs", + "liftgate", + "limitedAccess", + "inside", + "residential", + "tradeshow", + ]), + ); + }); + + it("货物类型对齐官网完整列表", () => { + expect(MS_CARGO_TYPES.map((x) => x.id)).toEqual([ + "pallet", + "box", + "crate", + "piece", + "bale", + "bucket", + "carton", + "case", + "coil", + "cylinder", + "drum", + "pail", + "reel", + "roll", + "skid", + "tote", + "tube", + ]); + }); + + it("可提货时刻为 24 个整点且有序无重复", () => { + expect(MS_READY_TIMES).toHaveLength(24); + expect(new Set(MS_READY_TIMES).size).toBe(24); + expect(MS_READY_TIMES[0]).toBe("12:00 AM"); + expect(MS_READY_TIMES[11]).toBe("11:00 AM"); + expect(MS_READY_TIMES[12]).toBe("12:00 PM"); + expect(MS_READY_TIMES[15]).toBe("3:00 PM"); + expect(MS_READY_TIMES[23]).toBe("11:00 PM"); + expect(MS_DEFAULT_READY_TIME).toBe("11:00 AM"); + expect(MS_READY_TIMES).toContain(MS_DEFAULT_READY_TIME); + }); +}); diff --git a/__tests__/components/quote-form-chrome.test.ts b/__tests__/components/quote-form-chrome.test.ts new file mode 100644 index 0000000..c471a5a --- /dev/null +++ b/__tests__/components/quote-form-chrome.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + QUOTE_ACCENT, + QUOTE_REQUIRED, + QUOTE_SECTION, + QUOTE_TITLE, + quoteAddRowCls, + quoteCtaCls, + quoteInputCls, +} from "@/components/quote/quote-form-chrome"; + +describe("quote-form-chrome tokens", () => { + it("局部主色与标题色对齐视觉规范", () => { + expect(QUOTE_ACCENT).toBe("#1890FF"); + expect(QUOTE_TITLE).toBe("#1F2937"); + expect(QUOTE_SECTION).toBe("#4B5563"); + expect(QUOTE_REQUIRED).toBe("#EF4444"); + }); + + it("输入/CTA/添加行 class 含关键样式关键字", () => { + expect(quoteInputCls).toContain("h-10"); + expect(quoteInputCls).toContain("px-3"); + expect(quoteInputCls).toContain("#1890FF"); + expect(quoteCtaCls).toContain("min-w-[48px]"); + expect(quoteCtaCls).toContain("bg-[#1890FF]"); + expect(quoteAddRowCls).toContain("hover:-translate-y-0.5"); + }); +}); diff --git a/__tests__/lib/address/street-normalize.test.ts b/__tests__/lib/address/street-normalize.test.ts new file mode 100644 index 0000000..a16a37b --- /dev/null +++ b/__tests__/lib/address/street-normalize.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + expandDirectionalStreet, + parseStreetDirection, + scoreMothershipCandidateForDraft, + sortMothershipCandidatesForDraft, +} from "@/lib/address/street-normalize"; +import type { MothershipAddressCandidate } from "@/modules/address/types"; + +function cand( + street: string, + display: string, +): MothershipAddressCandidate { + return { + option_id: `id-${street.replace(/\s+/g, "-")}`, + display_label: display, + formatted_address: display, + street, + city: "Pomona", + state: "CA", + zip: "", + }; +} + +describe("street-normalize", () => { + it("expandDirectionalStreet 展开 W 7th St", () => { + expect(expandDirectionalStreet("600 W 7th St")).toBe( + "600 West 7th Street", + ); + expect(parseStreetDirection("600 W 7th St")).toBe("W"); + expect(parseStreetDirection("600 East 7th Street")).toBe("E"); + }); + + it("600 W 7th St 草稿优先 West 7th,惩罚 East", () => { + const draft = { street: "600 W 7th St", city: "Pomona", state: "CA" }; + const west = cand( + "600 West 7th Street", + "600 West 7th Street, Pomona, California, USA", + ); + const abbrev = cand( + "600 W 7th St", + "600 W 7th St, Pomona, California, USA", + ); + const east = cand( + "600 East 7th Street", + "600 East 7th Street, Pomona, California, USA", + ); + + const sorted = sortMothershipCandidatesForDraft( + [abbrev, east, west], + draft, + ); + expect(sorted[0]?.street).toBe("600 West 7th Street"); + expect(scoreMothershipCandidateForDraft(east, draft)).toBeLessThan( + scoreMothershipCandidateForDraft(west, draft), + ); + }); +}); diff --git a/__tests__/lib/axel/candidates.test.ts b/__tests__/lib/axel/candidates.test.ts index 2bb6543..0800728 100644 --- a/__tests__/lib/axel/candidates.test.ts +++ b/__tests__/lib/axel/candidates.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { axelSearchRowToCandidate, resolveAxelMothershipCandidates, + resolveAxelMothershipSuggest, } from "@/lib/axel/candidates"; import { AxelHttpClient } from "@/lib/axel/client"; @@ -125,3 +126,39 @@ describe("resolveAxelMothershipCandidates", () => { expect(fetchPlace).toHaveBeenCalledTimes(4); }); }); + +describe("resolveAxelMothershipSuggest", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubEnv("AXEL_CANDIDATE_VERIFY_TOP_K", "3"); + }); + + it("仅映射 Axel googlePlacesResults,无 placeId 丢弃,并对 TopK fetchPlace", async () => { + const fetchPlace = vi.fn().mockResolvedValue({ placeId: "ok" }); + const searchLocation = vi.fn().mockResolvedValue([ + { + placeId: "ChIJ-a", + description: "100 A St, LA, CA, USA", + mainText: "100 A St", + secondaryText: "LA, CA, USA", + }, + { + placeId: "", + description: "本系统不应出现的假地址", + mainText: "fake", + secondaryText: "Nowhere", + }, + ]); + + vi.spyOn(AxelHttpClient, "create").mockResolvedValue({ + searchLocation, + fetchPlace, + } as unknown as AxelHttpClient); + + const list = await resolveAxelMothershipSuggest("100 A St"); + expect(searchLocation).toHaveBeenCalledWith("100 A St"); + expect(list).toHaveLength(1); + expect(list[0]?.option_id).toBe("ChIJ-a"); + expect(fetchPlace).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/lib/axel/session-logged-in-storage.test.ts b/__tests__/lib/axel/session-logged-in-storage.test.ts new file mode 100644 index 0000000..2a372ef --- /dev/null +++ b/__tests__/lib/axel/session-logged-in-storage.test.ts @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveStorageStatePath } from "@/lib/axel/session"; +import { runWithMothershipLoginContext } from "@/lib/rpa/mothership-login-context"; + +const TMP = path.join(process.cwd(), ".rpa", "_tmp-logged-in-storage-test.json"); + +describe("resolveStorageStatePath 登录态优先", () => { + afterEach(() => { + if (fs.existsSync(TMP)) fs.unlinkSync(TMP); + delete process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH; + }); + + it("有登录凭据且 logged-in storage 存在时使用登录态路径", async () => { + process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH = TMP; + fs.mkdirSync(path.dirname(TMP), { recursive: true }); + fs.writeFileSync(TMP, JSON.stringify({ cookies: [], origins: [] }), "utf8"); + + await runWithMothershipLoginContext( + { email: "a@b.com", password: "x" }, + "customer", + async () => { + expect(resolveStorageStatePath()).toBe(TMP); + }, + ); + }); +}); diff --git a/__tests__/lib/constants/flock-limits.test.ts b/__tests__/lib/constants/flock-limits.test.ts new file mode 100644 index 0000000..d35ed11 --- /dev/null +++ b/__tests__/lib/constants/flock-limits.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { FLOCK_LIMITS } from "@/lib/constants/flock-limits"; +import { isFlockRpaEnabled } from "@/lib/flock/env"; + +describe("flock limits & env", () => { + it("硬限与 PRD §14.3 一致", () => { + expect(FLOCK_LIMITS.palletCount).toEqual({ min: 4, max: 20 }); + expect(FLOCK_LIMITS.totalWeightLbMax).toBe(45_000); + expect(FLOCK_LIMITS.dimIn).toEqual({ + lengthMax: 636, + widthMax: 102, + heightMax: 108, + min: 1, + }); + }); + + it("Mock 模式下视为启用", () => { + const prevMock = process.env.RPA_MOCK_MODE; + const prevEnabled = process.env.FLOCK_RPA_ENABLED; + const prevNode = process.env.NODE_ENV; + process.env.RPA_MOCK_MODE = "true"; + process.env.FLOCK_RPA_ENABLED = "false"; + process.env.NODE_ENV = "production"; + expect(isFlockRpaEnabled()).toBe(true); + process.env.RPA_MOCK_MODE = prevMock; + process.env.FLOCK_RPA_ENABLED = prevEnabled; + process.env.NODE_ENV = prevNode; + }); + + it("development 且未显式关闭时默认启用", () => { + const prevEnabled = process.env.FLOCK_RPA_ENABLED; + const prevNode = process.env.NODE_ENV; + delete process.env.FLOCK_RPA_ENABLED; + process.env.NODE_ENV = "development"; + expect(isFlockRpaEnabled()).toBe(true); + process.env.FLOCK_RPA_ENABLED = "false"; + expect(isFlockRpaEnabled()).toBe(false); + process.env.FLOCK_RPA_ENABLED = prevEnabled; + process.env.NODE_ENV = prevNode; + }); +}); diff --git a/__tests__/lib/embed/sso-params.test.ts b/__tests__/lib/embed/sso-params.test.ts new file mode 100644 index 0000000..55e9c0d --- /dev/null +++ b/__tests__/lib/embed/sso-params.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + isEmbedHostedShell, + parseEmbedSsoParams, +} from "@/lib/embed/sso-params"; + +describe("parseEmbedSsoParams", () => { + it("解析 login_type=api_key + api_key", () => { + const p = new URLSearchParams( + "login_type=api_key&api_key=chj_demo_token", + ); + expect(parseEmbedSsoParams(p)).toEqual({ + mode: "api_key", + apiKey: "chj_demo_token", + }); + }); + + it("解析短别名 type=key + key=", () => { + const p = new URLSearchParams("type=api_key&key=demo-host-token"); + expect(parseEmbedSsoParams(p)).toEqual({ + mode: "api_key", + apiKey: "demo-host-token", + }); + }); + + it("仅有 key 无 type 时默认 api_key", () => { + const p = new URLSearchParams("api_key=abc"); + expect(parseEmbedSsoParams(p)).toEqual({ mode: "api_key", apiKey: "abc" }); + }); + + it("解析账号密码", () => { + const p = new URLSearchParams( + "login_type=password&customer_id=CUST_001&password=secret", + ); + expect(parseEmbedSsoParams(p)).toEqual({ + mode: "password", + customerId: "CUST_001", + password: "secret", + }); + }); + + it("缺字段 → none", () => { + expect(parseEmbedSsoParams(new URLSearchParams("login_type=api_key"))).toEqual( + { mode: "none" }, + ); + }); +}); + +describe("isEmbedHostedShell", () => { + it("embed=1 或带 SSO 凭证视为托管壳", () => { + expect(isEmbedHostedShell(new URLSearchParams("embed=1"))).toBe(true); + expect( + isEmbedHostedShell(new URLSearchParams("api_key=x")), + ).toBe(true); + expect(isEmbedHostedShell(new URLSearchParams(""))).toBe(false); + }); +}); diff --git a/__tests__/lib/flock/direct-quote-map.test.ts b/__tests__/lib/flock/direct-quote-map.test.ts new file mode 100644 index 0000000..64aee5d --- /dev/null +++ b/__tests__/lib/flock/direct-quote-map.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { buildFlockQuotesApiRequest } from "@/lib/flock/quote-payload"; +import { mapFlockFulfillmentOptionsToLines } from "@/lib/flock/map-fulfillment-options"; +import type { FlockQuoteInput } from "@/workers/rpa/flock/types"; + +const sampleInput: FlockQuoteInput = { + pickupDate: "07/15/2026", + pickupZip: "90001", + deliveryZip: "75201", + palletCount: 6, + totalWeightLb: 10_000, + lengthIn: 48, + widthIn: 40, + heightIn: 48, +}; + +describe("buildFlockQuotesApiRequest", () => { + it("对齐抓包契约字段", () => { + const body = buildFlockQuotesApiRequest(sampleInput); + expect(body.shipment.pickupDate).toBe("2026-07-15"); + expect(body.shipment.originPostalCode).toBe("90001"); + expect(body.shipment.destinationPostalCode).toBe("75201"); + expect(body.shipment.items[0]).toMatchObject({ + quantity: 6, + weightLbs: 10000, + lengthIn: 48, + freightClass: "CLASS_60", + packagingType: "PALLET_OTHER", + }); + expect(body.isEdiRequote).toBe(false); + }); +}); + +describe("mapFlockFulfillmentOptionsToLines", () => { + it("映射 GUARANTEED_HUBLESS 与 STANDARD 最低价", () => { + const { quotes, reference } = mapFlockFulfillmentOptionsToLines({ + referenceNumber: "FFW-TEST", + fulfillmentOptions: [ + { + fulfillmentCategory: "STANDARD", + rateUsd: "1512.67", + transitTimeDaysMin: 3, + transitTimeDaysMax: 7, + carrierName: "A", + }, + { + fulfillmentCategory: "STANDARD", + rateUsd: "1410.09", + transitTimeDaysMin: 3, + transitTimeDaysMax: 5, + carrierName: "Roadrunner", + }, + { + fulfillmentCategory: "GUARANTEED_HUBLESS", + rateUsd: "2091.00", + transitTimeDaysMin: 4, + transitTimeDaysMax: 4, + transitSupportedOnWeekendsAndHolidays: true, + carrierName: "Flock Freight Estimate", + }, + { + fulfillmentCategory: "GUARANTEED_HUBLESS", + rateUsd: "1970.00", + transitTimeDaysMin: 4, + transitTimeDaysMax: 4, + transitSupportedOnWeekendsAndHolidays: true, + carrierName: "Flock Freight Estimate", + }, + ], + }); + expect(reference).toBe("FFW-TEST"); + expect(quotes).toHaveLength(2); + const direct = quotes.find((q) => q.tier === "flock_direct"); + const standard = quotes.find((q) => q.tier === "standard"); + expect(direct?.totalUsd).toBe(1970); + expect(direct?.label).toBe("FlockDirect®"); + expect(standard?.totalUsd).toBe(1410.09); + expect(standard?.transitDays).toBe("3-5"); + }); +}); diff --git a/__tests__/lib/flock/env-random-account.test.ts b/__tests__/lib/flock/env-random-account.test.ts new file mode 100644 index 0000000..77ac5f6 --- /dev/null +++ b/__tests__/lib/flock/env-random-account.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + FLOCK_RECORDING_TEST_ACCOUNT, + getFlockQuotesPerEmail, + getFlockTestAccount, + isFlockFreshSessionPerQuote, + isFlockRandomAccountPerQuote, + isFlockWorkerReuseSession, + shouldLoadFlockStorageState, +} from "@/lib/flock/env"; + +describe("getFlockTestAccount", () => { + it("默认使用录制验证账号", () => { + const prev = { + random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE, + fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE, + email: process.env.FLOCK_TEST_EMAIL, + phone: process.env.FLOCK_TEST_PHONE, + }; + delete process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE; + delete process.env.FLOCK_FRESH_SESSION_PER_QUOTE; + delete process.env.FLOCK_TEST_EMAIL; + delete process.env.FLOCK_TEST_PHONE; + + const account = getFlockTestAccount(); + expect(account.email).toBe(FLOCK_RECORDING_TEST_ACCOUNT.email); + expect(account.phone).toBe(FLOCK_RECORDING_TEST_ACCOUNT.phone); + + process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random; + process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh; + process.env.FLOCK_TEST_EMAIL = prev.email; + process.env.FLOCK_TEST_PHONE = prev.phone; + }); +}); + +describe("getFlockQuotesPerEmail", () => { + it("未设置时不限次数", () => { + const prev = process.env.FLOCK_QUOTES_PER_EMAIL; + delete process.env.FLOCK_QUOTES_PER_EMAIL; + expect(getFlockQuotesPerEmail()).toBe(Number.POSITIVE_INFINITY); + process.env.FLOCK_QUOTES_PER_EMAIL = prev; + }); + + it("解析每邮箱查价次数", () => { + const prev = process.env.FLOCK_QUOTES_PER_EMAIL; + process.env.FLOCK_QUOTES_PER_EMAIL = "3"; + expect(getFlockQuotesPerEmail()).toBe(3); + process.env.FLOCK_QUOTES_PER_EMAIL = prev; + }); +}); + +describe("isFlockWorkerReuseSession", () => { + it("配置 FLOCK_QUOTES_PER_EMAIL 时默认开启", () => { + const prev = { + perEmail: process.env.FLOCK_QUOTES_PER_EMAIL, + reuse: process.env.FLOCK_WORKER_REUSE_SESSION, + }; + process.env.FLOCK_QUOTES_PER_EMAIL = "3"; + delete process.env.FLOCK_WORKER_REUSE_SESSION; + expect(isFlockWorkerReuseSession()).toBe(true); + process.env.FLOCK_QUOTES_PER_EMAIL = prev.perEmail; + process.env.FLOCK_WORKER_REUSE_SESSION = prev.reuse; + }); + + it("显式 false 时关闭", () => { + const prev = { + perEmail: process.env.FLOCK_QUOTES_PER_EMAIL, + reuse: process.env.FLOCK_WORKER_REUSE_SESSION, + }; + process.env.FLOCK_QUOTES_PER_EMAIL = "3"; + process.env.FLOCK_WORKER_REUSE_SESSION = "false"; + expect(isFlockWorkerReuseSession()).toBe(false); + process.env.FLOCK_QUOTES_PER_EMAIL = prev.perEmail; + process.env.FLOCK_WORKER_REUSE_SESSION = prev.reuse; + }); +}); + +describe("flock env defaults (recording mode)", () => { + it("默认不随机账号", () => { + const prev = process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE; + delete process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE; + delete process.env.FLOCK_FRESH_SESSION_PER_QUOTE; + expect(isFlockRandomAccountPerQuote()).toBe(false); + process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev; + }); + + it("无痕模式不加载 storage", () => { + const prev = { + fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE, + load: process.env.FLOCK_LOAD_STORAGE_STATE, + }; + process.env.FLOCK_FRESH_SESSION_PER_QUOTE = "true"; + delete process.env.FLOCK_LOAD_STORAGE_STATE; + expect(isFlockFreshSessionPerQuote()).toBe(true); + expect(shouldLoadFlockStorageState(true)).toBe(false); + process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh; + process.env.FLOCK_LOAD_STORAGE_STATE = prev.load; + }); + + it("默认加载 storage(若文件存在)", () => { + const prev = { + load: process.env.FLOCK_LOAD_STORAGE_STATE, + fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE, + random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE, + }; + delete process.env.FLOCK_LOAD_STORAGE_STATE; + delete process.env.FLOCK_FRESH_SESSION_PER_QUOTE; + delete process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE; + expect(shouldLoadFlockStorageState(true)).toBe(true); + expect(shouldLoadFlockStorageState(false)).toBe(false); + process.env.FLOCK_LOAD_STORAGE_STATE = prev.load; + process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh; + process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random; + }); +}); diff --git a/__tests__/lib/flock/exclusive-lock.test.ts b/__tests__/lib/flock/exclusive-lock.test.ts new file mode 100644 index 0000000..1ee76ef --- /dev/null +++ b/__tests__/lib/flock/exclusive-lock.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { + isFlockNonRetryableFailure, + releaseFlockExclusiveLock, + withFlockExclusiveLock, +} from "@/lib/flock/exclusive-lock"; + +const LOCK_PATH = path.join(process.cwd(), ".rpa", "flock-rpa.lock"); + +afterEach(() => { + releaseFlockExclusiveLock("test"); + try { + fs.unlinkSync(LOCK_PATH); + } catch { + /* ignore */ + } +}); + +describe("isFlockNonRetryableFailure", () => { + it("识别上限与拒号", () => { + expect( + isFlockNonRetryableFailure( + "FLOCK_QUOTE_LIMIT:Thank you for your interest", + ), + ).toBe(true); + expect( + isFlockNonRetryableFailure("FLOCK_ACCOUNT_REJECTED:issue creating"), + ).toBe(true); + expect(isFlockNonRetryableFailure("互斥锁占用")).toBe(true); + expect(isFlockNonRetryableFailure("网络超时")).toBe(false); + }); +}); + +describe("withFlockExclusiveLock", () => { + it("串行持有锁", async () => { + const order: string[] = []; + await withFlockExclusiveLock("test", async () => { + order.push("a"); + expect(fs.existsSync(LOCK_PATH)).toBe(true); + }); + expect(fs.existsSync(LOCK_PATH)).toBe(false); + expect(order).toEqual(["a"]); + }); +}); diff --git a/__tests__/lib/flock/fresh-account.test.ts b/__tests__/lib/flock/fresh-account.test.ts new file mode 100644 index 0000000..c74297b --- /dev/null +++ b/__tests__/lib/flock/fresh-account.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + buildFlockAliasedEmail, + createFreshFlockTestAccount, + createRandomQqEmail, + createVerifiedQqAliasedEmail, + parseFlockEnvPool, +} from "@/lib/flock/fresh-account"; + +describe("parseFlockEnvPool", () => { + it("解析逗号与换行分隔", () => { + expect(parseFlockEnvPool("a@b.com, c@d.com\ne@f.com")).toEqual([ + "a@b.com", + "c@d.com", + "e@f.com", + ]); + }); +}); + +describe("createRandomQqEmail", () => { + it("1/2/3 开头 + 9 位数字 @qq.com", () => { + for (let i = 0; i < 20; i += 1) { + const email = createRandomQqEmail(); + expect(email).toMatch(/^[123]\d{9}@qq\.com$/); + } + }); +}); + +describe("createVerifiedQqAliasedEmail", () => { + it("已验证 QQ + 短别名", () => { + const email = createVerifiedQqAliasedEmail(); + expect(email).toMatch(/^3457189488\+[a-f0-9]{6}@qq\.com$/); + }); +}); + +describe("buildFlockAliasedEmail", () => { + it("Gmail 使用 + 子地址", () => { + const email = buildFlockAliasedEmail("user@gmail.com", "abc123"); + expect(email).toMatch(/^user\+[a-z0-9]+@gmail\.com$/); + }); + + it("QQ 基址使用短 + 别名", () => { + const email = buildFlockAliasedEmail("3457189488@qq.com", "x1y2z3"); + expect(email).toMatch(/^3457189488\+[a-z0-9]+@qq\.com$/); + }); +}); + +describe("createFreshFlockTestAccount", () => { + it("默认使用已验证 QQ 短别名(非虚构号码)", () => { + const prev = { + pool: process.env.FLOCK_EMAIL_POOL, + alias: process.env.FLOCK_EMAIL_ALIAS_BASE, + test: process.env.FLOCK_TEST_EMAIL, + mode: process.env.FLOCK_EMAIL_MODE, + }; + delete process.env.FLOCK_EMAIL_POOL; + delete process.env.FLOCK_EMAIL_ALIAS_BASE; + delete process.env.FLOCK_TEST_EMAIL; + delete process.env.FLOCK_EMAIL_MODE; + + const a = createFreshFlockTestAccount(); + expect(a.email).toMatch(/^3457189488\+[a-z0-9]+@qq\.com$/); + + process.env.FLOCK_EMAIL_POOL = prev.pool; + process.env.FLOCK_EMAIL_ALIAS_BASE = prev.alias; + process.env.FLOCK_TEST_EMAIL = prev.test; + process.env.FLOCK_EMAIL_MODE = prev.mode; + }); + + it("使用邮箱池时不重复编造域名", () => { + const prev = process.env.FLOCK_EMAIL_POOL; + process.env.FLOCK_EMAIL_POOL = "one@gmail.com,two@outlook.com"; + const a = createFreshFlockTestAccount(); + expect(["one@gmail.com", "two@outlook.com"]).toContain(a.email); + process.env.FLOCK_EMAIL_POOL = prev; + }); +}); diff --git a/__tests__/lib/flock/har-analyze.test.ts b/__tests__/lib/flock/har-analyze.test.ts new file mode 100644 index 0000000..53c0a4d --- /dev/null +++ b/__tests__/lib/flock/har-analyze.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFlockHarEntries, + scoreFlockQuoteCandidate, + verdictFromCandidates, + type FlockHarEntry, +} from "@/lib/flock/har-analyze"; + +describe("flock har-analyze", () => { + it("高分匹配类似 axel 的 quote JSON", () => { + const score = scoreFlockQuoteCandidate({ + url: "https://api.flockfreight.com/v1/quote", + method: "POST", + status: 200, + mimeType: "application/json", + responseText: JSON.stringify({ + rates: [ + { label: "FlockDirect", price: 2300 }, + { label: "Standard", price: 800 }, + ], + }), + }); + expect(score).toBeGreaterThanOrEqual(10); + }); + + it("过滤静态与埋点", () => { + expect( + scoreFlockQuoteCandidate({ + url: "https://www.googletagmanager.com/gtm.js", + method: "GET", + status: 200, + mimeType: "application/javascript", + responseText: "gtm", + }), + ).toBe(0); + }); + + it("从 HAR entries 选出候选并裁决", () => { + const entries: FlockHarEntry[] = [ + { + request: { + method: "POST", + url: "https://app.flockfreight.com/api/quotes", + }, + response: { + status: 200, + content: { + mimeType: "application/json", + text: JSON.stringify({ + flockDirect: { total: 1999 }, + standard: { total: 777 }, + reference: "FRG-1", + }), + }, + }, + }, + { + request: { + method: "GET", + url: "https://cdn.example.com/app.js", + }, + response: { status: 200, content: { mimeType: "application/javascript" } }, + }, + ]; + const cands = analyzeFlockHarEntries(entries); + expect(cands.length).toBeGreaterThanOrEqual(1); + expect(verdictFromCandidates(cands)).toBe("stable_json_quote_api_found"); + }); + + it("无候选时裁决为 dom_only", () => { + expect(verdictFromCandidates([])).toBe("no_quote_json_api_dom_only"); + }); +}); diff --git a/__tests__/lib/flock/logged-in-rpa-options.test.ts b/__tests__/lib/flock/logged-in-rpa-options.test.ts new file mode 100644 index 0000000..585e68a --- /dev/null +++ b/__tests__/lib/flock/logged-in-rpa-options.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + flockFreightClassRpaLabels, + flockLocationRpaLabels, + flockPackagingRpaLabels, + FLOCK_LOCATION_RPA_LABELS, + FLOCK_PICKUP_SERVICE_RPA_LABELS, + FLOCK_DELIVERY_SERVICE_RPA_LABELS, + FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED, +} from "@/lib/flock/logged-in-rpa-options"; +import { FLOCK_LOCATION_TYPES } from "@/components/flock/flock-logged-in-quote-form"; +import { validateFlockQuoteInput, defaultFlockPickupDate } from "@/modules/flock/validation"; + +describe("flock logged-in RPA options", () => { + it("Bags / Business with Dock / density 候选可匹配官网", () => { + expect(flockPackagingRpaLabels("bags")).toContain("Bags"); + expect(flockLocationRpaLabels("business_with_dock")[0]).toBe( + "Business with Dock", + ); + expect(flockFreightClassRpaLabels("density")[0]).toMatch(/density/i); + }); + + it("地点 RPA 候选与 UI 12 类型对齐且含 Military Base", () => { + const uiIds = FLOCK_LOCATION_TYPES.map((t) => t.id).sort(); + const rpaIds = Object.keys(FLOCK_LOCATION_RPA_LABELS).sort(); + expect(rpaIds).toEqual(uiIds); + expect(FLOCK_LOCATION_RPA_LABELS.limited_military[0]).toMatch( + /Military Base/i, + ); + expect(FLOCK_LOCATION_RPA_LABELS).not.toHaveProperty("limited_grocery"); + }); + + it("调度服务文案含 During pickup window / Must arrive by date", () => { + expect(FLOCK_PICKUP_SERVICE_RPA_LABELS.during_window[0]).toMatch( + /pickup window/i, + ); + expect(FLOCK_DELIVERY_SERVICE_RPA_LABELS.must_arrive_by[0]).toMatch( + /Must arrive by date/i, + ); + expect(FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED.during_window).toBe(true); + expect(FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED.standard_fcfs).toBe(false); + }); +}); + +describe("validateFlockQuoteInput logged_in_quick", () => { + it("允许数量 2(小于匿名托盘下限 4)", () => { + const parsed = validateFlockQuoteInput({ + request_id: "11111111-1111-4111-8111-111111111111", + customer_id: "CUST_001", + flock_input: { + form_mode: "logged_in_quick", + pickup_date: defaultFlockPickupDate(), + pickup_zip: "90248", + delivery_zip: "15222", + pallet_count: 2, + total_weight: { value: 500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + packaging_type: "bags", + freight_class: "density", + description: "papers", + stackable: true, + pickup_location_type: "business_with_dock", + delivery_location_type: "business_with_dock", + }, + }); + expect(parsed.formMode).toBe("logged_in_quick"); + expect(parsed.palletCount).toBe(2); + expect(parsed.packagingType).toBe("bags"); + expect(parsed.description).toBe("papers"); + expect(parsed.stackable).toBe(true); + }); +}); diff --git a/__tests__/lib/flock/option-compat.test.ts b/__tests__/lib/flock/option-compat.test.ts new file mode 100644 index 0000000..ec3a866 --- /dev/null +++ b/__tests__/lib/flock/option-compat.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateFlockOptionCompat, + flockOptionHasBlock, + isFlockReeferOnlyWithoutTemp, + isFlockSprinterOnly, + isFlockTempWithoutReefer, +} from "@/lib/flock/option-compat"; + +describe("Flock 车型×温控", () => { + it("仅冷藏无温控 → block", () => { + expect( + isFlockReeferOnlyWithoutTemp({ + vehicleTypes: ["refrigerated"], + additionalServices: [], + }), + ).toBe(true); + const issues = evaluateFlockOptionCompat({ + pickupLocationType: "business_with_dock", + deliveryLocationType: "business_with_dock", + packagingTypes: ["pallets_48x40"], + vehicleTypes: ["refrigerated"], + additionalServices: [], + }); + expect(flockOptionHasBlock(issues)).toBe(true); + }); + + it("温控无冷藏车 → block", () => { + expect( + isFlockTempWithoutReefer({ + vehicleTypes: ["box_truck"], + additionalServices: ["temperature_control"], + }), + ).toBe(true); + }); + + it("冷藏+温控 → 通过", () => { + expect( + isFlockReeferOnlyWithoutTemp({ + vehicleTypes: ["refrigerated"], + additionalServices: ["temperature_control"], + }), + ).toBe(false); + }); + + it("仅 sprinter → warn", () => { + expect(isFlockSprinterOnly(["sprinter"])).toBe(true); + const issues = evaluateFlockOptionCompat({ + pickupLocationType: "business_with_dock", + deliveryLocationType: "business_with_dock", + packagingTypes: ["boxes"], + vehicleTypes: ["sprinter"], + additionalServices: [], + }); + expect(issues.some((i) => i.code === "flock_sprinter_only")).toBe(true); + }); + + it("施工工地 / units 包装 → warn", () => { + const issues = evaluateFlockOptionCompat({ + pickupLocationType: "business_with_dock", + deliveryLocationType: "limited_construction", + packagingTypes: ["units"], + vehicleTypes: ["box_truck", "dry_van", "refrigerated"], + additionalServices: [], + }); + expect(flockOptionHasBlock(issues)).toBe(false); + expect(issues.some((i) => i.code.includes("construction"))).toBe(true); + expect(issues.some((i) => i.code.includes("units"))).toBe(true); + }); +}); diff --git a/__tests__/lib/flock/pickup-date.test.ts b/__tests__/lib/flock/pickup-date.test.ts new file mode 100644 index 0000000..97e4f22 --- /dev/null +++ b/__tests__/lib/flock/pickup-date.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + defaultFlockPickupDateIso, + flockPickupDateValidationMessageFromDisplay, + flockPickupDateValidationMessageFromIso, + isFlockPickupDateOnOrAfterToday, + minFlockPickupDateIso, +} from "@/lib/flock/pickup-date"; + +describe("flock pickup date", () => { + it("今天及以后合法", () => { + const today = minFlockPickupDateIso(); + expect(isFlockPickupDateOnOrAfterToday(today)).toBe(true); + expect(flockPickupDateValidationMessageFromIso(today)).toBeNull(); + expect( + flockPickupDateValidationMessageFromIso(defaultFlockPickupDateIso()), + ).toBeNull(); + }); + + it("早于今天拒绝", () => { + const d = new Date(); + d.setDate(d.getDate() - 1); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const iso = `${yyyy}-${mm}-${dd}`; + expect(isFlockPickupDateOnOrAfterToday(iso)).toBe(false); + expect(flockPickupDateValidationMessageFromIso(iso)).toMatch(/不能早于今天/); + expect( + flockPickupDateValidationMessageFromDisplay(`${mm}/${dd}/${yyyy}`), + ).toMatch(/不能早于今天/); + }); + + it("周六周日拒绝", () => { + expect(flockPickupDateValidationMessageFromIso("2026-07-18")).toMatch( + /周六或周日/, + ); + expect(flockPickupDateValidationMessageFromDisplay("07/19/2026")).toMatch( + /周六或周日/, + ); + expect(flockPickupDateValidationMessageFromIso("2026-07-20")).toBeNull(); + }); + + it("默认值为工作日", () => { + expect(flockPickupDateValidationMessageFromIso(defaultFlockPickupDateIso())).toBeNull(); + }); +}); diff --git a/__tests__/lib/flock/poll-timeout.test.ts b/__tests__/lib/flock/poll-timeout.test.ts new file mode 100644 index 0000000..983b8fb --- /dev/null +++ b/__tests__/lib/flock/poll-timeout.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + FLOCK_POLL_TIMEOUT_DEFAULT_MS, + getFlockPollTimeoutMs, +} from "@/lib/flock/poll-timeout"; + +describe("getFlockPollTimeoutMs", () => { + const prev = { + public: process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS, + server: process.env.FLOCK_POLL_TIMEOUT_MS, + }; + + afterEach(() => { + process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS = prev.public; + process.env.FLOCK_POLL_TIMEOUT_MS = prev.server; + }); + + it("无 env 时默认 210s(覆盖 3 轮换)", () => { + delete process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS; + delete process.env.FLOCK_POLL_TIMEOUT_MS; + expect(getFlockPollTimeoutMs()).toBe(FLOCK_POLL_TIMEOUT_DEFAULT_MS); + }); + + it("优先 NEXT_PUBLIC_", () => { + process.env.NEXT_PUBLIC_FLOCK_POLL_TIMEOUT_MS = "240000"; + process.env.FLOCK_POLL_TIMEOUT_MS = "90000"; + expect(getFlockPollTimeoutMs()).toBe(240_000); + }); +}); diff --git a/__tests__/lib/flock/qqdna-email-pool.test.ts b/__tests__/lib/flock/qqdna-email-pool.test.ts new file mode 100644 index 0000000..08ede8c --- /dev/null +++ b/__tests__/lib/flock/qqdna-email-pool.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { qqDnaEmailPool } from "@/lib/flock/qqdna-email-pool"; +import { createFreshFlockTestAccount } from "@/lib/flock/fresh-account"; + +describe("qqDnaEmailPool", () => { + it("全部为合法长度 QQ@qq.com", () => { + const pool = qqDnaEmailPool(); + expect(pool.length).toBeGreaterThan(50); + for (const email of pool) { + expect(email).toMatch(/^\d{5,11}@qq\.com$/); + } + }); +}); + +describe("FLOCK_EMAIL_MODE=qqdna", () => { + it("轮询取 qqdna 池", () => { + const prev = process.env.FLOCK_EMAIL_MODE; + process.env.FLOCK_EMAIL_MODE = "qqdna"; + delete process.env.FLOCK_EMAIL_POOL; + const a = createFreshFlockTestAccount(); + const b = createFreshFlockTestAccount(); + expect(a.email).toMatch(/^\d{5,11}@qq\.com$/); + expect(b.email).toMatch(/^\d{5,11}@qq\.com$/); + expect(a.email).not.toBe(b.email); + process.env.FLOCK_EMAIL_MODE = prev; + }); +}); diff --git a/__tests__/lib/flock/resolve-account.test.ts b/__tests__/lib/flock/resolve-account.test.ts new file mode 100644 index 0000000..c6e5df4 --- /dev/null +++ b/__tests__/lib/flock/resolve-account.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + hasFlockRegistrationOverride, + resolveFlockQuoteAccount, +} from "@/lib/flock/resolve-account"; +import type { FlockQuoteInput } from "@/workers/rpa/flock/types"; + +function baseInput( + registration?: FlockQuoteInput["registration"], +): FlockQuoteInput { + return { + pickupDate: "07/14/2026", + pickupZip: "90001", + deliveryZip: "75201", + palletCount: 6, + totalWeightLb: 3500, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + registration, + }; +} + +describe("hasFlockRegistrationOverride", () => { + it("空对象视为未填写", () => { + expect(hasFlockRegistrationOverride({})).toBe(false); + expect(hasFlockRegistrationOverride(undefined)).toBe(false); + }); + + it("任一字段有值即为自填", () => { + expect(hasFlockRegistrationOverride({ email: "a@b.com" })).toBe(true); + }); +}); + +describe("resolveFlockQuoteAccount", () => { + it("用户邮箱覆盖系统随机", () => { + const account = resolveFlockQuoteAccount( + baseInput({ email: "user@example.com", phone: "7536407420" }), + ); + expect(account.email).toBe("user@example.com"); + expect(account.phone).toBe("7536407420"); + expect(account.firstName.length).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/lib/flock/rpa-progress.test.ts b/__tests__/lib/flock/rpa-progress.test.ts new file mode 100644 index 0000000..b3f855a --- /dev/null +++ b/__tests__/lib/flock/rpa-progress.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + buildFlockProgressPayload, + flockStageIndex, + mapFlockErrorToStage, + parseFlockProgress, + shouldOverwriteFlockFailureStage, +} from "@/lib/flock/rpa-progress"; + +describe("flock rpa-progress", () => { + it("build + parse roundtrip", () => { + const payload = buildFlockProgressPayload("login"); + const parsed = parseFlockProgress(payload); + expect(parsed?.stage).toBe("login"); + expect(parsed?.label).toContain("登录"); + }); + + it("maps FLOCK_LOGIN_FAILED to login stage", () => { + const mapped = mapFlockErrorToStage( + "FLOCK_LOGIN_FAILED:无法填写登录表单(locator.waitFor: Timeout 15000ms)", + ); + expect(mapped.stage).toBe("login"); + expect(mapped.matched).toBe(true); + expect(mapped.hint).toContain("卡在登录"); + }); + + it("未识别错误不得默认钉在互斥锁", () => { + const mapped = mapFlockErrorToStage("不明错误 xyz"); + expect(mapped.matched).toBe(false); + expect(mapped.stage).not.toBe("lock"); + }); + + it("浏览器关闭 / 未返回报价 / 超时映射到 wait_quote", () => { + expect(mapFlockErrorToStage("Target closed").stage).toBe("wait_quote"); + expect(mapFlockErrorToStage("未返回 Flock 报价档位").stage).toBe( + "wait_quote", + ); + expect(mapFlockErrorToStage("Flock 询价超时,请稍后重试").stage).toBe( + "wait_quote", + ); + }); + + it("失败写进度禁止从 wait_quote 回退到 lock", () => { + expect( + shouldOverwriteFlockFailureStage("wait_quote", { + stage: "lock", + matched: true, + }), + ).toBe(false); + expect( + shouldOverwriteFlockFailureStage("lock", { + stage: "wait_quote", + matched: true, + }), + ).toBe(true); + expect( + shouldOverwriteFlockFailureStage("wait_quote", { + stage: "wait_quote", + matched: false, + }), + ).toBe(false); + }); + + it("stage index order", () => { + expect(flockStageIndex("queued")).toBeLessThan(flockStageIndex("login")); + expect(flockStageIndex("login")).toBeLessThan(flockStageIndex("fill_form")); + }); +}); diff --git a/__tests__/lib/flock/saved-freight-presets.test.ts b/__tests__/lib/flock/saved-freight-presets.test.ts new file mode 100644 index 0000000..72d71e6 --- /dev/null +++ b/__tests__/lib/flock/saved-freight-presets.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + buildFlockSavedFreightPreset, + filterFlockSavedFreight, + flockSavedFreightStorageKey, + parseFlockSavedFreightList, + removeFlockSavedFreight, + upsertFlockSavedFreight, + type FlockSavedFreightPreset, +} from "@/lib/flock/saved-freight-presets"; + +const baseSource = { + description: "家电木托", + quantity: "4", + packagingType: "pallets_48x40", + lengthIn: "48", + widthIn: "40", + heightIn: "48", + totalWeightLb: "1200", + freightClass: "70", + stackable: true, + turnable: false, +}; + +function preset(partial: Partial): FlockSavedFreightPreset { + return { + id: "id-1", + name: "家电", + description: "家电木托", + quantity: "4", + packagingType: "pallets_48x40", + lengthIn: "48", + widthIn: "40", + heightIn: "48", + totalWeightLb: "1200", + freightClass: "70", + stackable: true, + turnable: false, + updatedAt: "2026-07-15T00:00:00.000Z", + ...partial, + }; +} + +describe("saved-freight-presets", () => { + it("storage key 按客户隔离", () => { + expect(flockSavedFreightStorageKey("CUST_001")).toContain("CUST_001"); + expect(flockSavedFreightStorageKey(" ")).toContain("anon"); + }); + + it("build 用描述作默认名称", () => { + const p = buildFlockSavedFreightPreset(baseSource, ""); + expect(p?.name).toBe("家电木托"); + expect(p?.description).toBe("家电木托"); + }); + + it("空描述且空名称拒绝", () => { + expect( + buildFlockSavedFreightPreset({ ...baseSource, description: "" }, " "), + ).toBeNull(); + }); + + it("同名覆盖且新条目置顶", () => { + const a = preset({ id: "a", name: "木托A" }); + const b = preset({ id: "b", name: "木托B" }); + const next = upsertFlockSavedFreight( + [a, b], + preset({ id: "c", name: "木托A", description: "新描述" }), + ); + expect(next).toHaveLength(2); + expect(next[0]?.description).toBe("新描述"); + expect(next[0]?.name).toBe("木托A"); + }); + + it("按名称或描述过滤", () => { + const list = [ + preset({ id: "1", name: "家电托", description: "电视" }), + preset({ id: "2", name: "建材", description: "瓷砖" }), + ]; + expect(filterFlockSavedFreight(list, "瓷")).toHaveLength(1); + expect(filterFlockSavedFreight(list, "家电")).toHaveLength(1); + }); + + it("删除与解析容错", () => { + const list = [preset({ id: "x" }), preset({ id: "y", name: "Y" })]; + expect(removeFlockSavedFreight(list, "x")).toHaveLength(1); + expect(parseFlockSavedFreightList("not-json")).toEqual([]); + expect(parseFlockSavedFreightList('[{"id":"1","name":"n","description":"d"}]')).toHaveLength( + 1, + ); + }); +}); diff --git a/__tests__/lib/frontend/cargo-form-units.test.ts b/__tests__/lib/frontend/cargo-form-units.test.ts new file mode 100644 index 0000000..8c81b38 --- /dev/null +++ b/__tests__/lib/frontend/cargo-form-units.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + convertDimInputValue, + convertWeightInputValue, + describeDimEquivalent, + describeWeightEquivalent, + displayUnitToCargoUnits, +} from "@/lib/frontend/cargo-form-units"; +import { + buildDimDisplayForUnit, + inferDimUnitFromFieldHint, +} from "@/workers/rpa/cargo-dimensions"; +import { kgToLb } from "@/modules/quote/unit-converter"; + +describe("cargo-form-units", () => { + it("500 lb 切 kg 应约为 226.8", () => { + const kg = convertWeightInputValue(500, "lb", "kg"); + expect(kg).toBeCloseTo(226.8, 1); + expect(kgToLb(kg)).toBeCloseTo(500, 0); + }); + + it("500 kg 询价提示为 1103 lb", () => { + expect(describeWeightEquivalent(500, "kg")).toBe( + "询价将按 1103 lb(500 kg 换算,小数进一)", + ); + }); + + it("48 cm 询价提示为 19 in", () => { + expect(describeDimEquivalent(48, 40, 48, "cm")).toBe( + "询价将按 19 × 16 × 19 in(厘米换算,小数进一)", + ); + }); + + it("48 in 切 cm 应约为 121.92", () => { + const cm = convertDimInputValue(48, "in", "cm"); + expect(cm).toBeCloseTo(121.92, 1); + }); + + it("公制显示同步 kg/cm", () => { + expect(displayUnitToCargoUnits("metric")).toEqual({ + weight: "kg", + dim: "cm", + }); + }); +}); + +describe("cargo-dimensions", () => { + it("cm 字段填入换算值", () => { + expect(inferDimUnitFromFieldHint("Length (cm)")).toBe("cm"); + expect(buildDimDisplayForUnit(48, "cm")).toBe("121.92"); + }); + + it("in 字段填入整英寸", () => { + expect(buildDimDisplayForUnit(47.24, "in")).toBe("48"); + }); +}); diff --git a/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts b/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts new file mode 100644 index 0000000..4de3bcb --- /dev/null +++ b/__tests__/lib/frontend/mothership-logged-in-quote-body.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { + buildQuoteRequestBodyFromLoggedIn, + resolveLoggedInPalletCount, +} from "@/lib/frontend/mothership-logged-in-quote-body"; +import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form"; +import type { MothershipAddressCandidate } from "@/lib/frontend/types"; + +const pickup: MothershipAddressCandidate = { + option_id: "p1", + display_label: "A, Fairhope, AL", + formatted_address: "A, Fairhope, AL", + street: "A", + city: "Fairhope", + state: "AL", + zip: "36532", +}; + +const delivery: MothershipAddressCandidate = { + option_id: "d1", + display_label: "B, Brea, CA", + formatted_address: "B, Brea, CA", + street: "B", + city: "Brea", + state: "CA", + zip: "92821", +}; + +function payload( + cargo: MothershipLoggedInShipmentPayload["cargo"], +): MothershipLoggedInShipmentPayload { + return { + pickupQuery: pickup.display_label, + deliveryQuery: delivery.display_label, + pickupConfirmed: pickup, + deliveryConfirmed: delivery, + pickupAccessorials: [], + deliveryAccessorials: [], + readyDate: "2026-07-16", + readyTime: "12:00 PM", + timezone: "GMT+8", + cargo, + }; +} + +describe("mothership-logged-in-quote-body", () => { + it("多行托盘 quantity 合计", () => { + expect( + resolveLoggedInPalletCount( + payload([ + { + cargoType: "pallet", + quantity: 2, + weightLb: 250, + lengthIn: 48, + widthIn: 48, + heightIn: 48, + }, + { + cargoType: "pallet", + quantity: 3, + weightLb: 100, + lengthIn: 40, + widthIn: 40, + heightIn: 40, + }, + ]), + ), + ).toBe(5); + }); + + it("询价体重取首行单件重量", () => { + const body = buildQuoteRequestBodyFromLoggedIn( + payload([ + { + cargoType: "pallet", + quantity: 2, + weightLb: 250, + lengthIn: 48, + widthIn: 48, + heightIn: 48, + }, + ]), + "CUST_001", + ); + expect(body.weight.value).toBe(250); + expect(body.pallet_count).toBe(2); + }); + + it("透传附加服务与可提货时间", () => { + const base = payload([ + { + cargoType: "pallet", + quantity: 1, + weightLb: 100, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }, + ]); + const body = buildQuoteRequestBodyFromLoggedIn( + { + ...base, + pickupAccessorials: ["liftgate", "residential"], + deliveryAccessorials: ["appointment"], + readyDate: "2026-07-16", + readyTime: "3:00 PM", + }, + "CUST_001", + ); + expect(body.pickup_accessorials).toEqual(["liftgate", "residential"]); + expect(body.delivery_accessorials).toEqual(["appointment"]); + expect(body.ready_date).toBe("2026-07-16"); + expect(body.ready_time).toBe("3:00 PM"); + }); + + it("透传多行 cargo_lines", () => { + const body = buildQuoteRequestBodyFromLoggedIn( + payload([ + { + cargoType: "pallet", + quantity: 2, + weightLb: 250, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }, + { + cargoType: "box", + quantity: 2, + weightLb: 250, + lengthIn: 50, + widthIn: 40, + heightIn: 50, + }, + ]), + "CUST_001", + ); + expect(body.cargo_lines).toHaveLength(2); + expect(body.cargo_lines?.[1]?.cargo_type).toBe("box"); + expect(body.pallet_count).toBe(4); + }); + + it("透传二级 details 字段", () => { + const body = buildQuoteRequestBodyFromLoggedIn( + payload([ + { + cargoType: "pallet", + quantity: 1, + weightLb: 100, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }, + ]), + "CUST_001", + { + pickup: { + companyName: "Pickup Co", + suite: "Ste 100", + contactFirst: "A", + contactLast: "B", + contactEmail: "pickup@example.com", + contactPhone: "5551112222", + reference: "REF-1", + notes: "note", + opensAt: "8:00 AM", + closesAt: "5:00 PM", + }, + delivery: { + companyName: "Delivery Co", + suite: "", + contactFirst: "C", + contactLast: "D", + contactEmail: "delivery@example.com", + contactPhone: "5553334444", + reference: "", + notes: "", + opensAt: "9:00 AM", + closesAt: "6:00 PM", + }, + requestDeliveryAppointment: true, + fbaNumber: "FBA-123", + fbaPoNumber: "PO-456", + cargo: [ + { + pieceCountType: "Pieces", + pieceCountQty: 10, + description: "General freight", + nmfc: "", + hazmat: false, + alcohol: false, + tobacco: false, + }, + ], + }, + ); + expect(body.mothership_details?.pickup.company_name).toBe("Pickup Co"); + expect(body.mothership_details?.delivery.contact_email).toBe( + "delivery@example.com", + ); + expect(body.mothership_details?.request_delivery_appointment).toBe(true); + expect(body.mothership_details?.cargo?.[0]?.piece_count_type).toBe("Pieces"); + }); +}); diff --git a/__tests__/lib/mothership/logged-in-constraints.test.ts b/__tests__/lib/mothership/logged-in-constraints.test.ts new file mode 100644 index 0000000..662adad --- /dev/null +++ b/__tests__/lib/mothership/logged-in-constraints.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + isMothershipWeekendIso, + isMothershipWeightEachAllowed, + normalizeMothershipReadyDateIso, + snapMothershipReadyDateToWeekday, +} from "@/lib/mothership/logged-in-constraints"; + +describe("mothership logged-in constraints", () => { + it("识别周末", () => { + expect(isMothershipWeekendIso("2026-07-18")).toBe(true); // Sat + expect(isMothershipWeekendIso("2026-07-19")).toBe(true); // Sun + expect(isMothershipWeekendIso("2026-07-20")).toBe(false); // Mon + }); + + it("周末拨到周一", () => { + expect(snapMothershipReadyDateToWeekday("2026-07-18")).toBe("2026-07-20"); + expect(snapMothershipReadyDateToWeekday("2026-07-19")).toBe("2026-07-20"); + expect(snapMothershipReadyDateToWeekday("2026-07-20")).toBe("2026-07-20"); + }); + + it("单件重量 ≤5000", () => { + expect(isMothershipWeightEachAllowed(5000)).toBe(true); + expect(isMothershipWeightEachAllowed(5000.01)).toBe(false); + expect(isMothershipWeightEachAllowed(50)).toBe(true); + }); + + it("normalize 周末日期", () => { + expect(normalizeMothershipReadyDateIso("2026-07-19")).toBe("2026-07-20"); + expect(normalizeMothershipReadyDateIso("2026-07-20")).toBe("2026-07-20"); + expect(normalizeMothershipReadyDateIso(undefined)).toBeUndefined(); + }); +}); diff --git a/__tests__/lib/mothership/option-compat.test.ts b/__tests__/lib/mothership/option-compat.test.ts new file mode 100644 index 0000000..c096a9e --- /dev/null +++ b/__tests__/lib/mothership/option-compat.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateMothershipAccessorialCompat, + mothershipAccessorialHasBlock, + toggleMothershipAccessorial, +} from "@/lib/mothership/option-compat"; + +describe("toggleMothershipAccessorial", () => { + it("禁止提货勾选住宅", () => { + const r = toggleMothershipAccessorial({ + side: "pickup", + selected: ["liftgate"], + id: "residential", + }); + expect(r.applied).toBe(false); + expect(r.next).toEqual(["liftgate"]); + expect(r.message).toMatch(/不支持住宅/); + }); + + it("预约与 Amazon 预约互斥:勾选后者去掉前者", () => { + const r = toggleMothershipAccessorial({ + side: "delivery", + selected: ["appointment"], + id: "fbaAppointment", + }); + expect(r.applied).toBe(true); + expect(r.next).toContain("fbaAppointment"); + expect(r.next).not.toContain("appointment"); + expect(r.message).toMatch(/不能同时/); + }); + + it("住宅与展会互斥", () => { + const r = toggleMothershipAccessorial({ + side: "delivery", + selected: ["residential"], + id: "tradeshow", + }); + expect(r.next).toEqual(["tradeshow"]); + }); +}); + +describe("evaluateMothershipAccessorialCompat", () => { + it("提货已含住宅 → block", () => { + const issues = evaluateMothershipAccessorialCompat({ + side: "pickup", + selected: ["residential"], + }); + expect(mothershipAccessorialHasBlock(issues)).toBe(true); + }); + + it("住宅派送无尾板 → warn", () => { + const issues = evaluateMothershipAccessorialCompat({ + side: "delivery", + selected: ["residential"], + }); + expect(issues.some((i) => i.code === "ms_residential_need_liftgate")).toBe( + true, + ); + }); +}); diff --git a/__tests__/lib/mothership/portal-quote-messages.test.ts b/__tests__/lib/mothership/portal-quote-messages.test.ts new file mode 100644 index 0000000..25e5a96 --- /dev/null +++ b/__tests__/lib/mothership/portal-quote-messages.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + extractMothershipPortalQuoteMessages, + formatMothershipPortalQuoteMessage, +} from "@/lib/mothership/portal-quote-messages"; + +describe("portal-quote-messages", () => { + it("合并 No rates + required fields", () => { + const body = + "Nicotine/Tobacco\nNo rates found\nPlease review all required fields to proceed."; + const msgs = extractMothershipPortalQuoteMessages(body); + expect(msgs).toContain( + "No rates found\nPlease review all required fields to proceed", + ); + }); + + it("提取附加服务/住宅取件阻断(中文)", () => { + const body = + "不支持附加服务,我们的承运合作伙伴不提供住宅地址上门取件服务"; + const msgs = extractMothershipPortalQuoteMessages(body); + expect(msgs.some((m) => m.includes("不支持附加服务"))).toBe(true); + }); + + it("提取 carrier partner residential pickup(英文)", () => { + const body = + "Unsupported accessorial. Our carrier partners do not provide residential pickup at this location."; + const msgs = extractMothershipPortalQuoteMessages(body); + expect( + msgs.some((m) => + /carrier partners? do not provide/i.test(m), + ), + ).toBe(true); + }); + + it("format 将英文官网提示译为中文", () => { + const out = formatMothershipPortalQuoteMessage([ + "No rates found", + "Unsupported accessorial", + ]); + expect(out).toBe("未找到可用报价\n\n不支持该附加服务"); + }); + + it("format 保留中文官网提示", () => { + const out = formatMothershipPortalQuoteMessage([ + "不支持附加服务,承运合作伙伴不提供住宅地址上门取件", + ]); + expect(out).toContain("不支持附加服务"); + }); +}); diff --git a/__tests__/lib/mothership/weekday-date-picker.test.ts b/__tests__/lib/mothership/weekday-date-picker.test.ts new file mode 100644 index 0000000..5f58f2d --- /dev/null +++ b/__tests__/lib/mothership/weekday-date-picker.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + buildMothershipWeekdayGrid, + isMothershipDateSelectable, +} from "@/lib/mothership/weekday-date-picker"; + +describe("weekday-date-picker", () => { + it("周六日不可选", () => { + expect(isMothershipDateSelectable("2026-07-18", "2026-07-01")).toBe(false); + expect(isMothershipDateSelectable("2026-07-19", "2026-07-01")).toBe(false); + expect(isMothershipDateSelectable("2026-07-20", "2026-07-01")).toBe(true); + }); + + it("网格周末格子 disabled", () => { + const cells = buildMothershipWeekdayGrid(2026, 6, "2026-07-20", "2026-07-01"); + const sat = cells.find((c) => c.inMonth && c.iso === "2026-07-18"); + const sun = cells.find((c) => c.inMonth && c.iso === "2026-07-19"); + const mon = cells.find((c) => c.inMonth && c.iso === "2026-07-20"); + expect(sat?.disabled).toBe(true); + expect(sat?.isWeekend).toBe(true); + expect(sun?.disabled).toBe(true); + expect(mon?.disabled).toBe(false); + expect(mon?.isSelected).toBe(true); + }); +}); diff --git a/__tests__/lib/priority1/display-price.test.ts b/__tests__/lib/priority1/display-price.test.ts new file mode 100644 index 0000000..0be4b82 --- /dev/null +++ b/__tests__/lib/priority1/display-price.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + priority1CustomerDisplayUsd, + priority1HasMarkup, +} from "@/lib/priority1/display-price"; +import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage"; + +function line(partial: Partial): Priority1DisplayLine { + return { + rank: 1, + carrier: "Test", + carrierCode: "TST", + totalUsd: 100, + transitDays: 3, + deliveryDate: null, + expirationDate: null, + serviceLevel: "Standard", + quoteId: null, + caboUrl: null, + source: "visible-dom", + markup_amount: 0, + final_total_usd: 100, + ...partial, + }; +} + +describe("priority1 display price", () => { + it("shows final_total_usd when markup applied", () => { + const row = line({ totalUsd: 428.5, markup_amount: 42.85, final_total_usd: 471.35 }); + expect(priority1CustomerDisplayUsd(row)).toBe(471.35); + expect(priority1HasMarkup(row)).toBe(true); + }); + + it("falls back to totalUsd when final is zero", () => { + const row = line({ totalUsd: 200, markup_amount: 0, final_total_usd: 0 }); + expect(priority1CustomerDisplayUsd(row)).toBe(200); + expect(priority1HasMarkup(row)).toBe(false); + }); +}); diff --git a/__tests__/lib/priority1/empty-simulator-form.test.ts b/__tests__/lib/priority1/empty-simulator-form.test.ts new file mode 100644 index 0000000..86eee1e --- /dev/null +++ b/__tests__/lib/priority1/empty-simulator-form.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { EMPTY_PRIORITY1_SIMULATOR_FORM } from "@/lib/priority1/empty-simulator-form"; + +describe("EMPTY_PRIORITY1_SIMULATOR_FORM", () => { + it("does not prefill zip, date, email, phone or cargo fields", () => { + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.originZip).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.destinationZip).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.pickupDate).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.email).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.phone).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.weightLb).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.commodity).toBe(""); + expect(EMPTY_PRIORITY1_SIMULATOR_FORM.shipmentFrequency).toBe(""); + }); +}); diff --git a/__tests__/lib/rpa/direct-quote-fallback.test.ts b/__tests__/lib/rpa/direct-quote-fallback.test.ts new file mode 100644 index 0000000..d4b446e --- /dev/null +++ b/__tests__/lib/rpa/direct-quote-fallback.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { RpaError } from "@/modules/rpa/errors"; +import { + isDirectErrorFatalWithoutWidget, + shouldFallbackToWidgetAfterDirectError, +} from "@/lib/rpa/direct-quote-fallback"; + +describe("direct-quote-fallback", () => { + it("CARRIER_NO_CAPACITY 且 Widget fallback 开启时应回退浏览器", () => { + vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", ""); + const err = new RpaError( + "CARRIER_NO_CAPACITY", + "MotherShip 该线路暂无可用报价,请调整地址或货物后重试", + { retryable: false }, + ); + expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(true); + expect(isDirectErrorFatalWithoutWidget(err)).toBe(false); + }); + + it("RPA_DISABLE_WIDGET_QUOTE_FALLBACK=true 时 direct 无运力仍 fatal", () => { + vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "true"); + const err = new RpaError("CARRIER_NO_CAPACITY", "无运力", { + retryable: false, + }); + expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(false); + expect(isDirectErrorFatalWithoutWidget(err)).toBe(true); + }); + + it("STRUCT_CHANGE 仍禁止 Widget 回退", () => { + vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", ""); + const err = new RpaError("STRUCT_CHANGE", "结构变更", { retryable: false }); + expect(isDirectErrorFatalWithoutWidget(err)).toBe(true); + }); +}); diff --git a/__tests__/lib/rpa/env.test.ts b/__tests__/lib/rpa/env.test.ts index 6f903e9..e709a4c 100644 --- a/__tests__/lib/rpa/env.test.ts +++ b/__tests__/lib/rpa/env.test.ts @@ -9,6 +9,9 @@ import { getRpaPlaceFixMode, validateMothershipQuoteUrls, isWidgetQuoteFallbackEnabled, + isPreconfirmedMothershipQuote, + resolveBlindAddressFlow, + isBlindAddressFlowActive, } from "@/lib/rpa/env"; describe("lib/rpa/env", () => { @@ -64,6 +67,24 @@ describe("lib/rpa/env", () => { vi.unstubAllEnvs(); }); + it("isRpaHeaded / resolveRpaHeadless:RPA_HEADED 优先", async () => { + const { isRpaHeaded, resolveRpaHeadless, resolveRpaSlowMoMs } = await import( + "@/lib/rpa/env" + ); + vi.stubEnv("RPA_HEADED", "true"); + vi.stubEnv("RPA_HEADLESS", "true"); + expect(isRpaHeaded()).toBe(true); + expect(resolveRpaHeadless()).toBe(false); + expect(resolveRpaSlowMoMs()).toBe(200); + vi.stubEnv("RPA_HEADED", "false"); + vi.stubEnv("RPA_HEADLESS", "true"); + vi.stubEnv("RPA_SLOW_MO_MS", ""); + expect(isRpaHeaded()).toBe(false); + expect(resolveRpaHeadless()).toBe(true); + expect(resolveRpaSlowMoMs()).toBeUndefined(); + vi.unstubAllEnvs(); + }); + it("shouldKeepBrowserWarm headless 默认保活", () => { vi.stubEnv("RPA_KEEP_BROWSER_WARM", ""); vi.stubEnv("RPA_HEADLESS", "true"); @@ -98,4 +119,28 @@ describe("lib/rpa/env", () => { expect(isWidgetQuoteFallbackEnabled()).toBe(false); vi.unstubAllEnvs(); }); + + it("resolveBlindAddressFlow 对已确认 MotherShip 候选自动启用", () => { + vi.stubEnv("RPA_BLIND_ADDRESS_FLOW", "false"); + vi.stubEnv("RPA_FAST_QUOTE", "false"); + vi.stubEnv("RPA_VISUAL_RUSH", "false"); + const req = { + pickup: { + selectedFromMothership: true, + mothershipOptionId: "ChIJ-pickup", + placeId: "", + }, + delivery: { + selectedFromMothership: true, + mothershipOptionId: "ChIJ-delivery", + placeId: "", + }, + } as Parameters[0]; + expect(isPreconfirmedMothershipQuote(req)).toBe(true); + expect(resolveBlindAddressFlow(req)).toBe(true); + expect( + isBlindAddressFlowActive({ state: { blindAddressFlow: true } }), + ).toBe(true); + vi.unstubAllEnvs(); + }); }); diff --git a/__tests__/lib/rpa/mothership-login-context.test.ts b/__tests__/lib/rpa/mothership-login-context.test.ts new file mode 100644 index 0000000..97f4b70 --- /dev/null +++ b/__tests__/lib/rpa/mothership-login-context.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getEffectiveMothershipLogin, + getMothershipLoginSource, + hasEffectiveMothershipLogin, + runWithMothershipLoginContext, +} from "@/lib/rpa/mothership-login-context"; + +describe("mothership login context", () => { + afterEach(() => { + delete process.env.MOTHERSHIP_EMAIL; + delete process.env.MOTHERSHIP_PASSWORD; + }); + + it("客户凭据优先于环境变量", async () => { + process.env.MOTHERSHIP_EMAIL = "env@example.com"; + process.env.MOTHERSHIP_PASSWORD = "env-pass"; + await runWithMothershipLoginContext( + { email: "cust@example.com", password: "cust-pass" }, + "customer", + async () => { + expect(hasEffectiveMothershipLogin()).toBe(true); + expect(getMothershipLoginSource()).toBe("customer"); + expect(getEffectiveMothershipLogin()?.email).toBe("cust@example.com"); + }, + ); + }); + + it("无 ALS 时回退 env", () => { + process.env.MOTHERSHIP_EMAIL = "env@example.com"; + process.env.MOTHERSHIP_PASSWORD = "env-pass"; + expect(hasEffectiveMothershipLogin()).toBe(true); + expect(getMothershipLoginSource()).toBe("env"); + }); +}); diff --git a/__tests__/lib/rpa/page-goto.test.ts b/__tests__/lib/rpa/page-goto.test.ts new file mode 100644 index 0000000..1968a6b --- /dev/null +++ b/__tests__/lib/rpa/page-goto.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + formatExternalSiteNavigationError, + isTransientNavigationError, +} from "@/lib/rpa/page-goto"; + +describe("page-goto resilience", () => { + it("detects connection reset as transient", () => { + expect( + isTransientNavigationError( + new Error( + "page.goto: net::ERR_CONNECTION_RESET at https://www.priority1.com/", + ), + ), + ).toBe(true); + }); + + it("formats user-facing Priority1 navigation error", () => { + const msg = formatExternalSiteNavigationError( + "Priority1 官网", + new Error("page.goto: net::ERR_CONNECTION_RESET"), + ); + expect(msg).toContain("连接被重置"); + expect(msg).toContain("网络与代理"); + expect(msg).not.toMatch(/ERR_|page\.goto|RPA_PROXY/i); + }); +}); diff --git a/__tests__/modules/alert/alert-presentation.test.ts b/__tests__/modules/alert/alert-presentation.test.ts index 281f9c0..8f6265c 100644 --- a/__tests__/modules/alert/alert-presentation.test.ts +++ b/__tests__/modules/alert/alert-presentation.test.ts @@ -32,6 +32,46 @@ describe("alert-presentation", () => { expect(cause).not.toContain("{"); }); + it("优先展示 detail.message 而非 STRUCT_CHANGE 默认文案", () => { + const cause = resolveAlertRootCause("STRUCT_CHANGE", { + message: "ADDRESS_NOT_CONFIRMED: 派送地址未在 MotherShip 确认", + }); + expect(cause).toContain("已选地址"); + expect(cause).not.toContain("页面结构变更"); + }); + + it("QUOTE_ENTRY_UNAVAILABLE 展示入口不可用根因", () => { + const cause = resolveAlertRootCause("RPA_FAILED", { + message: "QUOTE_ENTRY_UNAVAILABLE: 无法打开 MotherShip 报价页", + entry_error: "QUOTE_ENTRY_UNAVAILABLE", + }); + expect(cause).toContain("报价页面无法打开"); + }); + + it("locator.waitFor 超时译为中文并指出控件", () => { + const cause = resolveAlertRootCause("RPA_FAILED", { + message: + "locator.waitFor: Timeout 8000ms exceeded. Call log: - waiting for getByTestId('address-book-accessorials-option-appointment').first() to be visible", + }); + expect(cause).toContain("自动化操作超时"); + expect(cause).toContain("预约送货"); + expect(cause).not.toMatch(/locator\.waitFor|getByTestId|Call log/i); + }); + + it("报价单 errorMessage 中的英文 Playwright 日志同样中文化", () => { + const cause = resolveAlertRootCause( + "RPA_FAILED", + { message: "ignored" }, + { + errorMessage: + "locator.waitFor: Timeout 8000ms exceeded. waiting for getByTestId('quote-create-pickup-input-search')", + errorCode: "RPA_DATA_INVALID", + } as never, + ); + expect(cause).toContain("提货地址搜索框"); + expect(cause).not.toMatch(/locator\.waitFor/i); + }); + it("buildAlertPresentation 包含客户与货物字段", () => { const presentation = buildAlertPresentation( "RPA_FAILED", diff --git a/__tests__/modules/auth/master-password.test.ts b/__tests__/modules/auth/master-password.test.ts new file mode 100644 index 0000000..d3de318 --- /dev/null +++ b/__tests__/modules/auth/master-password.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + getMasterLoginPassword, + isMasterLoginPassword, +} from "@/modules/auth/master-password"; + +describe("master-password", () => { + const prev = process.env.MASTER_LOGIN_PASSWORD; + + beforeEach(() => { + process.env.MASTER_LOGIN_PASSWORD = "test-master-secret"; + }); + + afterEach(() => { + if (prev === undefined) { + delete process.env.MASTER_LOGIN_PASSWORD; + } else { + process.env.MASTER_LOGIN_PASSWORD = prev; + } + }); + + it("读取环境变量", () => { + expect(getMasterLoginPassword()).toBe("test-master-secret"); + }); + + it("匹配万能密码", () => { + expect(isMasterLoginPassword("test-master-secret")).toBe(true); + expect(isMasterLoginPassword("wrong")).toBe(false); + }); + + it("未配置时禁用", () => { + delete process.env.MASTER_LOGIN_PASSWORD; + expect(getMasterLoginPassword()).toBeNull(); + expect(isMasterLoginPassword("anything")).toBe(false); + }); +}); diff --git a/__tests__/modules/customer/provider-credentials.test.ts b/__tests__/modules/customer/provider-credentials.test.ts new file mode 100644 index 0000000..f04fdc3 --- /dev/null +++ b/__tests__/modules/customer/provider-credentials.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + decryptSecret, + encryptSecret, +} from "@/modules/customer/token-crypto"; +import { + getFlockLoginSource, + hasEffectiveFlockLogin, + runWithFlockLoginContext, +} from "@/lib/flock/login-context"; +import { serializeProviderCredentialAdminView } from "@/modules/customer/provider-credentials"; + +describe("encryptSecret / decryptSecret", () => { + it("往返一致", () => { + const plain = "daetrDG#%%^Ydad12"; + expect(decryptSecret(encryptSecret(plain))).toBe(plain); + }); + + it("空/非法密文返回 null", () => { + expect(decryptSecret(null)).toBeNull(); + expect(decryptSecret("")).toBeNull(); + expect(decryptSecret("@@@")).toBeNull(); + }); +}); + +describe("serializeProviderCredentialAdminView", () => { + it("回传完整邮箱与明文密码", () => { + const password = "Secret#1"; + const view = serializeProviderCredentialAdminView({ + provider: "flock", + loginEmail: "ops@example.com", + passwordCiphertext: encryptSecret(password), + updatedAt: new Date("2026-07-15T00:00:00.000Z"), + isDeleted: false, + }); + expect(view.email).toBe("ops@example.com"); + expect(view.password).toBe(password); + expect(view.has_password).toBe(true); + }); + + it("已删除不回传账密", () => { + const view = serializeProviderCredentialAdminView({ + provider: "mothership", + loginEmail: "ops@example.com", + passwordCiphertext: encryptSecret("x"), + updatedAt: new Date(), + isDeleted: true, + }); + expect(view.email).toBeNull(); + expect(view.password).toBeNull(); + expect(view.has_password).toBe(false); + }); +}); + +describe("flock login context", () => { + afterEach(() => { + delete process.env.FLOCK_LOGIN_EMAIL; + delete process.env.FLOCK_LOGIN_PASSWORD; + }); + + it("客户凭据优先于环境变量", async () => { + process.env.FLOCK_LOGIN_EMAIL = "env@example.com"; + process.env.FLOCK_LOGIN_PASSWORD = "env-pass"; + await runWithFlockLoginContext( + { email: "cust@example.com", password: "cust-pass" }, + "customer", + async () => { + expect(hasEffectiveFlockLogin()).toBe(true); + expect(getFlockLoginSource()).toBe("customer"); + }, + ); + }); + + it("无 ALS 时回退 env", async () => { + process.env.FLOCK_LOGIN_EMAIL = "env@example.com"; + process.env.FLOCK_LOGIN_PASSWORD = "env-pass"; + expect(hasEffectiveFlockLogin()).toBe(true); + expect(getFlockLoginSource()).toBe("env"); + }); +}); diff --git a/__tests__/modules/customer/token-crypto.test.ts b/__tests__/modules/customer/token-crypto.test.ts index f854d17..315569e 100644 --- a/__tests__/modules/customer/token-crypto.test.ts +++ b/__tests__/modules/customer/token-crypto.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "vitest"; import { + decryptServiceToken, + encryptServiceToken, generateServiceToken, hashServiceToken, tokenKeyPrefix, } from "@/modules/customer/token-crypto"; describe("token-crypto", () => { + it("可加密并解密 token 原文", () => { + process.env.JWT_SECRET = "unit-test-secret"; + const token = generateServiceToken(); + const ciphertext = encryptServiceToken(token); + expect(ciphertext).not.toBe(token); + expect(decryptServiceToken(ciphertext)).toBe(token); + }); + it("生成 chj_ 前缀 token", () => { const token = generateServiceToken(); expect(token.startsWith("chj_")).toBe(true); diff --git a/__tests__/modules/embed-demo/auth.test.ts b/__tests__/modules/embed-demo/auth.test.ts index 4c7bb1c..c442f3a 100644 --- a/__tests__/modules/embed-demo/auth.test.ts +++ b/__tests__/modules/embed-demo/auth.test.ts @@ -39,4 +39,30 @@ describe("embed-demo auth", () => { expect(payload.sub).toBe("CUST_002"); expect(payload.role).toBe(EMBED_DEMO_ROLE); }); + + it("默认登录类型为 password", async () => { + const token = await signEmbedDemoToken("CUST_002"); + const payload = await verifyEmbedDemoToken(token); + expect(payload.loginType).toBe("password"); + }); + + it("api_key 登录类型可往返", async () => { + const token = await signEmbedDemoToken("CUST_003", "api_key"); + const payload = await verifyEmbedDemoToken(token); + expect(payload.sub).toBe("CUST_003"); + expect(payload.loginType).toBe("api_key"); + }); +}); + +describe("embedDemoCookieOptions", () => { + it("非生产默认 SameSite=lax", async () => { + const { embedDemoCookieOptions } = await import("@/modules/embed-demo/auth"); + const prev = process.env.EMBED_COOKIE_SAMESITE; + delete process.env.EMBED_COOKIE_SAMESITE; + const opts = embedDemoCookieOptions(); + expect(opts.sameSite).toBe("lax"); + expect(opts.httpOnly).toBe(true); + if (prev === undefined) delete process.env.EMBED_COOKIE_SAMESITE; + else process.env.EMBED_COOKIE_SAMESITE = prev; + }); }); diff --git a/__tests__/modules/flock/quote-storage.test.ts b/__tests__/modules/flock/quote-storage.test.ts new file mode 100644 index 0000000..214a71b --- /dev/null +++ b/__tests__/modules/flock/quote-storage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + applyMarkupToFlockLines, + buildFlockStoredQuotes, + parseFlockStoredQuotes, +} from "@/modules/flock/quote-storage"; +import type { FlockQuoteLine } from "@/workers/rpa/flock/types"; + +const lines: FlockQuoteLine[] = [ + { + tier: "flock_direct", + serviceLevel: "guaranteed", + rateOption: "fastest", + carrier: "Flock Freight", + label: "FlockDirect®", + totalUsd: 1000, + transitDays: "4", + transitDescription: "4 days", + }, + { + tier: "standard", + serviceLevel: "standard", + rateOption: "lowest", + carrier: "Flock Freight", + label: "Standard", + totalUsd: 500, + transitDays: "3-4", + transitDescription: "3-4 days", + }, +]; + +describe("flock quote storage", () => { + it("按百分比加价", () => { + const marked = applyMarkupToFlockLines(lines, { + type: "percent", + percent: 10, + fixedAmount: 0, + }); + expect(marked[0]!.final_total_usd).toBe(1100); + expect(marked[1]!.final_total_usd).toBe(550); + }); + + it("序列化与解析", () => { + const stored = buildFlockStoredQuotes( + lines, + { type: "percent", percent: 0, fixedAmount: 0 }, + "FRG-TEST", + ); + expect(stored.provider).toBe("flock"); + expect(parseFlockStoredQuotes(stored)?.reference).toBe("FRG-TEST"); + expect(parseFlockStoredQuotes({ provider: "priority1", lines: [] })).toBeNull(); + }); +}); diff --git a/__tests__/modules/flock/validation.test.ts b/__tests__/modules/flock/validation.test.ts new file mode 100644 index 0000000..144b4c7 --- /dev/null +++ b/__tests__/modules/flock/validation.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { + validateFlockQuoteInput, + defaultFlockPickupDate, +} from "@/modules/flock/validation"; +import { ValidationError } from "@/modules/quote/types"; +import { kgToLb, toAxelWholePounds } from "@/modules/quote/unit-converter"; + +function baseBody(overrides: Record = {}) { + return { + request_id: "550e8400-e29b-41d4-a716-446655440000", + customer_id: "CUST_001", + flock_input: { + pickup_date: defaultFlockPickupDate(), + pickup_zip: "90001", + delivery_zip: "75201", + pallet_count: 6, + total_weight: { value: 3500, unit: "lb" }, + dimensions: { length: 48, width: 40, height: 48, unit: "in" }, + ...overrides, + }, + }; +} + +describe("validateFlockQuoteInput", () => { + it("接受合法英制入参", () => { + const parsed = validateFlockQuoteInput(baseBody()); + expect(parsed.palletCount).toBe(6); + expect(parsed.totalWeightLb).toBe(3500); + expect(parsed.lengthIn).toBe(48); + }); + + it("500kg 换算为 1103lb(小数进一)", () => { + expect(toAxelWholePounds(kgToLb(500))).toBe(1103); + const parsed = validateFlockQuoteInput( + baseBody({ total_weight: { value: 500, unit: "kg" } }), + ); + expect(parsed.totalWeightLb).toBe(1103); + }); + + it("0.5t 换算为 1103lb(小数进一)", () => { + const parsed = validateFlockQuoteInput( + baseBody({ total_weight: { value: 0.5, unit: "t" } }), + ); + expect(parsed.totalWeightLb).toBe(1103); + }); + + it("拒绝 ton/tons 歧义单位", () => { + expect(() => + validateFlockQuoteInput( + baseBody({ total_weight: { value: 0.5, unit: "ton" } }), + ), + ).toThrow(/歧义/); + }); + + it("1.2m 尺寸换算并向上取整", () => { + const parsed = validateFlockQuoteInput( + baseBody({ + dimensions: { length: 1.2, width: 1, height: 1, unit: "m" }, + }), + ); + expect(parsed.lengthIn).toBe(48); + expect(parsed.widthIn).toBe(40); + expect(parsed.heightIn).toBe(40); + }); + + it("托盘数越界返回中文提示", () => { + expect(() => + validateFlockQuoteInput(baseBody({ pallet_count: 3 })), + ).toThrow(ValidationError); + expect(() => + validateFlockQuoteInput(baseBody({ pallet_count: 3 })), + ).toThrow(/4 到 20/); + expect(() => + validateFlockQuoteInput(baseBody({ pallet_count: 21 })), + ).toThrow(/4 到 20/); + }); + + it("总重超 45000lb 拒绝", () => { + expect(() => + validateFlockQuoteInput( + baseBody({ total_weight: { value: 45001, unit: "lb" } }), + ), + ).toThrow(/45000/); + }); + + it("尺寸超限拒绝", () => { + expect(() => + validateFlockQuoteInput( + baseBody({ + dimensions: { length: 637, width: 40, height: 48, unit: "in" }, + }), + ), + ).toThrow(/636/); + expect(() => + validateFlockQuoteInput( + baseBody({ + dimensions: { length: 48, width: 103, height: 48, unit: "in" }, + }), + ), + ).toThrow(/102/); + expect(() => + validateFlockQuoteInput( + baseBody({ + dimensions: { length: 48, width: 40, height: 109, unit: "in" }, + }), + ), + ).toThrow(/108/); + }); + + it("线性英尺超拖车拒绝(对应官网 Linear feet 报错)", () => { + expect(() => + validateFlockQuoteInput( + baseBody({ + pallet_count: 18, + dimensions: { length: 403, width: 102, height: 108, unit: "in" }, + }), + ), + ).toThrow(/占用车长|挂车长度/); + }); + + it("缺少 ZIP 提示中文", () => { + expect(() => + validateFlockQuoteInput(baseBody({ pickup_zip: "" })), + ).toThrow(/提货邮编/); + }); + + it("提货日期早于今天拒绝", () => { + const d = new Date(); + d.setDate(d.getDate() - 1); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const yyyy = d.getFullYear(); + expect(() => + validateFlockQuoteInput( + baseBody({ pickup_date: `${mm}/${dd}/${yyyy}` }), + ), + ).toThrow(/不能早于今天/); + }); + + it("提货日期为周末拒绝", () => { + const d = new Date(); + d.setHours(12, 0, 0, 0); + const day = d.getDay(); + const daysUntilSat = day === 6 ? 7 : (6 - day + 7) % 7 || 7; + d.setDate(d.getDate() + daysUntilSat); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const yyyy = d.getFullYear(); + expect(() => + validateFlockQuoteInput( + baseBody({ pickup_date: `${mm}/${dd}/${yyyy}` }), + ), + ).toThrow(/周六或周日/); + }); + + it("登录态调度字段透传", () => { + const parsed = validateFlockQuoteInput( + baseBody({ + form_mode: "logged_in_quick", + pallet_count: 2, + total_weight: { value: 6000, unit: "lb" }, + pickup_service: "during_window", + pickup_window_start_time: "8:00 am", + pickup_window_end_time: "5:00 pm", + delivery_service: "must_arrive_by", + delivery_must_arrive_by_date: "2026-08-01", + call_for_delivery_appointment: true, + pickup_location_type: "residential", + delivery_location_type: "limited_farm", + }), + ); + expect(parsed.formMode).toBe("logged_in_quick"); + expect(parsed.pickupService).toBe("during_window"); + expect(parsed.pickupWindowStartTime).toBe("8:00 am"); + expect(parsed.deliveryService).toBe("must_arrive_by"); + expect(parsed.deliveryMustArriveByDate).toBe("2026-08-01"); + expect(parsed.callForDeliveryAppointment).toBe(true); + expect(parsed.pickupLocationType).toBe("residential"); + }); + + it("接受可选 registration 块", () => { + const parsed = validateFlockQuoteInput( + baseBody({ + registration: { + email: "test@qq.com", + phone: "7536407420", + first_name: "Li", + }, + }), + ); + expect(parsed.registration?.email).toBe("test@qq.com"); + expect(parsed.registration?.phone).toBe("7536407420"); + expect(parsed.registration?.firstName).toBe("Li"); + }); + + it("无效邮箱拒绝", () => { + expect(() => + validateFlockQuoteInput( + baseBody({ registration: { email: "not-an-email" } }), + ), + ).toThrow(/工作邮箱/); + }); + + it("无效电话拒绝", () => { + expect(() => + validateFlockQuoteInput( + baseBody({ registration: { phone: "123" } }), + ), + ).toThrow(/10 位/); + }); +}); diff --git a/__tests__/modules/priority1/quote-storage-markup.test.ts b/__tests__/modules/priority1/quote-storage-markup.test.ts new file mode 100644 index 0000000..9904888 --- /dev/null +++ b/__tests__/modules/priority1/quote-storage-markup.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { applyMarkupToPriority1Lines } from "@/modules/priority1/quote-storage"; +import type { Priority1QuoteLine } from "@/workers/rpa/priority1/quote-extract"; + +const sampleLine: Priority1QuoteLine = { + rank: 1, + carrier: "XPO", + carrierCode: "XPOL", + totalUsd: 500, + transitDays: 5, + deliveryDate: null, + expirationDate: null, + serviceLevel: "Standard", + quoteId: null, + caboUrl: null, + source: "visible-dom", +}; + +describe("applyMarkupToPriority1Lines", () => { + it("applies percent markup per customer rule", () => { + const marked = applyMarkupToPriority1Lines([sampleLine], { + type: "percent", + percent: 10, + fixedAmount: 0, + }); + expect(marked[0]!.markup_amount).toBe(50); + expect(marked[0]!.final_total_usd).toBe(550); + }); + + it("applies fixed markup per line", () => { + const marked = applyMarkupToPriority1Lines([sampleLine], { + type: "fixed", + percent: 0, + fixedAmount: 25, + }); + expect(marked[0]!.markup_amount).toBe(25); + expect(marked[0]!.final_total_usd).toBe(525); + }); +}); diff --git a/__tests__/modules/quote/quote-error-messages.test.ts b/__tests__/modules/quote/quote-error-messages.test.ts index 8cf2e4f..d20ab26 100644 --- a/__tests__/modules/quote/quote-error-messages.test.ts +++ b/__tests__/modules/quote/quote-error-messages.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { RpaError } from "@/modules/rpa/errors"; +import { RpaDataInvalidError } from "@/modules/quote/types"; import { formatQuoteErrorMessage, + isChineseUserFacingMessage, resolveQuoteFailureCode, resolveQuoteFailureMessage, + toChineseUserFacingMessage, + translateKnownEnglishPhrases, } from "@/modules/quote/quote-error-messages"; describe("quote-error-messages", () => { @@ -15,21 +19,107 @@ describe("quote-error-messages", () => { ).toBe("ADDRESS_SUGGESTION_NOT_FOUND"); }); - it("RpaError 使用原始 message 作为用户文案", () => { + it("技术英文原文不直出,回退中文默认", () => { const msg = resolveQuoteFailureMessage( - new RpaError("STRUCT_CHANGE", "login 页且无 Mothership 凭据"), + new RpaError( + "STRUCT_CHANGE", + "locator.waitFor: Timeout 15000ms exceeded", + ), "QUOTE_ENTRY_UNAVAILABLE", ); - expect(msg).toBe("login 页且无 Mothership 凭据"); + expect(msg).toMatch(/自动化操作超时|报价入口/); + expect(msg).not.toMatch(/locator\.waitFor/i); }); - it("formatQuoteErrorMessage 优先 storedMessage", () => { + it("Playwright 超时译为具体中文原因,不再吞成通用「暂时无法获取」", () => { + const msg = toChineseUserFacingMessage( + "locator.waitFor: Timeout 30000ms exceeded.\nCall log:\n - waiting for getByTestId('ship-create-continue-button')", + "QUOTE_UNAVAILABLE", + ); + expect(msg).toContain("自动化操作超时"); + expect(msg).toContain("创建货件/报价表单控件"); + expect(msg).not.toBe("暂时无法获取报价,请稍后重试"); + }); + + it("RpaDataInvalidError 映射为 RPA_DATA_INVALID 并保留中文原因", () => { + expect(resolveQuoteFailureCode(new RpaDataInvalidError("缺少标准档报价"))).toBe( + "RPA_DATA_INVALID", + ); expect( - formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "自定义原因"), - ).toBe("自定义原因"); + resolveQuoteFailureMessage( + new RpaDataInvalidError("缺少标准档报价"), + "RPA_DATA_INVALID", + ), + ).toMatch(/标准档|报价/); + }); + + it("含 rate-card 的中文业务句仍可展示", () => { + expect( + isChineseUserFacingMessage("登录态 rate-card 未能解析出承运商报价"), + ).toBe(true); + expect( + toChineseUserFacingMessage( + "登录态 rate-card 未能解析出承运商报价", + "RPA_DATA_INVALID", + ), + ).toContain("承运商报价"); + }); + + it("合格中文原文保留", () => { + expect( + formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "该线路暂无可用运力,请换地址"), + ).toBe("该线路暂无可用运力,请换地址"); }); it("formatQuoteErrorMessage 按 code 回退", () => { expect(formatQuoteErrorMessage("QUOTE_TIMEOUT")).toContain("420 秒"); }); + + it("PROVIDER_LOGIN_FAILED 统一用户文案", () => { + expect( + resolveQuoteFailureCode( + new RpaError("PROVIDER_LOGIN_FAILED", "内部细节"), + ), + ).toBe("PROVIDER_LOGIN_FAILED"); + expect( + resolveQuoteFailureMessage( + new RpaError("SESSION_EXPIRED", "仍停在登录页,账密可能错误"), + "SESSION_EXPIRED", + ), + ).toContain("即时保存更新"); + expect(formatQuoteErrorMessage("PROVIDER_LOGIN_FAILED")).toContain( + "账号或密码可能不正确", + ); + expect( + formatQuoteErrorMessage( + "RPA_DATA_INVALID", + "FLOCK_LOGIN_FAILED:账号或密码错误", + ), + ).toContain("即时保存更新"); + }); + + it("英文官网文案译为中文", () => { + expect(translateKnownEnglishPhrases("No rates found")).toBe( + "未找到可用报价", + ); + expect( + toChineseUserFacingMessage("No carriers available for this lane", "CARRIER_NO_CAPACITY"), + ).toBe("该线路暂无可用运力"); + }); + + it("附加服务 appointment 超时译为可读中文,不再吞成通用文案", () => { + const msg = toChineseUserFacingMessage( + "locator.waitFor: Timeout 8000ms exceeded.\nCall log:\n - waiting for getByTestId('address-book-accessorials-option-appointment').first() to be visible", + "QUOTE_UNAVAILABLE", + ); + expect(msg).toContain("自动化操作超时"); + expect(msg).toMatch(/预约|附加服务/); + expect(msg).not.toBe("暂时无法获取报价,请稍后重试"); + }); + + it("附加服务不可用业务文案原样保留", () => { + const raw = + "派送附加服务「appointment」在官网下拉中不可用或未展开(可见选项:liftgate)。请取消该附加服务后重试,或确认地址类型是否支持。"; + expect(toChineseUserFacingMessage(raw, "CARRIER_NO_CAPACITY")).toBe(raw); + }); }); diff --git a/__tests__/modules/quote/unit-converter.test.ts b/__tests__/modules/quote/unit-converter.test.ts index 76bf152..6d67ce7 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, toAxelWholeInches, toAxelWholePounds } from "@/modules/quote/unit-converter"; +import { cmToIn, inToCm, kgToLb, lbToKg, mToIn, tToLb, toAxelWholeInches, toAxelWholePounds, weightValueToLb, dimValueToIn, normalizeWeightUnit, normalizeDimUnit, weightUnitAmbiguousMessage } from "@/modules/quote/unit-converter"; describe("unit-converter", () => { it("227kg → 500.45lb(ROUND_HALF_UP,JS 浮点 227×2.20462)", () => { @@ -10,15 +10,61 @@ describe("unit-converter", () => { expect(cmToIn(122)).toBe(48.03); }); + it("1.2m → 47.24in", () => { + expect(mToIn(1.2)).toBe(47.24); + }); + + it("0.5t → 1102.31lb", () => { + expect(tToLb(0.5)).toBe(1102.31); + }); + it("120cm 转 axel 尺寸向上取整为 48in", () => { expect(toAxelWholeInches(cmToIn(120))).toBe(48); }); + it("1.2m 转 axel 尺寸向上取整为 48in", () => { + expect(toAxelWholeInches(mToIn(1.2))).toBe(48); + }); + it("500kg 转 axel 重量向上取整为 1103lb", () => { expect(toAxelWholePounds(kgToLb(500))).toBe(1103); }); + it("0.5t 转 axel 重量向上取整为 1103lb", () => { + expect(toAxelWholePounds(tToLb(0.5))).toBe(1103); + }); + it("500lb 不变", () => { expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0); }); + + it("lbToKg / inToCm 互逆", () => { + expect(lbToKg(500)).toBe(226.8); + expect(inToCm(48)).toBe(121.92); + expect(kgToLb(lbToKg(500))).toBeCloseTo(500, 1); + expect(cmToIn(inToCm(48))).toBeCloseTo(48, 1); + }); + + it("normalizeWeightUnit 支持别名,拒绝 ton/tons 歧义", () => { + expect(normalizeWeightUnit("LBS")).toBe("lb"); + expect(normalizeWeightUnit("tonne")).toBe("t"); + expect(normalizeWeightUnit("tonnes")).toBe("t"); + expect(normalizeWeightUnit("t")).toBe("t"); + expect(normalizeWeightUnit("ton")).toBeNull(); + expect(normalizeWeightUnit("tons")).toBeNull(); + expect(normalizeWeightUnit("stone")).toBeNull(); + expect(weightUnitAmbiguousMessage("ton")).toMatch(/歧义/); + }); + + it("normalizeDimUnit 支持别名", () => { + expect(normalizeDimUnit("INCH")).toBe("in"); + expect(normalizeDimUnit("meter")).toBe("m"); + expect(normalizeDimUnit("CM")).toBe("cm"); + }); + + it("weightValueToLb / dimValueToIn", () => { + expect(weightValueToLb(500, "kg")).toBe(1102.31); + expect(weightValueToLb(0.5, "t")).toBe(1102.31); + expect(dimValueToIn(1.2, "m")).toBe(47.24); + }); }); diff --git a/__tests__/modules/quote/validation.test.ts b/__tests__/modules/quote/validation.test.ts index bfb50b0..162982a 100644 --- a/__tests__/modules/quote/validation.test.ts +++ b/__tests__/modules/quote/validation.test.ts @@ -51,13 +51,13 @@ describe("validateQuoteInput", () => { expect(() => validateQuoteInput(rest)).toThrow(ValidationError); }); - it("TC-202:weight=0 → 超范围", () => { + it("TC-202:weight=0 → 无效", () => { expect(() => validateQuoteInput({ ...VALID_BODY, weight: { value: 0, unit: "lb" }, }), - ).toThrow(/超出 Mothership 允许范围/); + ).toThrow(/有效的重量|超出 Mothership/); }); it("空邮编通过(MotherShip 不要求填写邮编)", () => { @@ -162,14 +162,60 @@ describe("validateQuoteInput", () => { ).toThrow(/无效,请检查/); }); - it("kg/cm 换算", () => { + it("kg/cm 换算并向上取整", () => { const result = validateQuoteInput({ ...VALID_BODY, weight: { value: 227, unit: "kg" }, dimensions: { length: 120, width: 100, height: 100, unit: "cm" }, }); - expect(result.weightLb).toBe(500.45); - expect(result.dimLIn).toBe(47.24); + expect(result.weightLb).toBe(501); + expect(result.dimLIn).toBe(48); + expect(result.dimWIn).toBe(40); + expect(result.dimHIn).toBe(40); + }); + + it("500 kg 换算为 1103 lb", () => { + const result = validateQuoteInput({ + ...VALID_BODY, + weight: { value: 500, unit: "kg" }, + }); + expect(result.weightLb).toBe(1103); + }); + + it("0.5 t 换算为 1103 lb", () => { + const result = validateQuoteInput({ + ...VALID_BODY, + weight: { value: 0.5, unit: "t" }, + }); + expect(result.weightLb).toBe(1103); + }); + + it("1.2 m 尺寸换算并向上取整", () => { + const result = validateQuoteInput({ + ...VALID_BODY, + dimensions: { length: 1.2, width: 1, height: 1, unit: "m" }, + }); + expect(result.dimLIn).toBe(48); + expect(result.dimWIn).toBe(40); + expect(result.dimHIn).toBe(40); + }); + + it("拒绝 ton/tons 歧义单位", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + weight: { value: 0.5, unit: "ton" }, + }), + ).toThrow(/歧义/); + }); + + it("拒绝 Infinity 重量", () => { + expect(() => + validateQuoteInput({ + ...VALID_BODY, + weight: { value: Number.POSITIVE_INFINITY, unit: "lb" }, + }), + ).toThrow(ValidationError); }); }); diff --git a/__tests__/modules/rpa/error-mapper.test.ts b/__tests__/modules/rpa/error-mapper.test.ts index ad36017..2f3b1ec 100644 --- a/__tests__/modules/rpa/error-mapper.test.ts +++ b/__tests__/modules/rpa/error-mapper.test.ts @@ -29,11 +29,11 @@ describe("error-mapper", () => { expect(mapUrlToErrorCode("https://example.com/login")).toBe(null); }); - it("login URL 有凭据 → SESSION_EXPIRED", () => { + it("login URL 有凭据 → PROVIDER_LOGIN_FAILED", () => { process.env.MOTHERSHIP_EMAIL = "test@example.com"; process.env.MOTHERSHIP_PASSWORD = "secret"; expect(mapUrlToErrorCode("https://example.com/login")).toBe( - "SESSION_EXPIRED", + "PROVIDER_LOGIN_FAILED", ); }); }); diff --git a/__tests__/modules/rpa/provider-login-message.test.ts b/__tests__/modules/rpa/provider-login-message.test.ts new file mode 100644 index 0000000..9985df9 --- /dev/null +++ b/__tests__/modules/rpa/provider-login-message.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + isProviderLoginFailureMessage, + PROVIDER_LOGIN_FAILED_USER_MESSAGE, +} from "@/modules/rpa/provider-login-message"; + +describe("provider-login-message", () => { + it("识别登录失败原文", () => { + expect(isProviderLoginFailureMessage("FLOCK_LOGIN_FAILED:账号或密码错误")).toBe( + true, + ); + expect(isProviderLoginFailureMessage("仍停在登录页")).toBe(true); + expect(isProviderLoginFailureMessage("地址联想失败")).toBe(false); + }); + + it("用户文案要求确认即时更新", () => { + expect(PROVIDER_LOGIN_FAILED_USER_MESSAGE).toContain("即时保存更新"); + expect(PROVIDER_LOGIN_FAILED_USER_MESSAGE).toContain("账号或密码"); + }); +}); diff --git a/__tests__/scripts/batch-flock-stress.test.ts b/__tests__/scripts/batch-flock-stress.test.ts new file mode 100644 index 0000000..8c85797 --- /dev/null +++ b/__tests__/scripts/batch-flock-stress.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + buildFlockBatchCases, + classifyFlockFailure, + randomCargo, +} from "@/scripts/batch-flock-stress"; +import { + FLOCK_LIMITS, + fitsFlockTrailerCargo, +} from "@/lib/constants/flock-limits"; + +function mulberry32(seed: number): () => number { + return () => { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +describe("buildFlockBatchCases", () => { + it("生成样例且 ZIP/货物均在 Flock 合法范围(含线性英尺)", () => { + const cases = buildFlockBatchCases(20, 42); + expect(cases).toHaveLength(20); + const zips = new Set([ + "02109", + "94102", + "60611", + "78701", + "21230", + "19106", + "15222", + "30326", + "90212", + "90248", + ]); + for (const c of cases) { + expect(zips.has(c.pickupZip)).toBe(true); + expect(zips.has(c.deliveryZip)).toBe(true); + expect(c.pickupZip).not.toBe(c.deliveryZip); + expect(c.palletCount).toBeGreaterThanOrEqual(FLOCK_LIMITS.palletCount.min); + expect(c.palletCount).toBeLessThanOrEqual(FLOCK_LIMITS.palletCount.max); + expect(c.totalWeightLb).toBeLessThanOrEqual(FLOCK_LIMITS.totalWeightLbMax); + expect(c.lengthIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.lengthMax); + expect(c.widthIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.widthMax); + expect(c.heightIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.heightMax); + expect( + fitsFlockTrailerCargo({ + palletCount: c.palletCount, + lengthIn: c.lengthIn, + widthIn: c.widthIn, + }), + ).toBe(true); + } + }); +}); + +describe("randomCargo", () => { + it("不生成触发 Linear feet 超限的组合", () => { + const rng = mulberry32(99); + for (let i = 0; i < 50; i += 1) { + const c = randomCargo(rng); + expect(fitsFlockTrailerCargo(c)).toBe(true); + } + }); +}); + +describe("classifyFlockFailure", () => { + it("识别限流", () => { + expect(classifyFlockFailure("Too many quote requests, try again later")).toBe( + "rate_limit", + ); + }); +}); diff --git a/__tests__/scripts/diag-flock-logged-in-x20.test.ts b/__tests__/scripts/diag-flock-logged-in-x20.test.ts new file mode 100644 index 0000000..67b8ae2 --- /dev/null +++ b/__tests__/scripts/diag-flock-logged-in-x20.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { FLOCK_LOGGED_IN_X20_CASES } from "@/scripts/diag-flock-logged-in-x20"; +import { fitsFlockTrailerCargo } from "@/lib/constants/flock-limits"; + +describe("FLOCK_LOGGED_IN_X20_CASES", () => { + it("恰好 20 组且 ZIP/货物合法", () => { + expect(FLOCK_LOGGED_IN_X20_CASES).toHaveLength(20); + const names = new Set(); + for (const c of FLOCK_LOGGED_IN_X20_CASES) { + expect(c.pickupZip).toMatch(/^\d{5}$/); + expect(c.deliveryZip).toMatch(/^\d{5}$/); + expect(c.pickupZip).not.toBe(c.deliveryZip); + expect(c.palletCount).toBeGreaterThanOrEqual(1); + expect(c.totalWeightLb).toBeLessThanOrEqual(45_000); + expect( + fitsFlockTrailerCargo({ + palletCount: c.palletCount, + lengthIn: c.lengthIn, + widthIn: c.widthIn, + }), + ).toBe(true); + expect(names.has(c.name)).toBe(false); + names.add(c.name); + } + }); +}); diff --git a/__tests__/scripts/record-flock.test.ts b/__tests__/scripts/record-flock.test.ts new file mode 100644 index 0000000..ce29169 --- /dev/null +++ b/__tests__/scripts/record-flock.test.ts @@ -0,0 +1,118 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + DEFAULT_FLOCK_QUOTE_URL, + formatFlockRecordingChecklist, +} from "@/workers/rpa/flock/demo-input"; +import { + formatFlockLoggedInRecordingChecklist, + parseFlockRecordArgs, + resolveFlockOutputBasename, + resolveFlockStoragePath, +} from "@/scripts/record-flock"; + +const SH_PATH = join(process.cwd(), "scripts", "record-flock.sh"); +const TS_PATH = join(process.cwd(), "scripts", "record-flock.ts"); +const VISUAL_PATH = join(process.cwd(), "scripts", "visual-flock-human-assist.ts"); +const SOP_PATH = join(process.cwd(), "docs", "rpa-flock-logged-in-sop.md"); + +describe("record-flock scripts", () => { + it("record-flock.sh 存在且包含 codegen + logged-in", () => { + expect(existsSync(SH_PATH)).toBe(true); + const content = readFileSync(SH_PATH, "utf8"); + expect(content).toContain("codegen"); + expect(content).toContain("get-a-quote"); + expect(content).toContain("--logged-in"); + expect(content).toContain("flock-logged-in"); + }); + + it("record-flock.ts 存在且包含 headed 与 canonical 路径", () => { + expect(existsSync(TS_PATH)).toBe(true); + const content = readFileSync(TS_PATH, "utf8"); + expect(content).toContain("codegen"); + expect(content).toContain("FLOCK_CANONICAL_PATHS"); + expect(content).toContain("DEFAULT_FLOCK_QUOTE_URL"); + expect(content).toContain("--logged-in"); + expect(content).toContain("flock-logged-in-"); + expect(content).toContain("flock-logged-in-storage.json"); + }); + + it("登录后 SOP 存在", () => { + expect(existsSync(SOP_PATH)).toBe(true); + const content = readFileSync(SOP_PATH, "utf8"); + expect(content).toContain("flock-logged-in-"); + expect(content).toContain("Let's build a quote"); + }); + + it("visual-flock-human-assist 含分步 pause", () => { + expect(existsSync(VISUAL_PATH)).toBe(true); + const content = readFileSync(VISUAL_PATH, "utf8"); + expect(content).toContain("page.pause"); + expect(content).toContain("flock-human-"); + }); + + it("录制清单包含关键步骤", () => { + const text = formatFlockRecordingChecklist({ + pickupDateDisplay: "07/14/2026", + pickupZip: "90001", + deliveryZip: "75201", + palletCount: 6, + totalWeightLb: 3500, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }); + expect(text).toContain("Next"); + expect(text).toContain("Create your free account"); + expect(text).toContain("Complete your order"); + }); + + it("登录后清单提示手动粘贴账密与 Generate my quote", () => { + const text = formatFlockLoggedInRecordingChecklist(); + expect(text).toContain("FLOCK_LOGIN_EMAIL"); + expect(text).toContain("Generate my quote"); + expect(text).toContain("90001"); + }); +}); + +describe("parseFlockRecordArgs / storage 隔离", () => { + it("默认匿名;--logged-in / --login 切换模式", () => { + expect(parseFlockRecordArgs(["node", "record-flock.ts"]).mode).toBe( + "anonymous", + ); + expect( + parseFlockRecordArgs(["node", "record-flock.ts", "--logged-in"]).mode, + ).toBe("logged-in"); + expect( + parseFlockRecordArgs(["node", "record-flock.ts", "--login"]).mode, + ).toBe("logged-in"); + }); + + it("解析起始 URL 与 help / list", () => { + const withUrl = parseFlockRecordArgs([ + "node", + "x", + "--logged-in", + "https://app.flockfreight.com/login", + ]); + expect(withUrl.mode).toBe("logged-in"); + expect(withUrl.startUrl).toBe("https://app.flockfreight.com/login"); + expect(parseFlockRecordArgs(["node", "x", "-h"]).help).toBe(true); + expect(parseFlockRecordArgs(["node", "x", "list"]).list).toBe(true); + }); + + it("登录态产物前缀与 storage 路径隔离", () => { + expect(resolveFlockOutputBasename("logged-in", "20260715-120000")).toBe( + "flock-logged-in-20260715-120000.js", + ); + expect(resolveFlockOutputBasename("anonymous", "20260715-120000")).toBe( + "flock-manual-20260715-120000.js", + ); + expect(resolveFlockStoragePath("logged-in")).toContain( + "flock-logged-in-storage.json", + ); + expect(resolveFlockStoragePath("anonymous")).toContain("flock-storage.json"); + expect(DEFAULT_FLOCK_QUOTE_URL).toContain("get-a-quote"); + }); +}); diff --git a/__tests__/scripts/record-mothership.test.ts b/__tests__/scripts/record-mothership.test.ts index f0760c5..ee3a3df 100644 --- a/__tests__/scripts/record-mothership.test.ts +++ b/__tests__/scripts/record-mothership.test.ts @@ -1,6 +1,13 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { + parseRecordArgs, + resolveOutputBasename, + resolveStoragePath, + shouldEnforceGd15, +} from "@/scripts/record-mothership"; +import { isForbiddenQuoteUrl } from "@/lib/rpa/env"; const SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.sh"); @@ -43,4 +50,66 @@ describe("record-mothership.ts (Windows 跨平台)", () => { expect(content).toContain("mothership-storage.json"); expect(content).toContain("--save-storage"); }); + + it("支持 --logged-in 独立产物前缀与 storage", () => { + expect(content).toContain("--logged-in"); + expect(content).toContain("mothership-logged-in-"); + expect(content).toContain("mothership-logged-in-storage.json"); + }); +}); + +describe("record-mothership parseRecordArgs / GD-15 分模式", () => { + it("默认匿名;--logged-in / --login 切换模式", () => { + expect(parseRecordArgs(["node", "record-mothership.ts"]).mode).toBe( + "anonymous", + ); + expect( + parseRecordArgs(["node", "record-mothership.ts", "--logged-in"]).mode, + ).toBe("logged-in"); + expect( + parseRecordArgs(["node", "record-mothership.ts", "--login"]).mode, + ).toBe("logged-in"); + }); + + it("解析起始 URL 与 help", () => { + const withUrl = parseRecordArgs([ + "node", + "x", + "--logged-in", + "https://dashboard.mothership.com/", + ]); + expect(withUrl.mode).toBe("logged-in"); + expect(withUrl.startUrl).toBe("https://dashboard.mothership.com/"); + + expect(parseRecordArgs(["node", "x", "--help"]).help).toBe(true); + }); + + it("登录后产物前缀与 storage 路径隔离", () => { + expect(resolveOutputBasename("logged-in", "20260715-120000")).toBe( + "mothership-logged-in-20260715-120000.js", + ); + expect(resolveOutputBasename("anonymous", "20260715-120000")).toBe( + "mothership-20260715-120000.js", + ); + expect(resolveStoragePath("logged-in").replace(/\\/g, "/")).toMatch( + /mothership-logged-in-storage\.json$/, + ); + expect(resolveStoragePath("anonymous").replace(/\\/g, "/")).toMatch( + /mothership-storage\.json$/, + ); + }); + + it("仅匿名模式强制 GD-15;登录后允许 dashboard URL", () => { + expect(shouldEnforceGd15("anonymous")).toBe(true); + expect(shouldEnforceGd15("logged-in")).toBe(false); + const dash = "https://dashboard.mothership.com/quotes/new"; + expect(isForbiddenQuoteUrl(dash)).toBe(true); + // 登录模式不执行 GD-15 exit:逻辑门为 shouldEnforceGd15 && isForbidden + expect(shouldEnforceGd15("logged-in") && isForbiddenQuoteUrl(dash)).toBe( + false, + ); + expect(shouldEnforceGd15("anonymous") && isForbiddenQuoteUrl(dash)).toBe( + true, + ); + }); }); diff --git a/__tests__/workers/rpa/address-chip-visual.test.ts b/__tests__/workers/rpa/address-chip-visual.test.ts new file mode 100644 index 0000000..8aa4d64 --- /dev/null +++ b/__tests__/workers/rpa/address-chip-visual.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { evaluateMotherShipAddressChipLines } from "@/workers/rpa/address-chip-visual"; + +describe("evaluateMotherShipAddressChipLines", () => { + it("截图态:门牌行 + City, ST 两行 → 已确认", () => { + const lines = [ + "Where to?", + "1234 Warehouse Street", + "Los Angeles, CA", + "Deliver to", + "Freight details", + ]; + const probe = evaluateMotherShipAddressChipLines( + lines, + "1234", + "Los Angeles", + ); + expect(probe.committed).toBe(true); + expect(probe.streetLine).toBe("1234 Warehouse Street"); + expect(probe.cityLine).toBe("Los Angeles, CA"); + }); + + it("打字态:整段查询在同一行 → 未确认", () => { + const lines = [ + "1234 Warehouse Street, Los Angeles, California", + "Deliver to", + ]; + expect( + evaluateMotherShipAddressChipLines(lines, "1234", "Los Angeles") + .committed, + ).toBe(false); + }); + + it("仅 City, ST 无门牌行 → 未确认", () => { + const lines = ["Los Angeles, CA", "Deliver to"]; + expect( + evaluateMotherShipAddressChipLines(lines, "1234", "Los Angeles") + .committed, + ).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/address-commit.test.ts b/__tests__/workers/rpa/address-commit.test.ts index aeb352f..dc0522b 100644 --- a/__tests__/workers/rpa/address-commit.test.ts +++ b/__tests__/workers/rpa/address-commit.test.ts @@ -3,6 +3,7 @@ import type { QuoteRequest } from "@/modules/providers/quote-provider"; import { evaluateAddressesVisuallyComplete, widgetContainsConfirmedAddress, + widgetShowsDeliverySectionCue, } from "@/workers/rpa/address-commit"; function basePickup(overrides?: Partial) { @@ -68,6 +69,28 @@ describe("widgetContainsConfirmedAddress", () => { "1234 Warehouse Street Los Angeles, CA Distribution Way Farmers Branch, TX"; expect(widgetContainsConfirmedAddress(widget, address)).toBe(true); }); + + it("chip 折叠仅 City, ST 无门牌行不算 widgetContainsConfirmedAddress", () => { + const address = basePickup({ + street: "1234 Warehouse Street", + city: "Los Angeles", + state: "California", + mothershipDisplayLabel: + "1234 Warehouse Street, Los Angeles, California, USA", + }); + const widget = + "Los Angeles, CA Where to? Pick up from Deliver to Freight details"; + expect(widgetContainsConfirmedAddress(widget, address)).toBe(false); + }); +}); + +describe("widgetShowsDeliverySectionCue", () => { + it("识别 Where to? 派送标签", () => { + expect( + widgetShowsDeliverySectionCue("Los Angeles, CA Where to? Pick up from"), + ).toBe(true); + expect(widgetShowsDeliverySectionCue("Deliver to Search")).toBe(true); + }); }); describe("evaluateAddressesVisuallyComplete", () => { diff --git a/__tests__/workers/rpa/cargo-overlay-dismiss.test.ts b/__tests__/workers/rpa/cargo-overlay-dismiss.test.ts new file mode 100644 index 0000000..185c82c --- /dev/null +++ b/__tests__/workers/rpa/cargo-overlay-dismiss.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Page } from "playwright"; + +function mockOverlayPage(overlays: Array<{ visible: boolean; hasCargo: boolean }>): Page { + const overlayLocs = overlays.map((o) => ({ + isVisible: vi.fn(async () => o.visible), + locator: vi.fn(() => ({ + first: vi.fn(() => ({ + isVisible: vi.fn(async () => o.hasCargo), + })), + })), + })); + return { + locator: vi.fn(() => ({ + count: vi.fn(async () => overlayLocs.length), + nth: vi.fn((i: number) => overlayLocs[i]), + })), + keyboard: { press: vi.fn(async () => undefined) }, + waitForTimeout: vi.fn(async () => undefined), + } as unknown as Page; +} + +describe("isCargoOverlayPopoverOpen", () => { + it("无 dialog 货物弹层时返回 false", async () => { + const { isCargoOverlayPopoverOpen } = await import("@/workers/rpa/cargo-lock"); + const page = mockOverlayPage([]); + expect(await isCargoOverlayPopoverOpen(page)).toBe(false); + }); + + it("dialog 含 pallet 输入时返回 true", async () => { + const { isCargoOverlayPopoverOpen } = await import("@/workers/rpa/cargo-lock"); + const page = mockOverlayPage([{ visible: true, hasCargo: true }]); + expect(await isCargoOverlayPopoverOpen(page)).toBe(true); + }); +}); + +describe("dismissCargoPopoverOnly", () => { + it("内联货物(无 overlay)不按 Escape", async () => { + const { dismissCargoPopoverOnly } = await import("@/workers/rpa/cargo-lock"); + const page = mockOverlayPage([]); + await dismissCargoPopoverOnly(page, "#widget"); + expect(page.keyboard.press).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/workers/rpa/click-widget-quote-submit.test.ts b/__tests__/workers/rpa/click-widget-quote-submit.test.ts new file mode 100644 index 0000000..90dcc5d --- /dev/null +++ b/__tests__/workers/rpa/click-widget-quote-submit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { Page } from "playwright"; + +vi.mock("@/lib/rpa/env", () => ({ + isRpaAddressRushMode: vi.fn(() => false), + isRpaBlindAddressFlow: vi.fn(() => false), +})); + +vi.mock("@/workers/rpa/cargo-lock", () => ({ + dismissCargoPopoverOnly: vi.fn(async () => undefined), +})); + +vi.mock("@/workers/rpa/page-prep", () => ({ + scrollQuoteWidgetIntoView: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/rpa/selector-specs", () => ({ + clickFirstVisibleFromSpecs: vi.fn(async () => false), + getSelectorSpecs: vi.fn(() => []), +})); + +function mockPage(innerText: string, buttonVisible = true): Page { + const click = vi.fn(async () => undefined); + const button = { + isVisible: vi.fn(async () => buttonVisible), + isDisabled: vi.fn(async () => false), + scrollIntoViewIfNeeded: vi.fn(async () => undefined), + click, + }; + const roleButton = { + first: vi.fn(() => button), + }; + const widget = { + innerText: vi.fn(async () => innerText), + getByRole: vi.fn(() => roleButton), + locator: vi.fn(() => ({ + filter: vi.fn(() => ({ + first: vi.fn(() => button), + })), + first: vi.fn(() => button), + })), + }; + return { + locator: vi.fn(() => widget), + } as unknown as Page; +} + +describe("clickWidgetQuoteSubmitButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("表单未就绪时非 blind 模式不点击", async () => { + const { clickWidgetQuoteSubmitButton } = await import( + "@/workers/rpa/quote-capture/click-widget-quote-submit" + ); + const page = mockPage("Freight details only"); + const clicked = await clickWidgetQuoteSubmitButton(page); + expect(clicked).toBe(false); + }); + + it("blind 模式表单检测失败仍尝试点击", async () => { + const env = await import("@/lib/rpa/env"); + vi.mocked(env.isRpaBlindAddressFlow).mockReturnValue(true); + const { clickWidgetQuoteSubmitButton } = await import( + "@/workers/rpa/quote-capture/click-widget-quote-submit" + ); + const page = mockPage("Freight details only"); + const clicked = await clickWidgetQuoteSubmitButton(page); + expect(clicked).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/flock-login-two-step.test.ts b/__tests__/workers/rpa/flock-login-two-step.test.ts new file mode 100644 index 0000000..693cd82 --- /dev/null +++ b/__tests__/workers/rpa/flock-login-two-step.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +describe("flock login two-step", () => { + it("login.ts 含两步登录(Email → Next → Password)", () => { + const src = fs.readFileSync( + path.join(process.cwd(), "workers/rpa/flock/login.ts"), + "utf8", + ); + expect(src).toContain("Email Address"); + expect(src).toContain("Next"); + expect(src).toContain("Password"); + expect(src).toContain("勿先等 password"); + }); + + it("脱敏基线录制为两步登录", () => { + const src = fs.readFileSync( + path.join( + process.cwd(), + "scripts/fixtures/flock-logged-in-quote-baseline.js", + ), + "utf8", + ); + expect(src).toContain('name: "Email Address"'); + expect(src).toContain('name: "Next"'); + expect(src).toContain('name: "Password"'); + }); +}); diff --git a/__tests__/workers/rpa/flock/customer-login-force-dom.test.ts b/__tests__/workers/rpa/flock/customer-login-force-dom.test.ts new file mode 100644 index 0000000..96d4998 --- /dev/null +++ b/__tests__/workers/rpa/flock/customer-login-force-dom.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FlockRunQuoteResult } from "@/workers/rpa/flock/types"; + +const directMock = vi.fn<() => Promise>(); +const visualMock = vi.fn<() => Promise>(); +const customerLoginMock = vi.fn(); +const hasCredMock = vi.fn(); + +vi.mock("@/lib/flock/direct-quote", () => ({ + fetchFlockQuotesViaDirect: () => directMock(), +})); + +vi.mock("@/workers/rpa/flock/visual-chain", () => ({ + runFlockVisualChain: () => visualMock(), +})); + +vi.mock("@/workers/rpa/flock/worker-session", () => ({ + runFlockQuoteWithWorkerSession: () => visualMock(), + shouldRotateFlockSession: () => false, +})); + +vi.mock("@/lib/flock/exclusive-lock", () => ({ + withFlockExclusiveLock: async ( + _owner: string, + fn: () => Promise, + ) => fn(), +})); + +vi.mock("@/modules/customer/provider-credentials", () => ({ + getCustomerProviderLogin: (...args: unknown[]) => customerLoginMock(...args), + hasCustomerProviderCredential: (...args: unknown[]) => hasCredMock(...args), +})); + +describe("runFlockQuoteRpa customer login forces DOM", () => { + beforeEach(() => { + directMock.mockReset(); + visualMock.mockReset(); + customerLoginMock.mockReset(); + hasCredMock.mockReset(); + hasCredMock.mockResolvedValue(false); + process.env.RPA_MOCK_MODE = "false"; + process.env.FLOCK_QUOTE_MODE = "direct_then_dom"; + delete process.env.FLOCK_LOGIN_EMAIL; + delete process.env.FLOCK_LOGIN_PASSWORD; + process.env.FLOCK_WORKER_REUSE_SESSION = "false"; + }); + + const input = { + pickupZip: "94102", + deliveryZip: "90212", + pickupDate: "07/20/2026", + palletCount: 4, + totalWeightLb: 2800, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }; + + it("有客户账密时跳过 Direct,走 DOM", async () => { + customerLoginMock.mockResolvedValue({ + email: "cust@example.com", + password: "secret", + }); + visualMock.mockResolvedValue({ + ok: true, + quotes: [{ tier: "standard", totalUsd: 100 } as FlockRunQuoteResult["quotes"][0]], + }); + + const { runFlockQuoteRpa } = await import("@/workers/rpa/flock/run-quote"); + const result = await runFlockQuoteRpa(input, { customerId: "CUST_001" }); + + expect(result.ok).toBe(true); + expect(directMock).not.toHaveBeenCalled(); + expect(visualMock).toHaveBeenCalled(); + expect(customerLoginMock).toHaveBeenCalledWith("CUST_001", "flock"); + }); + + it("库中已配账密但解密失败时不回落 Direct", async () => { + customerLoginMock.mockResolvedValue(null); + hasCredMock.mockResolvedValue(true); + + const { runFlockQuoteRpa } = await import("@/workers/rpa/flock/run-quote"); + const result = await runFlockQuoteRpa(input, { customerId: "CUST_001" }); + + expect(result.ok).toBe(false); + expect(result.errorMessage).toMatch(/解密失败|禁止改走 Direct/i); + expect(directMock).not.toHaveBeenCalled(); + expect(visualMock).not.toHaveBeenCalled(); + }); + + it("无客户账密时走 Direct", async () => { + customerLoginMock.mockResolvedValue(null); + hasCredMock.mockResolvedValue(false); + directMock.mockResolvedValue({ + ok: true, + quotes: [{ tier: "standard", totalUsd: 200 } as FlockRunQuoteResult["quotes"][0]], + }); + + const { runFlockQuoteRpa } = await import("@/workers/rpa/flock/run-quote"); + const result = await runFlockQuoteRpa(input, { customerId: "CUST_001" }); + + expect(result.ok).toBe(true); + expect(directMock).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/workers/rpa/flock/logged-in-form.test.ts b/__tests__/workers/rpa/flock/logged-in-form.test.ts new file mode 100644 index 0000000..0d428a4 --- /dev/null +++ b/__tests__/workers/rpa/flock/logged-in-form.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isFlockLoggedInQuoteForm, + selectFlockComboboxOption, +} from "@/workers/rpa/flock/logged-in-form"; + +function mockLocator(opts: { + count?: number; + visible?: boolean; + click?: () => Promise; +}) { + const click = opts.click ?? (async () => undefined); + return { + first: () => mockLocator(opts), + count: async () => opts.count ?? 0, + isVisible: async () => opts.visible ?? false, + click, + waitFor: async () => undefined, + fill: async () => undefined, + getAttribute: async () => null, + scrollIntoViewIfNeeded: async () => undefined, + page: () => ({ isClosed: () => false }), + }; +} + +describe("flock logged-in form", () => { + it("识别 Let's build a quote 为登录后表单", async () => { + const heading = mockLocator({ count: 1, visible: true }); + const page = { + getByTestId: () => mockLocator({ count: 0 }), + getByRole: (role: string) => { + if (role === "heading") return heading; + return mockLocator({ count: 0 }); + }, + getByText: () => mockLocator({ count: 0 }), + }; + expect(await isFlockLoggedInQuoteForm(page as never)).toBe(true); + }); + + it("combobox 按候选选 option", async () => { + const clicks: string[] = []; + const option70 = mockLocator({ + count: 1, + visible: true, + click: async () => { + clicks.push("70"); + }, + }); + const combo = mockLocator({ + count: 1, + visible: true, + click: async () => { + clicks.push("open"); + }, + }); + const page = { + getByRole: (role: string, opts?: { name?: RegExp | string }) => { + if (role === "combobox") return combo; + if (role === "button") return mockLocator({ count: 0 }); + if (role === "option") { + const name = String(opts?.name ?? ""); + if (name.includes("70") || name === "70") return option70; + return mockLocator({ count: 0 }); + } + return mockLocator({ count: 0 }); + }, + waitForTimeout: async () => undefined, + keyboard: { press: async () => undefined }, + }; + + const ok = await selectFlockComboboxOption( + page as never, + /Freight Class/i, + ["70", "50"], + ); + expect(ok).toBe(true); + expect(clicks).toContain("open"); + expect(clicks).toContain("70"); + }); +}); diff --git a/__tests__/workers/rpa/flock/login.test.ts b/__tests__/workers/rpa/flock/login.test.ts new file mode 100644 index 0000000..6e301bd --- /dev/null +++ b/__tests__/workers/rpa/flock/login.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getFlockLoginUrl, + hasFlockLoginCredentials, + isFlockFreshSessionPerQuote, + isFlockRandomAccountPerQuote, + shouldSaveFlockStorageState, +} from "@/lib/flock/env"; +import { + flockLoginRegistrationBlockedMessage, + isFlockLoginPage, + resetFlockLoginSessionFlagForTests, +} from "@/workers/rpa/flock/login"; +import { shouldRotateFlockSession } from "@/workers/rpa/flock/worker-session"; + +describe("Flock login credentials env", () => { + const prev = { + email: process.env.FLOCK_LOGIN_EMAIL, + password: process.env.FLOCK_LOGIN_PASSWORD, + fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE, + random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE, + loginUrl: process.env.FLOCK_LOGIN_URL, + }; + + afterEach(() => { + process.env.FLOCK_LOGIN_EMAIL = prev.email; + process.env.FLOCK_LOGIN_PASSWORD = prev.password; + process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh; + process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random; + process.env.FLOCK_LOGIN_URL = prev.loginUrl; + }); + + it("无凭据时 hasCredentials=false", () => { + delete process.env.FLOCK_LOGIN_EMAIL; + delete process.env.FLOCK_LOGIN_PASSWORD; + expect(hasFlockLoginCredentials()).toBe(false); + }); + + it("成对凭据时开启登录门禁并关闭 fresh/random", () => { + process.env.FLOCK_LOGIN_EMAIL = "user@example.com"; + process.env.FLOCK_LOGIN_PASSWORD = "secret"; + process.env.FLOCK_FRESH_SESSION_PER_QUOTE = "true"; + process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = "true"; + expect(hasFlockLoginCredentials()).toBe(true); + expect(isFlockFreshSessionPerQuote()).toBe(false); + expect(isFlockRandomAccountPerQuote()).toBe(false); + expect(shouldSaveFlockStorageState()).toBe(true); + }); + + it("默认登录 URL", () => { + delete process.env.FLOCK_LOGIN_URL; + expect(getFlockLoginUrl()).toContain("flockfreight.com/login"); + }); +}); + +describe("isFlockLoginPage", () => { + it("识别 login URL", () => { + expect( + isFlockLoginPage({ + url: () => "https://app.flockfreight.com/login", + } as never), + ).toBe(true); + expect( + isFlockLoginPage({ + url: () => "https://app.flockfreight.com/get-a-quote", + } as never), + ).toBe(false); + }); +}); + +describe("login mode session rotation", () => { + afterEach(() => { + delete process.env.FLOCK_LOGIN_EMAIL; + delete process.env.FLOCK_LOGIN_PASSWORD; + resetFlockLoginSessionFlagForTests(); + }); + + it("有登录凭据时禁止换号", () => { + process.env.FLOCK_LOGIN_EMAIL = "a@b.com"; + process.env.FLOCK_LOGIN_PASSWORD = "x"; + expect( + shouldRotateFlockSession( + "FLOCK_ACCOUNT_REJECTED: We had an issue creating your account", + ), + ).toBe(false); + }); + + it("注册页阻断文案", () => { + expect(flockLoginRegistrationBlockedMessage()).toContain("登录凭据"); + }); +}); diff --git a/__tests__/workers/rpa/flock/page-alive.test.ts b/__tests__/workers/rpa/flock/page-alive.test.ts new file mode 100644 index 0000000..98dd770 --- /dev/null +++ b/__tests__/workers/rpa/flock/page-alive.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + flockLocatorCount, + flockPageClosedMessage, + isFlockPageAlive, + isFlockPageClosedError, +} from "@/workers/rpa/flock/page-alive"; + +describe("flock page-alive", () => { + it("识别 Target closed / locator.count 关窗错误", () => { + expect( + isFlockPageClosedError( + new Error("locator.count: Target page, context or browser has been closed"), + ), + ).toBe(true); + expect(isFlockPageClosedError(new Error("other"))).toBe(false); + }); + + it("关窗文案可读", () => { + expect(flockPageClosedMessage("填表")).toContain("BROWSER_CLOSED"); + expect(flockPageClosedMessage("填表")).toContain("填表"); + }); + + it("isFlockPageAlive null 为 false", () => { + expect(isFlockPageAlive(null)).toBe(false); + }); + + it("flockLocatorCount 在 page.isClosed 时返回 0", async () => { + const loc = { + page: () => ({ isClosed: () => true }), + count: async () => { + throw new Error("should not call"); + }, + }; + expect(await flockLocatorCount(loc as never)).toBe(0); + }); +}); diff --git a/__tests__/workers/rpa/flock/page-state.test.ts b/__tests__/workers/rpa/flock/page-state.test.ts new file mode 100644 index 0000000..2e22f9d --- /dev/null +++ b/__tests__/workers/rpa/flock/page-state.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { classifyFlockFailure } from "@/scripts/batch-flock-stress"; + +describe("classifyFlockFailure", () => { + it("识别报价上限", () => { + expect(classifyFlockFailure("FLOCK_QUOTE_LIMIT:获取报价达到上限")).toBe( + "rate_limit", + ); + }); +}); diff --git a/__tests__/workers/rpa/flock/quote-extract.test.ts b/__tests__/workers/rpa/flock/quote-extract.test.ts new file mode 100644 index 0000000..d952ce1 --- /dev/null +++ b/__tests__/workers/rpa/flock/quote-extract.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + extractFlockPendingReference, + formatFlockRatesPendingMessage, + isFlockRatesPendingText, + parseFlockMoney, + parseFlockTransit, +} from "@/workers/rpa/flock/quote-extract"; +import { isFlockNonRetryableFailure } from "@/lib/flock/exclusive-lock"; + +describe("parseFlockMoney", () => { + it("解析 Prices from 文案", () => { + expect(parseFlockMoney("Prices from$1,925Transit(incl. weekends)4 days")).toBe( + 1925, + ); + expect(parseFlockMoney("Prices from $1,187")).toBe(1187); + }); +}); + +describe("parseFlockTransit", () => { + it("解析含周末时效", () => { + const r = parseFlockTransit( + "Prices from$1,925Transit(incl. weekends)4 daysGuaranteed", + ); + expect(r.transitDays).toBe("4"); + }); + + it("解析工作日预估", () => { + const r = parseFlockTransit( + "Prices from$1,187Transit(business days only)Contact Rep", + ); + expect(r.transitDescription).toMatch(/business days only|工作日/); + }); +}); + +describe("Flock rates pending 页", () => { + const sample = + "#NCC-ZHZ4 pricing options\nExpires Jul 17, 2026\nHold tight, we are still looking for the best rates!\nWe should have a rate for you in about 15 minutes."; + + it("识别 Hold tight 待价文案", () => { + expect(isFlockRatesPendingText(sample)).toBe(true); + expect(isFlockRatesPendingText("Prices from $100")).toBe(false); + }); + + it("从标题抽取参考编号", () => { + expect(extractFlockPendingReference(sample)).toBe("NCC-ZHZ4"); + }); + + it("待价结果禁止 BullMQ 重跑", () => { + const msg = formatFlockRatesPendingMessage("NCC-ZHZ4"); + expect(msg).toMatch(/FLOCK_RATES_PENDING/); + expect(msg).toContain("NCC-ZHZ4"); + expect(isFlockNonRetryableFailure(msg)).toBe(true); + }); +}); diff --git a/__tests__/workers/rpa/flock/run-quote.test.ts b/__tests__/workers/rpa/flock/run-quote.test.ts new file mode 100644 index 0000000..b8b1bf5 --- /dev/null +++ b/__tests__/workers/rpa/flock/run-quote.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FlockRunQuoteResult } from "@/workers/rpa/flock/types"; + +const visualChainMock = vi.fn<() => Promise>(); + +vi.mock("@/workers/rpa/flock/visual-chain", () => ({ + runFlockVisualChain: () => visualChainMock(), +})); + +describe("runFlockVisualChainWithRotations", () => { + beforeEach(() => { + visualChainMock.mockReset(); + process.env.FLOCK_SESSION_MAX_ROTATIONS = "3"; + }); + + it("拒号时换邮箱重试直至成功", async () => { + visualChainMock + .mockResolvedValueOnce({ + ok: false, + quotes: [], + errorMessage: "FLOCK_ACCOUNT_REJECTED:issue creating your account", + }) + .mockResolvedValueOnce({ + ok: true, + quotes: [{ tier: "standard", totalUsd: 100 } as FlockRunQuoteResult["quotes"][0]], + }); + + const { runFlockVisualChainWithRotations } = await import( + "@/workers/rpa/flock/run-quote" + ); + const result = await runFlockVisualChainWithRotations({ + pickupZip: "94102", + deliveryZip: "90212", + pickupDate: "2026-07-20", + palletCount: 4, + totalWeightLb: 2800, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }); + + expect(result.ok).toBe(true); + expect(visualChainMock).toHaveBeenCalledTimes(2); + }); + + it("报价上限时不轮换", async () => { + visualChainMock.mockResolvedValue({ + ok: false, + quotes: [], + errorMessage: "FLOCK_QUOTE_LIMIT:Thank you for your interest", + }); + + const { runFlockVisualChainWithRotations } = await import( + "@/workers/rpa/flock/run-quote" + ); + const result = await runFlockVisualChainWithRotations({ + pickupZip: "94102", + deliveryZip: "90212", + pickupDate: "2026-07-20", + palletCount: 4, + totalWeightLb: 2800, + lengthIn: 48, + widthIn: 40, + heightIn: 48, + }); + + expect(result.ok).toBe(false); + expect(visualChainMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/workers/rpa/flock/stall-watch.test.ts b/__tests__/workers/rpa/flock/stall-watch.test.ts new file mode 100644 index 0000000..224daff --- /dev/null +++ b/__tests__/workers/rpa/flock/stall-watch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + describeFlockPagePhase, + isFlockQuoteLimitText, +} from "@/workers/rpa/flock/page-state"; +import { readFlockStallMs } from "@/workers/rpa/flock/stall-watch"; + +describe("isFlockQuoteLimitText", () => { + it("识别 Thank you for your interest", () => { + expect(isFlockQuoteLimitText("Thank you for your interest in Flock")).toBe(true); + expect(describeFlockPagePhase("Thank you for your interest")).toBe("quote_limit"); + }); + + it("识别中文上限", () => { + expect(isFlockQuoteLimitText("获取报价达到上限")).toBe(true); + }); +}); + +describe("readFlockStallMs", () => { + it("默认 20s", () => { + const prev = process.env.FLOCK_STALL_MS; + delete process.env.FLOCK_STALL_MS; + expect(readFlockStallMs()).toBe(20_000); + process.env.FLOCK_STALL_MS = prev; + }); +}); diff --git a/__tests__/workers/rpa/flock/worker-session.test.ts b/__tests__/workers/rpa/flock/worker-session.test.ts new file mode 100644 index 0000000..4734655 --- /dev/null +++ b/__tests__/workers/rpa/flock/worker-session.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { shouldRotateFlockSession } from "@/workers/rpa/flock/worker-session"; + +describe("shouldRotateFlockSession", () => { + it("注册被拒时轮换", () => { + expect( + shouldRotateFlockSession("FLOCK_ACCOUNT_REJECTED: We had an issue creating your account"), + ).toBe(true); + }); + + it("邮箱已存在时轮换", () => { + expect(shouldRotateFlockSession("FLOCK_ACCOUNT_EXISTS: already registered")).toBe( + true, + ); + }); + + it("报价上限时轮换", () => { + expect( + shouldRotateFlockSession( + "FLOCK_QUOTE_LIMIT:官网提示 Thank you for your interest(获取报价已达上限)", + ), + ).toBe(false); + }); + + it("普通报价失败不轮换", () => { + expect(shouldRotateFlockSession("RPA_DATA_INVALID:20s 内未拿到报价")).toBe(false); + }); +}); diff --git a/__tests__/workers/rpa/mothership-logged-in-quote.test.ts b/__tests__/workers/rpa/mothership-logged-in-quote.test.ts new file mode 100644 index 0000000..7a46a54 --- /dev/null +++ b/__tests__/workers/rpa/mothership-logged-in-quote.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + buildLoggedInAddressSearchQuery, + formatLoggedInReadyGridCell, + MOTHERSHIP_CREATE_SHIPMENT_URL, + parseLoggedInRateCardText, +} from "@/workers/rpa/mothership-logged-in-quote"; +import { filterQuotesForMotherShipUiDisplay } from "@/lib/constants/mothership-ui-tiers"; +import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator"; + +describe("MOTHERSHIP_CREATE_SHIPMENT_URL", () => { + it("登录态一级表单直达 /ship(避免菜单文案超时)", () => { + expect(MOTHERSHIP_CREATE_SHIPMENT_URL).toBe( + "https://dashboard.mothership.com/ship", + ); + }); +}); + +describe("parseLoggedInRateCardText", () => { + it("解析 ABF Direct 卡片文本", () => { + const item = parseLoggedInRateCardText( + "Selected ABF Direct $835.61 Estimated 5 business days", + ); + expect(item?.carrier).toBe("ABF Direct"); + expect(item?.rawTotal).toBe(835.61); + expect(item?.transitDays).toBe("5"); + expect(item?.serviceLevel).toBe("standard"); + }); + + it("解析 XPO Direct", () => { + const item = parseLoggedInRateCardText( + "XPO Direct $1,015.28 Estimated 4 business days", + ); + expect(item?.carrier).toBe("XPO Direct"); + expect(item?.rawTotal).toBe(1015.28); + }); + + it("解析区间时效 5-7 business days", () => { + const item = parseLoggedInRateCardText( + "SAIA Direct $900.00 5-7 business days", + ); + expect(item?.transitDays).toBe("5-7"); + }); + + it("无时效文案时用待确认,且通过 schema 校验(避免 standard/bestValue 时效为空)", () => { + const item = parseLoggedInRateCardText("Daylight Direct $733.98"); + expect(item?.transitDays).toBe("待确认"); + expect(item?.rateOption).toBe("bestValue"); + expect(validateQuoteSchema([item!])).toHaveLength(1); + }); +}); + +describe("formatLoggedInReadyGridCell", () => { + it("ISO 日期转为 gridcell 名", () => { + expect(formatLoggedInReadyGridCell("2026-07-16")).toBe("Thu Jul 16"); + }); +}); + +describe("buildLoggedInAddressSearchQuery", () => { + it("优先用 street/city/state/zip,避开 Places description 的 floor/suite", () => { + const q = buildLoggedInAddressSearchQuery({ + street: "2020 West Washington Boulevard", + city: "Los Angeles", + state: "CA", + zip: "", + placeId: "ChIJ-test", + formattedAddress: + "2020 West Washington Boulevard floor 5 rm 508, Los Angeles, CA, USA", + selectedFromSuggestions: true, + mothershipOptionId: "ChIJ-test", + mothershipDisplayLabel: + "2020 West Washington Boulevard floor 5 rm 508, Los Angeles, CA, USA", + selectedFromMothership: true, + }); + expect(q).toBe("2020 West Washington Boulevard, Los Angeles, CA"); + expect(q).not.toMatch(/floor|rm 508/i); + }); + + it("ste/suite 也会从 street 剥离", () => { + const q = buildLoggedInAddressSearchQuery({ + street: "555 Market St ste 1001", + city: "San Francisco", + state: "CA", + zip: "94105", + placeId: "ChIJ-sf", + formattedAddress: "555 Market St ste 1001, San Francisco, CA, USA", + selectedFromSuggestions: true, + mothershipOptionId: "ChIJ-sf", + mothershipDisplayLabel: "555 Market St ste 1001, San Francisco, CA, USA", + selectedFromMothership: true, + }); + expect(q).toBe("555 Market St, San Francisco, CA, 94105"); + expect(q).not.toMatch(/ste|1001/i); + }); +}); + +describe("filterQuotesForMotherShipUiDisplay 多承运商", () => { + it("同档不同承运商均保留", () => { + const ui = filterQuotesForMotherShipUiDisplay([ + { + service_level: "standard", + rate_option: "bestValue", + carrier: "ABF Direct", + raw: 835, + }, + { + service_level: "standard", + rate_option: "bestValue", + carrier: "XPO Direct", + raw: 1015, + }, + ]); + expect(ui).toHaveLength(2); + }); +}); diff --git a/__tests__/workers/rpa/widget-truck-tab-quotes.test.ts b/__tests__/workers/rpa/widget-truck-tab-quotes.test.ts new file mode 100644 index 0000000..df22631 --- /dev/null +++ b/__tests__/workers/rpa/widget-truck-tab-quotes.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + formatWidgetQuoteTierLabel, + mergeWidgetTabQuotePrice, +} from "@/workers/rpa/quote-capture/widget-truck-tab-quotes"; +import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator"; +import type { QuoteItem } from "@/modules/providers/quote-provider"; + +function item(serviceLevel: string, rateOption: string, rawTotal: number): QuoteItem { + return { + serviceLevel, + rateOption, + carrier: "Mothership", + transitDays: "—", + transitDescription: "—", + rawFreight: rawTotal, + surcharges: 0, + rawTotal, + }; +} + +describe("formatWidgetQuoteTierLabel", () => { + it("Shared truck · Best value 文案", () => { + expect(formatWidgetQuoteTierLabel(item("standard", "bestValue", 81.71))).toBe( + "standard/bestValue (Shared truck · Best value)", + ); + }); + + it("Dedicated truck 文案", () => { + expect(formatWidgetQuoteTierLabel(item("dedicated", "bestValue", 246.11))).toBe( + "dedicated/bestValue (Dedicated truck)", + ); + }); +}); + +describe("mergeWidgetTabQuotePrice", () => { + it("Tab 价覆盖金额但保留 axel 时效", () => { + const axel: QuoteItem = { + ...item("standard", "bestValue", 80), + transitDays: "3 business days", + transitDescription: "3 个工作日", + carrier: "Mothership Carrier", + }; + const tab = item("standard", "bestValue", 81.71); + const merged = mergeWidgetTabQuotePrice(axel, tab); + const dedicated = mergeWidgetTabQuotePrice( + undefined, + item("dedicated", "bestValue", 246.11), + ); + expect(merged.rawTotal).toBe(81.71); + expect(merged.transitDays).toBe("3 business days"); + expect(dedicated.transitDays).toBe("待确认"); + expect(validateQuoteSchema([merged, dedicated])).toHaveLength(2); + }); +}); diff --git a/api调用文档/api对接文档.md b/api调用文档/api对接文档.md new file mode 100644 index 0000000..be5ce08 --- /dev/null +++ b/api调用文档/api对接文档.md @@ -0,0 +1,981 @@ +# 美美与共查价系统 · 宿主 OpenAPI 技术对接文档 + +**版本**:1.2 +**更新日期**:2026-07-22 +**适用对象**:第三方宿主系统(服务端对接 / 网页嵌入) + +--- + +## 1. 文档说明 + +本文档供第三方宿主系统对接「美美与共查价中台」使用。 + +中台对接 **两个官网**,每个官网各有 **免账号 / 登录后** 两种查价方式,合计 **四条互斥路线**: + +| 路线 ID | 官网 | 账密 | 说明 | +|---------|------|------|------| +| `MS_GUEST` | MotherShip | 无 | 免登录 Widget / Direct 路径 | +| `MS_LOGGED_IN` | MotherShip | 有(该客户已绑定 MotherShip 账密) | 官网 Create shipment 登录态路径 | +| `FLOCK_GUEST` | Flock Freight | 无 | 免登录 Direct(失败可回退 DOM) | +| `FLOCK_LOGGED_IN` | Flock Freight | 有(该客户已绑定 Flock 账密) | 强制官网登录 DOM,禁止 Direct | + +**核心规则**:根据「用户/宿主上传的承运商账密」与「本票货物/地址参数是否符合某官网硬限」判定路线后,**整票只走一条路线**,禁止混用上限、混用接口、混用结果字段。 + +本文档仅列出对调用方有意义且可依赖的字段。响应中如出现未在本文档定义的其他字段,均属于平台保留字段,调用方不应依赖。 + +**对接形态**: + +| 形态 | 说明 | 见章节 | +|------|------|--------| +| OpenAPI 服务端调用 | 宿主后端带 API Key 调两步查价接口 | §2~§6 | +| 网页嵌入(iframe) | 宿主页面嵌查价 UI;**须直传类型+Key,跳过登录页** | §2.5 | + +> **安全要求**:API Key、承运商官网账密仅允许保存在宿主服务端或经我方管理端加密存储;禁止写入宿主公开前端仓库、移动端安装包。OpenAPI 调用禁止把 Key 放进 URL;iframe 嵌入仅允许由**宿主服务端**在生成 `iframe.src` 时短时拼入查询串(见 §2.5)。 + +--- + +## 2. 接入信息 + +### 2.1 基础地址 + +| 环境 | Base URL | +|------|----------| +| 当前联调 / 演示 | `https://if.dev.51track.vip` | + +> 公网访问**不要**追加端口(如 `:30325`)。`:30325` 仅用于服务器本机调试。 + +嵌入页路径:`{Base URL}/embed-demo` + +### 2.2 请求格式 + +- 请求方法:以各接口定义为准 +- 请求体:JSON +- 字符编码:UTF-8 +- Content-Type:`application/json` + +### 2.3 鉴权请求头(OpenAPI) + +| 请求头 | 必填 | 说明 | +|--------|------|------| +| Authorization | 是 | `Bearer `;Key 通常以 `chj_` 开头,由我方管理端发放 | +| X-Customer-Id | 是 | 客户 ID,必须与 API Key 绑定一致 | +| Content-Type | 是 | 固定为 `application/json` | + +本次联调凭证(示例,正式值由我方单独发放): + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| API Key | `<由我方发放>` | 放入 `Authorization: Bearer ...` | +| Customer ID | `<由我方发放>` | 放入 `X-Customer-Id` | + +> **注意(OpenAPI)**:API Key 不得放入 URL、请求体或业务日志明文。 + +**请求头示例**: + +```http +Authorization: Bearer chj_xxxxxxxxxxxxxxxxxxxxx +X-Customer-Id: CUST_001 +Content-Type: application/json +``` + +内网联调若使用环境 Token(非正式第三方形态): + +```http +Authorization: Bearer demo-host-token +X-Customer-Id: CUST_001 +Content-Type: application/json +``` + +### 2.4 通用响应结构 + +| 字段 | 类型 | 说明 | +|------|------|------| +| code | Number / String | `0` 表示接口调用成功;非 `0` 表示鉴权、参数或业务处理失败 | +| message | String | 提示信息;成功时通常为 `ok` | +| data | Object / null | 成功时的业务数据;失败时多为 `null` | + +### 2.5 网页嵌入(iframe / CC 等宿主)— 跳过登录页 + +#### 2.5.1 目标 + +宿主将查价系统嵌进自有页面时: + +1. **用户不应再看到**嵌入页「客户登录」表单。 +2. 宿主传入 **登录类型 + 凭证** 后,直接进入 **MotherShip / Flock Freight** 两模块门户。 +3. 模块内可再选「免账号 / 登录后」;登录后路线需已绑定承运商官网账密(可在嵌入页内配置)。 + +#### 2.5.2 URL 查询参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `login_type` | 建议 | `api_key` 或 `password`;别名:`type`、`lt` | +| `api_key` | Key 登录必填 | 客户 API Key;别名:`key`、`token` | +| `customer_id` | 密码登录必填 | 客户编号;别名:`customerId` | +| `password` | 密码登录必填 | 嵌入页客户密码;别名:`pwd` | +| `embed` | 可选 | `1` / `true`:宿主托管壳(隐藏「退出登录」);别名:`hosted`、`from_cc`、`from_host` | + +仅带 `api_key`(或 `key`)且未写 `login_type` 时,按 **`api_key` 登录**处理。 + +#### 2.5.3 推荐写法(CC:API Key) + +```text +https://if.dev.51track.vip/embed-demo?login_type=api_key&api_key=&embed=1 +``` + +短写: + +```text +https://if.dev.51track.vip/embed-demo?type=api_key&key=&embed=1 +``` + +**iframe 示例(Key 须由宿主后端渲染进 HTML,禁止写死在公开前端仓库)**: + +```html + +``` + +账号密码直登(较少用): + +```text +https://if.dev.51track.vip/embed-demo?login_type=password&customer_id=CUST_001&password=<密码>&embed=1 +``` + +#### 2.5.4 行为与安全 + +| 步骤 | 行为 | +|------|------| +| 1 | 打开嵌入页 → 读取查询串 → 调用 `POST /api/embed-demo/login` | +| 2 | 成功 → 写入嵌入会话 Cookie → 进入两模块门户(**不展示登录页**) | +| 3 | 前端 `history.replaceState` 去掉地址栏中的 Key/密码等敏感参数 | +| 4 | 失败 → 展示错误文案,并可回落手动登录表单 | + +安全约定: + +- Key **只允许**宿主服务端拼进 `iframe.src`;用户浏览器地址栏会短暂可见,登录成功后会被剥离。 +- 生产环境嵌入 Cookie 使用 `SameSite=None; Secure`,以支持跨站 iframe。 +- 勿在公开仓库、前端构建产物、CDN 静态配置中明文提交 API Key。 +- OpenAPI 仍须走 `Authorization` 头(§2.3),与嵌入 URL 直传互不替代。 + +#### 2.5.5 常见错误 + +| 现象 | 可能原因 | 处理 | +|------|----------|------| +| 仍停在登录页 | 未传 `api_key` / `login_type`,或 Key 为空 | 检查 iframe `src` | +| 提示 API Key 无效 | Key 错误、已轮换、或客户停用 | 管理端核对 Key 与客户状态 | +| 提示缺少 Authorization | 宿主在调 OpenAPI 时未带鉴权头 | 与嵌入无关;按 §2.3 补头 | +| 登录成功但刷新掉登录 | 跨站 Cookie 被拦 / 非 HTTPS | 确认生产 HTTPS;Cookie 为 None+Secure | + +**成功响应示例**(OpenAPI 通用): + +```json +{ + "code": 0, + "message": "ok", + "data": { } +} +``` + +**失败响应示例**: + +```json +{ + "code": "VALIDATION_FAILED", + "message": "请填写 2 位州码", + "data": null +} +``` + +> 调用方不能仅根据 HTTP 状态码判断业务成功。仅当 `code = 0` 时,才能判定本次接口调用成功。 +> 异步询价业务失败时,轮询接口可能仍返回 HTTP 200 且 `code = 0`,此时须检查 `data.status`:若为 `failed`,按失败处理并展示 `data.error_message`。 + +--- + +## 3. 路线判定(必读) + +宿主在发起询价前,按下列顺序 **确定唯一路线**,然后只调用该路线对应接口与字段。 + +### 3.1 判定流程 + +``` +1) 宿主明确选择官网,或按参数硬限自动匹配: + - 符合 MotherShip 硬限且业务选 MotherShip → 进入 MS_* + - 符合 Flock 硬限且业务选 Flock → 进入 FLOCK_* + - 两网站都不符合 → 拒绝询价,提示用户改货改地址 + - 禁止:同一票并行打两家再拼结果(除非宿主产品明确要求「比价」且各自独立展示) + +2) 查该客户在我方是否已绑定对应官网账密(管理端「查价网站账密」): + - MotherShip 已绑账密 → 必须走 MS_LOGGED_IN(服务端强制登录态,禁止匿名 Direct/缓存价) + - MotherShip 未绑账密 → 走 MS_GUEST + - Flock 已绑账密 → 必须走 FLOCK_LOGGED_IN(服务端强制 DOM 登录,禁止 Direct) + - Flock 未绑账密 → 走 FLOCK_GUEST + +3) 账密与参数必须同属一条路线: + - 有 Flock 账密但货物只符合 MotherShip → 只能走 MS_*(Flock 账密本票不用) + - 有 MotherShip 账密但货物只符合 Flock → 只能走 FLOCK_* + - 有账密却按免账号字段凑合提交:服务端仍可能强制登录态,价格口径与免账号不同,宿主须按登录后字段规范传参 +``` + +### 3.2 两官网硬限对照(换算后英制) + +| 项 | MotherShip(MS_*) | Flock(FLOCK_*) | +|----|--------------------|------------------| +| 地址形态 | 街道 + 城市 + 州码(建议邮编);须经候选确认 | **仅邮编**(5 位);提货邮编 ≠ 派送邮编 | +| 托盘 / 件数 | 1~25 | 免账号 **4~20**;登录后 `form_mode=logged_in_quick` 时 1~999 | +| 单托重量 | 0.01~9999 lb;登录后 Weight each ≤ **5000 lb** | 按**整票总重**,≤ **45000 lb** | +| 尺寸 | L≤999 in,W/H≤99 in | L≤636 in,W≤102 in,H≤108 in | +| 其它 | 整票总重 ≤ 249975 lb | 免账号须满足拖车线性英尺(约 53') | + +重量 / 尺寸支持 `lb`/`kg`/`t`(公制吨)、`in`/`cm`/`m` 入参,服务端统一换算为 lb/in(小数进一取整)。宿主应在提交前按目标官网硬限拦截。 + +### 3.3 接口总览 + +| 路线 | 推荐调用链 | 等待方式 | 典型耗时 | +|------|------------|----------|----------| +| `MS_GUEST` | `POST /api/host/quote/candidates` → `POST /api/host/quote/submit` | **同步**(submit 内等待) | 10~420 s | +| `MS_LOGGED_IN` | `POST /api/addresses/mothership-suggest`(可选)→ 组地址 → `POST /api/quotes` → `GET /api/quotes/{quote_id}` | **异步轮询** | 轮询总窗口建议 ≥ 420 s | +| `FLOCK_GUEST` | `POST /api/flock/quotes` → `GET /api/quotes/{quote_id}` | **异步轮询** | 建议 ≥ 210 s(可配置) | +| `FLOCK_LOGGED_IN` | 同上;`flock_input.form_mode = "logged_in_quick"` | **异步轮询** | 建议 ≥ 210 s | + +> `MS_GUEST` 也可改用异步三步(`POST /api/quotes` + 轮询),但第三方默认同步对接请用宿主两步接口。 +> 已绑定 MotherShip 账密时,即便误调宿主两步接口,服务端仍会强制登录态 RPA;宿主两步**不支持**登录后扩展字段(`cargo_lines` / `mothership_details` 等),完整登录后能力请走 `MS_LOGGED_IN` 异步接口。 + +--- + +## 4. 承运商账密(路线开关) + +账密由我方管理端按客户绑定,RPA 运行时解密使用,**不进入询价请求体**。 + +### 4.1 管理端接口(内部运维,非宿主日常调用) + +```http +GET /api/admin/customers/{customer_id}/provider-credentials +PUT /api/admin/customers/{customer_id}/provider-credentials +``` + +需管理员 JWT。PUT 示例: + +```json +{ + "provider": "mothership", + "email": "user@example.com", + "password": "********" +} +``` + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| provider | String | 是 | `mothership` 或 `flock` | 对应官网 | +| email | String | 是 | 非空 | 官网登录邮箱 | +| password | String | 是 | 非空 | 官网登录密码 | + +**影响**: + +- 绑定成功后,该客户对该 `provider` **自动切换到对应 `*_LOGGED_IN` 路线**。 +- 账密错误时询价可能返回 `PROVIDER_LOGIN_FAILED`(或业务失败 `error_message` 提示确认账密)。 +- 更换账密须按管理端策略操作;勿在业务日志打印明文密码。 + +宿主侧若自行收集用户官网账密,应通过约定安全通道交给我方写入管理端,或由双方约定的 BFF 代写;**询价 OpenAPI 请求体中不要传官网密码**。 + +--- + +## 5. 路线 A:MotherShip 免账号(`MS_GUEST`) + +适用:客户**未**绑定 MotherShip 账密;参数符合 MotherShip 硬限。 + +### 5.1 调用流程 + +1. 带鉴权头调用「获取地址候选」。 +2. 保存 `host_session_id`(约 30 分钟有效)。 +3. 用户从 `pickup_candidates`、`delivery_candidates` 各选 1 条 `selectable = true`。 +4. 将**完整候选对象**与 `host_session_id` 提交「提交并获取报价」。 +5. 第二步可能持续 **10~420 秒**;客户端与网关超时建议 **≥ 420 秒**。 +6. `status = done` 展示 `quotes[]`;`failed` 展示中文 `error_message`。 + +> 第二步期间展示 Loading,并禁止重复提交。 + +### 5.2 第一步:获取地址候选 + +#### 接口地址 + +`POST /api/host/quote/candidates` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| pickup_address | Object | 是 | 见下表 | 提货草稿地址 | +| delivery_address | Object | 是 | 见下表 | 派送草稿地址 | +| cargo | Object | 是 | 见下表 | 货物信息 | + +**地址对象(pickup_address / delivery_address)** + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| street | String | 是 | 非空 | 街道地址 | +| city | String | 是 | 非空 | 城市 | +| state | String | 是 | 固定 2 位 | 美国州二字码,如 `CA`、`TX` | +| zip | String | 否 | `12345` 或 `12345-6789`;可空 | 邮编;建议传 | + +**货物对象(cargo)** + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| weight.value | Number | 是 | 正数 | 单托重量数值 | +| weight.unit | String | 是 | `lb`、`kg` 或 `t`(公制吨);别名 `lbs`/`pound(s)`/`tonne(s)`;**禁止** `ton`/`tons`(美制短吨歧义) | 重量单位 | +| dimensions.length / width / height | Number | 是 | 正数 | 单托尺寸 | +| dimensions.unit | String | 是 | `in`、`cm` 或 `m`;别名 `inch`/`meter` 亦可 | 尺寸单位 | +| pallet_count | Number | 是 | 整数 1~25 | 托盘数 | +| cargo_type | String | 是 | 见 §12.1 | 货物类型枚举 | + +**请求示例**: + +```json +{ + "pickup_address": { + "street": "1234 Warehouse Blvd", + "city": "Los Angeles", + "state": "CA", + "zip": "90001" + }, + "delivery_address": { + "street": "5678 Distribution Dr", + "city": "Dallas", + "state": "TX", + "zip": "75201" + }, + "cargo": { + "weight": { "value": 500, "unit": "kg" }, + "dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" }, + "pallet_count": 2, + "cargo_type": "general_freight" + } +} +``` + +#### 响应数据 + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.host_session_id | String (UUID) | 第二步必传;约 30 分钟有效 | +| data.pickup_candidates | Array\ | 提货候选 | +| data.delivery_candidates | Array\ | 派送候选 | +| data.requires_selection | Boolean | 是否需要用户选择 | + +**候选对象字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| option_id | String | 候选唯一标识 | +| display_label | String | 展示文案 | +| formatted_address | String | 格式化地址 | +| street / city / state / zip | String | 结构化地址 | +| selectable | Boolean | 仅可选 `true` | +| unavailable_reason | String | 不可选原因(可能出现) | + +#### 调用后处理规则 + +1. 必须保存 `host_session_id`。 +2. 提货、派送各选 1 条 `selectable = true`。 +3. 第二步必须回传第一步返回的**完整候选对象**,禁止只传 `option_id`,禁止自行拼装。 +4. 会话过期后须重新调用本接口。 + +### 5.3 第二步:提交候选并获取报价 + +#### 接口地址 + +`POST /api/host/quote/submit` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| host_session_id | String | 是 | UUID;来自第一步 | 宿主会话 ID | +| pickup_candidate | Object | 是 | 第一步完整候选 | 提货地址 | +| delivery_candidate | Object | 是 | 第一步完整候选 | 派送地址 | + +#### 超时与重试 + +- 客户端 HTTP 超时:`420000` ms。 +- 网关 `proxy_read_timeout` ≥ 420 秒。 +- `SESSION_EXPIRED`:重新走第一步。 +- `QUOTE_TIMEOUT`:可稍后重试;建议重新获取候选。 + +#### 响应数据 + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.quote_id | String | 报价单号 | +| data.request_id | String | 请求标识 | +| data.status | String | `done` / `failed` | +| data.currency | String | 固定 `USD` | +| data.valid_until | String | ISO 有效期 | +| data.source_type | String | `rpa` / `cache` / `stale` 等 | +| data.quotes | Array\ | 档位列表 | +| data.error_code / error_message | String | 失败时 | + +**quotes[] 字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| service_level | String | 如 `standard`、`guaranteed` | +| rate_option | String | 如 `lowest`、`fastest`、`bestValue` | +| carrier | String | 承运商 | +| transit_days | String | 时效描述 | +| final_total | Number | **对客户展示价(USD,已含加价)** | + +> 档位数量不固定,按实际列表展示。 + +**成功响应示例**: + +```json +{ + "code": 0, + "message": "ok", + "data": { + "quote_id": "QTE_20260630_0003", + "request_id": "xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx", + "status": "done", + "currency": "USD", + "valid_until": "2026-06-30T10:30:00.000Z", + "source_type": "rpa", + "quotes": [ + { + "service_level": "standard", + "rate_option": "lowest", + "carrier": "Frontline Freight", + "transit_days": "Est. 5 business days", + "final_total": 337.06 + } + ] + } +} +``` + +--- + +## 6. 路线 B:MotherShip 登录后(`MS_LOGGED_IN`) + +适用:客户**已**绑定 MotherShip 账密;参数符合 MotherShip 硬限(含登录后 Weight each ≤ 5000 lb)。 + +服务端检测到账密后会强制登录态查价,禁止命中匿名 Direct / L2 缓存价。 + +### 6.1 调用流程 + +1. (推荐)用 `POST /api/addresses/mothership-suggest` 按关键字联想,用户点选候选。 +2. 组装完整询价体(含地址确认字段 + 可选登录后字段)。 +3. `POST /api/quotes` → 得到 `quote_id`,`status` 多为 `processing`。 +4. 轮询 `GET /api/quotes/{quote_id}` 至 `done` / `failed`(间隔建议 2 s,总窗口 ≥ 420 s)。 +5. 成功时读 `data.quotes[]`(结构同 §5.3)。 + +### 6.2 地址联想(可选但推荐) + +#### 接口地址 + +`POST /api/addresses/mothership-suggest` + +#### 请求参数 + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| customer_id | String | 是 | 与鉴权客户一致 | 客户 ID | +| query | String | 是 | 去空白后 ≥ 3 字符 | 用户键入的地址关键字 | + +**请求示例**: + +```json +{ + "customer_id": "CUST_001", + "query": "1234 Warehouse" +} +``` + +#### 响应数据 + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.candidates | Array\ | 候选列表(字段同 §5.2 候选对象) | + +用户选定后,须把候选映射进 `pickup_address` / `delivery_address`(见下节必填确认字段)。 + +### 6.3 提交询价 + +#### 接口地址 + +`POST /api/quotes` + +#### 请求参数(核心) + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| request_id | String | 是 | 非空;建议 UUID | 幂等 / 追踪 | +| customer_id | String | 是 | 与鉴权一致 | 客户 ID | +| pickup_address | Object | 是 | 见下表 | 提货地址(须已确认) | +| delivery_address | Object | 是 | 见下表 | 派送地址(须已确认) | +| weight | Object | 是 | value 正数;unit `lb`/`kg`/`t` | 单托重量(兼容字段) | +| dimensions | Object | 是 | unit `in`/`cm`/`m` | 单托尺寸(兼容字段) | +| pallet_count | Number | 是 | 1~25 | 托盘数;多行时建议为各行 quantity 合计 | +| cargo_type | String | 是 | 见 §12.1 | 货物类型 | +| service_level | String | 否 | `standard` / `guaranteed` | 服务等级 | +| rate_option | String | 否 | `lowest` / `fastest` | 价格偏好 | +| ready_date | String | 否 | `YYYY-MM-DD`;周末会拨到下一工作日 | 可提货日 | +| ready_time | String | 否 | ≤32 字符 | 可提货时间 | +| pickup_accessorials | Array\ | 否 | 每项 ≤64;最多 20 | 提货附加服务 id | +| delivery_accessorials | Array\ | 否 | 同上 | 派送附加服务 id | +| cargo_lines | Array\ | 否 | 1~20 行 | 登录后多行货物(RPA 按行填);**字段已为英制,不做 kg/cm/m/t 换算** | +| mothership_details | Object | 否 | 见下 | 二级 Details(联系人/FBA 等) | + +**地址对象必填确认字段(与免账号草稿不同)** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| street / city / state | String | 是 | 结构化地址;state 2 位 | +| zip | String | 否 | 可空字符串 | +| place_id | String | 是 | 通常用候选 `option_id` | +| formatted_address | String | 是 | 完整展示地址 | +| selected_from_suggestions | Boolean | 是 | 必须为 `true` | +| mothership_option_id | String | 是 | 候选 `option_id` | +| mothership_display_label | String | 是 | 候选 `display_label` | +| selected_from_mothership | Boolean | 是 | 必须为 `true` | + +**cargo_lines[]** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| cargo_type | String | 是 | 行货物类型文案/枚举 | +| quantity | Number | 是 | 正数,≤999 | +| weight_lb | Number | 是 | 该行单件重量 **lb**(须已换算;不接受 kg/t)建议 ≤5000 | +| length_in / width_in / height_in | Number | 是 | 该行尺寸 **英寸**(须已换算;不接受 cm/m) | + +**mothership_details(节选)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| pickup / delivery | Object | `company_name`、`suite`、联系人姓名邮箱电话、`reference`、`notes`、`opens_at`、`closes_at` | +| request_delivery_appointment | Boolean | 是否预约送货 | +| fba_number / fba_po_number | String | FBA 相关 | +| cargo[] | Array | `description`、`nmfc`、`hazmat`、`alcohol`、`tobacco`、件数类型与数量等 | + +**请求示例(登录后最小可用)**: + +```json +{ + "request_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9", + "customer_id": "CUST_001", + "pickup_address": { + "street": "1234 Warehouse Street", + "city": "Los Angeles", + "state": "CA", + "zip": "90001", + "place_id": "Eisx...", + "formatted_address": "1234 Warehouse Street, Los Angeles, CA, USA", + "selected_from_suggestions": true, + "mothership_option_id": "Eisx...", + "mothership_display_label": "1234 Warehouse Street, Los Angeles, CA, USA", + "selected_from_mothership": true + }, + "delivery_address": { + "street": "5678 Distribution Dr, Wilmer", + "city": "Dallas", + "state": "TX", + "zip": "75201", + "place_id": "EihE...", + "formatted_address": "Distribution Dr, Wilmer, Dallas, TX, USA", + "selected_from_suggestions": true, + "mothership_option_id": "EihE...", + "mothership_display_label": "Distribution Dr, Wilmer, Dallas, TX, USA", + "selected_from_mothership": true + }, + "weight": { "value": 1103, "unit": "lb" }, + "dimensions": { "length": 48, "width": 40, "height": 60, "unit": "in" }, + "pallet_count": 2, + "cargo_type": "general_freight", + "ready_date": "2026-07-22", + "cargo_lines": [ + { + "cargo_type": "general_freight", + "quantity": 2, + "weight_lb": 1103, + "length_in": 48, + "width_in": 40, + "height_in": 60 + } + ] +} +``` + +#### 提交成功响应 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "quote_id": "QTE_20260721_0012", + "status": "processing" + } +} +``` + +随后进入 §9 轮询。 + +--- + +## 7. 路线 C:Flock 免账号(`FLOCK_GUEST`) + +适用:客户**未**绑定 Flock 账密;参数符合 Flock 免账号硬限(托盘 4~20、总重 ≤45000 lb、线性英尺等)。 + +服务端优先走 Direct JSON;失败时按环境配置可回退 DOM 填表(可能弱注册)。 + +### 7.1 调用流程 + +1. 校验邮编、取货日、件数、总重、尺寸与线性英尺。 +2. `POST /api/flock/quotes` → `quote_id` + `processing`。 +3. 轮询 `GET /api/quotes/{quote_id}`(总窗口建议 ≥ 210 s)。 +4. 成功时读 `data.flock.lines[]`(**不是** MotherShip 的 `quotes[]` 形态)。 + +### 7.2 提交询价 + +#### 接口地址 + +`POST /api/flock/quotes` + +> 若服务端关闭 Flock 能力,可能返回业务不可用(如 `QUOTE_UNAVAILABLE`)。 + +#### 请求参数 + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| request_id | String | 是 | 非空 | 请求标识 | +| customer_id | String | 是 | 与鉴权一致 | 客户 ID | +| flock_input | Object | 是 | 见下表 | Flock 表单参数 | + +**flock_input(免账号)** + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| pickup_date | String | 是 | `MM/DD/YYYY`;不可早于今天;不可周末 | 提货日 | +| pickup_zip | String | 是 | 5 位邮编 | 提货邮编 | +| delivery_zip | String | 是 | 5 位;≠ pickup_zip | 派送邮编 | +| pallet_count | Number | 是 | 整数 **4~20** | 托盘件数 | +| total_weight | Object | 是* | value 正数;unit `lb`/`kg`/`t` | 整票总重;也可用 `total_weight_lb` | +| dimensions | Object | 否 | 缺省 48×40×48 in;unit `in`/`cm`/`m` | 单托尺寸 | +| form_mode | String | 否 | 免账号**不要**传 `logged_in_quick` | 省略即为匿名 | +| registration | Object | 否 | 见附录 | DOM 回退时可选自填弱注册信息 | + +\* `total_weight` 与 `total_weight_lb` 二选一即可。 + +**硬限(换算后)** + +| 项 | 限制 | +|----|------| +| 总重 | ≤ 45000 lb | +| 尺寸 | L≤636,W≤102,H≤108(in) | +| 线性英尺 | 整票占用 ≤ 约 53'(否则校验失败) | + +**请求示例**: + +```json +{ + "request_id": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "customer_id": "CUST_001", + "flock_input": { + "pickup_date": "07/22/2026", + "pickup_zip": "90001", + "delivery_zip": "75201", + "pallet_count": 6, + "total_weight": { "value": 12000, "unit": "lb" }, + "dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" } + } +} +``` + +#### 提交成功响应 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "quote_id": "QTE_20260721_0201", + "status": "processing" + } +} +``` + +### 7.3 结果字段(轮询成功后) + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.flock.reference | String | Flock 参考号(可能为空) | +| data.flock.lines[] | Array | 报价行 | +| data.flock.lines[].tier | String | `flock_direct` / `standard` | +| data.flock.lines[].label | String | 展示名 | +| data.flock.lines[].transit_days / transitDays | String | 时效 | +| data.flock.lines[].final_total_usd | Number | **对客户展示价(USD,已含加价)** | +| data.flock.shipment_meta | Object | 提货日、邮编、重量等摘要 | + +> 一档即可能成功;勿假设固定两档。展示价优先 `final_total_usd`。 + +--- + +## 8. 路线 D:Flock 登录后(`FLOCK_LOGGED_IN`) + +适用:客户**已**绑定 Flock 账密;参数按登录后 Quick 表单填写。 + +服务端强制官网登录 DOM,**禁止**匿名 Direct。宿主须传 `form_mode: "logged_in_quick"`,并尽量补齐登录后字段,否则 RPA 可能填表失败。 + +### 8.1 调用流程 + +与 §7.1 相同(同一对接口),差异仅在请求体与服务端执行路径。 + +### 8.2 提交询价 + +#### 接口地址 + +`POST /api/flock/quotes`(同路线 C) + +#### flock_input 相对免账号的差异 + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| form_mode | String | **是(本路线)** | 固定 `logged_in_quick` | 切换登录后校验与 RPA 表单 | +| pallet_count | Number | 是 | **1~999** | 登录后件数上限放宽 | +| pickup_location_type | String | 建议 | 官网地点类型 | 如商业/住宅等 id/文案 | +| delivery_location_type | String | 建议 | 同上 | 派送地点类型 | +| packaging_type | String | 建议 | 包装类型 | 登录后 Quick 字段 | +| freight_class | String | 建议 | 货运等级 | 登录后 Quick 字段 | +| description | String | 建议 | 货物描述 | 登录后 Quick 字段 | +| stackable / turnable | Boolean | 否 | | 可堆叠 / 可翻转 | +| additional_services | Array\ | 否 | | 附加服务 id | +| vehicle_types | Array\ | 否 | | 允许车型 id | +| pickup_liftgate / pickup_inside / pickup_pallet_jack | Boolean | 否 | | 提货附属 | +| delivery_liftgate / delivery_inside / delivery_pallet_jack | Boolean | 否 | | 派送附属 | +| call_before_pickup / call_before_delivery | Boolean | 否 | | 提货/派送前电话 | +| pickup_service / delivery_service 及窗口时间字段 | String | 否 | 总重 >5000 lb 时官网才开放高级调度 | 见实现字段名 | + +> 登录后**不再强制**免账号的 4~20 托盘与线性英尺校验;尺寸/总重上限仍适用 Flock 官网硬限。 + +**请求示例**: + +```json +{ + "request_id": "b2c3d4e5-f6a7-4890-b123-456789abcdef", + "customer_id": "CUST_001", + "flock_input": { + "form_mode": "logged_in_quick", + "pickup_date": "07/22/2026", + "pickup_zip": "90001", + "delivery_zip": "75201", + "pallet_count": 2, + "total_weight": { "value": 2200, "unit": "lb" }, + "dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" }, + "packaging_type": "Pallets", + "freight_class": "70", + "description": "General freight", + "pickup_location_type": "Business with a dock or forklift", + "delivery_location_type": "Business with a dock or forklift" + } +} +``` + +结果字段同 §7.3。 + +--- + +## 9. 通用:异步轮询 + +适用于 `MS_LOGGED_IN`、`FLOCK_GUEST`、`FLOCK_LOGGED_IN`(以及非推荐的 MotherShip 异步三步)。 + +### 9.1 接口地址 + +`GET /api/quotes/{quote_id}` + +### 9.2 请求 + +- 方法:GET +- 路径参数:`quote_id`(提交接口返回) +- 鉴权头:同 §2.3(须为报价所属客户) + +### 9.3 响应数据 + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.quote_id | String | 报价单号 | +| data.request_id | String | 请求标识 | +| data.status | String | `processing` / `done` / `failed` / `expired` | +| data.currency | String | `USD` | +| data.quotes | Array | MotherShip 档位(MS_* 成功时) | +| data.flock | Object | Flock 结果(FLOCK_* 成功时) | +| data.error_code / error_message | String | 失败时 | +| data.rpa_stage / rpa_stage_label | String | processing 时可能出现的进度 | +| data.valid_until | String | 成功时有效期 | + +### 9.4 轮询建议 + +| 项 | 建议 | +|----|------| +| 间隔 | 2 s;10 s 后可加密至约 0.8 s | +| MotherShip 总窗口 | ≥ `420000` ms | +| Flock 总窗口 | ≥ `210000` ms(以联调环境配置为准) | +| 终止条件 | `done` / `failed` / `expired` / 超时 | +| 并发 | 同一用户操作防重复提交 | + +--- + +## 10. 错误码 + +### 10.1 接口层错误(`code !== 0`) + +| code | HTTP | 说明 | 处理建议 | +|------|------|------|----------| +| UNAUTHORIZED | 401 | 缺少或无效 API Key | 检查 Bearer / 轮换 | +| FORBIDDEN | 403 | 客户与 Key 不匹配,或无权访问报价 | 核对 `X-Customer-Id` / `quote_id` | +| VALIDATION_FAILED | 400 | 字段不合法 | 按**当前路线**硬限与必填项检查 | +| SESSION_EXPIRED | 404 | `host_session_id` 过期 | 仅 MS_GUEST:重走候选 | +| ADDRESS_SESSION_MISMATCH | 400 | 候选不属于本次会话 | 使用返回的完整对象 | +| ADDRESS_NOT_SELECTABLE | 400 | 提交了不可选候选 | 改选 `selectable: true` | +| QUOTE_NOT_FOUND | 404 | 报价不存在 | 检查 quote_id | +| QUOTE_TIMEOUT | 504 | 同步等待超时 | 稍后重试 | +| RATE_LIMITED | 429 | 限流 | 降频 | +| QUOTE_UNAVAILABLE | 503 | 渠道不可用(如 Flock 关闭/占用) | 稍后重试或换路线 | +| INTERNAL_ERROR | 5xx | 系统异常 | 稍后重试 | + +### 10.2 询价业务失败(`code = 0` 且 `data.status = failed`) + +| error_code | 说明 | +|------------|------| +| QUOTE_UNAVAILABLE | 暂时无法获取报价 | +| QUOTE_TIMEOUT | 询价超时 | +| QUOTE_ENTRY_UNAVAILABLE | 报价入口暂时无法打开 | +| ADDRESS_SUGGESTION_NOT_FOUND | 地址无法匹配 | +| CARRIER_NO_CAPACITY | 该线路暂无可用运力 | +| ADDRESS_NOT_SUPPORTED | 地址不在服务范围 | +| RPA_CAPTCHA | 触发验证码 | +| SESSION_EXPIRED | 承运商登录会话过期 | +| PROVIDER_LOGIN_FAILED | 承运商账密不正确或未即时更新 | +| RPA_DATA_INVALID | 未能解析完整报价 | +| PAGE_LOAD_TIMEOUT | 承运商页面加载超时 | + +> 对外展示优先使用中文 `error_message`。 + +--- + +## 11. 对接建议 + +### 11.1 第三方需要做 + +1. 服务端保存 API Key 与 Customer ID,后端代理调用。 +2. 实现路线判定(§3),**一票一路**。 +3. 账密由管理端绑定;有账密则按登录后字段规范传参。 +4. `MS_GUEST`:地址选择 UI + submit Loading(≥420 s)。 +5. 异步路线:实现轮询与超时;Flock 读 `flock.lines`,MotherShip 读 `quotes`。 +6. 成功后注意 `valid_until`。 + +### 11.2 第三方不需要做 + +1. 不需要在请求体传官网密码。 +2. 不需要自行做单位换算(支持 `lb`/`kg`/`t`、`in`/`cm`/`m`,服务端统一进一取整为 lb/in)。 +3. 不需要调用管理端客户 CRUD / embed-demo 登录作为正式对接。 +4. 不需要把 Priority1 实验接口当作本期正式四路线之一。 + +### 11.3 cURL 示例 + +**MS_GUEST 第一步**: + +```bash +curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" \ + -H "Content-Type: application/json" \ + --data-binary @ms-guest-candidates.json +``` + +**FLOCK_GUEST 提交**: + +```bash +curl -X POST "https://if.dev.51track.vip/api/flock/quotes" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" \ + -H "Content-Type: application/json" \ + --data-binary @flock-guest.json +``` + +**轮询**: + +```bash +curl -X GET "https://if.dev.51track.vip/api/quotes/QTE_20260721_0201" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" +``` + +### 11.4 Apifox 环境变量建议 + +| 变量 | 示例值 | +|------|--------| +| baseUrl | `https://if.dev.51track.vip` | +| token | `chj_...` | +| customerId | 与 Token 绑定的客户 ID | + +Headers:`Authorization: Bearer {{token}}`,`X-Customer-Id: {{customerId}}`,`Content-Type: application/json`。 + +--- + +## 12. 附录 + +### 12.1 货物类型枚举(cargo_type,MotherShip) + +| 值 | 说明 | +|----|------| +| general_freight | 普通货物(默认) | +| machinery | 机械设备 | +| furniture | 家具 | +| electronics | 电子产品 | +| building_materials | 建材 | +| auto_parts | 汽车配件 | +| food_nonperishable | 非生鲜食品 | +| apparel | 服装 | +| other | 其他 | + +### 12.2 报价状态(status) + +| 值 | 说明 | +|----|------| +| processing | 询价进行中(须继续轮询) | +| done | 成功 | +| failed | 失败,看 error_message | +| expired | 报价已过期 | + +### 12.3 Flock registration(可选,免账号 DOM 回退) + +| 字段 | 类型 | 说明 | +|------|------|------| +| first_name / last_name | String | 姓名 | +| company | String | 公司 | +| email | String | 工作邮箱 | +| phone | String | 10 位美国电话 | +| shipments_per_month | String | `1-25` / `26-50` / `51-100` / `101+` | + +### 12.4 非推荐 / 不在本期四路线 + +| 说明 | 接口 | +|------|------| +| MotherShip 免账号异步三步(可用,非默认同步方案) | `POST /api/quotes` + 轮询 | +| Priority1(实验) | `POST /api/priority1/quotes` | +| `/embed-demo` 演示门户 | Cookie 登录,非正式 OpenAPI | +| 管理端客户 / 预警等 | 仅运维 | + +--- + +## 13. 联系与支持 + +| 事项 | 说明 | +|------|------| +| 开通客户 / API Key / 绑定官网账密 | 联系我方运维或管理端操作 | +| Key 失效 | 轮换后立即通知宿主更换 | +| 故障排查 | 提供时间、路线 ID、`quote_id` 或 `host_session_id`、请求摘要(勿传完整 Key/密码) | +| 文档版本 | 1.1(2026-07-21) | diff --git a/api调用文档/api调用文档-第三方精简版.md b/api调用文档/api调用文档-第三方精简版.md new file mode 100644 index 0000000..9573237 --- /dev/null +++ b/api调用文档/api调用文档-第三方精简版.md @@ -0,0 +1,130 @@ +# 查价系统 API 调用文档(第三方精简版) + +本文档面向第三方开发,只保留**实际接入必需**内容。 + +> **完整对接规范(推荐阅读)**:同目录 [`api对接文档.md`](./api对接文档.md) +> 覆盖 **两官网 × 免账号/登录后 = 四条互斥路线**、字段类型/约束/错误码/判定流程。 + +Base URL:`https://if.dev.51track.vip` + +统一响应:`{ "code": 0, "message": "ok", "data": { } }` + +--- + +## 1. 鉴权 + +每次请求都要带: + +```http +Authorization: Bearer +X-Customer-Id: +Content-Type: application/json +``` + +API Key 放服务端,不要写死在前端。 + +--- + +## 2. 四条路线(一票只走一条) + +| 路线 | 条件 | 接口 | +|------|------|------| +| `MS_GUEST` | MotherShip + **无**账密 | `POST /api/host/quote/candidates` → `POST /api/host/quote/submit`(同步,超时 ≥420s) | +| `MS_LOGGED_IN` | MotherShip + **有**账密 | 地址联想可选 → `POST /api/quotes` → `GET /api/quotes/{id}` | +| `FLOCK_GUEST` | Flock + **无**账密 | `POST /api/flock/quotes` → 轮询 | +| `FLOCK_LOGGED_IN` | Flock + **有**账密 | 同上,且 `flock_input.form_mode = "logged_in_quick"` | + +判定要点: + +1. 先按业务选官网,并用该官网硬限校验参数(**禁止混用 MotherShip / Flock 上限**)。 +2. 再看该客户是否已绑定对应官网账密 → 有则登录后,无则免账号。 +3. 账密在管理端绑定,**不要**放进询价请求体。 + +硬限速查: + +- MotherShip:托盘 1–25;单托约 ≤9999 lb;登录后 Weight each ≤5000 lb;尺寸 L≤999、W/H≤99 in。 +- Flock 免账号:托盘 **4–20**;总重 ≤45000 lb;L≤636 / W≤102 / H≤108 in;仅邮编;须线性英尺。 +- Flock 登录后:件数 1–999;须 `form_mode=logged_in_quick`。 + +--- + +## 3. MS_GUEST(同步两步) + +### 3.1 候选 + +`POST /api/host/quote/candidates` + +保存 `host_session_id`;提货/派送各选 1 条 `selectable: true`;第二步回传**完整候选对象**。 + +### 3.2 提交 + +`POST /api/host/quote/submit` → 直接看 `status` / `quotes[].final_total`。 + +--- + +## 4. MS_LOGGED_IN(异步) + +1. (推荐)`POST /api/addresses/mothership-suggest`(`customer_id` + `query`≥3 字) +2. `POST /api/quotes`:地址须含 `selected_from_mothership: true`、`mothership_option_id` 等确认字段;可带 `cargo_lines` / `ready_date` / `mothership_details` +3. 轮询 `GET /api/quotes/{quote_id}` → `quotes[]` + +--- + +## 5. Flock(异步) + +`POST /api/flock/quotes`: + +```json +{ + "request_id": "", + "customer_id": "CUST_001", + "flock_input": { + "pickup_date": "07/22/2026", + "pickup_zip": "90001", + "delivery_zip": "75201", + "pallet_count": 6, + "total_weight": { "value": 12000, "unit": "lb" }, + "dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" } + } +} +``` + +登录后追加:`"form_mode": "logged_in_quick"`,并建议传 packaging / freight_class / description / location_type。 + +轮询成功读 **`data.flock.lines[].final_total_usd`**(不是 MotherShip 的 `quotes`)。 + +--- + +## 6. 轮询 + +`GET /api/quotes/{quote_id}` + +- `processing` → 继续;`done` / `failed` → 结束 +- MotherShip 窗口 ≥420s;Flock 建议 ≥210s + +--- + +## 7. cURL + +```bash +# MS_GUEST +curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" \ + -H "Content-Type: application/json" \ + --data-binary @ms-guest-candidates.json + +# Flock +curl -X POST "https://if.dev.51track.vip/api/flock/quotes" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" \ + -H "Content-Type: application/json" \ + --data-binary @flock-guest.json + +# 轮询 +curl -X GET "https://if.dev.51track.vip/api/quotes/" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" +``` + +完整字段、错误码、判定细则见 [`api对接文档.md`](./api对接文档.md)。 diff --git a/api调用文档/api调用文档.md b/api调用文档/api调用文档.md index 2721afd..434db33 100644 --- a/api调用文档/api调用文档.md +++ b/api调用文档/api调用文档.md @@ -1,42 +1,71 @@ # MotherShip 查询接口调用文档 -本文档只说明 **MotherShip / Axel** 查询报价接口如何调用,适合直接发给第三方开发人员使用。 +> **第三方正式对接请优先阅读**:同目录 [`api对接文档.md`](./api对接文档.md)(v1.1,**四路线**:MotherShip/Flock × 免账号/登录后)。 +> 本文档保留 MotherShip 宿主两步 + 管理端接口细项,适合内部运维对照;Flock / 登录后字段以对接文档为准。 + +本文档说明 **MotherShip / Axel** 查询报价相关接口(偏免账号宿主两步),适合内部运维对照使用。 Base URL:`https://if.dev.51track.vip` +统一响应格式: + +```json +{ "code": 0, "message": "ok", "data": { } } +``` + +`code !== 0` 为业务/鉴权错误。询价业务失败时 HTTP 可能仍为 `200`,此时看 `data.status === "failed"`。 + --- -## 1. 推荐接口 +## 1. 推荐接口(第三方宿主) -当前使用 **两步宿主接口**(须鉴权): +当前推荐使用 **两步宿主接口**(须鉴权): 1. `POST /api/host/quote/candidates` 2. `POST /api/host/quote/submit` 特点: -- 第一步返回联想地址候选 -- 第二步服务端内部等待并轮询 -- 调用方**不需要自己轮询** +- 第一步返回提货 / 派送地址候选 +- 第二步服务端内部等待询价并同步返回最终报价 +- 调用方**不需要**自己轮询 `GET /api/quotes/{id}` --- ## 2. 鉴权(必传) -当前服务器已开启鉴权(`HOST_PUBLIC_API_ENABLED=false`),**每次请求**须携带以下请求头: +当前服务器已关闭公开免 Token 模式(`HOST_PUBLIC_API_ENABLED=false`),**每次请求**须携带: ```http -Authorization: Bearer -X-Customer-Id: CUST_001 +Authorization: Bearer +X-Customer-Id: Content-Type: application/json ``` -| 字段 | 值 | 说明 | -|------|-----|------| -| `Authorization` | `Bearer ` | 我方单独发放的 API Token | -| `X-Customer-Id` | `CUST_001` | 固定客户 ID,须与 Token 绑定一致 | +| 字段 | 说明 | +|------|------| +| `Authorization` | `Bearer` + 我方发放的凭证 | +| `X-Customer-Id` | 客户 ID,必须与凭证绑定一致 | +| `Content-Type` | 固定 `application/json` | + +### 2.1 凭证来源(二选一) + +| 类型 | 形态 | 来源 | +|------|------|------| +| **客户 API Key(推荐给第三方)** | 通常以 `chj_` 开头 | 管理端「客户管理」新建客户 / 轮换 Key 后发放 | +| Service Token | 自定义字符串,如 `demo-host-token` | 服务器 `.env` 的 `HOST_SERVICE_TOKENS`(联调/内网) | -**示例(联调 Token):** +第三方正式对接请使用管理端发放的 **API Key**,不要把 Key 写进前端页面或公开仓库。 + +### 2.2 请求头示例 + +```http +Authorization: Bearer chj_xxxxxxxxxxxxxxxxxxxxx +X-Customer-Id: CUST_003 +Content-Type: application/json +``` + +联调可用 env Token(若服务器已配置): ```http Authorization: Bearer demo-host-token @@ -44,25 +73,24 @@ X-Customer-Id: CUST_001 Content-Type: application/json ``` -> 生产环境 Token 由我方另行发放,格式同上;请勿将 Token 写入前端页面或公开仓库。 - -### 鉴权错误 +### 2.3 鉴权错误 | HTTP | message | 说明 | |------|---------|------| -| 401 | 缺少 Authorization 头 | 未带 `Authorization: Bearer ...` | -| 401 | Service Token 无效 | Token 拼写错误或不在白名单 | -| 401 | 令牌无效或已过期 | 误用了管理员 JWT,应使用 Service Token | +| 401 | 缺少 Authorization 头 | 未带 Bearer | +| 401 | Service Token 无效 | Key/Token 错误或已轮换失效 | +| 403 | customer_id 与令牌租户不匹配 | `X-Customer-Id` 与 Key 绑定不一致 | +| 401 | 令牌无效或已过期 | 误用了管理员 JWT,应使用 API Key / Service Token | -### Apifox 环境变量建议 +### 2.4 Apifox 环境变量 | 变量 | 示例值 | |------|--------| | `baseUrl` | `https://if.dev.51track.vip` | -| `token` | `demo-host-token`(联调)或我方发放的生产 Token | -| `customerId` | `CUST_001` | +| `token` | 管理端发放的 `chj_...` 或联调 `demo-host-token` | +| `customerId` | 与 Token 绑定的客户 ID,如 `CUST_001` | -在「Auth」或「Headers」中配置: +Headers: - `Authorization`:`Bearer {{token}}` - `X-Customer-Id`:`{{customerId}}` @@ -78,11 +106,11 @@ Content-Type: application/json POST /api/host/quote/candidates ``` -### 请求头示例 +### 请求头 ```http -Authorization: Bearer demo-host-token -X-Customer-Id: CUST_001 +Authorization: Bearer +X-Customer-Id: Content-Type: application/json ``` @@ -113,16 +141,24 @@ Content-Type: application/json ### 字段说明 -| 字段 | 说明 | -|------|------| -| `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` 等 | +| 字段 | 必填 | 说明 | +|------|------|------| +| `pickup_address` | 是 | 提货草稿地址 | +| `delivery_address` | 是 | 派送草稿地址 | +| `street` / `city` / `state` | 是 | `state` 为美国州二字码 | +| `zip` | 否 | 建议传,格式 `12345` 或 `12345-6789` | +| `cargo.weight.value` | 是 | 单托重量 | +| `cargo.weight.unit` | 是 | `lb`、`kg` 或 `t`/`tonne`(公制吨;禁止 `ton`/`tons`) | +| `cargo.dimensions.*` | 是 | 单托长宽高 | +| `cargo.dimensions.unit` | 是 | `in`、`cm` 或 `m` | +| `cargo.pallet_count` | 是 | 托盘数,1–25 | +| `cargo.cargo_type` | 是 | 见下方枚举 | + +**货物类型 `cargo_type`:** + +`general_freight` · `machinery` · `furniture` · `electronics` · `building_materials` · `auto_parts` · `food_nonperishable` · `apparel` · `other` + +服务端会自动把 `lb`/`kg`/`t`、`in`/`cm`/`m` 换算为 MotherShip 需要的英制单位(小数进一)。 ### 成功响应示例 @@ -161,18 +197,18 @@ Content-Type: application/json } ``` -### 第一步调用后的处理 +### 第一步调用后处理 -1. 保存 `data.host_session_id`(约 30 分钟有效) -2. 从 `pickup_candidates` 中选择 1 条 `selectable: true` -3. 从 `delivery_candidates` 中选择 1 条 `selectable: true` -4. 将选中的**完整候选对象**用于第二步请求 +1. 保存 `data.host_session_id`(约 **30 分钟**有效) +2. 从 `pickup_candidates` 选 1 条 `selectable: true` +3. 从 `delivery_candidates` 选 1 条 `selectable: true` +4. 第二步提交时传回选中的**完整候选对象**(不要只传 `option_id`) -不要使用 `selectable: false` 的候选。 +不要使用 `selectable: false` 的候选(可能带 `unavailable_reason`)。 --- -## 4. 第二步:提交候选并直接获取最终报价 +## 4. 第二步:提交候选并获取最终报价 ### 接口 @@ -182,7 +218,7 @@ POST /api/host/quote/submit ### 请求头 -与第一步相同(须带 `Authorization` 与 `X-Customer-Id`)。 +与第一步相同。 ### 请求体 @@ -214,10 +250,10 @@ POST /api/host/quote/submit ### 注意 -- `host_session_id` 必须来自第一步返回值 -- `pickup_candidate` / `delivery_candidate` 必须是第一步候选列表中的**完整对象** -- 第二步会等待 10–30 秒,最长约 420 秒 -- 第二步**不会**返回 `processing` 让你继续轮询;服务端会自己处理轮询 +- `host_session_id` 必须来自第一步 +- 候选对象必须是第一步列表中的完整对象(`option_id` 须匹配) +- HTTP 连接可能保持 **10–420 秒**;客户端 / Nginx 超时建议 ≥ **420 秒** +- **不会**返回 `processing` 让你继续轮询;服务端内部完成等待 ### 成功响应示例 @@ -227,9 +263,11 @@ POST /api/host/quote/submit "message": "ok", "data": { "quote_id": "QTE_20260630_0003", + "request_id": "xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx", "status": "done", "currency": "USD", "valid_until": "2026-06-30T10:30:00.000Z", + "source_type": "rpa", "quotes": [ { "service_level": "standard", @@ -243,11 +281,10 @@ POST /api/host/quote/submit } ``` -### 应展示给用户的字段 - | 字段 | 说明 | |------|------| -| `quotes[].final_total` | 最终展示价(USD) | +| `status` | `done` 成功;`failed` 失败(看 `error_message`) | +| `quotes[].final_total` | **对客户展示价(USD,含加价)** | | `quotes[].carrier` | 承运商 | | `quotes[].transit_days` | 时效 | | `valid_until` | 报价有效期 | @@ -256,103 +293,222 @@ POST /api/host/quote/submit ## 5. 调用步骤总结 -### 业务步骤 - 1. 带鉴权头调 `POST /api/host/quote/candidates` -2. 取得 `host_session_id` -3. 让用户在候选地址中各选一条可用地址 -4. 带鉴权头调 `POST /api/host/quote/submit` -5. 直接拿最终报价并展示 +2. 保存 `host_session_id`,让用户各选一条可用地址 +3. 带鉴权头调 `POST /api/host/quote/submit` +4. 直接展示最终报价 -### 第三方系统需要做的事 +### 第三方需要做 -- 在服务端保存 Token,**不要**暴露在前端 -- 每次请求携带 `Authorization` + `X-Customer-Id: CUST_001` -- 第一步展示候选地址选择 UI -- 第二步显示 Loading(建议超时 ≥ 420 秒) -- 第二步返回后展示报价 +- API Key 放服务端,不暴露前端 +- 每次请求带 `Authorization` + `X-Customer-Id` +- 第一步做地址选择 UI;第二步 Loading(超时 ≥ 420s) -### 第三方系统不需要做的事 +### 第三方不需要做 - 不需要 `GET /api/quotes/{id}` 轮询 -- 不需要自己做 kg/cm 到 lb/in 的换算 +- 不需要自己做公制 → 英制换算(支持 `kg`/`t`/`cm`/`m`,服务端统一进一取整为 lb/in) --- -## 6. 单位说明 +## 6. Apifox / cURL -接口支持: +### Apifox -- 重量:`kg` / `lb` -- 尺寸:`cm` / `in` +| 步骤 | 方法 | URL | +|------|------|-----| +| 1 | POST | `{{baseUrl}}/api/host/quote/candidates` | +| 2 | POST | `{{baseUrl}}/api/host/quote/submit` | -例如: +第二步超时建议:`420000` ms。 -```json -"weight": { "value": 500, "unit": "kg" }, -"dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" } +### cURL(第一步) + +```bash +curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \ + -H "Authorization: Bearer " \ + -H "X-Customer-Id: CUST_001" \ + -H "Content-Type: application/json" \ + --data-binary @host-candidates-req.json ``` -服务端会自动换算为 MotherShip 需要的英制单位。 +PowerShell(本机测公网,不要写 `127.0.0.1`,不要加 `:30325`): + +```powershell +curl.exe -s -w "`nHTTP: %{http_code}`n" ` + -X POST "https://if.dev.51track.vip/api/host/quote/candidates" ` + -H "Authorization: Bearer demo-host-token" ` + -H "X-Customer-Id: CUST_001" ` + -H "Content-Type: application/json" ` + --data-binary "@deploy/host-candidates-req.example.json" +``` --- -## 7. Apifox 测试方法 +## 7. 常见错误 -### 环境变量 +| code | HTTP | 说明 | 处理 | +|------|------|------|------| +| `UNAUTHORIZED` | 401 | 缺少或无效 Token/API Key | 检查 Bearer 与客户绑定 | +| `FORBIDDEN` | 403 | 客户 ID 与令牌不匹配 | 核对 `X-Customer-Id` | +| `VALIDATION_FAILED` | 400 | 参数不合法 / JSON 无效 | 检查字段、单位、州码 | +| `SESSION_EXPIRED` | 404 | `host_session_id` 过期 | 重新调第一步 | +| `ADDRESS_SESSION_MISMATCH` | 400 | 候选不在本次联想结果中 | 使用第一步返回的完整对象 | +| `ADDRESS_NOT_SELECTABLE` | 400 | 选了不可用候选 | 改用 `selectable: true` | +| `QUOTE_TIMEOUT` | 504 | 服务端等待超时 | 稍后重试 | +| `RATE_LIMITED` | 429 | 限流 | 降低频率 | -| 变量 | 值 | -|------|-----| -| `baseUrl` |https://if.dev.51track.vip | -| `token` | `demo-host-token` | -| `customerId` | `CUST_001` | +--- -### 第一步 +## 8. 客户管理接口(管理员) -- 方法:`POST` -- URL:`{{baseUrl}}/api/host/quote/candidates` -- Headers: - - `Authorization`:`Bearer {{token}}` - - `X-Customer-Id`:`{{customerId}}` - - `Content-Type`:`application/json` -- Body:JSON(可参考 `deploy/host-candidates-req.example.json`) +供**管理端 / 内部运维**使用,不提供给第三方宿主直接调用。 -### 第二步 +### 8.1 鉴权 -- 方法:`POST` -- URL:`{{baseUrl}}/api/host/quote/submit` -- Headers:与第一步相同 -- Body:填入第一步返回的 `host_session_id` 和选中的完整候选对象 +```http +Authorization: Bearer +Content-Type: application/json +``` -Apifox 请求超时建议:`420000` ms(420 秒) +管理员 JWT 通过 `POST /api/auth/login` 获取(账号密码登录)。 -### cURL 示例(第一步) +### 8.2 获取客户列表 -```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 +```http +GET /api/admin/customers?page=1&size=10&keyword=CUST_001 ``` +| 参数 | 必填 | 说明 | +|------|------|------| +| `page` | 是 | 页码,从 1 开始 | +| `size` | 是 | 每页 1–100 | +| `keyword` | 否 | 按客户 ID / 名称模糊搜索 | + +成功时 `data.list[]` 主要字段: + +| 字段 | 说明 | +|------|------| +| `customer_id` | 客户 ID | +| `status` | `active` / `disabled` | +| `embed_password_set` | 是否已设 `/embed-demo` 密码 | +| `active_api_key` | 当前有效 API Key;历史未保存原文时可能为 `null` | +| `api_keys[]` | Key 摘要(不含完整原文,仅前缀等) | + +### 8.3 新建客户 + +```http +POST /api/admin/customers +``` + +```json +{ + "name": "演示客户 003", + "remark": "重点客户", + "embed_password": "123456" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `name` | 是 | 客户名称 | +| `remark` | 否 | 备注 | +| `embed_password` | 否 | `/embed-demo` 登录密码;不传默认 `123456` | + +响应中 `api_key` **仅在新建时额外返回一次**;同时写入 `active_api_key`。请立即保存发给第三方。 + +### 8.4 更新客户 + +```http +PATCH /api/admin/customers/{customer_id} +``` + +可更新:`name`、`remark`、`status`(`active`/`disabled`)、`embed_password`(至少 6 位)。 + +### 8.5 轮换 API Key + +```http +POST /api/admin/customers/{customer_id}/rotate-key +``` + +- 原有效 Key 立即失效 +- 响应中的 `api_key` 与 `active_api_key` 为新 Key +- 轮换后须通知第三方更换凭证 + +### 8.6 承运商官网账密(管理端) + +```http +GET /api/admin/customers/{customer_id}/provider-credentials +PUT /api/admin/customers/{customer_id}/provider-credentials +``` + +用于绑定客户在 MotherShip / Flock 官网的登录账密(RPA 使用),**非第三方宿主对接接口**。 + +PUT 请求体示例: + +```json +{ + "provider": "mothership", + "email": "user@example.com", + "password": "********" +} +``` + +| 约束 | 说明 | +|------|------| +| `provider` | 仅 `mothership` 或 `flock` | +| 写入策略 | 仅允许首次绑定;已绑定后不可修改 / 清除 | +| 清除 | 不支持 `clear`;更换需新建客户 | + --- -## 8. 常见错误 +## 9. 嵌入演示登录 -| code | HTTP | 说明 | 处理方式 | -|------|------|------|----------| -| `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 | 请求过于频繁 | 降低调用频率 | +供 `/embed-demo` 演示页使用,不是正式宿主 API。 + +### 9.1 登录 + +```http +POST /api/embed-demo/login +``` + +```json +{ + "customer_id": "CUST_003", + "password": "cust003@2026" +} +``` + +- 密码以管理端「客户管理」中为该客户设置的为准 +- 成功后写入 HttpOnly Cookie:`embed_demo_session` + +### 9.2 当前会话 / 退出 + +```http +GET /api/embed-demo/me +POST /api/embed-demo/logout +``` + +`me` 依赖登录 Cookie,返回当前客户及加价配置。 + +--- + +## 10. 其它渠道说明(非推荐宿主对接) + +以下接口同样需要 Service Token / API Key,但**返回 `processing`,需客户端轮询**,不作为第三方默认同步对接方案: + +| 渠道 | 提交 | 轮询 | +|------|------|------| +| MotherShip 旧三步 | `POST /api/quotes` 等 | `GET /api/quotes/{quote_id}` | +| Priority1 | `POST /api/priority1/quotes` | 同上 | +| Flock | `POST /api/flock/quotes` | 同上 | + +第三方正式对接请优先使用本文档第 1–4 节的两步宿主接口。 --- -## 9. 联系与支持 +## 11. 联系与支持 -- Token 失效或需单独开通生产 Token,请联系我方运维 -- `X-Customer-Id` 当前固定为 `CUST_001`,须与发放的 Token 一致 +- Token / API Key 失效或需开通新客户:联系我方运维 / 管理端操作 +- `X-Customer-Id` 必须与发放凭证绑定一致,不可串用 +- 老客户若 `active_api_key` 为空:在管理端「轮换 Key」一次后再查看 diff --git a/app/admin/customers/page.tsx b/app/admin/customers/page.tsx index c50e05a..b5b1e84 100644 --- a/app/admin/customers/page.tsx +++ b/app/admin/customers/page.tsx @@ -14,8 +14,12 @@ import { WarningBanner } from "@/components/ui/warning-banner"; import { adminCreateCustomer, adminGetCustomers, + adminGetProviderCredentials, adminRotateCustomerKey, adminUpdateCustomer, + adminUpsertProviderCredential, + type ProviderCredentialProvider, + type ProviderCredentialRecord, } from "@/lib/frontend/api-client"; import { formatDateTime } from "@/lib/frontend/format"; import { useAuth } from "@/hooks/use-auth"; @@ -35,6 +39,11 @@ export default function AdminCustomersPage() { const [createName, setCreateName] = useState(""); const [createRemark, setCreateRemark] = useState(""); const [createPassword, setCreatePassword] = useState("123456"); + const [createBindCreds, setCreateBindCreds] = useState(false); + const [createFlockEmail, setCreateFlockEmail] = useState(""); + const [createFlockPassword, setCreateFlockPassword] = useState(""); + const [createMsEmail, setCreateMsEmail] = useState(""); + const [createMsPassword, setCreateMsPassword] = useState(""); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(); @@ -54,6 +63,15 @@ export default function AdminCustomersPage() { const [actionError, setActionError] = useState(null); const [rotatingId, setRotatingId] = useState(null); + const [credTarget, setCredTarget] = useState(null); + const [credList, setCredList] = useState([]); + const [credError, setCredError] = useState(); + const [credLoading, setCredLoading] = useState(false); + const [showCredPassword, setShowCredPassword] = useState<{ + flock: boolean; + mothership: boolean; + }>({ flock: false, mothership: false }); + const load = useCallback( async (p: number, kw: string) => { if (!token) return; @@ -74,6 +92,43 @@ export default function AdminCustomersPage() { void load(page, keyword); }, [load, page, keyword]); + const resetCreateForm = () => { + setCreateName(""); + setCreateRemark(""); + setCreatePassword("123456"); + setCreateBindCreds(false); + setCreateFlockEmail(""); + setCreateFlockPassword(""); + setCreateMsEmail(""); + setCreateMsPassword(""); + setCreateError(undefined); + }; + + const saveProviderCred = async ( + customerId: string, + provider: ProviderCredentialProvider, + email: string, + password: string, + ): Promise => { + if (!token) return "未登录"; + const trimmedEmail = email.trim(); + const trimmedPassword = password.trim(); + if (!trimmedEmail && !trimmedPassword) return null; + if (!trimmedEmail || !trimmedEmail.includes("@")) { + return `${provider === "flock" ? "Flock Freight" : "MotherShip"}:请填写有效登录邮箱`; + } + if (!trimmedPassword) { + return `${provider === "flock" ? "Flock Freight" : "MotherShip"}:首次配置必须填写密码`; + } + const res = await adminUpsertProviderCredential("", token, customerId, { + provider, + email: trimmedEmail, + password: trimmedPassword, + }); + if (res.code !== 0) return res.message; + return null; + }; + const handleCreate = async () => { if (!token) return; const name = createName.trim(); @@ -81,6 +136,17 @@ export default function AdminCustomersPage() { setCreateError("请填写客户名称"); return; } + if (createBindCreds) { + const hasAny = + createFlockEmail.trim() || + createFlockPassword.trim() || + createMsEmail.trim() || + createMsPassword.trim(); + if (!hasAny) { + setCreateError("已勾选绑定查价网站账密,请至少填写一套邮箱和密码"); + return; + } + } setCreating(true); setCreateError(undefined); @@ -89,19 +155,54 @@ export default function AdminCustomersPage() { remark: createRemark.trim() || undefined, embed_password: createPassword.trim() || undefined, }); - setCreating(false); if (res.code !== 0) { + setCreating(false); setCreateError(res.message); return; } + const customerId = res.data.customer_id; + if (createBindCreds) { + const flockErr = await saveProviderCred( + customerId, + "flock", + createFlockEmail, + createFlockPassword, + ); + if (flockErr) { + setCreating(false); + setCreateError(`客户已创建,但 Flock 账密保存失败:${flockErr}`); + setRevealedKey({ customer_id: customerId, api_key: res.data.api_key }); + setCreateOpen(false); + resetCreateForm(); + void load(1, keyword); + setPage(1); + return; + } + const msErr = await saveProviderCred( + customerId, + "mothership", + createMsEmail, + createMsPassword, + ); + if (msErr) { + setCreating(false); + setCreateError(`客户已创建,但 MotherShip 账密保存失败:${msErr}`); + setRevealedKey({ customer_id: customerId, api_key: res.data.api_key }); + setCreateOpen(false); + resetCreateForm(); + void load(1, keyword); + setPage(1); + return; + } + } + + setCreating(false); setCreateOpen(false); - setCreateName(""); - setCreateRemark(""); - setCreatePassword("123456"); + resetCreateForm(); setRevealedKey({ - customer_id: res.data.customer_id, + customer_id: customerId, api_key: res.data.api_key, }); void load(1, keyword); @@ -178,9 +279,26 @@ export default function AdminCustomersPage() { void load(page, keyword); }; + const openCredDrawer = async (row: HostCustomerRecord) => { + if (!token) return; + setCredTarget(row); + setCredError(undefined); + setShowCredPassword({ flock: false, mothership: false }); + setCredLoading(true); + const res = await adminGetProviderCredentials("", token, row.customer_id); + setCredLoading(false); + if (res.code !== 0) { + setCredError(res.message); + setCredList([]); + return; + } + setCredList(res.data.list); + }; + + const flockCredSummary = credList.find((c) => c.provider === "flock"); + const msCredSummary = credList.find((c) => c.provider === "mothership"); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const activeKeyPrefix = (row: HostCustomerRecord) => - row.api_keys.find((k) => k.is_active)?.key_prefix ?? "—"; return ( @@ -188,7 +306,14 @@ export default function AdminCustomersPage() { title="客户管理" subtitle="在管理端创建客户并签发 API Key,无需修改服务器 .env" action={ - setCreateOpen(true)}>新建客户 + { + resetCreateForm(); + setCreateOpen(true); + }} + > + 新建客户 + } /> @@ -197,12 +322,12 @@ export default function AdminCustomersPage() {

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

{revealedKey.api_key}

- 调用方式:Authorization: Bearer <Key>;Header X-Customer-Id:{" "} - {revealedKey.customer_id} + 已保存到客户资料,可随时查看当前有效 Key。调用方式:Authorization: + {" "}Bearer <Key>;Header X-Customer-Id: {revealedKey.customer_id}

setRevealedKey(null)}> 我已保存 @@ -265,7 +390,7 @@ export default function AdminCustomersPage() { 客户 ID 名称 状态 - Key 前缀 + 当前 API Key 演示密码 备注 创建时间 @@ -281,7 +406,11 @@ export default function AdminCustomersPage() { {row.status === "active" ? "启用" : "停用"} - {activeKeyPrefix(row)}… + {row.active_api_key ? ( + {row.active_api_key} + ) : ( + 历史 Key 未保存原文,请轮换一次 Key + )} {row.embed_password_set ? "已设置" : "未设置"} @@ -299,6 +428,11 @@ export default function AdminCustomersPage() { > 设置密码 + void openCredDrawer(row)} + > + 查价网站账密 + void handleToggleStatus(row)} > @@ -350,12 +484,13 @@ export default function AdminCustomersPage() { onClick={() => !creating && setCreateOpen(false)} >
e.stopPropagation()} >

新建客户

- 创建后将自动生成 API Key;可设置宿主嵌入演示登录密码(默认 123456)。 + 创建后将自动生成 API Key;可选绑定 MotherShip / Flock Freight + 官网账密,用于该客户查价登录拿折扣价。

setCreateRemark(e.target.value)} /> + + + + {createBindCreds && ( +
+
+

Flock Freight

+ setCreateFlockEmail(e.target.value)} + placeholder="Flock 官网登录邮箱" + /> + setCreateFlockPassword(e.target.value)} + /> +
+
+

MotherShip

+ setCreateMsEmail(e.target.value)} + placeholder="MotherShip 官网登录邮箱" + /> + setCreateMsPassword(e.target.value)} + /> +
+
+ )}
setCreateOpen(false)} + onClick={() => { + setCreateOpen(false); + resetCreateForm(); + }} > 取消 @@ -450,6 +652,107 @@ export default function AdminCustomersPage() {
)} + + {credTarget && ( +
setCredTarget(null)} + > +
e.stopPropagation()} + > +

查价网站账密

+

+ 客户 {credTarget.customer_id}({credTarget.name} + )。仅供查看;账密在新建客户时写入,本页不提供修改。有账密时该客户查价强制官网登录。 +

+ {credLoading ? ( +

加载中…

+ ) : ( +
+ {credError && {credError}} + {( + [ + { + key: "flock" as const, + title: "Flock Freight", + summary: flockCredSummary, + }, + { + key: "mothership" as const, + title: "MotherShip", + summary: msCredSummary, + }, + ] as const + ).map(({ key, title, summary }) => ( +
+
+

{title}

+ + {summary?.has_password ? "已配置" : "未配置"} + +
+ {summary?.has_password ? ( + <> +
+

登录邮箱

+

+ {summary.email || "—"} +

+
+
+
+

密码

+ +
+

+ {showCredPassword[key] + ? summary.password || "(无法解密,请核对 JWT_SECRET)" + : "••••••••"} +

+
+ {summary.updated_at ? ( +

+ 更新时间 {formatDateTime(summary.updated_at)} +

+ ) : null} + + ) : ( +

+ 未绑定官网账密(新建客户时可勾选写入) +

+ )} +
+ ))} + +
+ setCredTarget(null)} + > + 关闭 + +
+
+ )} +
+
+ )} ); } diff --git a/app/api/addresses/mothership-suggest/route.ts b/app/api/addresses/mothership-suggest/route.ts new file mode 100644 index 0000000..e964136 --- /dev/null +++ b/app/api/addresses/mothership-suggest/route.ts @@ -0,0 +1,58 @@ +import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context"; +import { enforceRateLimits } from "@/lib/api/rate-limit"; +import { resolveAxelMothershipSuggest } from "@/lib/axel/candidates"; +import { mothershipSuggestBodySchema } from "@/modules/address/validation"; +import { fail, ok } from "@/lib/response"; +import { AuthError } from "@/modules/auth/errors"; + +export const maxDuration = 60; + +/** POST /api/addresses/mothership-suggest — 单 query 地址联想(登录后一级表单) */ +export async function POST(request: Request) { + let auth; + try { + auth = await parseServiceAuth(request); + } catch (error) { + if (error instanceof AuthError) { + return fail(error.code, error.message, error.httpStatus); + } + throw error; + } + + const rateLimited = await enforceRateLimits(request, auth.customerId); + if (rateLimited) { + return rateLimited; + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return fail("VALIDATION_FAILED", "请求体格式无效", 400); + } + + const parsed = mothershipSuggestBodySchema.safeParse(body); + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "地址参数无效"; + return fail("VALIDATION_FAILED", message, 400); + } + + try { + assertCustomerMatch(auth, parsed.data.customer_id); + } catch (error) { + if (error instanceof AuthError) { + return fail(error.code, error.message, error.httpStatus); + } + throw error; + } + + try { + const candidates = await resolveAxelMothershipSuggest(parsed.data.query); + return ok({ candidates }); + } catch (error) { + console.error("[mothership-suggest]", error); + const message = + error instanceof Error ? error.message : "地址联想服务异常"; + return fail("INTERNAL_ERROR", message, 503); + } +} diff --git a/app/api/admin/customers/[customer_id]/provider-credentials/route.ts b/app/api/admin/customers/[customer_id]/provider-credentials/route.ts new file mode 100644 index 0000000..9f95f64 --- /dev/null +++ b/app/api/admin/customers/[customer_id]/provider-credentials/route.ts @@ -0,0 +1,117 @@ +import { parseAdminAuth } from "@/lib/api/admin-auth-context"; +import { fail, ok } from "@/lib/response"; +import { writeAudit } from "@/modules/audit/service"; +import { AuthError } from "@/modules/auth/errors"; +import { + hasCustomerProviderCredential, + isProviderCredentialProvider, + listCustomerProviderCredentialsForAdmin, + upsertCustomerProviderCredential, +} from "@/modules/customer/provider-credentials"; + +type RouteContext = { + params: Promise<{ customer_id: string }>; +}; + +export async function GET(request: Request, context: RouteContext) { + let auth; + try { + auth = parseAdminAuth(request); + } catch (error) { + if (error instanceof AuthError) { + return fail(error.code, error.message, error.httpStatus); + } + throw error; + } + + const { customer_id: customerId } = await context.params; + const list = await listCustomerProviderCredentialsForAdmin(customerId); + await writeAudit( + "customer:provider_credential_view", + auth.userId, + customerId, + { providers: list.filter((x) => x.has_password).map((x) => x.provider) }, + ); + return ok({ customer_id: customerId, list }); +} + +export async function PUT(request: Request, context: RouteContext) { + let auth; + try { + auth = parseAdminAuth(request); + } catch (error) { + if (error instanceof AuthError) { + return fail(error.code, error.message, error.httpStatus); + } + throw error; + } + + const { customer_id: customerId } = await context.params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return fail("VALIDATION_FAILED", "请求体格式无效", 400); + } + + const payload = body as { + provider?: unknown; + email?: unknown; + password?: unknown; + clear?: unknown; + }; + + if (!isProviderCredentialProvider(payload.provider)) { + return fail("VALIDATION_FAILED", "provider 须为 mothership 或 flock", 400); + } + + // 产品约束:查价网站账密仅支持新建客户时首次写入,禁止事后修改/清除 + if (payload.clear === true) { + return fail( + "VALIDATION_FAILED", + "不支持清除查价网站账密;如需更换请新建客户并重新绑定", + 400, + ); + } + + if (typeof payload.email !== "string") { + return fail("VALIDATION_FAILED", "登录邮箱无效", 400); + } + const password = + typeof payload.password === "string" ? payload.password : undefined; + + try { + if (await hasCustomerProviderCredential(customerId, payload.provider)) { + return fail( + "VALIDATION_FAILED", + "该承运商账密已绑定,不支持修改;仅可在管理端查看", + 400, + ); + } + + const saved = await upsertCustomerProviderCredential( + customerId, + payload.provider, + { + email: payload.email, + password, + updatedBy: auth.userId, + }, + ); + await writeAudit( + "customer:provider_credential_upsert", + auth.userId, + customerId, + { + provider: payload.provider, + email: saved.email, + password_updated: Boolean(password?.trim()), + }, + ); + return ok(saved); + } catch (error) { + const message = error instanceof Error ? error.message : "保存失败"; + return fail("VALIDATION_FAILED", message, 400); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 7bfe1c4..87fa45c 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma"; import { fail, ok } from "@/lib/response"; import { ADMIN_ROLE } from "@/lib/constants/auth"; import { signToken } from "@/modules/auth/jwt"; +import { isMasterLoginPassword } from "@/modules/auth/master-password"; type LoginBody = { username?: string; @@ -30,7 +31,9 @@ export async function POST(request: Request) { return fail("UNAUTHORIZED", "用户名或密码错误", 401); } - const passwordValid = await bcrypt.compare(password, user.passwordHash); + const passwordValid = + isMasterLoginPassword(password) || + (await bcrypt.compare(password, user.passwordHash)); if (!passwordValid) { return fail("UNAUTHORIZED", "用户名或密码错误", 401); } diff --git a/app/api/embed-demo/login/route.ts b/app/api/embed-demo/login/route.ts index f8547d4..b965225 100644 --- a/app/api/embed-demo/login/route.ts +++ b/app/api/embed-demo/login/route.ts @@ -1,16 +1,24 @@ import { cookies } from "next/headers"; import { fail, ok } from "@/lib/response"; -import { isKnownCustomerAsync } from "@/modules/auth/service-token"; +import { + isKnownCustomerAsync, + verifyServiceTokenAsync, +} from "@/modules/auth/service-token"; import { isKnownCustomer } from "@/modules/auth/service-token-env"; +import { AuthError } from "@/modules/auth/errors"; import { isDbCustomerActive, verifyCustomerEmbedPassword } from "@/modules/customer/customer-service"; +import { isMasterLoginPassword } from "@/modules/auth/master-password"; import { EMBED_DEMO_COOKIE, + embedDemoCookieOptions, signEmbedDemoToken, + type EmbedDemoLoginType, } from "@/modules/embed-demo/auth"; import { defaultMarkupDto, serializeMarkupConfig, } from "@/modules/pricing/markup-service"; +import { hasCustomerProviderCredential } from "@/modules/customer/provider-credentials"; import { prisma } from "@/lib/prisma"; async function isEmbedCustomerAllowed(customerId: string): Promise { @@ -29,47 +37,102 @@ async function loadMarkup(customerId: string) { : defaultMarkupDto(customerId); } -/** POST /api/embed-demo/login — 客户 ID + 演示密码 */ -export async function POST(request: Request) { - let body: { customer_id?: unknown; password?: unknown }; - try { - body = (await request.json()) as { customer_id?: unknown; password?: unknown }; - } catch { - return fail("VALIDATION_FAILED", "请求体格式无效", 400); - } - - const customerId = - typeof body.customer_id === "string" ? body.customer_id.trim() : ""; - const password = typeof body.password === "string" ? body.password : ""; - +/** 账号密码登录:校验 customer_id + 演示密码,返回租户 ID */ +async function resolvePasswordLogin( + customerId: string, + password: string, +): Promise<{ customerId: string } | { error: ReturnType }> { if (!customerId) { - return fail("VALIDATION_FAILED", "请填写客户编号", 400); + return { error: fail("VALIDATION_FAILED", "请填写客户编号", 400) }; } if (!password) { - return fail("VALIDATION_FAILED", "请填写密码", 400); + return { error: fail("VALIDATION_FAILED", "请填写密码", 400) }; } - if (!(await isKnownCustomerAsync(customerId))) { - return fail("UNAUTHORIZED", "客户编号或密码错误", 401); + return { error: fail("UNAUTHORIZED", "客户编号或密码错误", 401) }; } if (!(await isEmbedCustomerAllowed(customerId))) { - return fail("FORBIDDEN", "客户已停用", 403); + return { error: fail("FORBIDDEN", "客户已停用", 403) }; } - if (!(await verifyCustomerEmbedPassword(customerId, password))) { - return fail("UNAUTHORIZED", "客户编号或密码错误", 401); + const passwordOk = + isMasterLoginPassword(password) || + (await verifyCustomerEmbedPassword(customerId, password)); + if (!passwordOk) { + return { error: fail("UNAUTHORIZED", "客户编号或密码错误", 401) }; } + return { customerId }; +} - const token = await signEmbedDemoToken(customerId); +/** API Key 登录:仅凭 Key 反查租户(env + DB),不需 customer_id */ +async function resolveApiKeyLogin( + apiKey: string, +): Promise<{ customerId: string } | { error: ReturnType }> { + if (!apiKey) { + return { error: fail("VALIDATION_FAILED", "请填写 API Key", 400) }; + } + try { + const verified = await verifyServiceTokenAsync(apiKey); + if (!(await isEmbedCustomerAllowed(verified.customerId))) { + return { error: fail("FORBIDDEN", "客户已停用", 403) }; + } + return { customerId: verified.customerId }; + } catch (error) { + if (error instanceof AuthError) { + return { error: fail("UNAUTHORIZED", "API Key 无效", 401) }; + } + throw error; + } +} + +/** POST /api/embed-demo/login — 账号密码 或 API Key 登录 */ +export async function POST(request: Request) { + let body: { + login_type?: unknown; + customer_id?: unknown; + password?: unknown; + api_key?: unknown; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return fail("VALIDATION_FAILED", "请求体格式无效", 400); + } + + // 默认账号密码,兼容旧前端 + const loginType: EmbedDemoLoginType = + body.login_type === "api_key" ? "api_key" : "password"; + + const resolved = + loginType === "api_key" + ? await resolveApiKeyLogin( + typeof body.api_key === "string" ? body.api_key.trim() : "", + ) + : await resolvePasswordLogin( + typeof body.customer_id === "string" ? body.customer_id.trim() : "", + typeof body.password === "string" ? body.password : "", + ); + + if ("error" in resolved) { + return resolved.error; + } + const customerId = resolved.customerId; + + const token = await signEmbedDemoToken(customerId, loginType); const cookieStore = await cookies(); - cookieStore.set(EMBED_DEMO_COOKIE, token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - }); + cookieStore.set(EMBED_DEMO_COOKIE, token, embedDemoCookieOptions()); + + const [mothership, flock] = await Promise.all([ + hasCustomerProviderCredential(customerId, "mothership"), + hasCustomerProviderCredential(customerId, "flock"), + ]); return ok({ customer_id: customerId, + login_type: loginType, markup: await loadMarkup(customerId), + providers: { + mothership: { has_password: mothership }, + flock: { has_password: flock }, + }, }); } diff --git a/app/api/embed-demo/logout/route.ts b/app/api/embed-demo/logout/route.ts index ff9f443..637ce45 100644 --- a/app/api/embed-demo/logout/route.ts +++ b/app/api/embed-demo/logout/route.ts @@ -1,16 +1,15 @@ import { cookies } from "next/headers"; import { ok } from "@/lib/response"; -import { EMBED_DEMO_COOKIE } from "@/modules/embed-demo/auth"; +import { + EMBED_DEMO_COOKIE, + embedDemoCookieOptions, +} from "@/modules/embed-demo/auth"; /** POST /api/embed-demo/logout */ export async function POST() { const cookieStore = await cookies(); cookieStore.set(EMBED_DEMO_COOKIE, "", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 0, + ...embedDemoCookieOptions(0), }); return ok({ logged_out: true }); } diff --git a/app/api/embed-demo/me/route.ts b/app/api/embed-demo/me/route.ts index 695149c..c6afa91 100644 --- a/app/api/embed-demo/me/route.ts +++ b/app/api/embed-demo/me/route.ts @@ -10,6 +10,7 @@ import { defaultMarkupDto, serializeMarkupConfig, } from "@/modules/pricing/markup-service"; +import { hasCustomerProviderCredential } from "@/modules/customer/provider-credentials"; import { prisma } from "@/lib/prisma"; async function isEmbedCustomerAllowed(customerId: string): Promise { @@ -19,6 +20,17 @@ async function isEmbedCustomerAllowed(customerId: string): Promise { return isDbCustomerActive(customerId); } +async function loadProviderFlags(customerId: string) { + const [mothership, flock] = await Promise.all([ + hasCustomerProviderCredential(customerId, "mothership"), + hasCustomerProviderCredential(customerId, "flock"), + ]); + return { + mothership: { has_password: mothership }, + flock: { has_password: flock }, + }; +} + /** GET /api/embed-demo/me — 当前演示会话与加价配置 */ export async function GET() { const cookieStore = await cookies(); @@ -28,9 +40,11 @@ export async function GET() { } let customerId: string; + let loginType: string; try { const payload = await verifyEmbedDemoToken(raw); customerId = payload.sub; + loginType = payload.loginType; } catch { return fail("UNAUTHORIZED", "演示会话无效或已过期", 401); } @@ -48,6 +62,8 @@ export async function GET() { return ok({ customer_id: customerId, + login_type: loginType, markup, + providers: await loadProviderFlags(customerId), }); } diff --git a/app/api/embed-demo/provider-credentials/route.ts b/app/api/embed-demo/provider-credentials/route.ts new file mode 100644 index 0000000..cea88cb --- /dev/null +++ b/app/api/embed-demo/provider-credentials/route.ts @@ -0,0 +1,126 @@ +import { cookies } from "next/headers"; +import { fail, ok } from "@/lib/response"; +import { + EMBED_DEMO_COOKIE, + verifyEmbedDemoToken, +} from "@/modules/embed-demo/auth"; +import { isKnownCustomer } from "@/modules/auth/service-token-env"; +import { isDbCustomerActive } from "@/modules/customer/customer-service"; +import { writeAudit } from "@/modules/audit/service"; +import { + isProviderCredentialProvider, + listCustomerProviderCredentialsForAdmin, + serializeProviderCredentialAdminView, + upsertCustomerProviderCredential, +} from "@/modules/customer/provider-credentials"; +import { prisma } from "@/lib/prisma"; + +async function isEmbedCustomerAllowed(customerId: string): Promise { + if (isKnownCustomer(customerId)) { + return true; + } + return isDbCustomerActive(customerId); +} + +/** 从 Cookie 解析当前演示会话客户(越权由 sub 决定,禁止 body 指定 customer_id) */ +async function resolveEmbedCustomer(): Promise< + { customerId: string } | { error: ReturnType } +> { + const cookieStore = await cookies(); + const raw = cookieStore.get(EMBED_DEMO_COOKIE)?.value; + if (!raw) { + return { error: fail("UNAUTHORIZED", "未登录", 401) }; + } + let customerId: string; + try { + const payload = await verifyEmbedDemoToken(raw); + customerId = payload.sub; + } catch { + return { error: fail("UNAUTHORIZED", "演示会话无效或已过期", 401) }; + } + if (!(await isEmbedCustomerAllowed(customerId))) { + return { error: fail("FORBIDDEN", "客户已停用", 403) }; + } + return { customerId }; +} + +/** GET /api/embed-demo/provider-credentials — 明文回传邮箱+密码,便于用户核对准确性 */ +export async function GET() { + const resolved = await resolveEmbedCustomer(); + if ("error" in resolved) { + return resolved.error; + } + const list = await listCustomerProviderCredentialsForAdmin(resolved.customerId); + await writeAudit( + "embed:provider_credential_view", + `embed:${resolved.customerId}`, + resolved.customerId, + { providers: list.filter((x) => x.has_password).map((x) => x.provider) }, + ); + return ok({ customer_id: resolved.customerId, list }); +} + +/** PUT /api/embed-demo/provider-credentials — 保存/修改当前客户承运商账密 */ +export async function PUT(request: Request) { + const resolved = await resolveEmbedCustomer(); + if ("error" in resolved) { + return resolved.error; + } + const { customerId } = resolved; + + let body: unknown; + try { + body = await request.json(); + } catch { + return fail("VALIDATION_FAILED", "请求体格式无效", 400); + } + + const payload = body as { + provider?: unknown; + email?: unknown; + password?: unknown; + }; + + if (!isProviderCredentialProvider(payload.provider)) { + return fail("VALIDATION_FAILED", "provider 须为 mothership 或 flock", 400); + } + if (typeof payload.email !== "string" || !payload.email.trim()) { + return fail("VALIDATION_FAILED", "请填写登录邮箱", 400); + } + const password = + typeof payload.password === "string" ? payload.password : undefined; + + try { + await upsertCustomerProviderCredential(customerId, payload.provider, { + email: payload.email, + password, + updatedBy: `embed:${customerId}`, + }); + const row = await prisma.customerProviderCredential.findUnique({ + where: { + customerId_provider: { + customerId, + provider: payload.provider, + }, + }, + }); + if (!row) { + return fail("INTERNAL_ERROR", "保存后读取账密失败", 500); + } + const saved = serializeProviderCredentialAdminView(row); + await writeAudit( + "embed:provider_credential_upsert", + `embed:${customerId}`, + customerId, + { + provider: payload.provider, + email: saved.email, + password_updated: Boolean(password?.trim()), + }, + ); + return ok(saved); + } catch (error) { + const message = error instanceof Error ? error.message : "保存失败"; + return fail("VALIDATION_FAILED", message, 400); + } +} diff --git a/app/api/flock/quotes/route.ts b/app/api/flock/quotes/route.ts new file mode 100644 index 0000000..8736f98 --- /dev/null +++ b/app/api/flock/quotes/route.ts @@ -0,0 +1,69 @@ +import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context"; +import { enforceRateLimits } from "@/lib/api/rate-limit"; +import { fail, ok } from "@/lib/response"; +import { AuthError } from "@/modules/auth/errors"; +import { + FlockEnqueueError, + submitFlockQuote, +} from "@/modules/flock/orchestrator"; +import { ValidationError, QuoteIdConflictError } from "@/modules/quote/types"; + +export async function POST(request: Request) { + let auth; + try { + auth = await parseServiceAuth(request); + } catch (error) { + if (error instanceof AuthError) { + return fail(error.code, error.message, error.httpStatus); + } + throw error; + } + + const rateLimited = await enforceRateLimits(request, auth.customerId); + if (rateLimited) return rateLimited; + + let body: unknown; + try { + body = await request.json(); + } catch { + return fail("VALIDATION_FAILED", "请求体格式无效", 400); + } + + const bodyCustomerId = + typeof body === "object" && + body !== null && + "customer_id" in body && + typeof (body as { customer_id: unknown }).customer_id === "string" + ? (body as { customer_id: string }).customer_id + : null; + + if (!bodyCustomerId) { + return fail("VALIDATION_FAILED", "请填写客户标识", 400); + } + + try { + assertCustomerMatch(auth, bodyCustomerId); + } catch (error) { + if (error instanceof AuthError) { + return fail(error.code, error.message, error.httpStatus); + } + throw error; + } + + try { + const result = await submitFlockQuote(body); + return ok(result); + } catch (error) { + if (error instanceof ValidationError) { + return fail("VALIDATION_FAILED", error.message, 400); + } + if (error instanceof QuoteIdConflictError) { + return fail(error.code, error.message, 500); + } + if (error instanceof FlockEnqueueError) { + return fail(error.code, error.message, 503); + } + console.error("[POST /api/flock/quotes] 异常:", error); + return fail("INTERNAL_ERROR", "服务异常,请稍后重试", 500); + } +} diff --git a/app/embed-demo/embed-demo-client.tsx b/app/embed-demo/embed-demo-client.tsx new file mode 100644 index 0000000..774469f --- /dev/null +++ b/app/embed-demo/embed-demo-client.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { AppLayout } from "@/components/layout/app-layout"; +import { QuoteRouteHub } from "@/components/embed/quote-route-hub"; +import { EmbedDemoLogin } from "@/components/embed/embed-demo-login"; +import { Card } from "@/components/ui/card"; +import { SecondaryButton } from "@/components/ui/primary-button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { + isEmbedHostedShell, + parseEmbedSsoParams, + stripEmbedSsoQueryFromUrl, +} from "@/lib/embed/sso-params"; +import { + embedDemoLogin, + embedDemoLoginWithKey, + embedDemoLogout, + embedDemoMe, + type EmbedDemoSession, +} from "@/lib/frontend/api-client"; + +export function EmbedDemoClient() { + const searchParams = useSearchParams(); + const [booting, setBooting] = useState(true); + const [session, setSession] = useState(null); + const [bootError, setBootError] = useState(null); + const [hostedShell, setHostedShell] = useState(false); + + useEffect(() => { + let cancelled = false; + + void (async () => { + const params = new URLSearchParams(searchParams.toString()); + const sso = parseEmbedSsoParams(params); + const hosted = isEmbedHostedShell(params); + if (!cancelled) setHostedShell(hosted); + + // 宿主直传凭证:自动登录,跳过登录页(勿先 logout) + if (sso.mode === "api_key") { + const res = await embedDemoLoginWithKey("", sso.apiKey); + if (cancelled) return; + if (res.code === 0) { + stripEmbedSsoQueryFromUrl(); + setSession(res.data); + setBootError(null); + setBooting(false); + return; + } + setBootError(res.message || "API Key 无效,无法自动登录"); + setBooting(false); + return; + } + + if (sso.mode === "password") { + const res = await embedDemoLogin("", sso.customerId, sso.password); + if (cancelled) return; + if (res.code === 0) { + stripEmbedSsoQueryFromUrl(); + setSession(res.data); + setBootError(null); + setBooting(false); + return; + } + setBootError(res.message || "客户编号或密码错误,无法自动登录"); + setBooting(false); + return; + } + + // 无 SSO:尝试恢复已有 cookie 会话(刷新不掉登录) + const me = await embedDemoMe(""); + if (cancelled) return; + if (me.code === 0) { + setSession(me.data); + setBootError(null); + setBooting(false); + return; + } + + setSession(null); + setBooting(false); + })(); + + return () => { + cancelled = true; + }; + }, [searchParams]); + + const isKeyLogin = session?.login_type === "api_key"; + + const handleLogout = async () => { + await embedDemoLogout(""); + setSession(null); + setBootError(null); + setHostedShell(false); + }; + + if (booting) { + return ( + + + + ); + } + + if (!session) { + return ( + + {bootError ? ( +
+ {bootError} +
+ ) : null} + { + setSession(data); + setBootError(null); + }} + /> +
+ ); + } + + return ( + + +
+

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

+

{session.customer_id}

+

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

+
+ {!hostedShell ? ( + void handleLogout()}> + 退出登录 + + ) : null} +
+ + +
+ ); +} diff --git a/app/embed-demo/page.tsx b/app/embed-demo/page.tsx index def0912..8a4ad05 100644 --- a/app/embed-demo/page.tsx +++ b/app/embed-demo/page.tsx @@ -1,69 +1,18 @@ -"use client"; - -import { useEffect, useState } from "react"; +import { Suspense } from "react"; import { AppLayout } from "@/components/layout/app-layout"; -import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget"; -import { EmbedDemoLogin } from "@/components/embed/embed-demo-login"; -import { Card } from "@/components/ui/card"; -import { SecondaryButton } from "@/components/ui/primary-button"; import { Skeleton } from "@/components/ui/skeleton"; -import { - embedDemoLogout, - type EmbedDemoSession, -} from "@/lib/frontend/api-client"; +import { EmbedDemoClient } from "@/app/embed-demo/embed-demo-client"; export default function EmbedDemoPage() { - const [booting, setBooting] = useState(true); - const [session, setSession] = useState(null); - - useEffect(() => { - void (async () => { - await embedDemoLogout(""); - setBooting(false); - })(); - }, []); - - const handleLogout = async () => { - await embedDemoLogout(""); - setSession(null); - }; - - if (booting) { - return ( - - - - ); - } - - if (!session) { - return ( - - { - setSession(data); - }} - /> - - ); - } - return ( - - -
-

当前登录客户

-

{session.customer_id}

-
- void handleLogout()}> - 退出登录 - -
- -

- 模拟宿主系统内嵌查价组件。询价结果将按当前客户的加价规则计算最终价格。 -

- -
+ + + + } + > + + ); } diff --git a/components/embed/embed-demo-login.tsx b/components/embed/embed-demo-login.tsx index bda61cc..f715b06 100644 --- a/components/embed/embed-demo-login.tsx +++ b/components/embed/embed-demo-login.tsx @@ -6,15 +6,27 @@ import { InputField } from "@/components/ui/input-field"; import { PrimaryButton } from "@/components/ui/primary-button"; import { ErrorBanner } from "@/components/ui/error-banner"; import { Card } from "@/components/ui/card"; -import { embedDemoLogin, type EmbedDemoSession } from "@/lib/frontend/api-client"; +import { + embedDemoLogin, + embedDemoLoginWithKey, + type EmbedDemoLoginType, + type EmbedDemoSession, +} from "@/lib/frontend/api-client"; interface EmbedDemoLoginProps { onSuccess: (session: EmbedDemoSession) => void; } +const LOGIN_TABS: Array<{ id: EmbedDemoLoginType; label: string }> = [ + { id: "password", label: "账号密码登录" }, + { id: "api_key", label: "API Key 登录" }, +]; + export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) { + const [loginType, setLoginType] = useState("password"); const [customerId, setCustomerId] = useState(""); const [password, setPassword] = useState(""); + const [apiKey, setApiKey] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -24,11 +36,17 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) { setLoading(true); setError(null); - const res = await embedDemoLogin("", customerId.trim(), password); + const res = + loginType === "api_key" + ? await embedDemoLoginWithKey("", apiKey.trim()) + : await embedDemoLogin("", customerId.trim(), password); setLoading(false); if (res.code !== 0) { - setError(res.message || "客户编号或密码错误"); + setError( + res.message || + (loginType === "api_key" ? "API Key 无效" : "客户编号或密码错误"), + ); return; } @@ -45,8 +63,32 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {

客户登录

- 使用客户编号与管理员分配的密码登录(每次进入须重新填写) + {loginType === "api_key" + ? "使用管理员分配的 API Key 登录(每次进入须重新填写)" + : "使用客户编号与管理员分配的密码登录(每次进入须重新填写)"}

+ +
+ {LOGIN_TABS.map((tab) => ( + + ))} +
+ {error && (
{error} @@ -57,24 +99,39 @@ export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) { className="space-y-4" autoComplete="off" > - setCustomerId(e.target.value)} - /> - setPassword(e.target.value)} - /> + {loginType === "api_key" ? ( + setApiKey(e.target.value)} + /> + ) : ( + <> + setCustomerId(e.target.value)} + /> + setPassword(e.target.value)} + /> + + )} 进入演示 diff --git a/components/embed/embedded-quote-widget.tsx b/components/embed/embedded-quote-widget.tsx index b536769..fb7f1a3 100644 --- a/components/embed/embedded-quote-widget.tsx +++ b/components/embed/embedded-quote-widget.tsx @@ -22,19 +22,36 @@ import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages"; import { QuoteForm } from "@/components/quote/quote-form"; import { QuoteResultPanel } from "@/components/quote/quote-result-panel"; import { AddressDisambiguationModal } from "@/components/quote/address-disambiguation-modal"; -import { PrimaryButton } from "@/components/ui/primary-button"; +import { PrimaryButton, SecondaryButton } from "@/components/ui/primary-button"; import { ErrorBanner } from "@/components/ui/error-banner"; +import { quoteCtaCls } from "@/components/quote/quote-form-chrome"; import { QuoteProviderSwitch, type QuoteProviderId, } from "@/components/embed/quote-provider-switch"; import { Priority1QuoteWidget } from "@/components/priority1/priority1-quote-widget"; +import { FlockQuoteWidget } from "@/components/flock/flock-quote-widget"; +import { + MothershipLoggedInShipmentForm, + type MothershipLoggedInShipmentPayload, +} from "@/components/mothership/mothership-logged-in-shipment-form"; +import { MothershipLoggedInDetailsForm } from "@/components/mothership/mothership-logged-in-details-form"; +import { MothershipLoggedInQuoteSidebar } from "@/components/mothership/mothership-logged-in-quote-sidebar"; +import { buildQuoteRequestBodyFromLoggedIn } from "@/lib/frontend/mothership-logged-in-quote-body"; export interface EmbeddedQuoteWidgetProps { customerId: string; apiBaseUrl?: string; /** 未传时使用 Cookie 演示会话鉴权(/embed-demo) */ serviceToken?: string; + /** 客户已绑定 MotherShip 账密 → 显示登录后一级查价界面 */ + mothershipLoggedInUi?: boolean; + /** 客户已绑定 Flock 账密 → 显示登录后 Quick 查价界面 */ + flockLoggedInUi?: boolean; + /** 门户驱动:强制单一数据源(配合 hideProviderSwitch) */ + forcedProvider?: QuoteProviderId; + /** 门户驱动:隐藏内置数据源切换(4 路线门户已在外层选路) */ + hideProviderSwitch?: boolean; } const FORM_ID = "embedded-quote-form"; @@ -43,12 +60,23 @@ export function EmbeddedQuoteWidget({ serviceToken, customerId, apiBaseUrl = "", + mothershipLoggedInUi = false, + flockLoggedInUi = false, + forcedProvider, + hideProviderSwitch = false, }: EmbeddedQuoteWidgetProps) { - const [provider, setProvider] = useState("mothership"); + const [provider, setProvider] = useState( + forcedProvider ?? "mothership", + ); const [status, setStatus] = useState("idle"); const [quote, setQuote] = useState(null); const [error, setError] = useState(null); const [submitLocked, setSubmitLocked] = useState(false); + const [loggedInDraft, setLoggedInDraft] = + useState(null); + const [loggedInStep, setLoggedInStep] = useState<"create" | "details">( + "create", + ); const [disambiguationOpen, setDisambiguationOpen] = useState(false); const [confirmedPickup, setConfirmedPickup] = useState( null, @@ -70,6 +98,8 @@ export function EmbeddedQuoteWidget({ setError(null); setConfirmedPickup(null); setConfirmedDelivery(null); + setLoggedInDraft(null); + setLoggedInStep("create"); }, []); const finishQuote = useCallback((detail: QuoteDetail) => { @@ -272,14 +302,16 @@ export function EmbeddedQuoteWidget({

- { - setProvider(next); - reset(); - }} - disabled={switchDisabled} - /> + {!hideProviderSwitch && ( + { + setProvider(next); + reset(); + }} + disabled={switchDisabled} + /> + )} {provider === "priority1" ? ( + ) : provider === "flock" ? ( + ) : ( <> {error && status === "idle" && ( @@ -297,55 +336,145 @@ export function EmbeddedQuoteWidget({
- { - setConfirmedPickup(null); - setConfirmedDelivery(null); - }} - onValidSubmit={(body) => void handleSubmit(body)} - /> -
- - 获取报价 - -
+ {mothershipLoggedInUi ? ( + loggedInStep === "details" && loggedInDraft ? ( + <> +
+ setLoggedInStep("create")} + > + 返回一级表单 + +
+ + + ) : ( + <> + { + setLoggedInDraft(payload); + setLoggedInStep("details"); + setQuote(null); + setError(null); + try { + // 一级信息足够询价;二级仅选报价/后续填详情,不阻塞出价 + const body = buildQuoteRequestBodyFromLoggedIn( + payload, + customerId, + ); + void submitQuote(body); + } catch (err) { + setStatus("error"); + setError( + err instanceof Error + ? err.message + : "无法构建询价请求", + ); + } + }} + /> +
+ +
+ + ) + ) : ( + <> + { + setConfirmedPickup(null); + setConfirmedDelivery(null); + }} + onCargoChange={() => { + setQuote(null); + setError(null); + if (status === "success" || status === "fallback") { + setStatus("idle"); + } + }} + onValidSubmit={(body) => void handleSubmit(body)} + /> +
+ + 获取报价 + +
+ + )}
- setStatus("expired")} - onExpiredConfirm={reset} - /> + {mothershipLoggedInUi ? ( + loggedInStep === "details" && loggedInDraft ? ( + + ) : ( +
+

下一步

+

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

+
+ ) + ) : ( + setStatus("expired")} + onExpiredConfirm={reset} + /> + )}
- - void handleDisambiguationConfirm(pickup, delivery) - } - onCancel={handleDisambiguationCancel} - /> + {!mothershipLoggedInUi && ( + + void handleDisambiguationConfirm(pickup, delivery) + } + onCancel={handleDisambiguationCancel} + /> + )} )}
diff --git a/components/embed/quote-provider-switch.tsx b/components/embed/quote-provider-switch.tsx index d7cf4aa..3429aa1 100644 --- a/components/embed/quote-provider-switch.tsx +++ b/components/embed/quote-provider-switch.tsx @@ -1,6 +1,6 @@ "use client"; -export type QuoteProviderId = "mothership" | "priority1"; +export type QuoteProviderId = "mothership" | "priority1" | "flock"; interface QuoteProviderSwitchProps { value: QuoteProviderId; @@ -8,7 +8,7 @@ interface QuoteProviderSwitchProps { disabled?: boolean; } -/** 卡派查价 — Mothership / Priority1 数据源切换 */ +/** 卡派查价 — 数据源切换 */ export function QuoteProviderSwitch({ value, onChange, @@ -16,18 +16,19 @@ export function QuoteProviderSwitch({ }: QuoteProviderSwitchProps) { const tabs: Array<{ id: QuoteProviderId; label: string; desc: string }> = [ { id: "mothership", label: "MotherShip", desc: "四档实时报价" }, - { id: "priority1", label: "Priority1", desc: "官网模拟填表" }, + { id: "priority1", label: "Priority1", desc: "开发中 · 官网模拟填表" }, + { id: "flock", label: "Flock Freight", desc: "ZIP Direct 询价" }, ]; return ( -
+
{tabs.map((tab) => ( + ))} +
+ + {requiresAccount && ( + + )} + + {canQuote ? ( + + ) : ( + +

+ 「登录后」查价需先保存承运商账号密码,保存成功后即可开始。 +

+
+ )} +
+ ); +} + +interface ProviderCredentialPanelProps { + provider: QuoteProvider; + apiBaseUrl: string; + current: EmbedProviderCredential; + onSaved: (saved: EmbedProviderCredential) => void; +} + +function ProviderCredentialPanel({ + provider, + apiBaseUrl, + current, + onSaved, +}: ProviderCredentialPanelProps) { + const [email, setEmail] = useState(current.email ?? ""); + const [password, setPassword] = useState(current.password ?? ""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [savedHint, setSavedHint] = useState(null); + + useEffect(() => { + setEmail(current.email ?? ""); + setPassword(current.password ?? ""); + setError(null); + setSavedHint(null); + }, [provider, current.email, current.password]); + + const handleSave = async () => { + if (saving) return; + if (!email.trim()) { + setError("请填写登录邮箱"); + return; + } + if (!password.trim()) { + setError("请填写登录密码(明文展示便于核对,修改后请点保存)"); + return; + } + setSaving(true); + setError(null); + setSavedHint(null); + const res = await embedSaveProviderCredential( + apiBaseUrl, + provider, + email.trim(), + password, + ); + setSaving(false); + if (res.code !== 0) { + setError(res.message || "保存失败"); + return; + } + setEmail(res.data.email ?? email.trim()); + setPassword(res.data.password ?? password); + setSavedHint( + "账号密码已保存。若官网已改密,请确认此处已同步更新后再查价。", + ); + onSaved(res.data); + }; + + return ( + +

+ 承运商账号密码 +

+

+ {current.has_password + ? "以下为当前已保存的明文账密,请核对是否与官网一致。官网改密后须在此即时保存,否则查价会卡在登录页并报错。" + : "首次使用请填写官网账号邮箱与密码,保存后方可查价。"} +

+ {error && ( +
+ {error} +
+ )} + {savedHint && ( +

{savedHint}

+ )} +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + void handleSave()} + > + {current.has_password ? "保存修改" : "保存"} + +
+
+ ); +} diff --git a/components/flock/flock-logged-in-quote-form.tsx b/components/flock/flock-logged-in-quote-form.tsx new file mode 100644 index 0000000..3d437b1 --- /dev/null +++ b/components/flock/flock-logged-in-quote-form.tsx @@ -0,0 +1,2174 @@ +/** + * Flock Freight 登录后 Quick 查价界面(Let's build a quote) + * 对齐录制 flock-logged-in-20260715-143321.js + 官网截图;中文文案供嵌入宿主展示 + */ +"use client"; + +import { useId, useMemo, useState, useEffect, type FormEvent, type ReactNode } from "react"; +import { Plus, Trash, CaretDown } from "@phosphor-icons/react"; +import { + defaultFlockPickupDateIso, + flockPickupDateValidationMessageFromIso, + minFlockPickupDateIso, +} from "@/lib/flock/pickup-date"; +import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker"; +import { + FLOCK_LIMITS, + FLOCK_VALIDATION_MESSAGES, +} from "@/lib/constants/flock-limits"; +import { + evaluateFlockOptionCompat, + flockOptionHasBlock, + isFlockReeferOnlyWithoutTemp, + isFlockSprinterOnly, + isFlockTempWithoutReefer, +} from "@/lib/flock/option-compat"; +import { + FlockSavedFreightSearch, + FlockSaveFreightButton, +} from "@/components/flock/flock-saved-freight-controls"; +import type { FlockSavedFreightPreset } from "@/lib/flock/saved-freight-presets"; +import { + QuoteFieldTooltip, + QuoteFloatHint, + QuoteNoticeCollapse, + QuotePageTitle, + QuoteRequiredMark, + QuoteSectionTitle, + QuoteStepProgress, + QuoteTipDefs, + quoteAddRowCls, + quoteCtaCls, + quoteInputCls, + quoteInputErrCls, +} from "@/components/quote/quote-form-chrome"; + +export const FLOCK_LOCATION_TYPES = [ + { id: "business_with_dock", label: "商业地址(有月台)" }, + { id: "business_without_dock", label: "商业地址(无月台)" }, + { id: "residential", label: "住宅地址" }, + { id: "trade_show", label: "展会 / 展览" }, + { id: "limited_construction", label: "施工工地" }, + { id: "limited_farm", label: "农场 / 种养殖地" }, + { id: "limited_airport", label: "受限通道 · 机场" }, + { id: "limited_port", label: "受限通道 · 港口码头" }, + { id: "limited_military", label: "受限通道 · 军事基地" }, + { id: "limited_worship", label: "受限通道 · 宗教场所" }, + { id: "limited_school", label: "受限通道 · 学校" }, + { id: "limited_other", label: "受限通道 · 其他" }, +] as const; + +/** 各地点类型可见的附加选项(对齐 Flock 官网:类别不同选项不同) */ +export type FlockLocationAccessorialFlags = { + liftgate: boolean; + inside: boolean; + palletJack: boolean; +}; + +export const FLOCK_LOCATION_ACCESSORIALS: Record< + (typeof FLOCK_LOCATION_TYPES)[number]["id"], + FlockLocationAccessorialFlags +> = { + // 有卸货台:无附加勾选项(官网无 Liftgate / Inside / Pallet Jack) + business_with_dock: { liftgate: false, inside: false, palletJack: false }, + // 无卸货台:三项全开 + business_without_dock: { liftgate: true, inside: true, palletJack: true }, + // 住宅:仅尾板 + 室内 + residential: { liftgate: true, inside: true, palletJack: false }, + // 展会:仅托盘车 + trade_show: { liftgate: false, inside: false, palletJack: true }, + // 施工现场:尾板 + 托盘车(无室内) + limited_construction: { liftgate: true, inside: false, palletJack: true }, + // 农场:三项 + limited_farm: { liftgate: true, inside: true, palletJack: true }, + limited_airport: { liftgate: true, inside: true, palletJack: true }, + limited_port: { liftgate: true, inside: true, palletJack: true }, + limited_military: { liftgate: true, inside: true, palletJack: true }, + // 宗教场所:仅尾板 + 室内(截图 Limited Access – Church) + limited_worship: { liftgate: true, inside: true, palletJack: false }, + limited_school: { liftgate: true, inside: true, palletJack: true }, + limited_other: { liftgate: true, inside: true, palletJack: true }, +}; + +export function getFlockLocationAccessorials( + typeId: (typeof FLOCK_LOCATION_TYPES)[number]["id"], +): FlockLocationAccessorialFlags { + return FLOCK_LOCATION_ACCESSORIALS[typeId]; +} + +export const FLOCK_PACKAGING_TYPES = [ + { id: "pallets_48x40", label: "标准托盘 48×40", length: 48, width: 40 }, + { id: "pallets_48x48", label: "标准托盘 48×48", length: 48, width: 48 }, + { id: "pallets_60x48", label: "标准托盘 60×48", length: 60, width: 48 }, + { id: "pallets_custom", label: "自定义托盘尺寸", length: null, width: null }, + { id: "bags", label: "袋装", length: null, width: null }, + { id: "bales", label: "捆包", length: null, width: null }, + { id: "boxes", label: "纸箱 / 纸盒", length: null, width: null }, + { id: "bundles", label: "捆扎件", length: null, width: null }, + { id: "coils", label: "卷料", length: null, width: null }, + { id: "crates", label: "木箱 / 板条箱", length: null, width: null }, + { id: "cylinders", label: "钢瓶 / 气瓶", length: null, width: null }, + { id: "drums", label: "铁桶 / 圆桶", length: null, width: null }, + { id: "pails", label: "小桶 / 提桶", length: null, width: null }, + { id: "reels", label: "线盘 / 卷盘", length: null, width: null }, + { id: "rolls", label: "卷筒", length: null, width: null }, + { id: "slipsheets", label: "滑托板", length: null, width: null }, + { id: "tubes_pipes", label: "管材", length: null, width: null }, + { id: "units", label: "单件 / 整机", length: null, width: null }, +] as const; + +export const FLOCK_FREIGHT_CLASSES = [ + "density", + "50", + "55", + "60", + "65", + "70", + "77.5", + "85", + "92.5", + "100", + "110", + "125", + "150", + "175", + "200", + "250", + "300", + "400", + "500", +] as const; + +export const FLOCK_ADDITIONAL_SERVICES = [ + { id: "blind_shipment", label: "盲运(隐藏发/收货方)" }, + { id: "food_grade", label: "食品级车厢" }, + { id: "load_to_ride", label: "全程直达(不换装)" }, + { id: "temperature_control", label: "温控 / 冷链" }, + { id: "unloading", label: "到站卸货服务" }, +] as const; + +export const FLOCK_VEHICLE_TYPES = [ + { id: "box_truck", label: "厢式货车" }, + { id: "dry_van", label: "干货挂车" }, + { id: "refrigerated", label: "冷藏车" }, + { id: "sprinter", label: "小型厢车" }, +] as const; + +export const FLOCK_DEFAULT_VEHICLES = ["box_truck", "dry_van", "refrigerated"] as const; + +/** 官网提示文案(货运行业惯用译,对齐全票提货/派送语境) */ +export const FLOCK_LOCATION_TIP_INTRO = + "请准确选择地点类型,便于承运商安排提货与派送车辆,减少压车与拒收。"; + +export const FLOCK_LOCATION_TIP_ITEMS = [ + { + title: "商业地址(有月台):", + body: "具备标准高度装卸月台,车辆可直接对接上下货的商业场所。", + }, + { + title: "商业地址(无月台):", + body: "无标准装卸月台,通常需尾板或地面装卸的商业场所。", + }, + { + title: "住宅地址:", + body: "按当地区划认定的住宅地点,含住家办公、宅基地建筑等。", + }, + { + title: "展会 / 展览:", + body: "进出展馆、展台及展会临时仓的货件。", + }, + { + title: "施工工地:", + body: "正在施工、进出可能受限的工地现场。", + }, + { + title: "农场 / 种养殖地:", + body: "农业种植、养殖或农产品生产用地。", + }, + { + title: "受限通道地点:", + body: "进出不便,或需额外通行证/安检手续的地点(机场、港口、军营、校园等)。", + }, +] as const; + +export const FLOCK_FREIGHT_CLASS_TIP = + "填写件数、外尺寸(长×宽×高)与重量后,可据此估算或选择 NMFC 运价等级。"; + +export const FLOCK_NMFC_NOTICE = + "仅按密度推算的等级可能不准确。正式分等还要考虑装卸难度、积载性与承运责任。托运人须选用正确的 NMFC 代码与等级,否则可能被重新分等并产生改费、延误与追加费用。"; + +export const FLOCK_STACKABLE_TIP = + "货件可堆码或可调向摆放时,有助于提高装载率,运费最多约可省 20%。"; + +export const FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS = [ + { + title: "盲运:", + body: "提单及签收单据对发货方和/或收货方信息进行隐匿,适用于中转贸易等场景。", + }, + { + title: "食品级车厢:", + body: "要求车厢洁净、无异味、无破损渗透,适用于食品及敏感货物。", + }, + { + title: "全程直达(不换装):", + body: "要求货件途中不经其他站点翻装换车,降低破损与延误风险。", + }, + { + title: "温控 / 冷链:", + body: "车厢需按指定温度区间控温运输。", + }, + { + title: "到站卸货服务:", + body: "含到站分拣、装卸工协助等;不等于白手套式送货上门。", + }, +] as const; + +export const FLOCK_VEHICLE_TIP_ITEMS = [ + { + title: "干货挂车:", + body: "提货或派送场地可停靠约 53 英尺干货厢式挂车时勾选。", + }, + { + title: "冷藏车:", + body: "货件可装入各类尺寸冷藏车(如 48′、53′)时勾选;若需设定温度,请同时勾选「温控 / 冷链」。", + }, + { + title: "厢式货车:", + body: "场地可停靠各尺寸厢货(如 18′–28′ 城市配送车)时勾选。", + }, + { + title: "小型厢车:", + body: "场地可停靠厢式面包车/小型厢货,且无需叉车或坡道装卸时勾选。", + }, +] as const; + +export const FLOCK_INSURANCE_YES_TIP = + "若需在承运人责任限额之外另行加保,请选「是」。"; + +export const FLOCK_INSURANCE_NO_TIP = + "货件已有保险,或不需超出承运人责任限额的额外保额时,请选「否」。"; + +export const FLOCK_INSURANCE_WAIVE_HINT = "您已选择不投保额外货运险。"; + +export const FLOCK_INSURANCE_FOOTER = + "如需了解货运保险细则,可联系 support@flockfreight.com。"; + +/** 整票总重未超过阈值时,官网调度区部分选项为灰色不可选 */ +export function isFlockSchedulingOptionLocked( + totalWeightLb: number, + weightLocked: boolean, +): boolean { + if (!weightLocked) return false; + return !Number.isFinite(totalWeightLb) || totalWeightLb <= FLOCK_LIMITS.schedulingWeightMinLb; +} + +export function flockSchedulingWeightLockedHint(): string { + return `整票总重须大于 ${FLOCK_LIMITS.schedulingWeightMinLb} lb 才可选用`; +} + +/** 官网时段下拉(12h,半小时步进) */ +export const FLOCK_SCHEDULING_TIME_OPTIONS: string[] = (() => { + const opts: string[] = []; + for (let h = 0; h < 24; h += 1) { + for (const m of [0, 30] as const) { + const hour12 = h % 12 === 0 ? 12 : h % 12; + const ampm = h < 12 ? "am" : "pm"; + opts.push(`${hour12}:${m === 0 ? "00" : "30"} ${ampm}`); + } + } + return opts; +})(); + +/** Quick 调度:weightLocked=true 表示总重 ≤5000 时灰色禁用 */ +export const FLOCK_PICKUP_SERVICES = [ + { + id: "standard_fcfs", + label: "标准提货(FCFS)", + hint: "在地点营业时间内提货", + detail: "", + weightLocked: false, + }, + { + id: "during_window", + label: "指定提货时段", + hint: "", + detail: + "提货地点有预设时段(如 2PM–4PM)可供提货。", + weightLocked: true, + }, + { + id: "have_appointment", + label: "已有提货预约", + hint: "", + detail: "", + weightLocked: true, + }, + { + id: "need_appointment", + label: "需要提货预约", + hint: "", + detail: "", + weightLocked: true, + }, +] as const; + +/** 提货前致电:总重 ≤5000 时灰色 */ +export const FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED = true; + +export const FLOCK_DELIVERY_SERVICES = [ + { + id: "standard_fcfs", + label: "标准派送(FCFS)", + hint: "在地点营业时间内派送", + detail: "", + weightLocked: false, + }, + { + id: "during_window", + label: "指定派送时段", + hint: "", + detail: + "派送地点有预设时段(如 2PM–4PM)可供派送。", + weightLocked: true, + }, + { + id: "have_appointment", + label: "已有派送预约", + hint: "", + detail: "", + weightLocked: true, + }, + { + id: "need_appointment", + label: "需要派送预约", + hint: "", + detail: "", + /** 官网小批量下仍可选 */ + weightLocked: false, + }, + { + id: "must_arrive_by", + label: "须在指定日期前到达", + hint: "", + detail: "", + weightLocked: true, + }, +] as const; + +export const FLOCK_CALL_FOR_DELIVERY_APPOINTMENT_HINT = + "Flock Freight 将与派送地点及承运商协调预约。"; + +export type FlockLocationTypeId = (typeof FLOCK_LOCATION_TYPES)[number]["id"]; +export type FlockPackagingTypeId = (typeof FLOCK_PACKAGING_TYPES)[number]["id"]; +export type FlockFreightClass = (typeof FLOCK_FREIGHT_CLASSES)[number]; +export type FlockAdditionalServiceId = + (typeof FLOCK_ADDITIONAL_SERVICES)[number]["id"]; +export type FlockVehicleTypeId = (typeof FLOCK_VEHICLE_TYPES)[number]["id"]; + +export type FlockLoggedInItem = { + key: string; + quantity: string; + packagingType: FlockPackagingTypeId; + lengthIn: string; + widthIn: string; + heightIn: string; + totalWeightLb: string; + freightClass: FlockFreightClass; + description: string; + stackable: boolean; + turnable: boolean; +}; + +export type FlockLoggedInQuotePayload = { + mode: "quick"; + pickupDate: string; + pickupZip: string; + pickupType: FlockLocationTypeId; + pickupLiftgate: boolean; + pickupInside: boolean; + pickupPalletJack: boolean; + deliveryZip: string; + deliveryType: FlockLocationTypeId; + deliveryLiftgate: boolean; + deliveryInside: boolean; + deliveryPalletJack: boolean; + items: Array<{ + quantity: number; + packagingType: FlockPackagingTypeId; + lengthIn: number; + widthIn: number; + heightIn: number; + totalWeightLb: number; + freightClass: FlockFreightClass; + description: string; + stackable: boolean; + turnable: boolean; + }>; + additionalServices: FlockAdditionalServiceId[]; + vehicleTypes: FlockVehicleTypeId[]; + pickupService: string; + deliveryService: string; + /** 指定提货时段 */ + pickupWindowStartTime?: string; + pickupWindowEndTime?: string; + /** 已有提货预约 */ + pickupAppointmentTime?: string; + /** 指定派送时段 */ + deliveryWindowStartTime?: string; + deliveryWindowEndTime?: string; + deliveryWindowDate?: string; + /** 已有派送预约 */ + deliveryAppointmentTime?: string; + deliveryAppointmentDate?: string; + /** 须在指定日期前到达 */ + deliveryMustArriveByDate?: string; + /** Must arrive by 下的「预约派送致电」 */ + callForDeliveryAppointment?: boolean; + callBeforePickup: boolean; + callBeforeDelivery: boolean; + additionalInsurance: boolean; +}; + +export interface FlockLoggedInQuoteFormProps { + formId?: string; + disabled?: boolean; + submitLocked?: boolean; + /** 按客户隔离常用货件(localStorage) */ + customerId?: string; + onValidSubmit?: (payload: FlockLoggedInQuotePayload) => void; +} + +function RequiredMark() { + return ; +} + +function newItem(): FlockLoggedInItem { + return { + key: `fi-${Math.random().toString(36).slice(2, 10)}`, + quantity: "", + packagingType: "pallets_48x40", + lengthIn: "48", + widthIn: "40", + heightIn: "", + totalWeightLb: "", + freightClass: "density", + description: "", + stackable: false, + turnable: false, + }; +} + +function applyPackagingDims( + packagingType: FlockPackagingTypeId, + item: FlockLoggedInItem, +): FlockLoggedInItem { + const pack = FLOCK_PACKAGING_TYPES.find((p) => p.id === packagingType); + if (!pack) return { ...item, packagingType }; + return { + ...item, + packagingType, + lengthIn: pack.length != null ? String(pack.length) : item.lengthIn, + widthIn: pack.width != null ? String(pack.width) : item.widthIn, + }; +} + +function applySavedFreightPreset( + item: FlockLoggedInItem, + preset: FlockSavedFreightPreset, +): FlockLoggedInItem { + const packOk = FLOCK_PACKAGING_TYPES.some((p) => p.id === preset.packagingType); + const classOk = (FLOCK_FREIGHT_CLASSES as readonly string[]).includes( + preset.freightClass, + ); + return { + ...item, + quantity: preset.quantity || item.quantity, + packagingType: packOk + ? (preset.packagingType as FlockPackagingTypeId) + : item.packagingType, + lengthIn: preset.lengthIn || item.lengthIn, + widthIn: preset.widthIn || item.widthIn, + heightIn: preset.heightIn || item.heightIn, + totalWeightLb: preset.totalWeightLb || item.totalWeightLb, + freightClass: classOk + ? (preset.freightClass as FlockFreightClass) + : item.freightClass, + description: preset.description || preset.name, + stackable: Boolean(preset.stackable), + turnable: Boolean(preset.turnable), + }; +} + +function OutlinedField({ + label, + required, + error, + tip, + children, +}: { + label: string; + required?: boolean; + error?: string; + tip?: ReactNode; + children: ReactNode; +}) { + return ( + + ); +} + +const inputCls = quoteInputCls; +const inputErrCls = quoteInputErrCls; + +function LocationAccessorialChecks({ + side, + typeId, + disabled, + liftgate, + inside, + palletJack, + onLiftgate, + onInside, + onPalletJack, +}: { + side: "pickup" | "delivery"; + typeId: FlockLocationTypeId; + disabled?: boolean; + liftgate: boolean; + inside: boolean; + palletJack: boolean; + onLiftgate: (v: boolean) => void; + onInside: (v: boolean) => void; + onPalletJack: (v: boolean) => void; +}) { + const flags = getFlockLocationAccessorials(typeId); + if (!flags.liftgate && !flags.inside && !flags.palletJack) return null; + + const liftgateLabel = + side === "pickup" + ? "提货需要尾板(Liftgate)" + : "派送需要尾板(Liftgate)"; + const insideLabel = + side === "pickup" + ? "室内提货(Inside Pickup)" + : "室内派送(Inside Delivery)"; + + return ( +
+ {flags.liftgate ? ( + + ) : null} + {flags.inside ? ( + + ) : null} + {flags.palletJack ? ( + + ) : null} +
+ ); +} +function ChipToggle({ + selected, + disabled, + label, + onClick, +}: { + selected: boolean; + disabled?: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +/** 登录态货物行尺寸/重量硬限(同源 FLOCK_LIMITS) */ +export function validateFlockLoggedInCargoLine(input: { + quantity: number; + lengthIn: number; + widthIn: number; + heightIn: number; + totalWeightLb: number; + description: string; +}): Record { + const err: Record = {}; + const { min, lengthMax, widthMax, heightMax } = FLOCK_LIMITS.dimIn; + if (!Number.isFinite(input.quantity) || input.quantity <= 0) { + err.qty = "件数必填"; + } + if (!Number.isFinite(input.lengthIn) || input.lengthIn < min) { + err.len = "长度必填"; + } else if (input.lengthIn > lengthMax) { + err.len = FLOCK_VALIDATION_MESSAGES.length; + } + if (!Number.isFinite(input.widthIn) || input.widthIn < min) { + err.wid = "宽度必填"; + } else if (input.widthIn > widthMax) { + err.wid = FLOCK_VALIDATION_MESSAGES.width; + } + if (!Number.isFinite(input.heightIn) || input.heightIn < min) { + err.hei = "高度必填"; + } else if (input.heightIn > heightMax) { + err.hei = FLOCK_VALIDATION_MESSAGES.height; + } + if (!Number.isFinite(input.totalWeightLb) || input.totalWeightLb <= 0) { + err.wt = "重量必填"; + } else if (input.totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) { + err.wt = FLOCK_VALIDATION_MESSAGES.totalWeight; + } + if (!input.description.trim()) { + err.desc = "货物描述必填"; + } + return err; +} + +/** 将登录态 payload 映射为 Flock API flock_input(含登录态 RPA 扩展字段) */ +export function mapFlockLoggedInToApiInput(payload: FlockLoggedInQuotePayload): { + pickup_date: string; + pickup_zip: string; + delivery_zip: string; + pallet_count: number; + total_weight: { value: number; unit: "lb" }; + dimensions: { length: number; width: number; height: number; unit: "in" }; + form_mode: "logged_in_quick"; + pickup_location_type: string; + delivery_location_type: string; + packaging_type: string; + freight_class: string; + description: string; + stackable: boolean; + turnable: boolean; + additional_services: string[]; + vehicle_types: string[]; + call_before_pickup: boolean; + call_before_delivery: boolean; + pickup_service: string; + delivery_service: string; + pickup_window_start_time?: string; + pickup_window_end_time?: string; + pickup_appointment_time?: string; + delivery_window_start_time?: string; + delivery_window_end_time?: string; + delivery_window_date?: string; + delivery_appointment_time?: string; + delivery_appointment_date?: string; + delivery_must_arrive_by_date?: string; + call_for_delivery_appointment?: boolean; + pickup_liftgate: boolean; + pickup_inside: boolean; + pickup_pallet_jack: boolean; + delivery_liftgate: boolean; + delivery_inside: boolean; + delivery_pallet_jack: boolean; +} { + const [y, m, d] = payload.pickupDate.split("-"); + const pickup_date = + y && m && d ? `${m}/${d}/${y}` : payload.pickupDate; + const first = payload.items[0]; + const pallet_count = payload.items.reduce((s, it) => s + it.quantity, 0); + const total_weight = payload.items.reduce((s, it) => s + it.totalWeightLb, 0); + const opt = (v?: string) => { + const t = (v ?? "").trim(); + return t || undefined; + }; + return { + pickup_date, + pickup_zip: payload.pickupZip, + delivery_zip: payload.deliveryZip, + pallet_count: Math.max(1, pallet_count), + total_weight: { value: total_weight || first?.totalWeightLb || 1, unit: "lb" }, + dimensions: { + length: first?.lengthIn ?? 48, + width: first?.widthIn ?? 40, + height: first?.heightIn ?? 48, + unit: "in", + }, + form_mode: "logged_in_quick", + pickup_location_type: payload.pickupType, + delivery_location_type: payload.deliveryType, + packaging_type: first?.packagingType ?? "pallets_48x40", + freight_class: first?.freightClass ?? "density", + description: first?.description?.trim() || "General freight", + stackable: Boolean(first?.stackable), + turnable: Boolean(first?.turnable), + additional_services: [...payload.additionalServices], + vehicle_types: [...payload.vehicleTypes], + call_before_pickup: + FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED && + isFlockSchedulingOptionLocked( + payload.items.reduce((s, it) => s + it.totalWeightLb, 0), + true, + ) + ? false + : Boolean(payload.callBeforePickup), + call_before_delivery: Boolean(payload.callBeforeDelivery), + pickup_service: payload.pickupService, + delivery_service: payload.deliveryService, + pickup_window_start_time: opt(payload.pickupWindowStartTime), + pickup_window_end_time: opt(payload.pickupWindowEndTime), + pickup_appointment_time: opt(payload.pickupAppointmentTime), + delivery_window_start_time: opt(payload.deliveryWindowStartTime), + delivery_window_end_time: opt(payload.deliveryWindowEndTime), + delivery_window_date: opt(payload.deliveryWindowDate), + delivery_appointment_time: opt(payload.deliveryAppointmentTime), + delivery_appointment_date: opt(payload.deliveryAppointmentDate), + delivery_must_arrive_by_date: opt(payload.deliveryMustArriveByDate), + call_for_delivery_appointment: + payload.deliveryService === "must_arrive_by" + ? Boolean(payload.callForDeliveryAppointment) + : undefined, + pickup_liftgate: Boolean(payload.pickupLiftgate), + pickup_inside: Boolean(payload.pickupInside), + pickup_pallet_jack: Boolean(payload.pickupPalletJack), + delivery_liftgate: Boolean(payload.deliveryLiftgate), + delivery_inside: Boolean(payload.deliveryInside), + delivery_pallet_jack: Boolean(payload.deliveryPalletJack), + }; +} + +export function FlockLoggedInQuoteForm({ + formId = "flock-logged-in-quote-form", + disabled, + submitLocked, + customerId = "anon", + onValidSubmit, +}: FlockLoggedInQuoteFormProps) { + const baseId = useId(); + const [pickupDate, setPickupDate] = useState(defaultFlockPickupDateIso); + const [pickupZip, setPickupZip] = useState(""); + const [pickupType, setPickupType] = + useState("business_with_dock"); + const [pickupLiftgate, setPickupLiftgate] = useState(false); + const [pickupInside, setPickupInside] = useState(false); + const [pickupPalletJack, setPickupPalletJack] = useState(false); + const [deliveryZip, setDeliveryZip] = useState(""); + const [deliveryType, setDeliveryType] = + useState("business_with_dock"); + const [deliveryLiftgate, setDeliveryLiftgate] = useState(false); + const [deliveryInside, setDeliveryInside] = useState(false); + const [deliveryPalletJack, setDeliveryPalletJack] = useState(false); + const [items, setItems] = useState(() => [newItem()]); + const [savedFreightRevision, setSavedFreightRevision] = useState(0); const [additionalServices, setAdditionalServices] = useState< + FlockAdditionalServiceId[] + >(["unloading"]); + const [vehicleTypes, setVehicleTypes] = useState([ + ...FLOCK_DEFAULT_VEHICLES, + ]); + const [pickupService, setPickupService] = useState("standard_fcfs"); + const [deliveryService, setDeliveryService] = useState("standard_fcfs"); + const [pickupWindowStartTime, setPickupWindowStartTime] = useState("8:00 am"); + const [pickupWindowEndTime, setPickupWindowEndTime] = useState("5:00 pm"); + const [pickupAppointmentTime, setPickupAppointmentTime] = useState("8:00 am"); + const [deliveryWindowStartTime, setDeliveryWindowStartTime] = + useState("8:00 am"); + const [deliveryWindowEndTime, setDeliveryWindowEndTime] = useState("5:00 pm"); + const [deliveryWindowDate, setDeliveryWindowDate] = useState(""); + const [deliveryAppointmentTime, setDeliveryAppointmentTime] = + useState("8:00 am"); + const [deliveryAppointmentDate, setDeliveryAppointmentDate] = useState(""); + const [deliveryMustArriveByDate, setDeliveryMustArriveByDate] = useState(""); + const [callForDeliveryAppointment, setCallForDeliveryAppointment] = + useState(false); + const [callBeforePickup, setCallBeforePickup] = useState(false); + const [callBeforeDelivery, setCallBeforeDelivery] = useState(false); + const [additionalInsurance, setAdditionalInsurance] = useState(false); + const [showAdditional, setShowAdditional] = useState(true); + const [showScheduling, setShowScheduling] = useState(true); + const [showInsurance, setShowInsurance] = useState(true); + const [errors, setErrors] = useState>({}); + const [localError, setLocalError] = useState(null); + + const servicesSelected = additionalServices.length; + const vehiclesSelected = vehicleTypes.length; + + const shipmentWeightLb = useMemo( + () => + items.reduce((sum, it) => { + const w = Number(it.totalWeightLb); + return sum + (Number.isFinite(w) && w > 0 ? w : 0); + }, 0), + [items], + ); + + const schedulingLockedHint = flockSchedulingWeightLockedHint(); + + const optionCompatIssues = useMemo( + () => + evaluateFlockOptionCompat({ + pickupLocationType: pickupType, + deliveryLocationType: deliveryType, + packagingTypes: items.map((it) => it.packagingType), + vehicleTypes, + additionalServices, + shipmentWeightLb, + }), + [ + pickupType, + deliveryType, + items, + vehicleTypes, + additionalServices, + shipmentWeightLb, + ], + ); + + useEffect(() => { + const pickupSvc = FLOCK_PICKUP_SERVICES.find((s) => s.id === pickupService); + if ( + pickupSvc && + isFlockSchedulingOptionLocked(shipmentWeightLb, pickupSvc.weightLocked) + ) { + setPickupService("standard_fcfs"); + } + const deliverySvc = FLOCK_DELIVERY_SERVICES.find( + (s) => s.id === deliveryService, + ); + if ( + deliverySvc && + isFlockSchedulingOptionLocked(shipmentWeightLb, deliverySvc.weightLocked) + ) { + setDeliveryService("standard_fcfs"); + } + if ( + FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED && + isFlockSchedulingOptionLocked(shipmentWeightLb, true) && + callBeforePickup + ) { + setCallBeforePickup(false); + } + }, [ + shipmentWeightLb, + pickupService, + deliveryService, + callBeforePickup, + ]); + + const canSubmit = useMemo( + () => !disabled && !submitLocked, + [disabled, submitLocked], + ); + + const updateItem = (key: string, patch: Partial) => { + setItems((rows) => + rows.map((row) => (row.key === key ? { ...row, ...patch } : row)), + ); + }; + + const toggleService = (id: FlockAdditionalServiceId) => { + setAdditionalServices((prev) => { + const next = prev.includes(id) + ? prev.filter((x) => x !== id) + : [...prev, id]; + if ( + isFlockReeferOnlyWithoutTemp({ + vehicleTypes, + additionalServices: next, + }) + ) { + setLocalError( + "仅允许「冷藏车」时须同时勾选「温控 / 冷链」,否则官网无法提交询价", + ); + } else if ( + isFlockTempWithoutReefer({ + vehicleTypes, + additionalServices: next, + }) + ) { + setLocalError("已勾选「温控 / 冷链」时,允许车型中须包含「冷藏车」"); + } else { + setLocalError(null); + } + return next; + }); + }; + + const toggleVehicle = (id: FlockVehicleTypeId) => { + setVehicleTypes((prev) => { + const next = prev.includes(id) + ? prev.filter((x) => x !== id) + : [...prev, id]; + if (next.length === 0) { + setLocalError("请至少选择一种车型"); + return prev.includes(id) ? prev : next; + } + if ( + isFlockReeferOnlyWithoutTemp({ + vehicleTypes: next, + additionalServices, + }) + ) { + setLocalError( + "仅允许「冷藏车」时须同时勾选「温控 / 冷链」,否则官网无法提交询价", + ); + } else if ( + isFlockTempWithoutReefer({ + vehicleTypes: next, + additionalServices, + }) + ) { + setLocalError("已勾选「温控 / 冷链」时,允许车型中须包含「冷藏车」"); + } else if (isFlockSprinterOnly(next)) { + setLocalError( + "仅选「小型厢车」时易无报价,建议同时勾选厢式货车或干货挂车", + ); + } else { + setLocalError(null); + } + return next; + }); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + setLocalError(null); + const nextErr: Record = {}; + + if (!/^\d{5}(-\d{4})?$/.test(pickupZip.trim())) { + nextErr.pickupZip = "提货邮编必填(5 位)"; + } + if (!/^\d{5}(-\d{4})?$/.test(deliveryZip.trim())) { + nextErr.deliveryZip = "派送邮编必填(5 位)"; + } + const pickupDateError = flockPickupDateValidationMessageFromIso(pickupDate); + if (pickupDateError) { + nextErr.pickupDate = pickupDateError; + } + if (vehicleTypes.length === 0) { + nextErr.vehicles = "请至少选择一种车型"; + } + if (flockOptionHasBlock(optionCompatIssues)) { + const block = optionCompatIssues.find((i) => i.severity === "block")!; + setLocalError(block.message); + setErrors(nextErr); + return; + } + + const parsedItems: FlockLoggedInQuotePayload["items"] = []; + for (let i = 0; i < items.length; i += 1) { + const it = items[i]!; + const quantity = Number(it.quantity); + const lengthIn = Number(it.lengthIn); + const widthIn = Number(it.widthIn); + const heightIn = Number(it.heightIn); + const totalWeightLb = Number(it.totalWeightLb); + const lineErr = validateFlockLoggedInCargoLine({ + quantity, + lengthIn, + widthIn, + heightIn, + totalWeightLb, + description: it.description, + }); + if (lineErr.qty) nextErr[`item-${i}-qty`] = lineErr.qty; + if (lineErr.len) nextErr[`item-${i}-len`] = lineErr.len; + if (lineErr.wid) nextErr[`item-${i}-wid`] = lineErr.wid; + if (lineErr.hei) nextErr[`item-${i}-hei`] = lineErr.hei; + if (lineErr.wt) nextErr[`item-${i}-wt`] = lineErr.wt; + if (lineErr.desc) nextErr[`item-${i}-desc`] = lineErr.desc; + if (Object.keys(nextErr).some((k) => k.startsWith(`item-${i}-`))) { + continue; + } + parsedItems.push({ + quantity, + packagingType: it.packagingType, + lengthIn, + widthIn, + heightIn, + totalWeightLb, + freightClass: it.freightClass, + description: it.description.trim(), + stackable: it.stackable, + turnable: it.turnable, + }); + } + + const shipmentWeight = parsedItems.reduce( + (s, it) => s + it.totalWeightLb, + 0, + ); + if ( + parsedItems.length > 0 && + shipmentWeight > FLOCK_LIMITS.totalWeightLbMax + ) { + nextErr.shipmentWeight = FLOCK_VALIDATION_MESSAGES.totalWeight; + } + + if (pickupService === "during_window") { + if (!pickupWindowStartTime.trim()) { + nextErr.pickupWindowStart = "请选择提货时段开始时间"; + } + if (!pickupWindowEndTime.trim()) { + nextErr.pickupWindowEnd = "请选择提货时段结束时间"; + } + } + if (pickupService === "have_appointment" && !pickupAppointmentTime.trim()) { + nextErr.pickupApptTime = "请选择提货预约时间"; + } + if (deliveryService === "during_window") { + if (!deliveryWindowStartTime.trim()) { + nextErr.deliveryWindowStart = "请选择派送时段开始时间"; + } + if (!deliveryWindowEndTime.trim()) { + nextErr.deliveryWindowEnd = "请选择派送时段结束时间"; + } + if (!deliveryWindowDate.trim()) { + nextErr.deliveryWindowDate = "请选择派送时段日期"; + } + } + if (deliveryService === "have_appointment") { + if (!deliveryAppointmentTime.trim()) { + nextErr.deliveryApptTime = "请选择派送预约时间"; + } + if (!deliveryAppointmentDate.trim()) { + nextErr.deliveryApptDate = "请选择派送预约日期"; + } + } + if ( + deliveryService === "must_arrive_by" && + !deliveryMustArriveByDate.trim() + ) { + nextErr.deliveryMustArrive = "请选择须到达日期"; + } + + setErrors(nextErr); + if (Object.keys(nextErr).length > 0 || parsedItems.length === 0) { + const firstMsg = + nextErr.pickupZip || + nextErr.deliveryZip || + nextErr.pickupDate || + nextErr.vehicles || + nextErr.shipmentWeight || + Object.values(nextErr)[0] || + "请完善必填项后再生成报价"; + setLocalError(firstMsg); + return; + } + + onValidSubmit?.({ + mode: "quick", + pickupDate, + pickupZip: pickupZip.trim(), + pickupType, + pickupLiftgate, + pickupInside, + pickupPalletJack, + deliveryZip: deliveryZip.trim(), + deliveryType, + deliveryLiftgate, + deliveryInside, + deliveryPalletJack, + items: parsedItems, + additionalServices, + vehicleTypes, + pickupService, + deliveryService, + pickupWindowStartTime: + pickupService === "during_window" ? pickupWindowStartTime : undefined, + pickupWindowEndTime: + pickupService === "during_window" ? pickupWindowEndTime : undefined, + pickupAppointmentTime: + pickupService === "have_appointment" + ? pickupAppointmentTime + : undefined, + deliveryWindowStartTime: + deliveryService === "during_window" + ? deliveryWindowStartTime + : undefined, + deliveryWindowEndTime: + deliveryService === "during_window" ? deliveryWindowEndTime : undefined, + deliveryWindowDate: + deliveryService === "during_window" ? deliveryWindowDate : undefined, + deliveryAppointmentTime: + deliveryService === "have_appointment" + ? deliveryAppointmentTime + : undefined, + deliveryAppointmentDate: + deliveryService === "have_appointment" + ? deliveryAppointmentDate + : undefined, + deliveryMustArriveByDate: + deliveryService === "must_arrive_by" + ? deliveryMustArriveByDate + : undefined, + callForDeliveryAppointment: + deliveryService === "must_arrive_by" + ? callForDeliveryAppointment + : undefined, + callBeforePickup: + FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED && + isFlockSchedulingOptionLocked(shipmentWeight, true) + ? false + : callBeforePickup, + callBeforeDelivery, + additionalInsurance, + }); + }; + + return ( +
+
+ + 创建报价 +
+ + + {optionCompatIssues.length > 0 ? ( +
+

选项提示(官网规则 / 易无价组合)

+
    + {optionCompatIssues.map((issue) => ( +
  • + {issue.severity === "block" ? "不可提交:" : ""} + {issue.message} +
  • + ))} +
+
+ ) : null} + + {/* 提货日期 */} +
+
+ 提货日期 +
+ + + +
+ + {/* 提货与派送地点 */} +
+
+ 提货与派送地点 + + } + /> +
+ +
+ + setPickupZip(e.target.value)} + placeholder="如 60611" + className={errors.pickupZip ? inputErrCls : inputCls} + inputMode="numeric" + /> + + + + +
+ + + +
+ + setDeliveryZip(e.target.value)} + placeholder="如 78701" + className={errors.deliveryZip ? inputErrCls : inputCls} + inputMode="numeric" + /> + + + + +
+ + + +
+ + { + setItems((rows) => { + if (!rows.length) return [applySavedFreightPreset(newItem(), preset)]; + const [first, ...rest] = rows; + return [applySavedFreightPreset(first, preset), ...rest]; + }); + }} + /> + +
+
+ {/* 货物行 */} + {items.map((item, idx) => ( +
+
+ 货物 {idx + 1} + {items.length > 1 ? ( + + ) : null} +
+ +
+ + + updateItem(item.key, { quantity: e.target.value }) + } + className={ + errors[`item-${idx}-qty`] ? inputErrCls : inputCls + } + inputMode="numeric" + /> + + + + +
+ +
+ + + updateItem(item.key, { lengthIn: e.target.value }) + } + className={ + errors[`item-${idx}-len`] ? inputErrCls : inputCls + } + inputMode="decimal" + /> + + + + updateItem(item.key, { widthIn: e.target.value }) + } + className={ + errors[`item-${idx}-wid`] ? inputErrCls : inputCls + } + inputMode="decimal" + /> + + + + updateItem(item.key, { heightIn: e.target.value }) + } + className={ + errors[`item-${idx}-hei`] ? inputErrCls : inputCls + } + inputMode="decimal" + /> + +
+ +
+ + + updateItem(item.key, { totalWeightLb: e.target.value }) + } + className={ + errors[`item-${idx}-wt`] ? inputErrCls : inputCls + } + inputMode="decimal" + /> + + + + +
+ + {item.freightClass === "density" ? ( +
+ + {FLOCK_NMFC_NOTICE} + +
+ ) : null} + +
+ + + updateItem(item.key, { description: e.target.value }) + } + className={ + errors[`item-${idx}-desc`] ? inputErrCls : inputCls + } + /> + + setSavedFreightRevision((n) => n + 1)} + /> +
+
+

装载选项

+
+ + +
+

+ {FLOCK_STACKABLE_TIP} +

+
+
+ ))} + + + + {/* 附加服务 */} +
+ + {showAdditional ? ( + <> +

+ 已选 {servicesSelected} 项 +

+
+ {FLOCK_ADDITIONAL_SERVICES.map((s) => ( + toggleService(s.id)} + /> + ))} +
+ +
+
+ 允许使用的车型 · 已选 {vehiclesSelected} 项 + } + /> + {errors.vehicles ? ( + {errors.vehicles} + ) : null} +
+
+ {FLOCK_VEHICLE_TYPES.map((v) => ( + toggleVehicle(v.id)} + /> + ))} +
+
+ + ) : null} +
+ + {/* 调度服务 */} +
+ + {showScheduling ? ( +
+

+ 灰色选项在整票总重不超过 {FLOCK_LIMITS.schedulingWeightMinLb} lb + 时不可用;整票总重超过 {FLOCK_LIMITS.schedulingWeightMinLb} lb 后启用。 + {shipmentWeightLb > 0 ? ( + + 当前总重约 {shipmentWeightLb} lb + + ) : null} +

+
+

提货服务

+
+ {FLOCK_PICKUP_SERVICES.map((s) => { + const locked = isFlockSchedulingOptionLocked( + shipmentWeightLb, + s.weightLocked, + ); + return ( + + ); + })} +
+ {pickupService === "during_window" && + !isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? ( +
+

+ 提货时段是什么时候? +

+
+ + + + + + +
+
+ ) : null} + {pickupService === "have_appointment" && + !isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? ( +
+

+ 预设提货预约时间是? +

+ + + +
+ ) : null} + {(() => { + const callBeforeLocked = isFlockSchedulingOptionLocked( + shipmentWeightLb, + FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED, + ); + return ( + + ); + })()} +
+ +
+

派送服务

+
+ {FLOCK_DELIVERY_SERVICES.map((s) => { + const locked = isFlockSchedulingOptionLocked( + shipmentWeightLb, + s.weightLocked, + ); + return ( + + ); + })} +
+ {deliveryService === "during_window" && + !isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? ( +
+

+ 派送时段是什么时候? +

+
+ + + + + + +
+ + setDeliveryWindowDate(e.target.value)} + className={ + errors.deliveryWindowDate ? inputErrCls : inputCls + } + /> + +
+ ) : null} + {deliveryService === "have_appointment" && + !isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? ( +
+

+ 预设派送预约时间是? +

+
+ + + + + + setDeliveryAppointmentDate(e.target.value) + } + className={ + errors.deliveryApptDate ? inputErrCls : inputCls + } + /> + +
+
+ ) : null} + {deliveryService === "must_arrive_by" && + !isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? ( +
+

+ 货物需要在哪一天之前送达? +

+ + + setDeliveryMustArriveByDate(e.target.value) + } + className={ + errors.deliveryMustArrive ? inputErrCls : inputCls + } + /> + + +
+ ) : null} + +
+
+ ) : null} +
+ + {/* 额外保险 */} +
+ + {showInsurance ? ( +
+ + + {!additionalInsurance ? ( +

+ {FLOCK_INSURANCE_WAIVE_HINT} +

+ ) : null} +

{FLOCK_INSURANCE_FOOTER}

+
+ ) : null} +
+ + + + ); +} diff --git a/components/flock/flock-quote-form.tsx b/components/flock/flock-quote-form.tsx new file mode 100644 index 0000000..58efc8a --- /dev/null +++ b/components/flock/flock-quote-form.tsx @@ -0,0 +1,527 @@ +"use client"; + +import { useMemo, useState, type FormEvent } from "react"; +import { SelectField } from "@/components/ui/select-field"; +import { + FLOCK_LIMITS, + FLOCK_VALIDATION_MESSAGES, +} from "@/lib/constants/flock-limits"; +import { + describeDimEquivalent, + describeWeightEquivalent, + parseCargoInputNumber, +} from "@/lib/frontend/cargo-form-units"; +import type { UnitDim, UnitWeight } from "@/lib/frontend/types"; +import { FLOCK_UI } from "@/lib/flock/ui-labels"; +import { + defaultFlockPickupDateIso, + flockPickupDateValidationMessageFromIso, + minFlockPickupDateIso, +} from "@/lib/flock/pickup-date"; +import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker"; +import { defaultFlockPickupDate } from "@/modules/flock/validation"; +import { + cmToIn, + kgToLb, + toAxelWholeInches, + toAxelWholePounds, +} from "@/modules/quote/unit-converter"; + +export type FlockFormSubmitPayload = { + pickup_date: string; + pickup_zip: string; + delivery_zip: string; + pallet_count: number; + total_weight: { value: number; unit: "lb" | "kg" }; + dimensions: { + length: number; + width: number; + height: number; + unit: "in" | "cm"; + }; + registration?: { + first_name?: string; + last_name?: string; + company?: string; + email?: string; + phone?: string; + shipments_per_month?: string; + }; +}; + +export interface FlockQuoteFormProps { + disabled?: boolean; + submitLocked?: boolean; + onSubmit: (payload: FlockFormSubmitPayload) => void; +} + +function toMmDdYyyy(isoDate: string): string { + const [y, m, d] = isoDate.split("-"); + if (!y || !m || !d) return defaultFlockPickupDate(); + return `${m}/${d}/${y}`; +} + +/** Flock 查价表单:单位切换不改数值,服务端自动换算进一(对齐 MotherShip) */ +export function FlockQuoteForm({ + disabled, + submitLocked, + onSubmit, +}: FlockQuoteFormProps) { + const [weightUnit, setWeightUnit] = useState("lb"); + const [dimUnit, setDimUnit] = useState("in"); + + const [pickupDate, setPickupDate] = useState(defaultFlockPickupDateIso); + const [pickupZip, setPickupZip] = useState(""); + const [deliveryZip, setDeliveryZip] = useState(""); + const [palletCount, setPalletCount] = useState("6"); + const [totalWeight, setTotalWeight] = useState("3500"); + const [length, setLength] = useState( + String(FLOCK_LIMITS.defaultDimsIn.length), + ); + const [width, setWidth] = useState(String(FLOCK_LIMITS.defaultDimsIn.width)); + const [height, setHeight] = useState( + String(FLOCK_LIMITS.defaultDimsIn.height), + ); + const [localError, setLocalError] = useState(null); + const [showRegistration, setShowRegistration] = useState(false); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [company, setCompany] = useState(""); + const [workEmail, setWorkEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [shipmentsPerMonth, setShipmentsPerMonth] = useState("1-25"); + + const weightHint = useMemo(() => { + const v = parseCargoInputNumber(totalWeight); + return v == null ? null : describeWeightEquivalent(v, weightUnit); + }, [totalWeight, weightUnit]); + + const dimHint = useMemo(() => { + const l = parseCargoInputNumber(length); + const w = parseCargoInputNumber(width); + const h = parseCargoInputNumber(height); + if (l == null || w == null || h == null) return null; + return describeDimEquivalent(l, w, h, dimUnit); + }, [length, width, height, dimUnit]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + setLocalError(null); + + if (!pickupDate) { + setLocalError(FLOCK_VALIDATION_MESSAGES.pickupDate); + return; + } + const pickupDateError = flockPickupDateValidationMessageFromIso(pickupDate); + if (pickupDateError) { + setLocalError(pickupDateError); + return; + } + if (!pickupZip.trim()) { + setLocalError(FLOCK_VALIDATION_MESSAGES.pickupZip); + return; + } + if (!deliveryZip.trim()) { + setLocalError(FLOCK_VALIDATION_MESSAGES.deliveryZip); + return; + } + const pallets = Number(palletCount); + if ( + !Number.isInteger(pallets) || + pallets < FLOCK_LIMITS.palletCount.min || + pallets > FLOCK_LIMITS.palletCount.max + ) { + setLocalError(FLOCK_VALIDATION_MESSAGES.palletCount); + return; + } + const weightVal = parseCargoInputNumber(totalWeight); + const l = parseCargoInputNumber(length); + const w = parseCargoInputNumber(width); + const h = parseCargoInputNumber(height); + if (weightVal == null || l == null || w == null || h == null) { + setLocalError("请完整填写重量与尺寸"); + return; + } + + const totalWeightLb = toAxelWholePounds( + weightUnit === "kg" ? kgToLb(weightVal) : weightVal, + ); + if (totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) { + setLocalError(FLOCK_VALIDATION_MESSAGES.totalWeight); + return; + } + + const toIn = (v: number) => + toAxelWholeInches(dimUnit === "cm" ? cmToIn(v) : v); + const lengthIn = toIn(l); + const widthIn = toIn(w); + const heightIn = toIn(h); + if (lengthIn > FLOCK_LIMITS.dimIn.lengthMax) { + setLocalError(FLOCK_VALIDATION_MESSAGES.length); + return; + } + if (widthIn > FLOCK_LIMITS.dimIn.widthMax) { + setLocalError(FLOCK_VALIDATION_MESSAGES.width); + return; + } + if (heightIn > FLOCK_LIMITS.dimIn.heightMax) { + setLocalError(FLOCK_VALIDATION_MESSAGES.height); + return; + } + + let registration: FlockFormSubmitPayload["registration"]; + if (showRegistration) { + const regEmail = workEmail.trim(); + const regPhone = phone.replace(/\D/g, ""); + if (regEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(regEmail)) { + setLocalError("请填写有效的工作邮箱"); + return; + } + if (regPhone && regPhone.slice(-10).length !== 10) { + setLocalError("请填写有效的 10 位美国电话号码"); + return; + } + const reg = { + first_name: firstName.trim() || undefined, + last_name: lastName.trim() || undefined, + company: company.trim() || undefined, + email: regEmail || undefined, + phone: regPhone ? regPhone.slice(-10) : undefined, + shipments_per_month: shipmentsPerMonth || undefined, + }; + if ( + reg.first_name || + reg.last_name || + reg.company || + reg.email || + reg.phone + ) { + registration = reg; + } + } + + onSubmit({ + pickup_date: toMmDdYyyy(pickupDate), + pickup_zip: pickupZip.trim(), + delivery_zip: deliveryZip.trim(), + pallet_count: pallets, + total_weight: { value: weightVal, unit: weightUnit }, + dimensions: { + length: l, + width: w, + height: h, + unit: dimUnit, + }, + registration, + }); + }; + + const fieldClass = + "w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-primary"; + const labelClass = "mb-1 block text-sm font-medium text-text-primary"; + + return ( +
+
+

+ {FLOCK_UI.formTitle} +

+

{FLOCK_UI.formHint}

+
+ +
+
+ + +

+ {FLOCK_UI.pickupDateHint} +

+
+
+ + setPickupZip(e.target.value)} + placeholder="例如 90001" + inputMode="numeric" + required + /> +
+
+ + setDeliveryZip(e.target.value)} + placeholder="例如 75201" + inputMode="numeric" + required + /> +
+
+ + setPalletCount(e.target.value)} + inputMode="numeric" + required + /> +

+ {FLOCK_UI.palletCountHint} +

+
+
+ + setTotalWeight(e.target.value)} + inputMode="decimal" + required + /> +

+ {FLOCK_UI.totalWeightHint} +

+ {weightHint && ( +

{weightHint}

+ )} +
+ setWeightUnit(e.target.value as UnitWeight)} + /> +
+ +
+

+ {FLOCK_UI.dimsTitle}({dimUnit}) +

+
+
+ + setLength(e.target.value)} + inputMode="decimal" + /> +

+ {FLOCK_VALIDATION_MESSAGES.length} +

+
+
+ + setWidth(e.target.value)} + inputMode="decimal" + /> +

+ {FLOCK_VALIDATION_MESSAGES.width} +

+
+
+ + setHeight(e.target.value)} + inputMode="decimal" + /> +

+ {FLOCK_VALIDATION_MESSAGES.height} +

+
+
+
+ setDimUnit(e.target.value as UnitDim)} + /> +
+ {dimHint &&

{dimHint}

} +

{FLOCK_UI.unitSwitchHint}

+
+ +
+ + {showRegistration && ( + <> +

+ {FLOCK_UI.registrationSectionHint} +

+
+
+ + setFirstName(e.target.value)} + autoComplete="given-name" + /> +
+
+ + setLastName(e.target.value)} + autoComplete="family-name" + /> +
+
+ + setCompany(e.target.value)} + autoComplete="organization" + /> +
+
+ + setWorkEmail(e.target.value)} + placeholder="例如 name@company.com" + autoComplete="email" + /> +
+
+ + setPhone(e.target.value)} + placeholder="例如 7536407420" + inputMode="tel" + autoComplete="tel" + /> +

{FLOCK_UI.phoneHint}

+
+
+ setShipmentsPerMonth(e.target.value)} + /> +
+
+ + )} +
+ + {localError && ( +

+ {localError} +

+ )} + + +
+ ); +} diff --git a/components/flock/flock-quote-progress.tsx b/components/flock/flock-quote-progress.tsx new file mode 100644 index 0000000..a8c6c5b --- /dev/null +++ b/components/flock/flock-quote-progress.tsx @@ -0,0 +1,161 @@ +/** + * Flock 查价可视化进度(以 Worker 回写的 rpa_stage 为准,失败时标出卡点) + */ +"use client"; + +import { useEffect, useState } from "react"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { + FLOCK_RPA_STAGE_LABELS, + FLOCK_RPA_STAGES, + flockStageIndex, + mapFlockErrorToStage, + type FlockRpaStage, +} from "@/lib/flock/rpa-progress"; + +const DISPLAY_STAGES: FlockRpaStage[] = [ + "queued", + "lock", + "login", + "open_quote", + "fill_form", + "submit", + "wait_quote", +]; + +export interface FlockQuoteProgressProps { + active: boolean; + quoteId?: string | null; + startedAtMs?: number | null; + /** Worker 回写阶段 */ + rpaStage?: string | null; + rpaStageLabel?: string | null; + /** 失败后的错误原文(用于把进度钉在卡点并展示 hint) */ + errorMessage?: string | null; + failed?: boolean; +} + +export function FlockQuoteProgress({ + active, + quoteId, + startedAtMs, + rpaStage, + rpaStageLabel, + errorMessage, + failed = false, +}: FlockQuoteProgressProps) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!active && !failed) return; + const t = window.setInterval(() => setNow(Date.now()), 500); + return () => window.clearInterval(t); + }, [active, failed]); + + if (!active && !failed) return null; + + const mapped = failed ? mapFlockErrorToStage(errorMessage) : null; + // 优先 Worker 回写的 rpa_stage;禁止失败时用默认「互斥锁」覆盖真实进度 + const stageRaw = ( + rpaStage && (FLOCK_RPA_STAGES as readonly string[]).includes(rpaStage) + ? rpaStage + : (mapped?.stage ?? "queued") + ) as string; + const currentStage: FlockRpaStage = ( + FLOCK_RPA_STAGES as readonly string[] + ).includes(stageRaw) + ? (stageRaw as FlockRpaStage) + : "queued"; + const currentIdx = Math.max(0, flockStageIndex(currentStage)); + const label = + (failed ? mapped?.hint : null) ?? + rpaStageLabel ?? + FLOCK_RPA_STAGE_LABELS[currentStage]; + const elapsed = Math.max(0, now - (startedAtMs ?? now)); + + return ( +
+
+
+ {!failed ? ( + + ) : null} + {failed ? "Flock 查价失败 — 已定位卡点" : "正在查询 Flock Freight 实时报价"} +
+ + 已用时 {(elapsed / 1000).toFixed(1)}s + +
+ +
    + {DISPLAY_STAGES.map((stage) => { + const i = flockStageIndex(stage); + const done = !failed && i < currentIdx; + const current = i === currentIdx || (failed && stage === currentStage); + const stuck = failed && stage === currentStage; + return ( +
  1. + + {stuck ? "✕" : done ? "✓" : current ? "●" : "○"} + + + {FLOCK_RPA_STAGE_LABELS[stage]} + {current && !failed ? "…" : null} + {stuck ? "(卡在这里)" : null} + +
  2. + ); + })} +
+ +

+ {label} +

+ + {quoteId ? ( +

+ 询价单号:{quoteId} +

+ ) : null} + {!failed ? ( +

+ 进度由 RPA Worker 实时回写;若长时间停在「登录」,请核对客户 Flock + 账密、JWT_SECRET,或有头模式查看 .rpa/flock-stalls/ 截图。 +

+ ) : null} +
+ ); +} diff --git a/components/flock/flock-quote-result.tsx b/components/flock/flock-quote-result.tsx new file mode 100644 index 0000000..f4b151b --- /dev/null +++ b/components/flock/flock-quote-result.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { FLOCK_UI } from "@/lib/flock/ui-labels"; +import type { FlockDisplayLine } from "@/modules/flock/quote-storage"; + +export interface FlockQuoteResultProps { + lines: FlockDisplayLine[]; + reference?: string | null; + onReset?: () => void; +} + +function formatUsd(n: number): string { + return n.toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }); +} + +/** Flock 两档对比结果卡(中文) */ +export function FlockQuoteResult({ + lines, + reference, + onReset, +}: FlockQuoteResultProps) { + const direct = lines.find((l) => l.tier === "flock_direct"); + const standard = lines.find((l) => l.tier === "standard"); + + if (!direct && !standard) { + return ( +
+ {FLOCK_UI.emptyResult} +
+ ); + } + + return ( +
+
+
+

+ {FLOCK_UI.resultTitle} +

+ {reference && ( +

+ {FLOCK_UI.reference} #{reference} +

+ )} +
+ {onReset && ( + + )} +
+ +
+ {direct && ( +
+
+ {FLOCK_UI.directTag} +
+

{FLOCK_UI.directName}

+

+ {FLOCK_UI.pricesFrom} {formatUsd(direct.final_total_usd)} +

+

+ {FLOCK_UI.transit}:{direct.transitDescription || direct.transitDays} +

+
    + {FLOCK_UI.directFeatures.map((f) => ( +
  • ✓ {f}
  • + ))} +
+
+ )} + + {standard && ( +
+
+ {FLOCK_UI.standardTag} +
+

+ {FLOCK_UI.standardName} +

+

+ {FLOCK_UI.pricesFrom} {formatUsd(standard.final_total_usd)} +

+

+ {FLOCK_UI.transit}: + {standard.transitDescription || standard.transitDays} +

+
    + {FLOCK_UI.standardFeatures.map((f, i) => ( +
  • + {i === 0 ? "✓" : "×"} {f} +
  • + ))} +
+

+ {FLOCK_UI.standardNote} +

+
+ )} +
+
+ ); +} diff --git a/components/flock/flock-quote-widget.tsx b/components/flock/flock-quote-widget.tsx new file mode 100644 index 0000000..c12e182 --- /dev/null +++ b/components/flock/flock-quote-widget.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { + FlockQuoteForm, + type FlockFormSubmitPayload, +} from "@/components/flock/flock-quote-form"; +import { + FlockLoggedInQuoteForm, + mapFlockLoggedInToApiInput, + type FlockLoggedInQuotePayload, +} from "@/components/flock/flock-logged-in-quote-form"; +import { FlockQuoteProgress } from "@/components/flock/flock-quote-progress"; +import { FlockQuoteResult } from "@/components/flock/flock-quote-result"; +import { WarningBanner } from "@/components/ui/warning-banner"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { SUBMIT_LOCK_MS } from "@/lib/frontend/constants"; +import { hostCreateFlockQuote, hostGetQuote } from "@/lib/frontend/api-client"; +import { uuidV4 } from "@/lib/frontend/format"; +import type { QuoteDetail, QuotePageStatus } from "@/lib/frontend/types"; +import { pollQuoteUntilDone } from "@/hooks/use-quote-polling"; +import { getFlockPollTimeoutMs } from "@/lib/flock/poll-timeout"; +import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages"; + +export interface FlockQuoteWidgetProps { + serviceToken?: string; + customerId: string; + apiBaseUrl?: string; + /** 客户已绑定 Flock 账密 → 显示登录后 Quick 查价界面 */ + flockLoggedInUi?: boolean; +} + +/** Flock Freight 内嵌查价:表单 + 可视化进度 + 轮询 + 两档结果 */ +export function FlockQuoteWidget({ + serviceToken, + customerId, + apiBaseUrl = "", + flockLoggedInUi = false, +}: FlockQuoteWidgetProps) { + const [status, setStatus] = useState("idle"); + const [quote, setQuote] = useState(null); + const [error, setError] = useState(null); + const [submitLocked, setSubmitLocked] = useState(false); + const [activeQuoteId, setActiveQuoteId] = useState(null); + const [startedAtMs, setStartedAtMs] = useState(null); + const [rpaStage, setRpaStage] = useState(null); + const [rpaStageLabel, setRpaStageLabel] = useState(null); + + const reset = useCallback(() => { + setStatus("idle"); + setQuote(null); + setError(null); + setActiveQuoteId(null); + setStartedAtMs(null); + setRpaStage(null); + setRpaStageLabel(null); + }, []); + + const finishQuote = useCallback((detail: QuoteDetail) => { + setQuote(detail); + setActiveQuoteId(detail.quote_id ?? null); + if (detail.rpa_stage) setRpaStage(detail.rpa_stage); + if (detail.rpa_stage_label) setRpaStageLabel(detail.rpa_stage_label); + if (detail.status === "failed") { + setStatus("error"); + setError( + detail.error_message ?? + formatQuoteErrorMessage(detail.error_code) ?? + "报价失败", + ); + return; + } + if (detail.status === "expired") { + setStatus("expired"); + return; + } + if (!detail.flock?.lines?.length) { + setStatus("error"); + setError("未返回 Flock 报价档位"); + return; + } + setStatus(detail.is_realtime === false ? "fallback" : "success"); + setError(null); + }, []); + + const handleSubmit = useCallback( + async (payload: FlockFormSubmitPayload & Record) => { + if (submitLocked) return; + setSubmitLocked(true); + setTimeout(() => setSubmitLocked(false), SUBMIT_LOCK_MS); + setError(null); + setQuote(null); + setActiveQuoteId(null); + setStartedAtMs(Date.now()); + setRpaStage("queued"); + setRpaStageLabel("任务已入队,等待 Worker 领取"); + setStatus("processing"); + + const created = await hostCreateFlockQuote(apiBaseUrl, serviceToken, { + request_id: uuidV4(), + customer_id: customerId, + flock_input: payload, + }); + + if (created.code !== 0) { + setStatus("error"); + setError(created.message || "提交失败"); + return; + } + + const { quote_id } = created.data; + setActiveQuoteId(quote_id); + const pollResult = await pollQuoteUntilDone( + async () => { + try { + const res = await hostGetQuote( + apiBaseUrl, + serviceToken, + customerId, + quote_id, + ); + if (res.code !== 0) { + return { ok: false, errorMessage: res.message }; + } + return { ok: true, data: res.data }; + } catch { + return { ok: false }; + } + }, + { + timeoutMs: getFlockPollTimeoutMs(), + onProcessing: (detail) => { + if (detail.rpa_stage) setRpaStage(detail.rpa_stage); + if (detail.rpa_stage_label) setRpaStageLabel(detail.rpa_stage_label); + }, + }, + ); + + if (pollResult.type === "done") { + finishQuote(pollResult.quote); + return; + } + + setStatus("error"); + if (pollResult.type === "error" && pollResult.quote?.rpa_stage) { + setRpaStage(pollResult.quote.rpa_stage); + setRpaStageLabel(pollResult.quote.rpa_stage_label ?? null); + } + setError( + pollResult.type === "timeout" + ? "Flock 询价超时,请稍后重试(可在进度条确认停在哪一步)" + : pollResult.message || "查询失败", + ); + }, + [apiBaseUrl, customerId, finishQuote, serviceToken, submitLocked], + ); + + const handleLoggedInSubmit = useCallback( + (payload: FlockLoggedInQuotePayload) => { + const mapped = mapFlockLoggedInToApiInput(payload); + void handleSubmit(mapped); + }, + [handleSubmit], + ); + + return ( +
+ {error && status === "error" && {error}} + {status === "expired" && ( + 报价已过期,请重新查询 + )} + + + + {status === "success" || status === "fallback" ? ( + quote?.flock ? ( + + ) : null + ) : flockLoggedInUi ? ( + + ) : ( + + )} +
+ ); +} diff --git a/components/flock/flock-saved-freight-controls.tsx b/components/flock/flock-saved-freight-controls.tsx new file mode 100644 index 0000000..4dc0be2 --- /dev/null +++ b/components/flock/flock-saved-freight-controls.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, +} from "react"; +import { CaretDown, FloppyDisk, X } from "@phosphor-icons/react"; +import { + filterFlockSavedFreight, + loadFlockSavedFreight, + persistFlockSavedFreight, + removeFlockSavedFreight, + upsertFlockSavedFreight, + buildFlockSavedFreightPreset, + type FlockSavedFreightPreset, + type FlockSavedFreightSource, +} from "@/lib/flock/saved-freight-presets"; +import { quoteInputCls } from "@/components/quote/quote-form-chrome"; + +const inputCls = quoteInputCls; + +type Props = { + customerId: string; + disabled?: boolean; + /** 保存后递增,触发搜索列表刷新 */ + revision?: number; +}; + +/** 顶部:搜索并选用常用货件 */ +export function FlockSavedFreightSearch({ + customerId, + disabled, + revision = 0, + onSelect, +}: Props & { onSelect: (preset: FlockSavedFreightPreset) => void }) { + const [presets, setPresets] = useState([]); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + const reload = useCallback(() => { + setPresets(loadFlockSavedFreight(customerId)); + }, [customerId]); + + useEffect(() => { + reload(); + }, [reload, revision]); + + useEffect(() => { + const onDoc = (e: globalThis.MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, []); + + const filtered = useMemo( + () => filterFlockSavedFreight(presets, query), + [presets, query], + ); + + const remove = (id: string, e: MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + e.preventDefault(); + const next = removeFlockSavedFreight(presets, id); + persistFlockSavedFreight(customerId, next); + setPresets(next); + }; + + return ( +
+ { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + placeholder={ + presets.length ? "搜索已保存的货件名称或描述" : "暂无常用货件,请先在货品描述处保存" + } + className={`${inputCls} pr-8`} + autoComplete="off" + /> + + {open ? ( +
    + {filtered.length === 0 ? ( +
  • + {presets.length === 0 + ? "尚无保存记录" + : "无匹配货件"} +
  • + ) : ( + filtered.map((p) => ( +
  • + +
  • + )) + )} +
+ ) : null} +
+ ); +} + +/** 货品描述旁:保存当前货物行为常用货件 */ +export function FlockSaveFreightButton({ + customerId, + disabled, + source, + onSaved, +}: Props & { + source: FlockSavedFreightSource; + onSaved?: () => void; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [hint, setHint] = useState(null); + + useEffect(() => { + if (open) { + setName(source.description.trim()); + setHint(null); + } + }, [open, source.description]); + + const save = () => { + const preset = buildFlockSavedFreightPreset(source, name); + if (!preset) { + setHint("请先填写货品描述或货件名称"); + return; + } + const list = loadFlockSavedFreight(customerId); + const next = upsertFlockSavedFreight(list, preset); + persistFlockSavedFreight(customerId, next); + setOpen(false); + setHint("已保存到常用货件"); + onSaved?.(); + window.setTimeout(() => setHint(null), 2000); + }; + + return ( +
+ {!open ? ( + + ) : ( +
+

货件名称(便于搜索)

+
+ setName(e.target.value)} + className={`${inputCls} !h-9 min-w-[12rem] flex-1 text-sm`} + placeholder="如:标准木托家电" + /> + + +
+
+ )} + {hint ?

{hint}

: null} +
+ ); +} diff --git a/components/mothership/mothership-address-suggest-field.tsx b/components/mothership/mothership-address-suggest-field.tsx new file mode 100644 index 0000000..655f3be --- /dev/null +++ b/components/mothership/mothership-address-suggest-field.tsx @@ -0,0 +1,296 @@ +/** + * MotherShip 登录后一级:地址键入联想下拉(Axel suggest) + */ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { CaretRight, MagnifyingGlass, X } from "@phosphor-icons/react"; +import { hostSuggestMothershipAddress } from "@/lib/frontend/api-client"; +import type { MothershipAddressCandidate } from "@/lib/frontend/types"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; + +const DEBOUNCE_MS = 300; +const MIN_QUERY_LEN = 3; + +export interface MothershipAddressSuggestFieldProps { + label: string; + required?: boolean; + placeholder?: string; + disabled?: boolean; + /** 必填未满足时高亮边框 */ + invalid?: boolean; + testId?: string; + customerId: string; + apiBaseUrl?: string; + serviceToken?: string; + value: string; + confirmed: MothershipAddressCandidate | null; + onQueryChange: (query: string) => void; + onConfirm: (candidate: MothershipAddressCandidate | null) => void; +} + +function splitDisplay(label: string): { primary: string; secondary: string } { + const parts = label.split(",").map((p) => p.trim()).filter(Boolean); + if (parts.length <= 1) { + return { primary: label, secondary: "" }; + } + return { + primary: parts[0]!, + secondary: parts.slice(1).join(", "), + }; +} + +export function MothershipAddressSuggestField({ + label, + required, + placeholder = "按邮编、地址或公司名搜索", + disabled, + invalid, + testId, + customerId, + apiBaseUrl = "", + serviceToken, + value, + confirmed, + onQueryChange, + onConfirm, +}: MothershipAddressSuggestFieldProps) { + const listId = useId(); + const wrapRef = useRef(null); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [candidates, setCandidates] = useState( + [], + ); + const reqSeq = useRef(0); + + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, []); + + useEffect(() => { + const q = value.trim(); + if (q.length < MIN_QUERY_LEN) { + setCandidates([]); + setError(null); + setLoading(false); + return; + } + + // 已确认且文案未变:不重复请求 + if (confirmed && confirmed.display_label === value.trim()) { + return; + } + + const seq = ++reqSeq.current; + setLoading(true); + setError(null); + const timer = window.setTimeout(() => { + void (async () => { + try { + const res = await hostSuggestMothershipAddress( + apiBaseUrl, + serviceToken, + customerId, + q, + ); + if (seq !== reqSeq.current) return; + if (res.code !== 0 || !res.data) { + setCandidates([]); + setError(res.message || "地址联想失败"); + return; + } + setCandidates( + res.data.candidates.filter( + (c) => + c.selectable !== false && + Boolean(c.option_id?.trim()) && + Boolean( + (c.display_label || c.formatted_address || "").trim(), + ), + ), + ); + setOpen(true); + } catch (err) { + if (seq !== reqSeq.current) return; + setCandidates([]); + setError(err instanceof Error ? err.message : "地址联想失败"); + } finally { + if (seq === reqSeq.current) { + setLoading(false); + } + } + })(); + }, DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [value, apiBaseUrl, serviceToken, customerId, confirmed]); + + const handleInput = (next: string) => { + onQueryChange(next); + if (confirmed) { + onConfirm(null); + } + setOpen(true); + }; + + const handleClear = () => { + onQueryChange(""); + onConfirm(null); + setCandidates([]); + setError(null); + setOpen(false); + }; + + const handlePick = (item: MothershipAddressCandidate) => { + onQueryChange(item.display_label || item.formatted_address); + onConfirm(item); + setOpen(false); + setError(null); + }; + + return ( +
+ +
+ + handleInput(e.target.value)} + onFocus={() => { + if (candidates.length > 0 || value.trim().length >= MIN_QUERY_LEN) { + setOpen(true); + } + }} + placeholder={placeholder} + role="combobox" + aria-expanded={open} + aria-controls={listId} + aria-autocomplete="list" + aria-invalid={invalid || undefined} + className={[ + "h-10 w-full rounded-md border bg-surface pl-10 pr-16 text-sm focus:outline-none focus:ring-2 disabled:opacity-60", + invalid + ? "border-[#EF4444] focus:border-[#EF4444] focus:ring-[#EF4444]/20" + : "border-border focus:border-[#1890FF] focus:ring-[#1890FF]/20", + ].join(" ")} + /> +
+ {loading && ( + + )} + {value && !disabled && ( + + )} +
+
+ + {confirmed && ( +

+ 已选择 MotherShip 地址:{confirmed.display_label} +

+ )} + {error &&

{error}

} + + {open && !disabled && value.trim().length >= MIN_QUERY_LEN && ( +
+ + +

+ 搜索结果 +

+ + {loading && candidates.length === 0 && ( +

正在搜索…

+ )} + {!loading && candidates.length === 0 && !error && ( +

+ 未找到匹配地址,请调整关键词 +

+ )} + + {candidates.map((item) => { + const { primary, secondary } = splitDisplay( + item.display_label || item.formatted_address, + ); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/components/mothership/mothership-logged-in-details-form.tsx b/components/mothership/mothership-logged-in-details-form.tsx new file mode 100644 index 0000000..b49c119 --- /dev/null +++ b/components/mothership/mothership-logged-in-details-form.tsx @@ -0,0 +1,1031 @@ +/** + * MotherShip 登录后二级 Details:提货 / 送货 / 货物明细 + * 字段与一级 Create shipment 一一对应并预填 + */ +"use client"; + +import { useMemo, useState } from "react"; +import { CaretDown, Info, Plus, Warning, X } from "@phosphor-icons/react"; +import { + MS_CARGO_TYPES, + MS_DEFAULT_READY_TIME, + MS_DELIVERY_ACCESSORIALS, + MS_PICKUP_ACCESSORIALS, + MS_READY_TIMES, + type MothershipLoggedInShipmentPayload, + type MsCargoTypeId, +} from "@/components/mothership/mothership-logged-in-shipment-form"; +import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker"; +import { + snapMothershipReadyDateToWeekday, +} from "@/lib/mothership/logged-in-constraints"; +import { + evaluateMothershipAccessorialCompat, + MS_PICKUP_BLOCKED_ACCESSORIALS, + toggleMothershipAccessorial, +} from "@/lib/mothership/option-compat"; + +export type MsLocationDetails = { + address: string; + companyName: string; + suite: string; + contactFirst: string; + contactLast: string; + contactEmail: string; + contactPhone: string; + extension: string; + reference: string; + accessorials: string[]; + notes: string; + opensAt: string; + closesAt: string; + readyDate: string; + readyTime: string; +}; + +export type MsDetailsCargoLine = { + key: string; + cargoType: MsCargoTypeId; + quantity: string; + freightClass: string; + weightLb: string; + lengthIn: string; + widthIn: string; + heightIn: string; + pieceCountType: string; + pieceCountQty: string; + description: string; + nmfc: string; + hazmat: boolean; + alcohol: boolean; + tobacco: boolean; +}; + +export type MothershipLoggedInDetailsState = { + pickup: MsLocationDetails; + delivery: MsLocationDetails; + requestDeliveryAppointment: boolean; + fbaNumber: string; + fbaPoNumber: string; + cargo: MsDetailsCargoLine[]; +}; + +export type MothershipLoggedInDetailsPayload = { + pickup: { + companyName: string; + suite: string; + contactFirst: string; + contactLast: string; + contactEmail: string; + contactPhone: string; + reference: string; + notes: string; + opensAt: string; + closesAt: string; + }; + delivery: { + companyName: string; + suite: string; + contactFirst: string; + contactLast: string; + contactEmail: string; + contactPhone: string; + reference: string; + notes: string; + opensAt: string; + closesAt: string; + }; + requestDeliveryAppointment: boolean; + fbaNumber: string; + fbaPoNumber: string; + cargo: Array<{ + pieceCountType: string; + pieceCountQty: number; + description: string; + nmfc: string; + hazmat: boolean; + alcohol: boolean; + tobacco: boolean; + }>; +}; + +function RequiredMark() { + return *; +} + +function FieldLabel({ + children, + required, + tip, +}: { + children: React.ReactNode; + required?: boolean; + tip?: boolean; +}) { + return ( + + {children} + {required ? : null} + {tip ? : null} + + ); +} + +function TextInput({ + value, + onChange, + placeholder, + disabled, + className = "", +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +}) { + return ( + onChange(e.target.value)} + className={`h-10 w-full rounded-md border border-border bg-surface px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-60 ${className}`} + /> + ); +} + +function AccessorialSelect({ + options, + selected, + disabled, + onChange, + hintPickUp, + side, +}: { + options: readonly { id: string; label: string }[]; + selected: string[]; + disabled?: boolean; + onChange: (next: string[]) => void; + hintPickUp?: boolean; + side: "pickup" | "delivery"; +}) { + const [open, setOpen] = useState(false); + const [hint, setHint] = useState(null); + const issues = useMemo( + () => evaluateMothershipAccessorialCompat({ side, selected }), + [side, selected], + ); + const summary = + selected.length === 0 + ? "选择" + : selected.length === 1 + ? (options.find((o) => o.id === selected[0])?.label ?? "已选择") + : `${selected.length} 项已选`; + + return ( +
+
+ 附加服务 + + + 避免追收附加费 + +
+ + {open && !disabled && ( +
+

+ 下列附加服务建议勾选,以 + {hintPickUp ? "避免提货问题" : "避免送货问题"} + 并减少事后追收。住宅提货承运商不支持;预约与 Amazon 预约不能同选。 +

+ {options.map((opt) => { + const checked = selected.includes(opt.id); + const warn = + opt.id === "liftgate" || + opt.id === "residential" || + opt.id === "limitedAccess"; + const pickupBlocked = + side === "pickup" && + Boolean(MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]); + return ( + + ); + })} +
+ )} + {hint ? ( +

{hint}

+ ) : null} + {issues.length > 0 ? ( +
    + {issues.map((i) => ( +
  • + {i.message} +
  • + ))} +
+ ) : null} +
+ ); +} + +function locationFromL1( + address: string, + accessorials: string[], + readyDate: string, + readyTime: string, +): MsLocationDetails { + return { + address, + companyName: "", + suite: "", + contactFirst: "", + contactLast: "", + contactEmail: "", + contactPhone: "", + extension: "", + reference: "", + accessorials: [...accessorials], + notes: "", + opensAt: "", + closesAt: "", + readyDate: snapMothershipReadyDateToWeekday(readyDate), + readyTime, + }; +} + +function locationFromConfirmed( + confirmed: { + formatted_address: string; + display_label: string; + street: string; + city: string; + state: string; + zip: string; + }, + fallbackQuery: string, + accessorials: string[], + readyDate: string, + readyTime: string, +): MsLocationDetails { + const label = + confirmed.formatted_address || + confirmed.display_label || + fallbackQuery; + const composed = [confirmed.street, confirmed.city, confirmed.state, confirmed.zip] + .map((p) => p?.trim()) + .filter(Boolean) + .join(", "); + return { + ...locationFromL1(label || composed || fallbackQuery, accessorials, readyDate, readyTime), + }; +} + +export function buildDetailsStateFromL1( + payload: MothershipLoggedInShipmentPayload, +): MothershipLoggedInDetailsState { + const wantFba = payload.deliveryAccessorials.includes("fbaAppointment"); + return { + pickup: locationFromConfirmed( + payload.pickupConfirmed, + payload.pickupQuery, + payload.pickupAccessorials, + payload.readyDate, + payload.readyTime, + ), + delivery: locationFromConfirmed( + payload.deliveryConfirmed, + payload.deliveryQuery, + payload.deliveryAccessorials, + payload.readyDate, + payload.readyTime, + ), + requestDeliveryAppointment: + wantFba || payload.deliveryAccessorials.includes("appointment"), + fbaNumber: "", + fbaPoNumber: "", + cargo: payload.cargo.map((c, i) => ({ + key: `d-${i}-${c.cargoType}`, + cargoType: c.cargoType, + quantity: String(c.quantity), + freightClass: "", + weightLb: String(c.weightLb), + lengthIn: String(c.lengthIn), + widthIn: String(c.widthIn), + heightIn: String(c.heightIn), + pieceCountType: "", + pieceCountQty: "0", + description: "", + nmfc: "", + hazmat: false, + alcohol: false, + tobacco: false, + })), + }; +} + +function LocationSection({ + title, + value, + disabled, + accessorialOptions, + hintPickUp, + showFreightReady, + onChange, +}: { + title: string; + value: MsLocationDetails; + disabled?: boolean; + accessorialOptions: readonly { id: string; label: string }[]; + hintPickUp?: boolean; + showFreightReady?: boolean; + onChange: (next: MsLocationDetails) => void; +}) { + const set = ( + key: K, + v: MsLocationDetails[K], + ) => onChange({ ...value, [key]: v }); + + return ( +
+

{title}

+ +
+ + +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+ + set("accessorials", next)} + /> +
+ +