From f211d12cdac9bb7209d4860119da7183b83beaed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:00:55 +0800 Subject: [PATCH 1/9] fix(ci): deploy admin frontend on target node --- Jenkinsfile | 34 ++++++++---------------------- src/deployment/nginxConfig.test.ts | 9 ++++++++ 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 6d6848f0..2afb4e67 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,11 +1,10 @@ pipeline { - agent any + agent { + label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}" + } environment { - DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8' - DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy' - DEVELOPMENT_SSH_KNOWN_HOSTS = '/var/jenkins_home/.ssh/known_hosts' - DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/frontend/admin-web' + DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml' } options { @@ -41,27 +40,14 @@ pipeline { sh ''' set -eu image="admin-web-development:${SOURCE_TAG}" - ssh_args="-i ${DEVELOPMENT_SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEVELOPMENT_SSH_KNOWN_HOSTS} -o ConnectTimeout=10" docker build \ --build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \ -t "$image" . - docker save "$image" | gzip -1 | ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" 'gzip -dc | docker load' - - ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" "install -d -m 750 '$DEVELOPMENT_DEPLOY_DIR'" - scp $ssh_args docker-compose.development.yml \ - "$DEVELOPMENT_SSH_TARGET:$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp" - ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" \ - "mv -f '$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp' '$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml'" - - ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" sh -s -- "$DEVELOPMENT_DEPLOY_DIR" "$SOURCE_TAG" <<'REMOTE' - set -eu - cd "$1" - export IMAGE_TAG="$2" - docker compose -f docker-compose.yml up -d --remove-orphans --pull never - docker compose -f docker-compose.yml ps - docker compose -f docker-compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz -REMOTE + export IMAGE_TAG="$SOURCE_TAG" + docker compose -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never + docker compose -f "$DEVELOPMENT_COMPOSE_FILE" ps + docker compose -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz ''' } } @@ -82,9 +68,7 @@ REMOTE failure { sh ''' if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then - ssh -i "$DEVELOPMENT_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$DEVELOPMENT_SSH_KNOWN_HOSTS" \ - "$DEVELOPMENT_SSH_TARGET" 'docker logs --tail=100 admin-web-development || true' || true + docker logs --tail=100 admin-web-development || true else docker compose -f docker-compose.test.yml logs --tail=100 || true fi diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 3bdbb884..a471eb7f 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8') const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8') const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8') +const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8') describe('admin nginx deployment config', () => { it('allows qualification uploads with multipart overhead through the API proxy', () => { @@ -37,4 +38,12 @@ describe('admin nginx deployment config', () => { expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"') expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"') }) + + it('runs each deployment on its target node without controller-local SSH files', () => { + expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'") + expect(jenkinsfile).toContain('docker compose -f "$DEVELOPMENT_COMPOSE_FILE" up -d') + expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh') + expect(jenkinsfile).not.toContain('docker save') + expect(jenkinsfile).not.toMatch(/\bssh\b/) + }) }) From 7033903e67cdc3f3bb03d4916b3591735ab27bed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:21:43 +0800 Subject: [PATCH 2/9] fix(ci): use internal frontend base images --- Dockerfile | 9 +++-- Jenkinsfile | 60 ++++++++++++++++++++++-------- docker-compose.test.yml | 3 ++ src/deployment/nginxConfig.test.ts | 12 ++++++ 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index b3ecd31c..8c6e73e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ -# 构建 Vue 静态文件 -FROM node:22-alpine AS build +# 构建 Vue 静态文件。CI 通过构建参数切换到内网基础镜像, +# 本地开发仍可使用 Docker Hub 默认值。 +ARG NODE_IMAGE=node:22-alpine +ARG NGINX_IMAGE=nginx:1.27-alpine +FROM ${NODE_IMAGE} AS build WORKDIR /app @@ -24,7 +27,7 @@ ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL} RUN pnpm build # 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。 -FROM nginx:1.27-alpine +FROM ${NGINX_IMAGE} COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh diff --git a/Jenkinsfile b/Jenkinsfile index 2afb4e67..4e107ca5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,6 +5,10 @@ pipeline { environment { DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml' + REGISTRY_HOST = '172.31.198.7:10082' + REGISTRY_CREDENTIALS = 'gitea-registry-token' + NODE_BASE_IMAGE = '172.31.198.7:10082/html/node:22-alpine' + NGINX_BASE_IMAGE = '172.31.198.7:10082/html/nginx:1.27-alpine' } options { @@ -26,29 +30,53 @@ pipeline { stage('构建并部署测试前端') { when { expression { env.IS_DEVELOPMENT != 'true' } } steps { - sh ''' - set -eu - docker compose -f docker-compose.test.yml build --pull=false - docker compose -f docker-compose.test.yml up -d --remove-orphans - ''' + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + sh ''' + set -eu + export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" + rm -rf "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" + cleanup_registry() { + docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" + } + trap cleanup_registry EXIT HUP INT TERM + printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" + docker compose -f docker-compose.test.yml build --pull=false + docker compose -f docker-compose.test.yml up -d --remove-orphans + ''' + } } } stage('构建并部署开发前端') { when { expression { env.IS_DEVELOPMENT == 'true' } } steps { - sh ''' - set -eu - image="admin-web-development:${SOURCE_TAG}" + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + sh ''' + set -eu + export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" + rm -rf "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" + cleanup_registry() { + docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" + } + trap cleanup_registry EXIT HUP INT TERM + printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" + image="admin-web-development:${SOURCE_TAG}" - docker build \ - --build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \ - -t "$image" . - export IMAGE_TAG="$SOURCE_TAG" - docker compose -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never - docker compose -f "$DEVELOPMENT_COMPOSE_FILE" ps - docker compose -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz - ''' + docker build \ + --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ + --build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \ + -t "$image" . + export IMAGE_TAG="$SOURCE_TAG" + docker compose -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never + docker compose -f "$DEVELOPMENT_COMPOSE_FILE" ps + docker compose -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz + ''' + } } } diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 4310900a..c76791f1 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -3,6 +3,9 @@ services: build: context: . dockerfile: Dockerfile + args: + NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" + NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" image: admin-web-test:local container_name: admin-web-test restart: unless-stopped diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index a471eb7f..48fba67f 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -6,6 +6,7 @@ const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template' const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8') const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8') const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8') +const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8') describe('admin nginx deployment config', () => { it('allows qualification uploads with multipart overhead through the API proxy', () => { @@ -46,4 +47,15 @@ describe('admin nginx deployment config', () => { expect(jenkinsfile).not.toContain('docker save') expect(jenkinsfile).not.toMatch(/\bssh\b/) }) + + it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => { + expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine') + expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine') + expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build') + expect(dockerfile).toContain('FROM ${NGINX_IMAGE}') + expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/html/node:22-alpine'") + expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/html/nginx:1.27-alpine'") + expect(jenkinsfile).toContain('withCredentials([usernamePassword(') + expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"') + }) }) From f6b5ad89a125c3e96494340b8a472e599b3d3371 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:37:55 +0800 Subject: [PATCH 3/9] fix(ci): use authorized frontend base image namespace --- Jenkinsfile | 4 ++-- src/deployment/nginxConfig.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4e107ca5..a0c868a7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -7,8 +7,8 @@ pipeline { DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml' REGISTRY_HOST = '172.31.198.7:10082' REGISTRY_CREDENTIALS = 'gitea-registry-token' - NODE_BASE_IMAGE = '172.31.198.7:10082/html/node:22-alpine' - NGINX_BASE_IMAGE = '172.31.198.7:10082/html/nginx:1.27-alpine' + NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine' + NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine' } options { diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 48fba67f..079261f5 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -53,8 +53,8 @@ describe('admin nginx deployment config', () => { expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine') expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build') expect(dockerfile).toContain('FROM ${NGINX_IMAGE}') - expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/html/node:22-alpine'") - expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/html/nginx:1.27-alpine'") + expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'") + expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'") expect(jenkinsfile).toContain('withCredentials([usernamePassword(') expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"') }) From 91db777cb8b05d59e68ab7c93e82aec34b85ec25 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:44:38 +0800 Subject: [PATCH 4/9] fix(ci): preserve admin compose project identity --- Jenkinsfile | 7 ++++--- src/deployment/nginxConfig.test.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index a0c868a7..c80b947f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,6 +5,7 @@ pipeline { environment { DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml' + DEVELOPMENT_COMPOSE_PROJECT = 'admin-web' REGISTRY_HOST = '172.31.198.7:10082' REGISTRY_CREDENTIALS = 'gitea-registry-token' NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine' @@ -72,9 +73,9 @@ pipeline { --build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \ -t "$image" . export IMAGE_TAG="$SOURCE_TAG" - docker compose -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never - docker compose -f "$DEVELOPMENT_COMPOSE_FILE" ps - docker compose -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz + docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never + docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps + docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz ''' } } diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 079261f5..1c389c18 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -42,7 +42,8 @@ describe('admin nginx deployment config', () => { it('runs each deployment on its target node without controller-local SSH files', () => { expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'") - expect(jenkinsfile).toContain('docker compose -f "$DEVELOPMENT_COMPOSE_FILE" up -d') + expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'") + expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d') expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh') expect(jenkinsfile).not.toContain('docker save') expect(jenkinsfile).not.toMatch(/\bssh\b/) From 964480a617768c5ad87453bdd7c0ca1266252b8c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:41:36 +0800 Subject: [PATCH 5/9] feat(phase4): automate admin integration acceptance --- .gitignore | 2 +- package.json | 1 + ...phase4-development-frontend-acceptance.mjs | 48 +++ ...se4-development-frontend-contract.test.mjs | 32 ++ .../phase4-development-frontend.acceptance.ts | 285 ++++++++++++++++++ .../api/platformOverseasBusiness.test.ts | 37 ++- .../overseas-business/contract.test.ts | 3 + .../pages/OverseasBusinessViews.test.ts | 8 +- src/modules/overseas-business/types.ts | 7 +- .../overseas-master-data/CONTRACT_GAPS.md | 9 +- .../overseas-master-data/PROTOTYPE_MATRIX.md | 20 +- .../api/overseasMasterData.test.ts | 154 ++++++++-- .../api/overseasMasterData.ts | 105 +++++-- .../overseas-master-data/downloadCsv.test.ts | 29 ++ .../pages/OverseasMasterDataViews.test.ts | 21 ++ .../OverseasMasterDataWriteBehavior.test.ts | 111 +++++++ .../pages/OverseasQuotesView.vue | 2 +- .../pages/VehiclesView.vue | 2 +- src/modules/overseas-master-data/types.ts | 10 +- .../API_INTEGRATION_MATRIX.md | 42 ++- .../overseas-warehouse/CONTRACT_GAPS.md | 36 ++- .../overseas-warehouse/api/dispatches.test.ts | 19 +- .../overseas-warehouse/api/pallets.test.ts | 14 +- src/modules/overseas-warehouse/api/pallets.ts | 15 +- .../api/relabelOrders.test.ts | 24 +- .../overseas-warehouse/api/relabelOrders.ts | 21 +- .../pages/PalletsView.test.ts | 52 ++++ .../overseas-warehouse/pages/PalletsView.vue | 29 +- .../pages/RelabelOrdersView.test.ts | 136 +++++++++ .../pages/RelabelOrdersView.vue | 109 +++++-- .../pages/prototypeContract.test.ts | 9 +- ...CarrierScheduleQueryPanel.behavior.test.ts | 15 + .../CarrierScheduleQueryPanel.test.ts | 11 + vitest.phase4-development.config.ts | 15 + 34 files changed, 1231 insertions(+), 202 deletions(-) create mode 100644 scripts/phase4-development-frontend-acceptance.mjs create mode 100644 scripts/phase4-development-frontend-contract.test.mjs create mode 100644 scripts/phase4-development-frontend.acceptance.ts create mode 100644 src/modules/overseas-master-data/downloadCsv.test.ts create mode 100644 src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts create mode 100644 src/modules/overseas-warehouse/pages/PalletsView.test.ts create mode 100644 src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts create mode 100644 vitest.phase4-development.config.ts diff --git a/.gitignore b/.gitignore index 594b81b5..3504937c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,10 @@ dist/ .codex-tmp/ .logs/ outputs/ +reports/ components.d.ts auto-imports.d.ts *.log .DS_Store Thumbs.db - diff --git a/package.json b/package.json index a7d055b2..c2c1c799 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", + "test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs", "preview": "vite preview --port 4173" }, "dependencies": { diff --git a/scripts/phase4-development-frontend-acceptance.mjs b/scripts/phase4-development-frontend-acceptance.mjs new file mode 100644 index 00000000..c358737c --- /dev/null +++ b/scripts/phase4-development-frontend-acceptance.mjs @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' + +function option(name) { + const index = process.argv.indexOf(name) + return index >= 0 ? process.argv[index + 1] : '' +} + +function normalizeOrigin(value, name) { + if (!value) throw new Error(`缺少 ${name}`) + const url = new URL(value) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/') { + throw new Error(`${name} 必须是无账号、无路径的 HTTP(S) origin`) + } + return url.origin +} + +function acceptancePassword() { + if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD + const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env' + if (!existsSync(file)) return '' + const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m) + return match?.[1]?.trim() || '' +} + +const environment = option('--environment') +if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development') +const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway') +const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway') +const password = acceptancePassword() +if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件') + +const executable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const result = spawnSync(executable, ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { + cwd: process.cwd(), + stdio: 'inherit', + env: { + ...process.env, + ADMIN_PHASE4_LIVE: '1', + ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', + ADMIN_ACCEPTANCE_PASSWORD: password, + ADMIN_DEVELOPMENT_GATEWAY: gateway, + ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway, + VITE_API_BASE_URL: `${gateway}/api`, + VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`, + }, +}) +process.exit(result.status ?? 1) diff --git a/scripts/phase4-development-frontend-contract.test.mjs b/scripts/phase4-development-frontend-contract.test.mjs new file mode 100644 index 00000000..2613f68a --- /dev/null +++ b/scripts/phase4-development-frontend-contract.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8') +const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8') + +test('development runner requires an explicit development environment and gateways', () => { + assert.match(runner, /--environment/) + assert.match(runner, /environment !== 'development'/) + assert.match(runner, /--gateway/) + assert.match(runner, /--overseas-gateway/) + assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/) +}) + +test('live suite uses production auth clients and domain adapters', () => { + assert.match(suite, /useAuthStore/) + assert.match(suite, /clearOverseasAccessToken/) + assert.match(suite, /createQuote/) + assert.match(suite, /createUsTruckOrder/) + assert.match(suite, /createLocalLooseboardOrder/) + assert.match(suite, /listLastMileOrders/) + assert.match(suite, /listDispatchCandidates/) + assert.match(suite, /listCarrierScheduleQueries/) +}) + +test('live suite owns marker resources and redacts credentials from trace reports', () => { + assert.match(suite, /ADMIN-FRONT-P4-/) + assert.match(suite, /password\|token\|authorization/i) + assert.match(suite, /createdResources/) + assert.match(suite, /coverageGaps/) +}) diff --git a/scripts/phase4-development-frontend.acceptance.ts b/scripts/phase4-development-frontend.acceptance.ts new file mode 100644 index 00000000..dc3360be --- /dev/null +++ b/scripts/phase4-development-frontend.acceptance.ts @@ -0,0 +1,285 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios' +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter } from 'vue-router' +import { defineComponent, nextTick } from 'vue' +import { mount, type VueWrapper } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { http, ApiResponseError } from '@/shared/api/http' +import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp' +import { useAuthStore } from '@/core/auth/auth' +import { listPlatformTenants } from '@/modules/iam/api/platformIam' +import { + changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle, + exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getQuote, getVehicle, listExchangeRates, + listQuoteWarehouseOptions, listQuotes, listVehicles, updateExchangeRate, updateQuote, updateVehicle, +} from '@/modules/overseas-master-data/api/overseasMasterData' +import { + createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, + listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases, + listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateUsTruckOrder, +} from '@/modules/overseas-business/api/platformOverseasBusiness' +import { listLastMileOrders, getLastMileOrder } from '@/modules/overseas-warehouse/api/lastMileOrders' +import { listInboundOrders } from '@/modules/overseas-warehouse/api/inboundOrders' +import { getInboundPlan } from '@/modules/overseas-warehouse/api/inboundPlans' +import { listDispatchCandidates, listDispatchDrafts, listDispatches } from '@/modules/overseas-warehouse/api/dispatches' +import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '@/modules/overseas-warehouse/api/inventory' +import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '@/modules/overseas-warehouse/api/pallets' +import { listRelabelOrders } from '@/modules/overseas-warehouse/api/relabelOrders' +import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '@/modules/port-schedules/api/platformCarrierScheduleQueries' +import OverseasQuotesView from '@/modules/overseas-master-data/pages/OverseasQuotesView.vue' +import PurchaseOrdersView from '@/modules/overseas-business/pages/PurchaseOrdersView.vue' +import TransportOrdersView from '@/modules/overseas-business/pages/TransportOrdersView.vue' +import LastMileOrdersView from '@/modules/overseas-warehouse/pages/LastMileOrdersView.vue' +import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersView.vue' +import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue' +import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue' +import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue' + +type Region = 'domestic' | 'overseas' +interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean } +interface CoverageGap { area: string; reason: string } + +const enabled = process.env.ADMIN_PHASE4_LIVE === '1' +const marker = `ADMIN-FRONT-P4-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}` +const traces: TraceStep[] = [] +const coverageGaps: CoverageGap[] = [] +const createdResources: Record = {} +const wrappers: VueWrapper[] = [] + +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, /password|token|authorization/i.test(key) ? '[REDACTED]' : redact(item)])) +} +function parsed(value: unknown) { + if (typeof value !== 'string') return value + try { return JSON.parse(value) } catch { return value } +} +function envelope(value: unknown) { + const body = parsed(value) as { code?: unknown; requestId?: unknown; traceId?: unknown } | null + return body && typeof body === 'object' ? body : null +} +function installTracingAdapter(client: AxiosInstance, region: Region) { + const nodeAdapter = axios.getAdapter('http') + client.defaults.adapter = async (config: InternalAxiosRequestConfig) => { + try { + const response = await nodeAdapter(config) + const body = envelope(response.data) + traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: response.status, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) }) + return response + } catch (error) { + if (axios.isAxiosError(error)) { + const body = envelope(error.response?.data) + traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: error.response?.status || 0, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) }) + } + throw error + } + } +} +function gap(area: string, reason: string) { coverageGaps.push({ area, reason }) } +function numericId(value: unknown) { return /^[1-9]\d*$/.test(String(value ?? '')) } +function expectPage(value: { records: unknown[]; page: number; size: number; total: number; pages: number }) { + expect(Array.isArray(value.records)).toBe(true); expect(value.page).toBeGreaterThanOrEqual(1); expect(value.size).toBeGreaterThan(0); expect(value.total).toBeGreaterThanOrEqual(value.records.length); expect(value.pages).toBeGreaterThanOrEqual(0) +} +async function expectConflict(action: () => Promise) { + await expect(action()).rejects.toSatisfy((error: unknown) => { + if (error instanceof ApiResponseError) return error.code.endsWith('409') + if (!axios.isAxiosError(error)) return false + const response = parsed(error.response?.data) as { code?: unknown } | undefined + return error.response?.status === 409 || String(response?.code || '').endsWith('409') + }) +} +function futureDate(days: number) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) } + +async function masterDataLifecycle() { + const options = await listQuoteWarehouseOptions() + const quotePage = await listQuotes({ page: 1, size: 20 }) + expectPage(quotePage) + if (options.length) { + const quote = await createQuote({ name: `${marker} 报价`, warehouseId: options[0].warehouseId, providerName: `${marker} Provider`, currency: 'USD', effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', versionCode: marker.slice(-32), warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: marker }], transitPrices: [], remark: marker }) + const id = quote.summary.id + createdResources.quote = id + let detail = await getQuote(id) + const stale = detail.summary.recordVersion + detail = await updateQuote(id, { recordVersion: stale, name: `${marker} 报价-编辑`, providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: `${marker} edited` }) + await expectConflict(() => updateQuote(id, { recordVersion: stale, name: 'STALE', providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: marker })) + detail = await changeQuoteStatus(id, 'ACTIVE', detail.summary.recordVersion) + expect(detail.summary.status).toBe('ACTIVE') + expect((await exportQuotes({ keyword: marker })).size).toBeGreaterThan(0) + detail = await changeQuoteStatus(id, 'INACTIVE', detail.summary.recordVersion) + expect(detail.summary.status).toBe('INACTIVE') + } else gap('海外报价', 'warehouse-options 为空,未猜测仓库 ID,跳过报价写生命周期') + + let exchangeMonth = '' + for (let index = 0; index < 24 && !exchangeMonth; index += 1) { + const month = `${2097 + Math.floor(index / 12)}-${String(index % 12 + 1).padStart(2, '0')}` + const page = await listExchangeRates({ page: 1, size: 20, monthFrom: month, monthTo: month }) + if (!page.total) exchangeMonth = month + } + if (!exchangeMonth) throw new Error('安全汇率月份池已占满') + let rate = await createExchangeRate({ month: exchangeMonth, usdToCnyAverage: 7.123456, remark: marker }) + createdResources.exchangeRate = exchangeMonth + const staleRate = rate.recordVersion + rate = await updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.234567, remark: `${marker} edited` }) + await expectConflict(() => updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.9, remark: 'STALE' })) + rate = await getExchangeRate(exchangeMonth) + rate = await changeExchangeRateStatus(exchangeMonth, 'INACTIVE', rate.recordVersion, `${marker} cleanup`) + expect(rate.status).toBe('INACTIVE') + expect((await exportExchangeRates({ monthFrom: exchangeMonth, monthTo: exchangeMonth })).size).toBeGreaterThan(0) + + let vehicle = await createVehicle({ plateNumber: `QA-${marker.slice(-18)}`, model: 'Phase4 Front QA', fuelConsumptionPer100Km: 18.5, mileageKm: 100, driverOrDispatchCompany: marker, phone: '+1-213-555-0188', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: marker }) + createdResources.vehicle = vehicle.id + const staleVehicle = vehicle.recordVersion + vehicle = await updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'Phase4 Front QA Edited', fuelConsumptionPer100Km: 18.75, mileageKm: 101, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: `${marker} edited` }) + await expectConflict(() => updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'STALE', fuelConsumptionPer100Km: 19, mileageKm: 102, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: 'STALE' })) + vehicle = await getVehicle(vehicle.id) + vehicle = await changeVehicleStatus(vehicle.id, 'INACTIVE', vehicle.recordVersion) + expect(vehicle.status).toBe('INACTIVE') + expect((await exportVehicles({ keyword: vehicle.plateNumber })).size).toBeGreaterThan(0) + expectPage(await listVehicles({ page: 1, size: 20, keyword: marker })) +} + +async function cleanupTransport(path: string, customerId: string, version: number) { + await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) +} +async function overseasBusinessLifecycle() { + const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE') + const active = customers.find(item => numericId(item.id)) + if (!active) { gap('卡车/散板', 'IAM 无 ACTIVE 客户'); return } + const customerId = String(active.id) + expect(Array.isArray(await listOverseasPurchaseCustomerOptions())).toBe(true) + expect(Array.isArray(await listOverseasPurchaseWarehouseOptions())).toBe(true) + expectPage(await listOverseasPurchases({ page: 1, size: 20 })) + const common = { origin: `${marker}-ORIGIN`, originAddress: 'Long Beach', destination: `${marker}-DEST`, destinationAddress: 'Ontario', containerType: '40HQ', packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, currency: 'USD', contactName: '四期前端验收', contactInfo: '13800000000', requestedDate: futureDate(7), remark: marker } + let truck = await createUsTruckOrder(customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, idempotencyKey: `${marker}-truck` }) + createdResources.truck = truck.orderNo + try { + truck = await getUsTruckOrder(truck.orderNo, customerId) + const stale = truck.version + truck = await updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: `${marker}-DEST-EDIT`, version: stale }) + await expectConflict(() => updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: 'STALE', version: stale })) + } finally { + truck = await getUsTruckOrder(truck.orderNo, customerId) + if (truck.status === 'PENDING_REVIEW') await cleanupTransport(`/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(truck.orderNo)}`, customerId, truck.version) + } + expectPage(await listUsTruckOrders({ page: 1, size: 20, customerId })) + + let loose = await createLocalLooseboardOrder(customerId, { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', ...common, idempotencyKey: `${marker}-looseboard` }) + createdResources.looseboard = loose.orderNo + try { + loose = await getLocalLooseboardOrder(loose.orderNo, customerId) + const stale = loose.version + loose = await updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: `${marker}-DEST-EDIT`, version: stale }) + await expectConflict(() => updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: 'STALE', version: stale })) + } finally { + loose = await getLocalLooseboardOrder(loose.orderNo, customerId) + if (loose.status === 'PENDING_ACCEPTANCE') await cleanupTransport(`/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(loose.orderNo)}`, customerId, loose.version) + } + expectPage(await listLocalLooseboardOrders({ page: 1, size: 20, customerId })) +} + +async function overseasWarehouseCoverage() { + const lastMile = await listLastMileOrders({ page: 1, size: 20 }); expectPage(lastMile) + if (lastMile.records[0]) await getLastMileOrder(String(lastMile.records[0].id)) + const inbound = await listInboundOrders({ page: 1, size: 20 }); expectPage(inbound) + if (inbound.records[0]) await getInboundPlan(inbound.records[0].planNo, inbound.records[0].customerId) + expectPage(await listDispatches({ page: 1, size: 20 })) + expect(Array.isArray(await listDispatchCandidates({}))).toBe(true) + expect(Array.isArray(await listDispatchDrafts())).toBe(true) + const inventoryQuery = { page: 1, size: 20 } + const balances = await listInventoryBalances(inventoryQuery); expectPage(balances) + await getInventorySummary(inventoryQuery) + await listInventoryLedger({ page: 1, size: 20 }) + if (balances.records[0]) await listInventoryPositions(String(balances.records[0].id), 1, 20) + expectPage(await listRelabelOrders({ page: 1, size: 20 })) + const warehouses = (await listCustomerWarehouses()).records.filter(item => item.status === 'ACTIVE') + if (!warehouses.length) { gap('公共托盘', '实际海外仓为空,跳过前端托盘写生命周期'); return } + const warehouse = warehouses[0] + const payload = { palletCode: `QA-${marker.slice(-20)}`, palletType: 'STANDARD', fbaWarehouseCode: 'ONT8', capacityCbm: 2.5, unitPrice: 10, status: 'IDLE', remark: marker } + const palletId = await createPallet(warehouse.id, payload) + createdResources.pallet = palletId + try { + const pallet = await getPallet(warehouse.id, palletId) + await updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 2.75, remark: `${marker} edited`, version: pallet.version }) + await expectConflict(() => updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 3, remark: 'STALE', version: pallet.version })) + expectPage(await listPallets(warehouse.id, { page: 1, size: 20, keyword: payload.palletCode })) + } + finally { + const current = await getPallet(warehouse.id, palletId) + if (current.status !== 'SCRAPPED') await updatePallet(warehouse.id, palletId, { ...payload, status: 'SCRAPPED', remark: `${marker} cleanup`, version: current.version }) + } + gap('FBA预约/智能预排/换标新增', '没有本轮 marker 入仓计划和托盘候选;为避免操作历史数据,本轮只自动覆盖列表、详情、候选和草稿查询') +} + +async function carrierCoverage() { + const page = await listCarrierScheduleQueries({ page: 1, size: 20 }) + expectPage(page) + if (page.records[0]) await getCarrierScheduleQuery(page.records[0].query.id) + else gap('船节点人工补录', '环境无真实查询任务;未创建或修改历史任务') +} + +async function waitForTrace(method: string, path: string, start: number) { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + if (traces.slice(start).some(item => item.method === method && item.path.startsWith(path))) return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`页面未发出 ${method} ${path}`) +} +async function mountPage(component: Parameters[0], method: string, path: string, props?: Record) { + const start = traces.length + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '
' }) }] }) + await router.push('/'); await router.isReady() + const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [ElementPlus, router, setActivePinia(createPinia())] } }) + wrappers.push(wrapper) + await waitForTrace(method, path, start) + await nextTick() + expect(wrapper.text()).not.toContain('服务暂时不可用') +} +async function pageRequestCoverage() { + await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes') + await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements') + await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' }) + await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' }) + await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders') + await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans') + await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances') + await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches') + await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications') +} + +describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => { + beforeAll(async () => { + Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }) + globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} } + installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas') + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(pinia) + await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '') + expect(localStorage.getItem('cc-admin-token')).toBeTruthy() + clearOverseasAccessToken() + await listVehicles({ page: 1, size: 1 }) + expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true) + expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true) + }) + it('runs marker-owned adapter lifecycles and real page requests', async () => { + await masterDataLifecycle() + await overseasBusinessLifecycle() + await overseasWarehouseCoverage() + await carrierCoverage() + await pageRequestCoverage() + expect(traces.length).toBeGreaterThan(20) + }) + afterAll(() => { + wrappers.splice(0).forEach(wrapper => wrapper.unmount()) + const reportDir = resolve(process.cwd(), 'reports') + mkdirSync(reportDir, { recursive: true }) + const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources } + const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) + writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.info(`PHASE4_FRONTEND_REPORT=${file}`) + }) +}) diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts index 7a262005..26ae5f4d 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts @@ -9,6 +9,7 @@ import { listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder, } from './platformOverseasBusiness' +import type { UpdateLocalTransportOrderRequest } from '../types' const envelope = (data: T) => ({ code: '200', message: 'success', data }) const purchaseSummary = { @@ -41,7 +42,8 @@ describe('platform overseas business API adapter', () => { expect(await listOverseasPurchaseCustomerOptions(' 客户 ', 50)).toEqual([{ customerId: '10001', customerName: '客户一' }]) expect(overseas.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/overseas-procurements/customer-options', { params: { keyword: '客户', limit: 50 } }) expect((await listOverseasPurchaseWarehouseOptions())[0]?.warehouseId).toBe('9007199254740995') - const result = await listOverseasPurchases({ page: 1, size: 20, orderNo: ' OP-1 ' }) + const result = await listOverseasPurchases({ page: 1, size: 20, customerId: '10001', orderNo: ' OP-1 ', purchaseWarehouseId: '9007199254740995' }) + expect(overseas.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/overseas-procurements', { params: expect.objectContaining({ page: 1, size: 20, customerId: '10001', orderNo: 'OP-1', purchaseWarehouseId: '9007199254740995' }) }) expect(result.records[0]).toMatchObject({ id: '9007199254740993', purchaseWarehouseId: '9007199254740995', budgetUnitPrice: '12.34' }) }) @@ -60,13 +62,13 @@ describe('platform overseas business API adapter', () => { overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail)) expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45') expect((await getUsTruckOrder('TR-1', '10002')).pallets[0]?.warehousePalletId).toBe('9007199254741003') - const create = { transportType: 'PORT_PICKUP' as const, origin: 'LA', destination: 'ONT', palletCount: 2, packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', requestedDate: '2026-08-20', idempotencyKey: 'truck-key' } - overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('10002', create) - expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '10002' } }) + const create = { transportType: 'PORT_PICKUP' as const, origin: 'LA', destination: 'ONT', palletCount: 2, packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', requestedDate: '2026-08-20', idempotencyKey: 'HTTP-20260824-MARKER-truck' } + overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('9007199254741999', create) + expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '9007199254741999' } }) const { idempotencyKey: _truckKey, ...truckFields } = create const update = { ...truckFields, version: 2 } - overseas.patch.mockResolvedValue(envelope(detail)); await updateUsTruckOrder('TR-1', '10002', update) - expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '10002' } }) + overseas.patch.mockResolvedValue(envelope(detail)); await updateUsTruckOrder('TR-1', '9007199254741999', update) + expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '9007199254741999' } }) }) it('supports looseboard list/detail/create and PUT update', async () => { @@ -74,12 +76,25 @@ describe('platform overseas business API adapter', () => { overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail)) expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005') expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE') - const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'loose-key' } - overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('10003', create) - expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '10003' } }) + const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' } + overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create) + expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } }) const { idempotencyKey: _looseKey, ...looseboardFields } = create const update = { ...looseboardFields, version: 0 } - overseas.put.mockResolvedValue(envelope(detail)); await updateLocalLooseboardOrder('LB-1', '10003', update) - expect(overseas.put).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders/LB-1', update, { params: { customerId: '10003' } }) + overseas.put.mockResolvedValue(envelope(detail)); await updateLocalLooseboardOrder('LB/1', '9007199254742001', update) + expect(overseas.put).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders/LB%2F1', update, { params: { customerId: '9007199254742001' } }) + }) + + it('propagates a 409 envelope without issuing any fallback write', async () => { + const payload: UpdateLocalTransportOrderRequest = { + version: 2, transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT-EDIT', palletCount: 2, + packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', + requestedDate: '2026-08-20', + } + overseas.patch.mockResolvedValue({ code: 'B0409', message: 'Order was changed', data: null, requestId: 'REQ-409' }) + await expect(updateUsTruckOrder('TR-1', '9007199254741999', payload)).rejects.toMatchObject({ code: 'B0409', requestId: 'REQ-409' }) + expect(overseas.patch).toHaveBeenCalledTimes(1) + expect(overseas.post).not.toHaveBeenCalled() + expect(overseas.put).not.toHaveBeenCalled() }) }) diff --git a/src/modules/overseas-business/contract.test.ts b/src/modules/overseas-business/contract.test.ts index 58e27ef5..be382783 100644 --- a/src/modules/overseas-business/contract.test.ts +++ b/src/modules/overseas-business/contract.test.ts @@ -32,6 +32,7 @@ describe('overseas business real contract pages', () => { expect(api).toContain('updateUsTruckOrder') expect(api).toContain('createLocalLooseboardOrder') expect(api).toContain('updateLocalLooseboardOrder') + expect(api).not.toContain('createOverseasPurchase') expect(api).not.toMatch(/\/merchant\/|receive-pallets|\/status/) expect(list).toContain('listLocalLooseboardOrders') expect(form).toContain('listPlatformTenants') @@ -46,5 +47,7 @@ describe('overseas business real contract pages', () => { expect(transportList).toContain("row.status === 'PENDING_REVIEW'") expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'") expect(transportForm).toContain("form.status === 'PENDING_REVIEW'") + expect(transportForm).toContain('idempotencyKey: form.idempotencyKey') + expect(transportForm.match(/idempotencyKey:\s*newIdempotencyKey\(\)/g)).toHaveLength(1) }) }) diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index b1a844b7..94f39b5a 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -62,12 +62,18 @@ describe('overseas business mounted pages', () => { it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) }) it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') }) it('mounts procurement detail operation history', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑') }) - it('submits procurement null clears and keeps a 409 visible', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, domesticCarrier: null, estimatedArrivalAt: '2026-08-25T12:00:00', purchaseWarehouseId: '20001' })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect(routing.push).not.toHaveBeenCalled() }) + it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() }) + it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') }) + it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') }) it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() }) it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') }) it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) }) + it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) + it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() }) it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(listUsTruckOrders).not.toHaveBeenCalled() }) it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) }) + it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) + it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() }) }) diff --git a/src/modules/overseas-business/types.ts b/src/modules/overseas-business/types.ts index 4619813a..9d2ecb07 100644 --- a/src/modules/overseas-business/types.ts +++ b/src/modules/overseas-business/types.ts @@ -62,7 +62,12 @@ export interface CreateLocalTransportOrderRequest { budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string remark?: string | null; idempotencyKey: string } -export interface UpdateLocalTransportOrderRequest extends Omit { version: number } +export interface UpdateLocalTransportOrderRequest { + version: number; transportType?: LocalTransportType; origin?: string; originAddress?: string | null + destination?: string; destinationAddress?: string | null; containerType?: string | null; palletCount?: number + packageCount?: number; totalWeightKg?: number; totalVolumeCbm?: number; budgetAmount?: number | null + currency?: string; contactName?: string; contactInfo?: string; requestedDate?: string; remark?: string | null +} export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION' export type PalletRequired = 'YES' | 'NO' diff --git a/src/modules/overseas-master-data/CONTRACT_GAPS.md b/src/modules/overseas-master-data/CONTRACT_GAPS.md index d38a0113..e104547b 100644 --- a/src/modules/overseas-master-data/CONTRACT_GAPS.md +++ b/src/modules/overseas-master-data/CONTRACT_GAPS.md @@ -1,13 +1,16 @@ -# 海外通用资料真实接口与剩余缺口 +# 海外通用资料真实接口边界 -三域统一经 `overseasHttp` 调用 `/api/platform/v1/last-leg/master-data`,长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。 +三域统一经 `overseasHttp` 使用浏览器业务路径 `/platform/v1/last-leg/master-data`,由海外 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。 - 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。 - 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。 - 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。 -剩余后端契约缺口: +当前明确边界: - 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。 - 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。 - 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。 +- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。 +- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。 +- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index aad904c7..50a84561 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -6,15 +6,15 @@ |---|---|---|---|---|---|---| | 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 | | 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 复用现有仓库居中弹框,开放 `OVERSEAS_PARTNER` 新增和编辑 | 分别复用 `platform:warehouse:create`、`platform:warehouse:edit`,不跨区域查询报价候选 | -| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 | -| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list | -| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 | -| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 | -| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` | -| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 | -| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 | -| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 | -| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 | -| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 | +| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14860-14894` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 | +| 海外仓报价 | 详情 | `10-general.html:208-216`、`app.js:14896-14933` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list | +| 海外仓报价 | 新增/编辑 | `10-general.html:177-204`、`app.js:14935-15055` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 | +| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:15056-15070` | 启停、删除为列表行操作;导出在工具栏 | 操作列第一列 | 已接真实启停与 CSV 导出;服务端无删除端点,未展示删除 | quote:status/export 独立校验;不推断 DELETE | +| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` | +| 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 | +| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:10437-10456`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 已接真实分页;区域降为查询下拉框,仅保留自有/外派归属页签;操作列按全局约定移到第一列 | 当前对话确认高于原型;查询提交 ownership/region/status,不拼装数据 | +| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 | +| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 | +| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE | 原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。 diff --git a/src/modules/overseas-master-data/api/overseasMasterData.test.ts b/src/modules/overseas-master-data/api/overseasMasterData.test.ts index 17696026..65c22eed 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.test.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.test.ts @@ -1,55 +1,143 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' import { overseasHttp } from '@/shared/api/overseasHttp' import { changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle, exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle, listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle, + type QuoteCreate, type QuoteUpdate, } from './overseasMasterData' vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } })) const ok = (data: unknown) => ({ code: '200', message: 'success', data }) +const longId = '9223372036854775807' +const quoteSummary = { + id: longId, name: '报价', warehouseId: '9223372036854775806', warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓', + countryCode: 'US', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', + versionCode: 'V1', status: 'INACTIVE', recordVersion: '7', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null, +} +const quoteDetail = { + summary: quoteSummary, + warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: '1.2500', minimumCharge: '2.5000', remark: null }], + transitPrices: [{ fbaWarehouseCode: 'ONT8', deliveryMethod: 'TRUCK', estimatedMiles: '12.50', price: '18.7500', minimumCharge: null, remark: null }], + createdBy: 'admin', createdAt: '2026-08-24T09:00:00', +} +const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null } +const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' } + beforeEach(() => vi.clearAllMocks()) describe('overseas master data API', () => { - it('uses the overseas quote contract including server-owned partner options', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: '1', size: '20', total: '0', pages: '0' })) - await listQuotes({ page: 1, size: 20, keyword: 'V1' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } }) - vi.mocked(overseasHttp.get).mockResolvedValue(ok([])); await listQuoteWarehouseOptions() - expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options') - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ summary: { id: '9223372036854775807' }, warehouseFees: [], transitPrices: [] })); await getQuote('9223372036854775807') - expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9223372036854775807') - const payload = { name: '报价', warehouseId: '9', providerName: '服务商', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', warehouseFees: [], transitPrices: [], remark: null } - vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createQuote(payload) - vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateQuote('9', { ...payload, recordVersion: 2 }) - vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeQuoteStatus('9', 'INACTIVE', 2) - expect(overseasHttp.patch).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9/status', { status: 'INACTIVE', recordVersion: 2 }) + it('normalizes quote page/detail decimals and preserves long string IDs', async () => { + vi.mocked(overseasHttp.get) + .mockResolvedValueOnce(ok({ records: [quoteSummary], page: '1', size: '20', total: '1', pages: '1' })) + .mockResolvedValueOnce(ok(quoteDetail)) + .mockResolvedValueOnce(ok([{ warehouseId: quoteSummary.warehouseId, warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓', countryCode: 'US' }])) + + const result = await listQuotes({ page: 1, size: 20, keyword: 'V1' }) + expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 }) + expect(result.records[0]).toMatchObject({ id: longId, warehouseId: quoteSummary.warehouseId, recordVersion: 7 }) + const detail = await getQuote(longId) + expect(detail.summary.id).toBe(longId) + expect(detail.summary.recordVersion).toBe(7) + expect(detail.warehouseFees[0]).toMatchObject({ unitPrice: 1.25, minimumCharge: 2.5 }) + expect(detail.transitPrices[0]).toMatchObject({ estimatedMiles: 12.5, price: 18.75, minimumCharge: null }) + await listQuoteWarehouseOptions() + + expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } }) + expect(overseasHttp.get).toHaveBeenNthCalledWith(2, `/platform/v1/last-leg/master-data/overseas-quotes/${longId}`) + expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options') }) - it('uses the monthly exchange-rate contract', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' }) - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ latestMonth: '2026-08' })); await getExchangeRateSummary() - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ month: '2026-08' })); await getExchangeRate('2026-08') - vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.18, remark: null }) - vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.19, remark: null }) - vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeExchangeRateStatus('2026-08', 'INACTIVE', 1, '停用') - expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 1, remark: '停用' }) + it('keeps quote create/update DTOs distinct and sends recordVersion on writes', async () => { + const create: QuoteCreate = { + name: '报价', warehouseId: quoteSummary.warehouseId, providerName: '服务商', currency: 'USD', + effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', + warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], + transitPrices: [], remark: null, + } + const update: QuoteUpdate = { + name: '报价-编辑', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', + warehouseFees: create.warehouseFees, transitPrices: [], remark: null, recordVersion: 7, + } + vi.mocked(overseasHttp.post).mockResolvedValue(ok(quoteDetail)) + vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, recordVersion: '8' } })) + vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, status: 'ACTIVE', recordVersion: '9' } })) + + await createQuote(create) + const updated = await updateQuote(longId, update) + await changeQuoteStatus(longId, 'ACTIVE', 8) + + expect(updated.summary.recordVersion).toBe(8) + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', create) + expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}`, update) + expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}/status`, { status: 'ACTIVE', recordVersion: 8 }) + expect(update).not.toHaveProperty('warehouseId') + expect(update).not.toHaveProperty('versionCode') }) - it('uses the vehicle contract and preserves string IDs', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listVehicles({ page: 1, size: 20, ownership: 'OWNED' }) - vi.mocked(overseasHttp.get).mockResolvedValue(ok({ id: '9223372036854775807' })); await getVehicle('9223372036854775807') - expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9223372036854775807') - const payload = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 1, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null, status: 'IN_SERVICE' as const } - vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createVehicle(payload) - vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateVehicle('9', { ...payload, recordVersion: 1 }) - vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeVehicleStatus('9', 'INACTIVE', 1) - expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9/status', { status: 'INACTIVE', recordVersion: 1 }) + it('normalizes exchange-rate decimal/version and uses month as the stable key', async () => { + vi.mocked(overseasHttp.get) + .mockResolvedValueOnce(ok({ records: [exchangeRate], page: '1', size: '20', total: '1', pages: '1' })) + .mockResolvedValueOnce(ok(exchangeRate)) + .mockResolvedValueOnce(ok({ latestMonth: '2026-08', latestUsdToCnyAverage: '7.12345678', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' })) + const page = await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' }) + const detail = await getExchangeRate('2026-08') + const summary = await getExchangeRateSummary() + expect(page.records[0]).toMatchObject({ usdToCnyAverage: 7.12345678, recordVersion: 3 }) + expect(detail).toMatchObject({ month: '2026-08', usdToCnyAverage: 7.12345678, recordVersion: 3 }) + expect(summary.latestUsdToCnyAverage).toBe(7.12345678) + + vi.mocked(overseasHttp.post).mockResolvedValue(ok(exchangeRate)) + vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...exchangeRate, recordVersion: '4' })) + vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...exchangeRate, status: 'INACTIVE', recordVersion: '5' })) + await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.12345678, remark: null }) + await updateExchangeRate('2026-08', { recordVersion: 3, usdToCnyAverage: 7.2, remark: null }) + await changeExchangeRateStatus('2026-08', 'INACTIVE', 4, '停用') + expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 4, remark: '停用' }) }) - it('downloads each CSV through overseasHttp', async () => { - const blob = new Blob(['csv']); vi.mocked(overseasHttp.get).mockResolvedValue(blob) - await exportQuotes({ status: 'ACTIVE' }); await exportExchangeRates({}); await exportVehicles({ ownership: 'OWNED' }) + it('normalizes vehicle decimals/version while preserving long string IDs', async () => { + vi.mocked(overseasHttp.get) + .mockResolvedValueOnce(ok({ records: [vehicle], page: '1', size: '20', total: '1', pages: '1' })) + .mockResolvedValueOnce(ok(vehicle)) + const result = await listVehicles({ page: 1, size: 20, ownership: 'OWNED' }) + const detail = await getVehicle(longId) + expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 }) + expect(detail.id).toBe(longId) + + const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null } + vi.mocked(overseasHttp.post).mockResolvedValue(ok(vehicle)) + vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' })) + vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' })) + await createVehicle({ ...editable, status: 'IN_SERVICE' }) + await updateVehicle(longId, { ...editable, recordVersion: 4 }) + await changeVehicleStatus(longId, 'INACTIVE', 5) + expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}`, { ...editable, recordVersion: 4 }) + expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}/status`, { status: 'INACTIVE', recordVersion: 5 }) + }) + + it('preserves A0409 metadata instead of translating it into success', async () => { + vi.mocked(overseasHttp.put).mockResolvedValue({ code: 'A0409', message: '记录版本已变化', data: null, requestId: 'request-conflict', traceId: 'trace-conflict' }) + const error = await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.2, remark: null }).catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' }) + }) + + it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => { + const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' }) + vi.mocked(overseasHttp.get) + .mockResolvedValueOnce(csv) + .mockResolvedValueOnce(csv) + .mockResolvedValueOnce(csv) + .mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0409', message: '导出条件已变化', data: null, requestId: 'request-export' })], { type: 'application/json' })) + + await expect(exportQuotes({ status: 'ACTIVE' })).resolves.toBe(csv) + await expect(exportExchangeRates({})).resolves.toBe(csv) + await expect(exportVehicles({ ownership: 'OWNED' })).resolves.toBe(csv) + const error = await exportVehicles({}).catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' }) expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'blob' }) expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'blob' }) expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'blob' }) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.ts b/src/modules/overseas-master-data/api/overseasMasterData.ts index 2f329342..842acf90 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.ts @@ -1,42 +1,99 @@ +import axios from 'axios' import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { - MasterDataStatus, MonthlyExchangeRate, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption, + MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption, VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus, } from '../types' export interface PageResult { records: T[]; page: number; size: number; total: number; pages: number } type PagePayload = Omit, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string } const base = '/platform/v1/last-leg/master-data' -function page(value: PagePayload): PageResult { return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } } -async function getPage(path: string, params: object) { return page(unwrapApiResponse(await overseasHttp.get>, ApiEnvelope>>(path, { params }))) } -async function download(path: string, params: object) { return overseasHttp.get(path, { params, responseType: 'blob' }) } +type Numeric = number | string +type QuoteWire = Omit & { recordVersion: Numeric } +type FeeWire = Omit & { unitPrice: Numeric; minimumCharge: Numeric | null } +type TransitPriceWire = Omit & { estimatedMiles: Numeric | null; price: Numeric; minimumCharge: Numeric | null } +type QuoteDetailWire = Omit & { summary: QuoteWire; warehouseFees: FeeWire[]; transitPrices: TransitPriceWire[] } +type ExchangeRateWire = Omit & { usdToCnyAverage: Numeric; recordVersion: Numeric } +type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null } +type ExchangeRateSummaryWire = Omit & { latestUsdToCnyAverage: Numeric | null } +type VehicleWire = Omit & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric } + +function page(value: PagePayload, normalize: (record: W) => T): PageResult { + return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } +} +async function getPage(path: string, params: object, normalize: (record: W) => T) { + return page(unwrapApiResponse(await overseasHttp.get>, ApiEnvelope>>(path, { params })), normalize) +} +const numeric = (value: Numeric) => Number(value) +const nullableNumeric = (value: Numeric | null) => value == null ? null : Number(value) +const normalizeQuote = (value: QuoteWire): OverseasWarehouseQuote => ({ ...value, recordVersion: numeric(value.recordVersion) }) +const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unitPrice: numeric(value.unitPrice), minimumCharge: nullableNumeric(value.minimumCharge) }) +const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) }) +const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) }) +const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) }) +const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) }) + +async function csvError(blob: Blob, status: number) { + if (!blob.type.toLowerCase().includes('json')) return null + try { + const payload = JSON.parse(await blob.text()) as Partial> + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid envelope') + return new ApiResponseError(payload.message || 'CSV 导出失败', payload.code || 'DOWNLOAD_FAILED', status, + payload.data, payload.requestId, payload.traceId) + } catch (error) { + if (error instanceof ApiResponseError) return error + return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status) + } +} +async function download(path: string, params: object) { + try { + const blob = await overseasHttp.get(path, { params, responseType: 'blob' }) + const error = await csvError(blob, 200) + if (error) throw error + return blob + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { + const responseError = await csvError(error.response.data, error.response.status) + if (responseError) throw responseError + } + throw error + } +} export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus } -export type QuoteSave = Pick - & { warehouseId?: string; versionCode?: string; recordVersion?: number } -export const listQuotes = (query: QuoteQuery) => getPage(`${base}/overseas-quotes`, query) -export async function getQuote(id: string) { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`)) } +type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] } +type QuoteEditableFields = Pick + & { effectiveEnd: string } & QuoteDetailFields +export type QuoteCreate = QuoteEditableFields & Pick +export type QuoteUpdate = QuoteEditableFields & Pick +export const listQuotes = (query: QuoteQuery) => getPage(`${base}/overseas-quotes`, query, normalizeQuote) +export async function getQuote(id: string) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`))) } export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/overseas-quotes/warehouse-options`)) } -export async function createQuote(payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/overseas-quotes`, payload)) } -export async function updateQuote(id: string, payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload)) } -export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion })) } +export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/overseas-quotes`, payload))) } +export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) } +export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } export const exportQuotes = (query: Omit) => download(`${base}/overseas-quotes/export`, query) export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus } -export const listExchangeRates = (query: ExchangeRateQuery) => getPage(`${base}/exchange-rates`, query) -export async function getExchangeRate(month: string) { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`)) } -export async function getExchangeRateSummary() { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>>(`${base}/exchange-rates/summary`)) } -export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/exchange-rates`, payload)) } -export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload)) } -export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark })) } +export const listExchangeRates = (query: ExchangeRateQuery) => getPage(`${base}/exchange-rates`, query, normalizeExchangeRate) +export async function getExchangeRate(month: string) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`))) } +export async function getExchangeRateSummary(): Promise { + const value = unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/exchange-rates/summary`)) + return { ...value, latestUsdToCnyAverage: nullableNumeric(value.latestUsdToCnyAverage) } +} +export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/exchange-rates`, payload))) } +export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) } +export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) } export const exportExchangeRates = (query: Omit) => download(`${base}/exchange-rates/export`, query) export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus } -export type VehicleSave = Omit & { status?: VehicleStatus; recordVersion?: number } -export const listVehicles = (query: VehicleQuery) => getPage(`${base}/vehicles`, query) -export async function getVehicle(id: string) { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`)) } -export async function createVehicle(payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/vehicles`, payload)) } -export async function updateVehicle(id: string, payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`, payload)) } -export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion })) } +type VehicleEditableFields = Omit +export type VehicleCreate = VehicleEditableFields & Pick +export type VehicleUpdate = VehicleEditableFields & Pick +export const listVehicles = (query: VehicleQuery) => getPage(`${base}/vehicles`, query, normalizeVehicle) +export async function getVehicle(id: string) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`))) } +export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/vehicles`, payload))) } +export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) } +export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } export const exportVehicles = (query: Omit) => download(`${base}/vehicles/export`, query) diff --git a/src/modules/overseas-master-data/downloadCsv.test.ts b/src/modules/overseas-master-data/downloadCsv.test.ts new file mode 100644 index 00000000..5233f5e6 --- /dev/null +++ b/src/modules/overseas-master-data/downloadCsv.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { saveCsv } from './downloadCsv' + +describe('saveCsv', () => { + beforeEach(() => vi.stubGlobal('URL', { createObjectURL: vi.fn(), revokeObjectURL: vi.fn() })) + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) + + it('downloads the provided Blob with the requested file name and always revokes the URL', () => { + const blob = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' }) + const createObjectURL = vi.mocked(URL.createObjectURL).mockReturnValue('blob:overseas-master-data') + const revokeObjectURL = vi.mocked(URL.revokeObjectURL) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + saveCsv(blob, 'vehicles.csv') + + expect(createObjectURL).toHaveBeenCalledWith(blob) + expect(click).toHaveBeenCalledOnce() + expect(revokeObjectURL).toHaveBeenCalledWith('blob:overseas-master-data') + }) + + it('revokes the Blob URL even when the browser click fails', () => { + vi.mocked(URL.createObjectURL).mockReturnValue('blob:failed-download') + const revokeObjectURL = vi.mocked(URL.revokeObjectURL) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { throw new Error('browser blocked') }) + + expect(() => saveCsv(new Blob(['csv']), 'quotes.csv')).toThrow('browser blocked') + expect(revokeObjectURL).toHaveBeenCalledWith('blob:failed-download') + }) +}) diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts index d8d64151..6b08326d 100644 --- a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts +++ b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts @@ -55,6 +55,19 @@ describe('overseas master data real contract pages', () => { expect(view).toContain('access.can(permission.') expect(view).toContain('ElMessage.success') } + expect(source('OverseasQuotesView')).toContain('导出CSV') + expect(source('VehiclesView')).toContain('导出CSV') + expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'") + for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) { + expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/) + } + expect(source('OverseasQuotesView')).toContain('changeQuoteStatus(row.id,next,row.recordVersion)') + expect(source('ExchangeRatesView')).toContain('changeExchangeRateStatus(row.month, next, row.recordVersion, null)') + expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)') + for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) { + expect(source(name)).toContain("masterDataErrorMessage(") + expect(source(name)).toContain('saveCsv(await export') + } }) it('uses an independent API-backed vehicle detail page without inventing fulfillment history', () => { @@ -81,5 +94,13 @@ describe('overseas master data real contract pages', () => { expect(gaps).toContain('/overseas-quotes/warehouse-options') expect(gaps).toContain('浏览器不调用国内') expect(gaps).toContain('没有删除端点') + expect(gaps).toContain('JSON 错误 Blob') + expect(gaps).not.toContain('尚无已确认') + expect(matrixSource()).not.toContain('写入口禁用') + expect(matrixSource()).not.toContain('等待查询接口') }) }) + +function matrixSource() { + return readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8') +} diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts new file mode 100644 index 00000000..cd99bdbe --- /dev/null +++ b/src/modules/overseas-master-data/pages/OverseasMasterDataWriteBehavior.test.ts @@ -0,0 +1,111 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import { ElMessage } from 'element-plus' +import * as api from '../api/overseasMasterData' +import ExchangeRatesView from './ExchangeRatesView.vue' +import OverseasQuotesView from './OverseasQuotesView.vue' +import VehiclesView from './VehiclesView.vue' + +vi.mock('element-plus', () => ({ + ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, + ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) }, +})) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('../api/overseasMasterData', () => ({ + listQuotes: vi.fn(), listQuoteWarehouseOptions: vi.fn(), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(), + listExchangeRates: vi.fn(), getExchangeRateSummary: vi.fn(), getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(), + listVehicles: vi.fn(), getVehicle: vi.fn(), createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(), +})) + +const longId = '9223372036854775807' +const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null } +const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' } +const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null } +const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' } +const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 } + +const slot = { template: '
' } +const stubs = { + AdminPage: slot, + ListPageLayout: slot, + QueryToolbar: true, + LoadErrorState: true, + DataTable: { + props: ['data'], + template: '
', + }, + AppDialog: { + props: ['modelValue'], emits: ['confirm', 'update:modelValue'], + template: '
', + }, + ElButton: { emits: ['click'], template: '' }, + ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true, + ElTable: slot, ElTableColumn: true, ElTag: slot, +} +const mountView = (component: object) => shallowMount(component, { global: { stubs } }) +const button = (wrapper: ReturnType, label: string) => { + const found = wrapper.findAll('button').find((item) => item.text().includes(label)) + expect(found, `button ${label}`).toBeDefined() + return found! +} + +describe('overseas master data failed writes preserve editor context', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(api.listQuotes).mockResolvedValue({ ...emptyPage, records: [quote], total: 1, pages: 1 }) + vi.mocked(api.listQuoteWarehouseOptions).mockResolvedValue([{ warehouseId: quote.warehouseId, warehouseCode: 'US-LAX', warehouseName: '合作仓', countryCode: 'US' }]) + vi.mocked(api.getQuote).mockResolvedValue(quoteDetail) + vi.mocked(api.listExchangeRates).mockResolvedValue({ ...emptyPage, records: [rate], total: 1, pages: 1 }) + vi.mocked(api.getExchangeRateSummary).mockResolvedValue({ latestMonth: rate.month, latestUsdToCnyAverage: rate.usdToCnyAverage, updatedBy: 'admin', updatedAt: rate.updatedAt }) + vi.mocked(api.getExchangeRate).mockResolvedValue(rate) + vi.mocked(api.listVehicles).mockResolvedValue({ ...emptyPage, records: [vehicle], total: 1, pages: 1 }) + vi.mocked(api.getVehicle).mockResolvedValue(vehicle) + }) + + it('keeps quote edit open, sends only update fields, and shows A0409 request ID', async () => { + vi.mocked(api.updateQuote).mockRejectedValue(new ApiResponseError('记录版本已变化', 'A0409', 200, null, 'req-quote-409')) + const wrapper = mountView(OverseasQuotesView) + await flushPromises() + await button(wrapper, '编辑').trigger('click') + await flushPromises() + await wrapper.get('[data-test="dialog-confirm"]').trigger('click') + await flushPromises() + + expect(api.updateQuote).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 7, name: quote.name })) + const payload = vi.mocked(api.updateQuote).mock.calls[0]?.[1] + expect(payload).not.toHaveProperty('warehouseId') + expect(payload).not.toHaveProperty('versionCode') + expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true) + expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-quote-409')) + }) + + it('keeps exchange-rate decimal/version in the open editor after A0409', async () => { + vi.mocked(api.updateExchangeRate).mockRejectedValue(new ApiResponseError('汇率版本已变化', 'A0409', 200, null, 'req-rate-409')) + const wrapper = mountView(ExchangeRatesView) + await flushPromises() + await button(wrapper, '编辑').trigger('click') + await flushPromises() + await wrapper.get('[data-test="dialog-confirm"]').trigger('click') + await flushPromises() + + expect(api.updateExchangeRate).toHaveBeenCalledWith('2026-08', { recordVersion: 3, usdToCnyAverage: 7.12345678, remark: null }) + expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true) + expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-rate-409')) + }) + + it('keeps vehicle long ID, decimal and recordVersion in the open editor after A0409', async () => { + vi.mocked(api.updateVehicle).mockRejectedValue(new ApiResponseError('车辆版本已变化', 'A0409', 200, null, 'req-vehicle-409')) + const wrapper = mountView(VehiclesView) + await flushPromises() + await button(wrapper, '编辑').trigger('click') + await flushPromises() + await wrapper.get('[data-test="dialog-confirm"]').trigger('click') + await flushPromises() + + expect(api.updateVehicle).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 4, fuelConsumptionPer100Km: 12.5, mileageKm: 101 })) + expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true) + expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-vehicle-409')) + }) +}) diff --git a/src/modules/overseas-master-data/pages/OverseasQuotesView.vue b/src/modules/overseas-master-data/pages/OverseasQuotesView.vue index 7ca2f8b6..766e7ecd 100644 --- a/src/modules/overseas-master-data/pages/OverseasQuotesView.vue +++ b/src/modules/overseas-master-data/pages/OverseasQuotesView.vue @@ -56,7 +56,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
- +
diff --git a/src/modules/overseas-master-data/pages/VehiclesView.vue b/src/modules/overseas-master-data/pages/VehiclesView.vue index 18f2e156..70de2a8e 100644 --- a/src/modules/overseas-master-data/pages/VehiclesView.vue +++ b/src/modules/overseas-master-data/pages/VehiclesView.vue @@ -22,6 +22,6 @@ function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=ro async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}} watch([page,pageSize],load);onMounted(load) -