Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
+1
-1
@@ -12,10 +12,10 @@ dist/
|
|||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
.logs/
|
.logs/
|
||||||
outputs/
|
outputs/
|
||||||
|
reports/
|
||||||
components.d.ts
|
components.d.ts
|
||||||
auto-imports.d.ts
|
auto-imports.d.ts
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -1,5 +1,8 @@
|
|||||||
# 构建 Vue 静态文件
|
# 构建 Vue 静态文件。CI 通过构建参数切换到内网基础镜像,
|
||||||
FROM node:22-alpine AS build
|
# 本地开发仍可使用 Docker Hub 默认值。
|
||||||
|
ARG NODE_IMAGE=node:22-alpine
|
||||||
|
ARG NGINX_IMAGE=nginx:1.27-alpine
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -24,7 +27,7 @@ ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
|
|||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
|
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
|
||||||
FROM nginx:1.27-alpine
|
FROM ${NGINX_IMAGE}
|
||||||
|
|
||||||
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||||
|
|||||||
Vendored
+50
-37
@@ -1,11 +1,15 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent {
|
||||||
|
label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}"
|
||||||
|
}
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8'
|
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
|
||||||
DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy'
|
DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'
|
||||||
DEVELOPMENT_SSH_KNOWN_HOSTS = '/var/jenkins_home/.ssh/known_hosts'
|
REGISTRY_HOST = '172.31.198.7:10082'
|
||||||
DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/frontend/admin-web'
|
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||||
|
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 {
|
options {
|
||||||
@@ -27,42 +31,53 @@ pipeline {
|
|||||||
stage('构建并部署测试前端') {
|
stage('构建并部署测试前端') {
|
||||||
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||||
set -eu
|
sh '''
|
||||||
docker compose -f docker-compose.test.yml build --pull=false
|
set -eu
|
||||||
docker compose -f docker-compose.test.yml up -d --remove-orphans
|
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('构建并部署开发前端') {
|
stage('构建并部署开发前端') {
|
||||||
when { expression { env.IS_DEVELOPMENT == 'true' } }
|
when { expression { env.IS_DEVELOPMENT == 'true' } }
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||||
set -eu
|
sh '''
|
||||||
image="admin-web-development:${SOURCE_TAG}"
|
set -eu
|
||||||
ssh_args="-i ${DEVELOPMENT_SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEVELOPMENT_SSH_KNOWN_HOSTS} -o ConnectTimeout=10"
|
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 \
|
docker build \
|
||||||
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
|
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||||
-t "$image" .
|
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||||
docker save "$image" | gzip -1 | ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" 'gzip -dc | docker load'
|
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
|
||||||
|
-t "$image" .
|
||||||
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" "install -d -m 750 '$DEVELOPMENT_DEPLOY_DIR'"
|
export IMAGE_TAG="$SOURCE_TAG"
|
||||||
scp $ssh_args docker-compose.development.yml \
|
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
||||||
"$DEVELOPMENT_SSH_TARGET:$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp"
|
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
|
||||||
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" \
|
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||||
"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
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +97,7 @@ REMOTE
|
|||||||
failure {
|
failure {
|
||||||
sh '''
|
sh '''
|
||||||
if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
|
if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
|
||||||
ssh -i "$DEVELOPMENT_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
docker logs --tail=100 admin-web-development || true
|
||||||
-o UserKnownHostsFile="$DEVELOPMENT_SSH_KNOWN_HOSTS" \
|
|
||||||
"$DEVELOPMENT_SSH_TARGET" 'docker logs --tail=100 admin-web-development || true' || true
|
|
||||||
else
|
else
|
||||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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
|
image: admin-web-test:local
|
||||||
container_name: admin-web-test
|
container_name: admin-web-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
basedir_win="$basedir"
|
||||||
|
exe=""
|
||||||
|
msys=""
|
||||||
|
|
||||||
|
case `uname -a` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir_win=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
exe=".exe"
|
||||||
|
msys="true"
|
||||||
|
;;
|
||||||
|
*WSL2*)
|
||||||
|
if command -v wslpath > /dev/null 2>&1; then
|
||||||
|
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
||||||
|
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
||||||
|
basedir_win="$basedir"
|
||||||
|
else
|
||||||
|
exe=".exe"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
else
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||||
|
fi
|
||||||
|
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
||||||
|
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsc" "$@"
|
||||||
|
elif [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||||
|
elif command -v node >/dev/null 2>&1; then
|
||||||
|
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||||
|
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
||||||
|
exec node.exe "$basedir_win/../typescript/bin/tsc" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||||
|
fi
|
||||||
|
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsc
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@SETLOCAL
|
||||||
|
@IF NOT DEFINED NODE_PATH (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
) ELSE (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||||
|
)
|
||||||
|
@IF EXIST "%~dp0\node.exe" (
|
||||||
|
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsc" %*
|
||||||
|
) ELSE (
|
||||||
|
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
node "%~dp0\..\typescript\bin\tsc" %*
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
$pathsep=":"
|
||||||
|
$env_node_path=$env:NODE_PATH
|
||||||
|
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
$pathsep=";"
|
||||||
|
} else {
|
||||||
|
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||||
|
$env:NODE_PATH=$new_node_path
|
||||||
|
} else {
|
||||||
|
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
$env:NODE_PATH=$env_node_path
|
||||||
|
exit $ret
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
basedir_win="$basedir"
|
||||||
|
exe=""
|
||||||
|
msys=""
|
||||||
|
|
||||||
|
case `uname -a` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir_win=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
exe=".exe"
|
||||||
|
msys="true"
|
||||||
|
;;
|
||||||
|
*WSL2*)
|
||||||
|
if command -v wslpath > /dev/null 2>&1; then
|
||||||
|
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
||||||
|
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
||||||
|
basedir_win="$basedir"
|
||||||
|
else
|
||||||
|
exe=".exe"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
else
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||||
|
fi
|
||||||
|
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
||||||
|
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsserver" "$@"
|
||||||
|
elif [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||||
|
elif command -v node >/dev/null 2>&1; then
|
||||||
|
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||||
|
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
||||||
|
exec node.exe "$basedir_win/../typescript/bin/tsserver" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||||
|
fi
|
||||||
|
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsserver
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@SETLOCAL
|
||||||
|
@IF NOT DEFINED NODE_PATH (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
) ELSE (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||||
|
)
|
||||||
|
@IF EXIST "%~dp0\node.exe" (
|
||||||
|
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsserver" %*
|
||||||
|
) ELSE (
|
||||||
|
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
node "%~dp0\..\typescript\bin\tsserver" %*
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
$pathsep=":"
|
||||||
|
$env_node_path=$env:NODE_PATH
|
||||||
|
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
$pathsep=";"
|
||||||
|
} else {
|
||||||
|
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||||
|
$env:NODE_PATH=$new_node_path
|
||||||
|
} else {
|
||||||
|
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
$env:NODE_PATH=$env_node_path
|
||||||
|
exit $ret
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
basedir_win="$basedir"
|
||||||
|
exe=""
|
||||||
|
msys=""
|
||||||
|
|
||||||
|
case `uname -a` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir_win=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
exe=".exe"
|
||||||
|
msys="true"
|
||||||
|
;;
|
||||||
|
*WSL2*)
|
||||||
|
if command -v wslpath > /dev/null 2>&1; then
|
||||||
|
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
||||||
|
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
||||||
|
basedir_win="$basedir"
|
||||||
|
else
|
||||||
|
exe=".exe"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
else
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||||
|
fi
|
||||||
|
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
||||||
|
exec "$basedir/node.exe" "$basedir_win/../vite/bin/vite.js" "$@"
|
||||||
|
elif [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
||||||
|
elif command -v node >/dev/null 2>&1; then
|
||||||
|
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||||
|
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
||||||
|
exec node.exe "$basedir_win/../vite/bin/vite.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||||
|
fi
|
||||||
|
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vite/bin/vite.js
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@SETLOCAL
|
||||||
|
@IF NOT DEFINED NODE_PATH (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
) ELSE (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||||
|
)
|
||||||
|
@IF EXIST "%~dp0\node.exe" (
|
||||||
|
"%~dp0\node.exe" "%~dp0\..\vite\bin\vite.js" %*
|
||||||
|
) ELSE (
|
||||||
|
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
node "%~dp0\..\vite\bin\vite.js" %*
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
$pathsep=":"
|
||||||
|
$env_node_path=$env:NODE_PATH
|
||||||
|
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
$pathsep=";"
|
||||||
|
} else {
|
||||||
|
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||||
|
$env:NODE_PATH=$new_node_path
|
||||||
|
} else {
|
||||||
|
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../vite/bin/vite.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../vite/bin/vite.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
$env:NODE_PATH=$env_node_path
|
||||||
|
exit $ret
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
basedir_win="$basedir"
|
||||||
|
exe=""
|
||||||
|
msys=""
|
||||||
|
|
||||||
|
case `uname -a` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir_win=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
exe=".exe"
|
||||||
|
msys="true"
|
||||||
|
;;
|
||||||
|
*WSL2*)
|
||||||
|
if command -v wslpath > /dev/null 2>&1; then
|
||||||
|
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
||||||
|
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
||||||
|
basedir_win="$basedir"
|
||||||
|
else
|
||||||
|
exe=".exe"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
else
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||||
|
fi
|
||||||
|
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
||||||
|
exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
|
||||||
|
elif [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
||||||
|
elif command -v node >/dev/null 2>&1; then
|
||||||
|
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||||
|
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
||||||
|
exec node.exe "$basedir_win/../vitest/vitest.mjs" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||||
|
fi
|
||||||
|
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vitest/vitest.mjs
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@SETLOCAL
|
||||||
|
@IF NOT DEFINED NODE_PATH (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
) ELSE (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||||
|
)
|
||||||
|
@IF EXIST "%~dp0\node.exe" (
|
||||||
|
"%~dp0\node.exe" "%~dp0\..\vitest\vitest.mjs" %*
|
||||||
|
) ELSE (
|
||||||
|
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
node "%~dp0\..\vitest\vitest.mjs" %*
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
$pathsep=":"
|
||||||
|
$env_node_path=$env:NODE_PATH
|
||||||
|
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
$pathsep=";"
|
||||||
|
} else {
|
||||||
|
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||||
|
$env:NODE_PATH=$new_node_path
|
||||||
|
} else {
|
||||||
|
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../vitest/vitest.mjs" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../vitest/vitest.mjs" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
$env:NODE_PATH=$env_node_path
|
||||||
|
exit $ret
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
basedir_win="$basedir"
|
||||||
|
exe=""
|
||||||
|
msys=""
|
||||||
|
|
||||||
|
case `uname -a` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir_win=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
exe=".exe"
|
||||||
|
msys="true"
|
||||||
|
;;
|
||||||
|
*WSL2*)
|
||||||
|
if command -v wslpath > /dev/null 2>&1; then
|
||||||
|
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
||||||
|
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
||||||
|
basedir_win="$basedir"
|
||||||
|
else
|
||||||
|
exe=".exe"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$NODE_PATH" ]; then
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
else
|
||||||
|
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||||
|
fi
|
||||||
|
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
||||||
|
exec "$basedir/node.exe" "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
|
||||||
|
elif [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
||||||
|
elif command -v node >/dev/null 2>&1; then
|
||||||
|
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
||||||
|
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
||||||
|
exec node.exe "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
||||||
|
fi
|
||||||
|
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vue-tsc/bin/vue-tsc.js
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@SETLOCAL
|
||||||
|
@IF NOT DEFINED NODE_PATH (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
) ELSE (
|
||||||
|
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
||||||
|
)
|
||||||
|
@IF EXIST "%~dp0\node.exe" (
|
||||||
|
"%~dp0\node.exe" "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
|
||||||
|
) ELSE (
|
||||||
|
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
node "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
$pathsep=":"
|
||||||
|
$env_node_path=$env:NODE_PATH
|
||||||
|
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
$pathsep=";"
|
||||||
|
} else {
|
||||||
|
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($env_node_path)) {
|
||||||
|
$env:NODE_PATH=$new_node_path
|
||||||
|
} else {
|
||||||
|
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
$env:NODE_PATH=$env_node_path
|
||||||
|
exit $ret
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
{
|
||||||
|
"hoistedDependencies": {
|
||||||
|
"undici-types@7.18.2": {
|
||||||
|
"undici-types": "private"
|
||||||
|
},
|
||||||
|
"@rolldown/pluginutils@1.0.1": {
|
||||||
|
"@rolldown/pluginutils": "private"
|
||||||
|
},
|
||||||
|
"@vue/compiler-dom@3.5.40": {
|
||||||
|
"@vue/compiler-dom": "private"
|
||||||
|
},
|
||||||
|
"js-beautify@1.15.4": {
|
||||||
|
"js-beautify": "private"
|
||||||
|
},
|
||||||
|
"vue-component-type-helpers@3.3.8": {
|
||||||
|
"vue-component-type-helpers": "private"
|
||||||
|
},
|
||||||
|
"@vue/server-renderer@3.5.40": {
|
||||||
|
"@vue/server-renderer": "private"
|
||||||
|
},
|
||||||
|
"follow-redirects@1.16.0(debug@4.4.3)": {
|
||||||
|
"follow-redirects": "private"
|
||||||
|
},
|
||||||
|
"form-data@4.0.6": {
|
||||||
|
"form-data": "private"
|
||||||
|
},
|
||||||
|
"https-proxy-agent@5.0.1": {
|
||||||
|
"https-proxy-agent": "private"
|
||||||
|
},
|
||||||
|
"proxy-from-env@2.1.0": {
|
||||||
|
"proxy-from-env": "private"
|
||||||
|
},
|
||||||
|
"@ctrl/tinycolor@4.2.0": {
|
||||||
|
"@ctrl/tinycolor": "private"
|
||||||
|
},
|
||||||
|
"@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3))": {
|
||||||
|
"@element-plus/icons-vue": "private"
|
||||||
|
},
|
||||||
|
"@floating-ui/dom@1.8.0": {
|
||||||
|
"@floating-ui/dom": "private"
|
||||||
|
},
|
||||||
|
"@sxzz/popperjs-es@2.11.8": {
|
||||||
|
"@popperjs/core": "private"
|
||||||
|
},
|
||||||
|
"@types/lodash@4.17.24": {
|
||||||
|
"@types/lodash": "private"
|
||||||
|
},
|
||||||
|
"@types/lodash-es@4.17.12": {
|
||||||
|
"@types/lodash-es": "private"
|
||||||
|
},
|
||||||
|
"@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3))": {
|
||||||
|
"@vueuse/core": "private"
|
||||||
|
},
|
||||||
|
"async-validator@4.2.5": {
|
||||||
|
"async-validator": "private"
|
||||||
|
},
|
||||||
|
"dayjs@1.11.21": {
|
||||||
|
"dayjs": "private"
|
||||||
|
},
|
||||||
|
"lodash@4.18.1": {
|
||||||
|
"lodash": "private"
|
||||||
|
},
|
||||||
|
"lodash-es@4.18.1": {
|
||||||
|
"lodash-es": "private"
|
||||||
|
},
|
||||||
|
"lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)": {
|
||||||
|
"lodash-unified": "private"
|
||||||
|
},
|
||||||
|
"memoize-one@6.0.0": {
|
||||||
|
"memoize-one": "private"
|
||||||
|
},
|
||||||
|
"normalize-wheel-es@1.2.0": {
|
||||||
|
"normalize-wheel-es": "private"
|
||||||
|
},
|
||||||
|
"@asamuzakjp/css-color@5.1.11": {
|
||||||
|
"@asamuzakjp/css-color": "private"
|
||||||
|
},
|
||||||
|
"@asamuzakjp/dom-selector@7.1.1": {
|
||||||
|
"@asamuzakjp/dom-selector": "private"
|
||||||
|
},
|
||||||
|
"@bramus/specificity@2.4.2": {
|
||||||
|
"@bramus/specificity": "private"
|
||||||
|
},
|
||||||
|
"@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)": {
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "private"
|
||||||
|
},
|
||||||
|
"@exodus/bytes@1.15.1": {
|
||||||
|
"@exodus/bytes": "private"
|
||||||
|
},
|
||||||
|
"css-tree@3.2.1": {
|
||||||
|
"css-tree": "private"
|
||||||
|
},
|
||||||
|
"data-urls@7.0.0": {
|
||||||
|
"data-urls": "private"
|
||||||
|
},
|
||||||
|
"decimal.js@10.6.0": {
|
||||||
|
"decimal.js": "private"
|
||||||
|
},
|
||||||
|
"html-encoding-sniffer@6.0.0": {
|
||||||
|
"html-encoding-sniffer": "private"
|
||||||
|
},
|
||||||
|
"is-potential-custom-element-name@1.0.1": {
|
||||||
|
"is-potential-custom-element-name": "private"
|
||||||
|
},
|
||||||
|
"lru-cache@11.5.2": {
|
||||||
|
"lru-cache": "private"
|
||||||
|
},
|
||||||
|
"parse5@8.0.1": {
|
||||||
|
"parse5": "private"
|
||||||
|
},
|
||||||
|
"saxes@6.0.0": {
|
||||||
|
"saxes": "private"
|
||||||
|
},
|
||||||
|
"symbol-tree@3.2.4": {
|
||||||
|
"symbol-tree": "private"
|
||||||
|
},
|
||||||
|
"tough-cookie@6.0.2": {
|
||||||
|
"tough-cookie": "private"
|
||||||
|
},
|
||||||
|
"undici@7.29.0": {
|
||||||
|
"undici": "private"
|
||||||
|
},
|
||||||
|
"w3c-xmlserializer@5.0.0": {
|
||||||
|
"w3c-xmlserializer": "private"
|
||||||
|
},
|
||||||
|
"webidl-conversions@8.0.1": {
|
||||||
|
"webidl-conversions": "private"
|
||||||
|
},
|
||||||
|
"whatwg-mimetype@5.0.0": {
|
||||||
|
"whatwg-mimetype": "private"
|
||||||
|
},
|
||||||
|
"whatwg-url@16.0.1": {
|
||||||
|
"whatwg-url": "private"
|
||||||
|
},
|
||||||
|
"xml-name-validator@5.0.0": {
|
||||||
|
"xml-name-validator": "private"
|
||||||
|
},
|
||||||
|
"@vue/devtools-api@8.2.1": {
|
||||||
|
"@vue/devtools-api": "private"
|
||||||
|
},
|
||||||
|
"nostics@1.2.0": {
|
||||||
|
"nostics": "private"
|
||||||
|
},
|
||||||
|
"local-pkg@1.2.1": {
|
||||||
|
"local-pkg": "private"
|
||||||
|
},
|
||||||
|
"magic-string@0.30.21": {
|
||||||
|
"magic-string": "private"
|
||||||
|
},
|
||||||
|
"picomatch@4.0.5": {
|
||||||
|
"picomatch": "private"
|
||||||
|
},
|
||||||
|
"unimport@5.7.0": {
|
||||||
|
"unimport": "private"
|
||||||
|
},
|
||||||
|
"unplugin@2.3.11": {
|
||||||
|
"unplugin": "private"
|
||||||
|
},
|
||||||
|
"unplugin-utils@0.3.2": {
|
||||||
|
"unplugin-utils": "private"
|
||||||
|
},
|
||||||
|
"chokidar@5.0.0": {
|
||||||
|
"chokidar": "private"
|
||||||
|
},
|
||||||
|
"mlly@1.8.2": {
|
||||||
|
"mlly": "private"
|
||||||
|
},
|
||||||
|
"obug@2.1.4": {
|
||||||
|
"obug": "private"
|
||||||
|
},
|
||||||
|
"tinyglobby@0.2.17": {
|
||||||
|
"tinyglobby": "private"
|
||||||
|
},
|
||||||
|
"lightningcss@1.33.0": {
|
||||||
|
"lightningcss": "private"
|
||||||
|
},
|
||||||
|
"postcss@8.5.23": {
|
||||||
|
"postcss": "private"
|
||||||
|
},
|
||||||
|
"rolldown@1.1.5": {
|
||||||
|
"rolldown": "private"
|
||||||
|
},
|
||||||
|
"yaml@2.9.0": {
|
||||||
|
"yaml": "private"
|
||||||
|
},
|
||||||
|
"@vitest/expect@4.1.10": {
|
||||||
|
"@vitest/expect": "private"
|
||||||
|
},
|
||||||
|
"@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))": {
|
||||||
|
"@vitest/mocker": "private"
|
||||||
|
},
|
||||||
|
"@vitest/pretty-format@4.1.10": {
|
||||||
|
"@vitest/pretty-format": "private"
|
||||||
|
},
|
||||||
|
"@vitest/runner@4.1.10": {
|
||||||
|
"@vitest/runner": "private"
|
||||||
|
},
|
||||||
|
"@vitest/snapshot@4.1.10": {
|
||||||
|
"@vitest/snapshot": "private"
|
||||||
|
},
|
||||||
|
"@vitest/spy@4.1.10": {
|
||||||
|
"@vitest/spy": "private"
|
||||||
|
},
|
||||||
|
"@vitest/utils@4.1.10": {
|
||||||
|
"@vitest/utils": "private"
|
||||||
|
},
|
||||||
|
"es-module-lexer@2.3.1": {
|
||||||
|
"es-module-lexer": "private"
|
||||||
|
},
|
||||||
|
"expect-type@1.4.0": {
|
||||||
|
"expect-type": "private"
|
||||||
|
},
|
||||||
|
"pathe@2.0.3": {
|
||||||
|
"pathe": "private"
|
||||||
|
},
|
||||||
|
"std-env@4.2.0": {
|
||||||
|
"std-env": "private"
|
||||||
|
},
|
||||||
|
"tinybench@2.9.0": {
|
||||||
|
"tinybench": "private"
|
||||||
|
},
|
||||||
|
"tinyexec@1.2.4": {
|
||||||
|
"tinyexec": "private"
|
||||||
|
},
|
||||||
|
"tinyrainbow@3.1.0": {
|
||||||
|
"tinyrainbow": "private"
|
||||||
|
},
|
||||||
|
"why-is-node-running@2.3.0": {
|
||||||
|
"why-is-node-running": "private"
|
||||||
|
},
|
||||||
|
"@babel/generator@8.0.0": {
|
||||||
|
"@babel/generator": "private"
|
||||||
|
},
|
||||||
|
"@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.9.3))": {
|
||||||
|
"@vue-macros/common": "private"
|
||||||
|
},
|
||||||
|
"ast-walker-scope@0.9.0": {
|
||||||
|
"ast-walker-scope": "private"
|
||||||
|
},
|
||||||
|
"json5@2.2.3": {
|
||||||
|
"json5": "private"
|
||||||
|
},
|
||||||
|
"muggle-string@0.4.1": {
|
||||||
|
"muggle-string": "private"
|
||||||
|
},
|
||||||
|
"scule@1.3.0": {
|
||||||
|
"scule": "private"
|
||||||
|
},
|
||||||
|
"@vue/compiler-sfc@3.5.40": {
|
||||||
|
"@vue/compiler-sfc": "private"
|
||||||
|
},
|
||||||
|
"@volar/typescript@2.4.28": {
|
||||||
|
"@volar/typescript": "private"
|
||||||
|
},
|
||||||
|
"@vue/language-core@3.3.8": {
|
||||||
|
"@vue/language-core": "private"
|
||||||
|
},
|
||||||
|
"@vue/runtime-dom@3.5.40": {
|
||||||
|
"@vue/runtime-dom": "private"
|
||||||
|
},
|
||||||
|
"@vue/shared@3.5.40": {
|
||||||
|
"@vue/shared": "private"
|
||||||
|
},
|
||||||
|
"@asamuzakjp/generational-cache@1.0.1": {
|
||||||
|
"@asamuzakjp/generational-cache": "private"
|
||||||
|
},
|
||||||
|
"@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
|
||||||
|
"@csstools/css-calc": "private"
|
||||||
|
},
|
||||||
|
"@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
|
||||||
|
"@csstools/css-color-parser": "private"
|
||||||
|
},
|
||||||
|
"@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": {
|
||||||
|
"@csstools/css-parser-algorithms": "private"
|
||||||
|
},
|
||||||
|
"@csstools/css-tokenizer@4.0.0": {
|
||||||
|
"@csstools/css-tokenizer": "private"
|
||||||
|
},
|
||||||
|
"@asamuzakjp/nwsapi@2.3.9": {
|
||||||
|
"@asamuzakjp/nwsapi": "private"
|
||||||
|
},
|
||||||
|
"bidi-js@1.0.3": {
|
||||||
|
"bidi-js": "private"
|
||||||
|
},
|
||||||
|
"@babel/parser@8.0.4": {
|
||||||
|
"@babel/parser": "private"
|
||||||
|
},
|
||||||
|
"@babel/types@8.0.4": {
|
||||||
|
"@babel/types": "private"
|
||||||
|
},
|
||||||
|
"@jridgewell/gen-mapping@0.3.13": {
|
||||||
|
"@jridgewell/gen-mapping": "private"
|
||||||
|
},
|
||||||
|
"@jridgewell/trace-mapping@0.3.31": {
|
||||||
|
"@jridgewell/trace-mapping": "private"
|
||||||
|
},
|
||||||
|
"@types/jsesc@2.5.1": {
|
||||||
|
"@types/jsesc": "private"
|
||||||
|
},
|
||||||
|
"jsesc@3.1.0": {
|
||||||
|
"jsesc": "private"
|
||||||
|
},
|
||||||
|
"@floating-ui/core@1.8.0": {
|
||||||
|
"@floating-ui/core": "private"
|
||||||
|
},
|
||||||
|
"@floating-ui/utils@0.2.12": {
|
||||||
|
"@floating-ui/utils": "private"
|
||||||
|
},
|
||||||
|
"@standard-schema/spec@1.1.0": {
|
||||||
|
"@standard-schema/spec": "private"
|
||||||
|
},
|
||||||
|
"@types/chai@5.2.3": {
|
||||||
|
"@types/chai": "private"
|
||||||
|
},
|
||||||
|
"chai@6.2.2": {
|
||||||
|
"chai": "private"
|
||||||
|
},
|
||||||
|
"estree-walker@3.0.3": {
|
||||||
|
"estree-walker": "private"
|
||||||
|
},
|
||||||
|
"convert-source-map@2.0.0": {
|
||||||
|
"convert-source-map": "private"
|
||||||
|
},
|
||||||
|
"@volar/language-core@2.4.28": {
|
||||||
|
"@volar/language-core": "private"
|
||||||
|
},
|
||||||
|
"path-browserify@1.0.1": {
|
||||||
|
"path-browserify": "private"
|
||||||
|
},
|
||||||
|
"vscode-uri@3.1.0": {
|
||||||
|
"vscode-uri": "private"
|
||||||
|
},
|
||||||
|
"@vue/compiler-core@3.5.40": {
|
||||||
|
"@vue/compiler-core": "private"
|
||||||
|
},
|
||||||
|
"@vue/compiler-ssr@3.5.40": {
|
||||||
|
"@vue/compiler-ssr": "private"
|
||||||
|
},
|
||||||
|
"source-map-js@1.2.1": {
|
||||||
|
"source-map-js": "private"
|
||||||
|
},
|
||||||
|
"@vue/devtools-kit@8.2.1": {
|
||||||
|
"@vue/devtools-kit": "private"
|
||||||
|
},
|
||||||
|
"alien-signals@3.2.1": {
|
||||||
|
"alien-signals": "private"
|
||||||
|
},
|
||||||
|
"@vue/reactivity@3.5.40": {
|
||||||
|
"@vue/reactivity": "private"
|
||||||
|
},
|
||||||
|
"@vue/runtime-core@3.5.40": {
|
||||||
|
"@vue/runtime-core": "private"
|
||||||
|
},
|
||||||
|
"csstype@3.2.3": {
|
||||||
|
"csstype": "private"
|
||||||
|
},
|
||||||
|
"ast-kit@2.2.0": {
|
||||||
|
"ast-kit": "private"
|
||||||
|
},
|
||||||
|
"magic-string-ast@1.0.3": {
|
||||||
|
"magic-string-ast": "private"
|
||||||
|
},
|
||||||
|
"@types/web-bluetooth@0.0.21": {
|
||||||
|
"@types/web-bluetooth": "private"
|
||||||
|
},
|
||||||
|
"@vueuse/metadata@14.3.0": {
|
||||||
|
"@vueuse/metadata": "private"
|
||||||
|
},
|
||||||
|
"@vueuse/shared@14.3.0(vue@3.5.40(typescript@5.9.3))": {
|
||||||
|
"@vueuse/shared": "private"
|
||||||
|
},
|
||||||
|
"readdirp@5.0.0": {
|
||||||
|
"readdirp": "private"
|
||||||
|
},
|
||||||
|
"mdn-data@2.27.1": {
|
||||||
|
"mdn-data": "private"
|
||||||
|
},
|
||||||
|
"debug@4.4.3": {
|
||||||
|
"debug": "private"
|
||||||
|
},
|
||||||
|
"asynckit@0.4.0": {
|
||||||
|
"asynckit": "private"
|
||||||
|
},
|
||||||
|
"combined-stream@1.0.8": {
|
||||||
|
"combined-stream": "private"
|
||||||
|
},
|
||||||
|
"es-set-tostringtag@2.1.0": {
|
||||||
|
"es-set-tostringtag": "private"
|
||||||
|
},
|
||||||
|
"hasown@2.0.4": {
|
||||||
|
"hasown": "private"
|
||||||
|
},
|
||||||
|
"mime-types@2.1.35": {
|
||||||
|
"mime-types": "private"
|
||||||
|
},
|
||||||
|
"agent-base@6.0.2": {
|
||||||
|
"agent-base": "private"
|
||||||
|
},
|
||||||
|
"config-chain@1.1.13": {
|
||||||
|
"config-chain": "private"
|
||||||
|
},
|
||||||
|
"editorconfig@1.0.7": {
|
||||||
|
"editorconfig": "private"
|
||||||
|
},
|
||||||
|
"glob@10.5.0": {
|
||||||
|
"glob": "private"
|
||||||
|
},
|
||||||
|
"js-cookie@3.0.8": {
|
||||||
|
"js-cookie": "private"
|
||||||
|
},
|
||||||
|
"nopt@7.2.1": {
|
||||||
|
"nopt": "private"
|
||||||
|
},
|
||||||
|
"detect-libc@2.1.2": {
|
||||||
|
"detect-libc": "private"
|
||||||
|
},
|
||||||
|
"lightningcss-win32-x64-msvc@1.33.0": {
|
||||||
|
"lightningcss-win32-x64-msvc": "private"
|
||||||
|
},
|
||||||
|
"pkg-types@2.3.1": {
|
||||||
|
"pkg-types": "private"
|
||||||
|
},
|
||||||
|
"quansync@0.2.11": {
|
||||||
|
"quansync": "private"
|
||||||
|
},
|
||||||
|
"@jridgewell/sourcemap-codec@1.5.5": {
|
||||||
|
"@jridgewell/sourcemap-codec": "private"
|
||||||
|
},
|
||||||
|
"acorn@8.17.0": {
|
||||||
|
"acorn": "private"
|
||||||
|
},
|
||||||
|
"ufo@1.6.4": {
|
||||||
|
"ufo": "private"
|
||||||
|
},
|
||||||
|
"entities@8.0.0": {
|
||||||
|
"entities": "private"
|
||||||
|
},
|
||||||
|
"nanoid@3.3.16": {
|
||||||
|
"nanoid": "private"
|
||||||
|
},
|
||||||
|
"picocolors@1.1.1": {
|
||||||
|
"picocolors": "private"
|
||||||
|
},
|
||||||
|
"@oxc-project/types@0.139.0": {
|
||||||
|
"@oxc-project/types": "private"
|
||||||
|
},
|
||||||
|
"@rolldown/binding-win32-x64-msvc@1.1.5": {
|
||||||
|
"@rolldown/binding-win32-x64-msvc": "private"
|
||||||
|
},
|
||||||
|
"xmlchars@2.2.0": {
|
||||||
|
"xmlchars": "private"
|
||||||
|
},
|
||||||
|
"fdir@6.5.0(picomatch@4.0.5)": {
|
||||||
|
"fdir": "private"
|
||||||
|
},
|
||||||
|
"tldts@7.4.9": {
|
||||||
|
"tldts": "private"
|
||||||
|
},
|
||||||
|
"escape-string-regexp@5.0.0": {
|
||||||
|
"escape-string-regexp": "private"
|
||||||
|
},
|
||||||
|
"strip-literal@3.1.0": {
|
||||||
|
"strip-literal": "private"
|
||||||
|
},
|
||||||
|
"@jridgewell/remapping@2.3.5": {
|
||||||
|
"@jridgewell/remapping": "private"
|
||||||
|
},
|
||||||
|
"webpack-virtual-modules@0.6.2": {
|
||||||
|
"webpack-virtual-modules": "private"
|
||||||
|
},
|
||||||
|
"tr46@6.0.0": {
|
||||||
|
"tr46": "private"
|
||||||
|
},
|
||||||
|
"siginfo@2.0.0": {
|
||||||
|
"siginfo": "private"
|
||||||
|
},
|
||||||
|
"stackback@0.0.2": {
|
||||||
|
"stackback": "private"
|
||||||
|
},
|
||||||
|
"@babel/helper-string-parser@8.0.0": {
|
||||||
|
"@babel/helper-string-parser": "private"
|
||||||
|
},
|
||||||
|
"@babel/helper-validator-identifier@8.0.4": {
|
||||||
|
"@babel/helper-validator-identifier": "private"
|
||||||
|
},
|
||||||
|
"@csstools/color-helpers@6.1.0": {
|
||||||
|
"@csstools/color-helpers": "private"
|
||||||
|
},
|
||||||
|
"@types/deep-eql@4.0.2": {
|
||||||
|
"@types/deep-eql": "private"
|
||||||
|
},
|
||||||
|
"assertion-error@2.0.1": {
|
||||||
|
"assertion-error": "private"
|
||||||
|
},
|
||||||
|
"@volar/source-map@2.4.28": {
|
||||||
|
"@volar/source-map": "private"
|
||||||
|
},
|
||||||
|
"@vue/devtools-shared@8.2.1": {
|
||||||
|
"@vue/devtools-shared": "private"
|
||||||
|
},
|
||||||
|
"birpc@2.9.0": {
|
||||||
|
"birpc": "private"
|
||||||
|
},
|
||||||
|
"hookable@5.5.3": {
|
||||||
|
"hookable": "private"
|
||||||
|
},
|
||||||
|
"perfect-debounce@2.1.0": {
|
||||||
|
"perfect-debounce": "private"
|
||||||
|
},
|
||||||
|
"require-from-string@2.0.2": {
|
||||||
|
"require-from-string": "private"
|
||||||
|
},
|
||||||
|
"delayed-stream@1.0.0": {
|
||||||
|
"delayed-stream": "private"
|
||||||
|
},
|
||||||
|
"ini@1.3.8": {
|
||||||
|
"ini": "private"
|
||||||
|
},
|
||||||
|
"proto-list@1.2.4": {
|
||||||
|
"proto-list": "private"
|
||||||
|
},
|
||||||
|
"ms@2.1.3": {
|
||||||
|
"ms": "private"
|
||||||
|
},
|
||||||
|
"@one-ini/wasm@0.1.1": {
|
||||||
|
"@one-ini/wasm": "private"
|
||||||
|
},
|
||||||
|
"commander@10.0.1": {
|
||||||
|
"commander": "private"
|
||||||
|
},
|
||||||
|
"minimatch@9.0.9": {
|
||||||
|
"minimatch": "private"
|
||||||
|
},
|
||||||
|
"semver@7.8.5": {
|
||||||
|
"semver": "private"
|
||||||
|
},
|
||||||
|
"es-errors@1.3.0": {
|
||||||
|
"es-errors": "private"
|
||||||
|
},
|
||||||
|
"get-intrinsic@1.3.0": {
|
||||||
|
"get-intrinsic": "private"
|
||||||
|
},
|
||||||
|
"has-tostringtag@1.0.2": {
|
||||||
|
"has-tostringtag": "private"
|
||||||
|
},
|
||||||
|
"@types/estree@1.0.9": {
|
||||||
|
"@types/estree": "private"
|
||||||
|
},
|
||||||
|
"foreground-child@3.3.1": {
|
||||||
|
"foreground-child": "private"
|
||||||
|
},
|
||||||
|
"jackspeak@3.4.3": {
|
||||||
|
"jackspeak": "private"
|
||||||
|
},
|
||||||
|
"minipass@7.1.3": {
|
||||||
|
"minipass": "private"
|
||||||
|
},
|
||||||
|
"package-json-from-dist@1.0.1": {
|
||||||
|
"package-json-from-dist": "private"
|
||||||
|
},
|
||||||
|
"path-scurry@1.11.1": {
|
||||||
|
"path-scurry": "private"
|
||||||
|
},
|
||||||
|
"function-bind@1.1.2": {
|
||||||
|
"function-bind": "private"
|
||||||
|
},
|
||||||
|
"mime-db@1.52.0": {
|
||||||
|
"mime-db": "private"
|
||||||
|
},
|
||||||
|
"abbrev@2.0.0": {
|
||||||
|
"abbrev": "private"
|
||||||
|
},
|
||||||
|
"confbox@0.1.8": {
|
||||||
|
"confbox": "private"
|
||||||
|
},
|
||||||
|
"exsolve@1.1.0": {
|
||||||
|
"exsolve": "private"
|
||||||
|
},
|
||||||
|
"js-tokens@9.0.1": {
|
||||||
|
"js-tokens": "private"
|
||||||
|
},
|
||||||
|
"tldts-core@7.4.9": {
|
||||||
|
"tldts-core": "private"
|
||||||
|
},
|
||||||
|
"punycode@2.3.1": {
|
||||||
|
"punycode": "private"
|
||||||
|
},
|
||||||
|
"@jridgewell/resolve-uri@3.1.2": {
|
||||||
|
"@jridgewell/resolve-uri": "private"
|
||||||
|
},
|
||||||
|
"cross-spawn@7.0.6": {
|
||||||
|
"cross-spawn": "private"
|
||||||
|
},
|
||||||
|
"signal-exit@4.1.0": {
|
||||||
|
"signal-exit": "private"
|
||||||
|
},
|
||||||
|
"call-bind-apply-helpers@1.0.2": {
|
||||||
|
"call-bind-apply-helpers": "private"
|
||||||
|
},
|
||||||
|
"es-define-property@1.0.1": {
|
||||||
|
"es-define-property": "private"
|
||||||
|
},
|
||||||
|
"es-object-atoms@1.1.2": {
|
||||||
|
"es-object-atoms": "private"
|
||||||
|
},
|
||||||
|
"get-proto@1.0.1": {
|
||||||
|
"get-proto": "private"
|
||||||
|
},
|
||||||
|
"gopd@1.2.0": {
|
||||||
|
"gopd": "private"
|
||||||
|
},
|
||||||
|
"has-symbols@1.1.0": {
|
||||||
|
"has-symbols": "private"
|
||||||
|
},
|
||||||
|
"math-intrinsics@1.1.0": {
|
||||||
|
"math-intrinsics": "private"
|
||||||
|
},
|
||||||
|
"@isaacs/cliui@8.0.2": {
|
||||||
|
"@isaacs/cliui": "private"
|
||||||
|
},
|
||||||
|
"@pkgjs/parseargs@0.11.0": {
|
||||||
|
"@pkgjs/parseargs": "private"
|
||||||
|
},
|
||||||
|
"brace-expansion@2.1.2": {
|
||||||
|
"brace-expansion": "private"
|
||||||
|
},
|
||||||
|
"string-width@5.1.2": {
|
||||||
|
"string-width": "private"
|
||||||
|
},
|
||||||
|
"string-width@4.2.3": {
|
||||||
|
"string-width-cjs": "private"
|
||||||
|
},
|
||||||
|
"strip-ansi@7.2.0": {
|
||||||
|
"strip-ansi": "private"
|
||||||
|
},
|
||||||
|
"strip-ansi@6.0.1": {
|
||||||
|
"strip-ansi-cjs": "private"
|
||||||
|
},
|
||||||
|
"wrap-ansi@8.1.0": {
|
||||||
|
"wrap-ansi": "private"
|
||||||
|
},
|
||||||
|
"wrap-ansi@7.0.0": {
|
||||||
|
"wrap-ansi-cjs": "private"
|
||||||
|
},
|
||||||
|
"balanced-match@1.0.2": {
|
||||||
|
"balanced-match": "private"
|
||||||
|
},
|
||||||
|
"path-key@3.1.1": {
|
||||||
|
"path-key": "private"
|
||||||
|
},
|
||||||
|
"shebang-command@2.0.0": {
|
||||||
|
"shebang-command": "private"
|
||||||
|
},
|
||||||
|
"which@2.0.2": {
|
||||||
|
"which": "private"
|
||||||
|
},
|
||||||
|
"dunder-proto@1.0.1": {
|
||||||
|
"dunder-proto": "private"
|
||||||
|
},
|
||||||
|
"shebang-regex@3.0.0": {
|
||||||
|
"shebang-regex": "private"
|
||||||
|
},
|
||||||
|
"emoji-regex@8.0.0": {
|
||||||
|
"emoji-regex": "private"
|
||||||
|
},
|
||||||
|
"is-fullwidth-code-point@3.0.0": {
|
||||||
|
"is-fullwidth-code-point": "private"
|
||||||
|
},
|
||||||
|
"eastasianwidth@0.2.0": {
|
||||||
|
"eastasianwidth": "private"
|
||||||
|
},
|
||||||
|
"ansi-regex@5.0.1": {
|
||||||
|
"ansi-regex": "private"
|
||||||
|
},
|
||||||
|
"isexe@2.0.0": {
|
||||||
|
"isexe": "private"
|
||||||
|
},
|
||||||
|
"ansi-styles@4.3.0": {
|
||||||
|
"ansi-styles": "private"
|
||||||
|
},
|
||||||
|
"color-convert@2.0.1": {
|
||||||
|
"color-convert": "private"
|
||||||
|
},
|
||||||
|
"color-name@1.1.4": {
|
||||||
|
"color-name": "private"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hoistPattern": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"included": {
|
||||||
|
"dependencies": true,
|
||||||
|
"devDependencies": true,
|
||||||
|
"optionalDependencies": true
|
||||||
|
},
|
||||||
|
"injectedDeps": {},
|
||||||
|
"layoutVersion": 5,
|
||||||
|
"nodeLinker": "isolated",
|
||||||
|
"packageManager": "pnpm@11.17.0",
|
||||||
|
"pendingBuilds": [],
|
||||||
|
"publicHoistPattern": [],
|
||||||
|
"prunedAt": "Thu, 20 Aug 2026 06:47:32 GMT",
|
||||||
|
"registries": {
|
||||||
|
"default": "https://registry.npmjs.org/",
|
||||||
|
"@jsr": "https://npm.jsr.io/"
|
||||||
|
},
|
||||||
|
"skipped": [
|
||||||
|
"@emnapi/core@1.11.1",
|
||||||
|
"@emnapi/runtime@1.11.1",
|
||||||
|
"@emnapi/wasi-threads@1.2.2",
|
||||||
|
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)",
|
||||||
|
"@rolldown/binding-android-arm64@1.1.5",
|
||||||
|
"@rolldown/binding-darwin-arm64@1.1.5",
|
||||||
|
"@rolldown/binding-darwin-x64@1.1.5",
|
||||||
|
"@rolldown/binding-freebsd-x64@1.1.5",
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf@1.1.5",
|
||||||
|
"@rolldown/binding-linux-arm64-gnu@1.1.5",
|
||||||
|
"@rolldown/binding-linux-arm64-musl@1.1.5",
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu@1.1.5",
|
||||||
|
"@rolldown/binding-linux-s390x-gnu@1.1.5",
|
||||||
|
"@rolldown/binding-linux-x64-gnu@1.1.5",
|
||||||
|
"@rolldown/binding-linux-x64-musl@1.1.5",
|
||||||
|
"@rolldown/binding-openharmony-arm64@1.1.5",
|
||||||
|
"@rolldown/binding-wasm32-wasi@1.1.5",
|
||||||
|
"@rolldown/binding-win32-arm64-msvc@1.1.5",
|
||||||
|
"@tybys/wasm-util@0.10.3",
|
||||||
|
"fsevents@2.3.3",
|
||||||
|
"lightningcss-android-arm64@1.33.0",
|
||||||
|
"lightningcss-darwin-arm64@1.33.0",
|
||||||
|
"lightningcss-darwin-x64@1.33.0",
|
||||||
|
"lightningcss-freebsd-x64@1.33.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf@1.33.0",
|
||||||
|
"lightningcss-linux-arm64-gnu@1.33.0",
|
||||||
|
"lightningcss-linux-arm64-musl@1.33.0",
|
||||||
|
"lightningcss-linux-x64-gnu@1.33.0",
|
||||||
|
"lightningcss-linux-x64-musl@1.33.0",
|
||||||
|
"lightningcss-win32-arm64-msvc@1.33.0",
|
||||||
|
"tslib@2.8.1"
|
||||||
|
],
|
||||||
|
"storeDir": "C:\\Users\\BY-OY\\AppData\\Local\\pnpm\\store\\v11",
|
||||||
|
"virtualStoreDir": "C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global-dev\\apps\\admin-web\\node_modules\\.pnpm",
|
||||||
|
"virtualStoreDirMaxLength": 60
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"lastValidatedTimestamp": 1787208452958,
|
||||||
|
"projects": {},
|
||||||
|
"pnpmfiles": [],
|
||||||
|
"settings": {
|
||||||
|
"autoInstallPeers": true,
|
||||||
|
"dedupeDirectDeps": false,
|
||||||
|
"dedupeInjectedDeps": true,
|
||||||
|
"dedupePeerDependents": true,
|
||||||
|
"dedupePeers": false,
|
||||||
|
"dev": true,
|
||||||
|
"excludeLinksFromLockfile": false,
|
||||||
|
"hoistPattern": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"hoistWorkspacePackages": true,
|
||||||
|
"injectWorkspacePackages": false,
|
||||||
|
"linkWorkspacePackages": false,
|
||||||
|
"minimumReleaseAge": 1440,
|
||||||
|
"minimumReleaseAgeIgnoreMissingTime": true,
|
||||||
|
"nodeLinker": "isolated",
|
||||||
|
"optional": true,
|
||||||
|
"peersSuffixMaxLength": 1000,
|
||||||
|
"preferWorkspacePackages": false,
|
||||||
|
"production": true,
|
||||||
|
"publicHoistPattern": []
|
||||||
|
},
|
||||||
|
"filteredInstall": false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs",
|
||||||
"preview": "vite preview --port 4173"
|
"preview": "vite preview --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (result.error) throw result.error
|
||||||
|
process.exit(result.status ?? 1)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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.match(runner, /shell: process\.platform === 'win32'/)
|
||||||
|
assert.match(runner, /if \(result\.error\) throw result\.error/)
|
||||||
|
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/)
|
||||||
|
})
|
||||||
@@ -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<string, unknown> = {}
|
||||||
|
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<string, unknown>).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<unknown>) {
|
||||||
|
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<typeof mount>[0], method: string, path: string, props?: Record<string, unknown>) {
|
||||||
|
const start = traces.length
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '<div />' }) }] })
|
||||||
|
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}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
|
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
|
||||||
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
|
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
|
||||||
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.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', () => {
|
describe('admin nginx deployment config', () => {
|
||||||
it('allows qualification uploads with multipart overhead through the API proxy', () => {
|
it('allows qualification uploads with multipart overhead through the API proxy', () => {
|
||||||
@@ -37,4 +39,24 @@ describe('admin nginx deployment config', () => {
|
|||||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
||||||
expect(developmentCompose).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("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/)
|
||||||
|
})
|
||||||
|
|
||||||
|
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/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"')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(wrapper.text()).not.toContain('服装')
|
expect(wrapper.text()).not.toContain('服装')
|
||||||
expect(wrapper.text()).not.toContain('鞋类')
|
expect(wrapper.text()).not.toContain('鞋类')
|
||||||
expect(wrapper.text()).not.toContain('SKU-1')
|
expect(wrapper.text()).not.toContain('SKU-1')
|
||||||
expect(wrapper.text()).not.toContain('IN-1')
|
expect(wrapper.text()).toContain('IN-1')
|
||||||
expect(wrapper.text()).toContain('IN-2')
|
expect(wrapper.text()).toContain('IN-2')
|
||||||
expect(wrapper.text()).not.toContain('ORD-1')
|
expect(wrapper.text()).not.toContain('ORD-1')
|
||||||
expect(wrapper.text()).not.toContain('ORD-2')
|
expect(wrapper.text()).not.toContain('ORD-2')
|
||||||
@@ -378,6 +378,34 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
|
expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows one row per inbound code and removes all internal specification rows', async () => {
|
||||||
|
const secondDetail = {
|
||||||
|
...plan.items[0], id: '102', inboundDetailId: '4', skuCode: 'SKU-2', productNameZh: '鞋类',
|
||||||
|
packageCount: 4, grossWeightKg: 40, volumeCbm: 0.8, loadSequence: 2,
|
||||||
|
}
|
||||||
|
const groupedPlan = { ...plan, items: [plan.items[0], secondDetail] }
|
||||||
|
const afterFirst = { ...plan, version: 3, items: [secondDetail] }
|
||||||
|
const emptyPlan = { ...plan, version: 4, items: [] }
|
||||||
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(groupedPlan).mockResolvedValue(emptyPlan)
|
||||||
|
vi.mocked(removeSmartLoadingItem).mockResolvedValueOnce(afterFirst).mockResolvedValueOnce(emptyPlan)
|
||||||
|
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
|
||||||
|
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const inboundRows = wrapper.findAll('tr').filter((row) => row.text().includes('IN-1'))
|
||||||
|
expect(inboundRows).toHaveLength(1)
|
||||||
|
expect(inboundRows[0].text()).toContain('多规格;改箱数请移除后重加')
|
||||||
|
expect(inboundRows[0].text()).toContain('100 kg')
|
||||||
|
expect(inboundRows[0].text()).toContain('2 m³')
|
||||||
|
|
||||||
|
await inboundRows[0].find('.el-button--danger').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
|
||||||
|
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondDetail.id, 3, 1)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses item capabilities to protect picked quantities and removal', async () => {
|
it('uses item capabilities to protect picked quantities and removal', async () => {
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||||
...plan,
|
...plan,
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ContainerBookingResource,
|
ContainerBookingResource,
|
||||||
SmartLoadingCandidate,
|
SmartLoadingCandidate,
|
||||||
SmartLoadingItem,
|
|
||||||
SmartLoadingPlan,
|
SmartLoadingPlan,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ const editable = computed(() => plan.value?.canEdit === true)
|
|||||||
const assignable = computed(() => plan.value?.canAssign === true)
|
const assignable = computed(() => plan.value?.canAssign === true)
|
||||||
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
||||||
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
||||||
|
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
|
||||||
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||||
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
||||||
@@ -127,6 +128,7 @@ const candidateColumns: DataTableColumn[] = [
|
|||||||
]
|
]
|
||||||
const itemColumns: DataTableColumn[] = [
|
const itemColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||||
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||||
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
||||||
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
|
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
|
||||||
@@ -138,7 +140,7 @@ function formatNumber(value: number, digits = 3) {
|
|||||||
}
|
}
|
||||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
||||||
function itemRow(source: object) { return source as SmartLoadingItem }
|
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
||||||
function adjustmentMax(source: object) {
|
function adjustmentMax(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
|
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
|
||||||
@@ -159,7 +161,7 @@ function displayActualContainerNo(container: ContainerBookingResource | null | u
|
|||||||
}
|
}
|
||||||
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
|
||||||
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
||||||
for (const item of next.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
for (const item of groupSmartLoadingItems(next.items || [])) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
||||||
}
|
}
|
||||||
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
|
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
|
||||||
plan.value = next
|
plan.value = next
|
||||||
@@ -328,34 +330,42 @@ async function adjust(source: object) {
|
|||||||
async function remove(source: object) {
|
async function remove(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value || !item.canRemove) return
|
if (!plan.value || !item.canRemove) return
|
||||||
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.customerName || '当前客户'}”的 ${item.packageCount} 箱明细吗?`, '移除装柜明细', {
|
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', {
|
||||||
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
||||||
}).catch(() => false)
|
}).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
|
let current = plan.value!
|
||||||
|
for (const original of item.sourceItems) {
|
||||||
|
const currentItem = current.items.find((value) => value.id === original.id)
|
||||||
|
if (!currentItem) continue
|
||||||
|
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
|
||||||
|
acceptPlan(current)
|
||||||
|
}
|
||||||
await loadSelectedData()
|
await loadSelectedData()
|
||||||
ElMessage.success('装柜明细已移除')
|
ElMessage.success('该入仓码已从当前柜次移除')
|
||||||
}, '移除装柜明细失败', { pickingConflict: true })
|
}, '移除装柜明细失败', { pickingConflict: true })
|
||||||
}
|
}
|
||||||
async function removeAll() {
|
async function removeAll() {
|
||||||
const removableItems = plan.value?.items.filter((item) => item.canRemove) || []
|
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
|
||||||
if (!removableItems.length) return
|
if (!removableItems.length) return
|
||||||
const itemCount = removableItems.length
|
const itemCount = removableItems.length
|
||||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 条未拣明细吗?已拣明细会保留。`, '移除未拣明细', {
|
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
|
||||||
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
||||||
}).catch(() => false)
|
}).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
let current = plan.value!
|
let current = plan.value!
|
||||||
for (const original of removableItems) {
|
for (const inboundItem of removableItems) {
|
||||||
const item = current.items.find((value) => value.id === original.id)
|
for (const original of inboundItem.sourceItems) {
|
||||||
if (!item) continue
|
const item = current.items.find((value) => value.id === original.id)
|
||||||
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
|
if (!item) continue
|
||||||
acceptPlan(current)
|
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
|
||||||
|
acceptPlan(current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await loadSelectedData()
|
await loadSelectedData()
|
||||||
ElMessage.success(`已移除 ${itemCount} 条未拣明细`)
|
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
|
||||||
}, '全部移除失败', { pickingConflict: true })
|
}, '全部移除失败', { pickingConflict: true })
|
||||||
}
|
}
|
||||||
async function pushPreload() {
|
async function pushPreload() {
|
||||||
@@ -528,8 +538,8 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
|
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
|
||||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
||||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
|
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
|
||||||
recommendSmartLoading, removeSmartLoadingItem,
|
recommendSmartLoading, removeSmartLoadingItem,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingItem, SmartLoadingPlan } from '../types'
|
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
|
||||||
|
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -42,6 +43,7 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
|
|||||||
const editable = computed(() => plan.value?.canEdit === true)
|
const editable = computed(() => plan.value?.canEdit === true)
|
||||||
const assignable = computed(() => plan.value?.canAssign === true)
|
const assignable = computed(() => plan.value?.canAssign === true)
|
||||||
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
|
||||||
|
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
|
||||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||||
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
||||||
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
|
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
|
||||||
@@ -75,7 +77,7 @@ const candidateColumns: DataTableColumn[] = [
|
|||||||
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
|
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
|
||||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
||||||
function itemRow(source: object) { return source as SmartLoadingItem }
|
function itemRow(source: object) { return source as SmartLoadingInboundItem }
|
||||||
function explainCandidate(source: object) {
|
function explainCandidate(source: object) {
|
||||||
const candidate = candidateRow(source)
|
const candidate = candidateRow(source)
|
||||||
const volume = Number(candidate.averageUnitVolumeCbm || 0)
|
const volume = Number(candidate.averageUnitVolumeCbm || 0)
|
||||||
@@ -95,7 +97,7 @@ function displayContainerNo(value: ContainerBookingResource | null | undefined)
|
|||||||
}
|
}
|
||||||
function syncDrafts(preserved: Record<string, number> = {}) {
|
function syncDrafts(preserved: Record<string, number> = {}) {
|
||||||
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
|
||||||
for (const item of plan.value?.items || []) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
||||||
}
|
}
|
||||||
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
|
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
|
||||||
|
|
||||||
@@ -178,11 +180,21 @@ async function adjust(source: object) {
|
|||||||
async function remove(source: object) {
|
async function remove(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value || !item.canRemove) return
|
if (!plan.value || !item.canRemove) return
|
||||||
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
await ElMessageBox.confirm(`确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
|
try {
|
||||||
|
let current = plan.value!
|
||||||
|
for (const original of item.sourceItems) {
|
||||||
|
const currentItem = current.items.find((value) => value.id === original.id)
|
||||||
|
if (!currentItem) continue
|
||||||
|
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
|
||||||
|
acceptPlan(current)
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
ElMessage.success('该入仓码已从装柜方案移除')
|
||||||
|
}
|
||||||
catch (error) { await handleMutationError(error, '移除失败', true) }
|
catch (error) { await handleMutationError(error, '移除失败', true) }
|
||||||
finally { mutating.value = false }
|
finally { mutating.value = false }
|
||||||
})
|
})
|
||||||
@@ -251,9 +263,9 @@ onMounted(load)
|
|||||||
<template v-else-if="plan">
|
<template v-else-if="plan">
|
||||||
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
||||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { SmartLoadingItem } from './types'
|
||||||
|
import { groupSmartLoadingItems } from './smartLoadingItems'
|
||||||
|
|
||||||
|
function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
|
||||||
|
return {
|
||||||
|
id: '1', loadingPlanId: '10', accountId: '20', inboundId: '30', inboundDetailId: '40',
|
||||||
|
orderId: '50', orderNo: 'FL-1', inboundCode: 'NYEXX2608180003', customerName: '柏悦物流',
|
||||||
|
destinationWarehouseId: '60', destinationWarehouseCode: 'LAX', destinationWarehouseName: '洛杉矶仓',
|
||||||
|
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 2, productQuantity: 20,
|
||||||
|
grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1,
|
||||||
|
pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupSmartLoadingItems', () => {
|
||||||
|
it('shows multiple specification rows as one inbound-code row with summed quantities', () => {
|
||||||
|
const grouped = groupSmartLoadingItems([
|
||||||
|
item(),
|
||||||
|
item({ id: '2', inboundDetailId: '41', skuCode: 'SKU-2', productNameZh: '货物二', packageCount: 2, grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 3 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(grouped).toHaveLength(1)
|
||||||
|
expect(grouped[0]).toMatchObject({
|
||||||
|
inboundCode: 'NYEXX2608180003', packageCount: 4, grossWeightKg: 1325.6,
|
||||||
|
volumeCbm: 0.668, detailCount: 2, canAdjust: false, canRemove: true,
|
||||||
|
skuCode: 'SKU-1、SKU-2', productNameZh: '货物一、货物二', loadSequence: 2,
|
||||||
|
})
|
||||||
|
expect(grouped[0].sourceItems.map((source) => source.id)).toEqual(['1', '2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not merge different inbound codes and keeps single-detail adjustment', () => {
|
||||||
|
const grouped = groupSmartLoadingItems([
|
||||||
|
item(),
|
||||||
|
item({ id: '2', inboundId: '31', inboundCode: 'NYEXX2608180004' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(grouped).toHaveLength(2)
|
||||||
|
expect(grouped.every((row) => row.detailCount === 1 && row.canAdjust)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { SmartLoadingItem } from './types'
|
||||||
|
|
||||||
|
export interface SmartLoadingInboundItem extends SmartLoadingItem {
|
||||||
|
sourceItems: SmartLoadingItem[]
|
||||||
|
detailCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumNullable(items: SmartLoadingItem[], value: (item: SmartLoadingItem) => number | null) {
|
||||||
|
const values = items.map(value).filter((item): item is number => item != null)
|
||||||
|
return values.length ? values.reduce((sum, item) => sum + Number(item || 0), 0) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem) => string | null) {
|
||||||
|
const values = [...new Set(items.map(value).map((item) => String(item || '').trim()).filter(Boolean))]
|
||||||
|
return values.length ? values.join('、') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端按规格保存装柜明细以保证重量、体积计算准确;作业页面按入仓码汇总展示。
|
||||||
|
*/
|
||||||
|
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
|
||||||
|
const groups = new Map<string, SmartLoadingItem[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.accountId}:${item.inboundId}`
|
||||||
|
groups.set(key, [...(groups.get(key) || []), item])
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()].map((sourceItems) => {
|
||||||
|
const first = sourceItems[0]
|
||||||
|
const pickedPackageCount = sourceItems.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0)
|
||||||
|
const loadSequences = sourceItems.map((item) => item.loadSequence).filter((item): item is number => item != null)
|
||||||
|
return {
|
||||||
|
...first,
|
||||||
|
id: sourceItems.length === 1 ? first.id : `inbound:${first.accountId}:${first.inboundId}`,
|
||||||
|
inboundDetailId: sourceItems.length === 1 ? first.inboundDetailId : '',
|
||||||
|
skuCode: joinDistinct(sourceItems, (item) => item.skuCode),
|
||||||
|
productNameZh: joinDistinct(sourceItems, (item) => item.productNameZh),
|
||||||
|
packageCount: sourceItems.reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
|
||||||
|
productQuantity: sumNullable(sourceItems, (item) => item.productQuantity),
|
||||||
|
grossWeightKg: sumNullable(sourceItems, (item) => item.grossWeightKg),
|
||||||
|
volumeCbm: sumNullable(sourceItems, (item) => item.volumeCbm),
|
||||||
|
loadSequence: loadSequences.length ? Math.min(...loadSequences) : null,
|
||||||
|
pickedPackageCount,
|
||||||
|
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
|
||||||
|
canAdjust: sourceItems.length === 1 && first.canAdjust,
|
||||||
|
canRemove: sourceItems.every((item) => item.canRemove),
|
||||||
|
sourceItems,
|
||||||
|
detailCount: sourceItems.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import {
|
import {
|
||||||
approveEnterpriseReview,
|
approveEnterpriseReview,
|
||||||
archivePlatformTenant,
|
archivePlatformTenant,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
listPlatformRoles,
|
listPlatformRoles,
|
||||||
listPlatformInternalUsers,
|
listPlatformInternalUsers,
|
||||||
listPlatformManagedUsers,
|
listPlatformManagedUsers,
|
||||||
|
listSharedReceivingOverseasWarehouseOptions,
|
||||||
getPlatformUserOptions,
|
getPlatformUserOptions,
|
||||||
qualificationFileName,
|
qualificationFileName,
|
||||||
replacePlatformRolePermissions,
|
replacePlatformRolePermissions,
|
||||||
@@ -49,6 +51,10 @@ vi.mock('@/shared/api/http', () => ({
|
|||||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/api/overseasHttp', () => ({
|
||||||
|
overseasHttp: { get: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
const role: PlatformRoleResource = {
|
const role: PlatformRoleResource = {
|
||||||
id: 4000002,
|
id: 4000002,
|
||||||
tenantId: 0,
|
tenantId: 0,
|
||||||
@@ -108,6 +114,7 @@ const managedUser: PlatformManagedUser = {
|
|||||||
roleNames: ['运营管理员'],
|
roleNames: ['运营管理员'],
|
||||||
lastLoginAt: '2026-06-01T10:30:00',
|
lastLoginAt: '2026-06-01T10:30:00',
|
||||||
protectedAccount: false,
|
protectedAccount: false,
|
||||||
|
overseasWarehouseId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('platform IAM roles', () => {
|
describe('platform IAM roles', () => {
|
||||||
@@ -116,6 +123,7 @@ describe('platform IAM roles', () => {
|
|||||||
vi.mocked(http.post).mockReset()
|
vi.mocked(http.post).mockReset()
|
||||||
vi.mocked(http.put).mockReset()
|
vi.mocked(http.put).mockReset()
|
||||||
vi.mocked(http.delete).mockReset()
|
vi.mocked(http.delete).mockReset()
|
||||||
|
vi.mocked(overseasHttp.get).mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads roles from the platform IAM prefix', async () => {
|
it('loads roles from the platform IAM prefix', async () => {
|
||||||
@@ -212,15 +220,15 @@ describe('platform IAM roles', () => {
|
|||||||
it('maintains paginated platform users through the unified user contract', async () => {
|
it('maintains paginated platform users through the unified user contract', async () => {
|
||||||
const userOptions = {
|
const userOptions = {
|
||||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
roles: [{ id: '40001', tenantId: '0', name: '运营管理员', status: 'ACTIVE' }],
|
roles: [{ id: '40001', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' }],
|
||||||
}
|
}
|
||||||
const create = {
|
const create = {
|
||||||
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
||||||
tenantId: '0', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
|
tenantId: '0', roleIds: ['40001'], overseasWarehouseId: null, password: 'Password1', status: 'ACTIVE' as const,
|
||||||
}
|
}
|
||||||
const update = {
|
const update = {
|
||||||
displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
||||||
tenantId: '0', roleIds: ['40001'],
|
tenantId: '0', roleIds: ['40001'], overseasWarehouseId: null,
|
||||||
}
|
}
|
||||||
const userPage = { records: [managedUser], page: 2, size: 10, total: 11, pages: 2 }
|
const userPage = { records: [managedUser], page: 2, size: 10, total: 11, pages: 2 }
|
||||||
vi.mocked(http.get).mockResolvedValueOnce({
|
vi.mocked(http.get).mockResolvedValueOnce({
|
||||||
@@ -247,6 +255,25 @@ describe('platform IAM roles', () => {
|
|||||||
expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' })
|
expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads only active shared receiving overseas warehouses through the overseas gateway', async () => {
|
||||||
|
vi.mocked(overseasHttp.get).mockResolvedValue({
|
||||||
|
code: '200', message: 'success', data: {
|
||||||
|
records: [
|
||||||
|
{ id: '90071992547409931', customerId: '0', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
|
||||||
|
{ id: '2', customerId: '100', warehouseCode: 'CUST', warehouseName: '客户仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
|
||||||
|
{ id: '3', customerId: '0', warehouseCode: 'PARTNER', warehouseName: '合作仓', warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', status: 'ACTIVE' },
|
||||||
|
], page: 1, size: 200, total: 3, pages: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(listSharedReceivingOverseasWarehouseOptions()).resolves.toEqual([
|
||||||
|
{ id: '90071992547409931', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', countryCode: 'US' },
|
||||||
|
])
|
||||||
|
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||||
|
params: { customerId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', status: 'ACTIVE' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('loads qualifications for the selected tenant', async () => {
|
it('loads qualifications for the selected tenant', async () => {
|
||||||
const qualification = {
|
const qualification = {
|
||||||
id: 12,
|
id: 12,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||||
|
|
||||||
export type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
export type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||||
@@ -253,6 +254,7 @@ export interface PlatformManagedUser {
|
|||||||
roleNames: string[]
|
roleNames: string[]
|
||||||
lastLoginAt: string | null
|
lastLoginAt: string | null
|
||||||
protectedAccount: boolean
|
protectedAccount: boolean
|
||||||
|
overseasWarehouseId: number | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformManagedUserPage {
|
export interface PlatformManagedUserPage {
|
||||||
@@ -272,7 +274,14 @@ export interface PlatformManagedUserQuery {
|
|||||||
|
|
||||||
export interface PlatformUserOptions {
|
export interface PlatformUserOptions {
|
||||||
tenants: Array<{ id: number | string; name: string; status: string }>
|
tenants: Array<{ id: number | string; name: string; status: string }>
|
||||||
roles: Array<{ id: number | string; tenantId: number | string; name: string; status: string }>
|
roles: Array<{ id: number | string; tenantId: number | string; roleCode: string; name: string; status: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedReceivingOverseasWarehouseOption {
|
||||||
|
id: string
|
||||||
|
warehouseCode: string
|
||||||
|
warehouseName: string
|
||||||
|
countryCode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePlatformManagedUserRequest {
|
export interface CreatePlatformManagedUserRequest {
|
||||||
@@ -282,6 +291,7 @@ export interface CreatePlatformManagedUserRequest {
|
|||||||
email: string
|
email: string
|
||||||
tenantId: number | string
|
tenantId: number | string
|
||||||
roleIds: Array<number | string>
|
roleIds: Array<number | string>
|
||||||
|
overseasWarehouseId: number | string | null
|
||||||
password: string
|
password: string
|
||||||
status: 'ACTIVE' | 'DISABLED'
|
status: 'ACTIVE' | 'DISABLED'
|
||||||
}
|
}
|
||||||
@@ -292,6 +302,7 @@ export interface UpdatePlatformManagedUserRequest {
|
|||||||
email: string
|
email: string
|
||||||
tenantId: number | string
|
tenantId: number | string
|
||||||
roleIds: Array<number | string>
|
roleIds: Array<number | string>
|
||||||
|
overseasWarehouseId: number | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getData<T>(path: string): Promise<T> {
|
async function getData<T>(path: string): Promise<T> {
|
||||||
@@ -558,6 +569,34 @@ export function getPlatformUserOptions() {
|
|||||||
return getData<PlatformUserOptions>('/platform/v1/iam/users/options')
|
return getData<PlatformUserOptions>('/platform/v1/iam/users/options')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listSharedReceivingOverseasWarehouseOptions() {
|
||||||
|
interface WarehousePage {
|
||||||
|
records: Array<{
|
||||||
|
id: number | string
|
||||||
|
customerId: number | string
|
||||||
|
warehouseCode: string
|
||||||
|
warehouseName: string
|
||||||
|
warehouseType: string
|
||||||
|
countryCode: string
|
||||||
|
status: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const response = await overseasHttp.get<ApiEnvelope<WarehousePage>, ApiEnvelope<WarehousePage>>(
|
||||||
|
'/platform/v1/warehouse-admin',
|
||||||
|
{ params: { customerId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', status: 'ACTIVE' } },
|
||||||
|
)
|
||||||
|
return unwrapApiResponse(response).records
|
||||||
|
.filter((warehouse) => String(warehouse.customerId) === '0'
|
||||||
|
&& warehouse.warehouseType === 'OVERSEAS'
|
||||||
|
&& warehouse.status === 'ACTIVE')
|
||||||
|
.map((warehouse) => ({
|
||||||
|
id: String(warehouse.id),
|
||||||
|
warehouseCode: warehouse.warehouseCode,
|
||||||
|
warehouseName: warehouse.warehouseName,
|
||||||
|
countryCode: warehouse.countryCode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function createPlatformManagedUser(payload: CreatePlatformManagedUserRequest) {
|
export function createPlatformManagedUser(payload: CreatePlatformManagedUserRequest) {
|
||||||
return postData<PlatformManagedUser>('/platform/v1/iam/users', payload)
|
return postData<PlatformManagedUser>('/platform/v1/iam/users', payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { createPinia, setActivePinia } from 'pinia'
|
|||||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
createPlatformManagedUser,
|
||||||
getPlatformUserOptions,
|
getPlatformUserOptions,
|
||||||
|
listSharedReceivingOverseasWarehouseOptions,
|
||||||
listPlatformManagedUsers,
|
listPlatformManagedUsers,
|
||||||
|
updatePlatformManagedUser,
|
||||||
type PlatformManagedUser,
|
type PlatformManagedUser,
|
||||||
} from '@/modules/iam/api/platformIam'
|
} from '@/modules/iam/api/platformIam'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
@@ -18,6 +21,7 @@ const internalUsersSource = readFileSync(resolve(process.cwd(), 'src/modules/iam
|
|||||||
vi.mock('@/modules/iam/api/platformIam', () => ({
|
vi.mock('@/modules/iam/api/platformIam', () => ({
|
||||||
listPlatformManagedUsers: vi.fn(),
|
listPlatformManagedUsers: vi.fn(),
|
||||||
getPlatformUserOptions: vi.fn(),
|
getPlatformUserOptions: vi.fn(),
|
||||||
|
listSharedReceivingOverseasWarehouseOptions: vi.fn(),
|
||||||
createPlatformManagedUser: vi.fn(),
|
createPlatformManagedUser: vi.fn(),
|
||||||
updatePlatformManagedUser: vi.fn(),
|
updatePlatformManagedUser: vi.fn(),
|
||||||
changePlatformManagedUserStatus: vi.fn(),
|
changePlatformManagedUserStatus: vi.fn(),
|
||||||
@@ -48,6 +52,7 @@ const user: PlatformManagedUser = {
|
|||||||
memberType: 'INTERNAL', memberNo: 'PLATFORM-30', status: 'ACTIVE', roleIds: ['40'],
|
memberType: 'INTERNAL', memberNo: 'PLATFORM-30', status: 'ACTIVE', roleIds: ['40'],
|
||||||
roleNames: ['运营管理员'], lastLoginAt: '2026-06-01T10:30:00',
|
roleNames: ['运营管理员'], lastLoginAt: '2026-06-01T10:30:00',
|
||||||
protectedAccount: false,
|
protectedAccount: false,
|
||||||
|
overseasWarehouseId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mountView() {
|
async function mountView() {
|
||||||
@@ -101,10 +106,15 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
{ id: '10001', name: '华远贸易', status: 'ACTIVE' },
|
{ id: '10001', name: '华远贸易', status: 'ACTIVE' },
|
||||||
],
|
],
|
||||||
roles: [
|
roles: [
|
||||||
{ id: '40', tenantId: '0', name: '运营管理员', status: 'ACTIVE' },
|
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
||||||
{ id: '41', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' },
|
{ id: '41', tenantId: '10001', roleCode: 'CUSTOMER_ADMIN', name: '客户管理员', status: 'ACTIVE' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockReset().mockResolvedValue([
|
||||||
|
{ id: '90071992547409931', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', countryCode: 'US' },
|
||||||
|
])
|
||||||
|
vi.mocked(createPlatformManagedUser).mockReset().mockResolvedValue(user)
|
||||||
|
vi.mocked(updatePlatformManagedUser).mockReset().mockResolvedValue(user)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads a server page and only renders internal-user fields', async () => {
|
it('loads a server page and only renders internal-user fields', async () => {
|
||||||
@@ -143,6 +153,73 @@ describe('InternalUsersView platform internal user management', () => {
|
|||||||
expect(document.body.textContent).toContain('确认添加')
|
expect(document.body.textContent).toContain('确认添加')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires a shared receiving warehouse for WAREHOUSE_PICKER', async () => {
|
||||||
|
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||||
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
|
roles: [
|
||||||
|
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
||||||
|
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const wrapper = await mountView()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().trim() === '新增用户')?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain('实际海外收货仓')
|
||||||
|
const roleSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||||
|
.find((component) => component.props('placeholder') === '选择角色')
|
||||||
|
expect(roleSelect).toBeDefined()
|
||||||
|
roleSelect?.vm.$emit('update:modelValue', ['4000005'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledOnce()
|
||||||
|
expect(document.body.textContent).toContain('实际海外收货仓')
|
||||||
|
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||||
|
.find((component) => component.props('placeholder') === '请选择平台共享海外收货仓')
|
||||||
|
expect(warehouseSelect).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the stable role code instead of localized role names to activate the warehouse binding', () => {
|
||||||
|
expect(internalUsersSource).toContain("role.roleCode === 'WAREHOUSE_PICKER'")
|
||||||
|
expect(internalUsersSource).toContain('overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null')
|
||||||
|
expect(internalUsersSource).not.toContain("role.name === '仓库拣货员'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the picker warehouse binding when editing a WAREHOUSE_PICKER user', async () => {
|
||||||
|
const pickerUser: PlatformManagedUser = {
|
||||||
|
...user,
|
||||||
|
roleIds: ['4000005'],
|
||||||
|
roleNames: ['仓库拣货员'],
|
||||||
|
overseasWarehouseId: '90071992547409931',
|
||||||
|
}
|
||||||
|
vi.mocked(listPlatformManagedUsers).mockResolvedValueOnce({
|
||||||
|
records: [pickerUser], page: 1, size: 10, total: 1, pages: 1,
|
||||||
|
})
|
||||||
|
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||||
|
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||||
|
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
|
||||||
|
})
|
||||||
|
vi.mocked(updatePlatformManagedUser).mockResolvedValueOnce(pickerUser)
|
||||||
|
|
||||||
|
const wrapper = await mountView()
|
||||||
|
const setupState = (wrapper.vm.$ as unknown as {
|
||||||
|
setupState: { openEdit: (row: PlatformManagedUser) => void }
|
||||||
|
}).setupState
|
||||||
|
setupState.openEdit(pickerUser)
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().trim() === '保存修改')?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(updatePlatformManagedUser).toHaveBeenCalledWith('30', {
|
||||||
|
displayName: '张伟',
|
||||||
|
phone: '13800138000',
|
||||||
|
email: 'zhangwei@example.com',
|
||||||
|
tenantId: '0',
|
||||||
|
roleIds: ['4000005'],
|
||||||
|
overseasWarehouseId: '90071992547409931',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('requests server-side pages and filters instead of slicing locally', async () => {
|
it('requests server-side pages and filters instead of slicing locally', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await wrapper.find('button[aria-label="第 2 页"]').trigger('click')
|
await wrapper.find('button[aria-label="第 2 页"]').trigger('click')
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
changePlatformManagedUserStatus,
|
changePlatformManagedUserStatus,
|
||||||
createPlatformManagedUser,
|
createPlatformManagedUser,
|
||||||
getPlatformUserOptions,
|
getPlatformUserOptions,
|
||||||
|
listSharedReceivingOverseasWarehouseOptions,
|
||||||
listPlatformManagedUsers,
|
listPlatformManagedUsers,
|
||||||
resetPlatformManagedUserPassword,
|
resetPlatformManagedUserPassword,
|
||||||
updatePlatformManagedUser,
|
updatePlatformManagedUser,
|
||||||
type PlatformManagedUser,
|
type PlatformManagedUser,
|
||||||
type PlatformUserOptions,
|
type PlatformUserOptions,
|
||||||
|
type SharedReceivingOverseasWarehouseOption,
|
||||||
} from '@/modules/iam/api/platformIam'
|
} from '@/modules/iam/api/platformIam'
|
||||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
@@ -30,6 +32,9 @@ const access = useAccessPreviewStore()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const users = ref<PlatformManagedUser[]>([])
|
const users = ref<PlatformManagedUser[]>([])
|
||||||
const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
|
const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
|
||||||
|
const overseasWarehouses = ref<SharedReceivingOverseasWarehouseOption[]>([])
|
||||||
|
const overseasWarehousesLoading = ref(false)
|
||||||
|
const overseasWarehousesError = ref<ApiErrorDetails | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -38,6 +43,7 @@ const formRef = ref<FormInstance>()
|
|||||||
const editingUser = ref<PlatformManagedUser | null>(null)
|
const editingUser = ref<PlatformManagedUser | null>(null)
|
||||||
const rowActionId = ref('')
|
const rowActionId = ref('')
|
||||||
let loadRequestId = 0
|
let loadRequestId = 0
|
||||||
|
let warehouseRequestId = 0
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -72,6 +78,7 @@ const emptyForm = () => ({
|
|||||||
email: '',
|
email: '',
|
||||||
tenantId: '0',
|
tenantId: '0',
|
||||||
roleIds: [] as string[],
|
roleIds: [] as string[],
|
||||||
|
overseasWarehouseId: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
|
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
|
||||||
@@ -98,6 +105,10 @@ const availableRoles = computed(() => options.value.roles.filter((role) =>
|
|||||||
String(role.tenantId) === '0'
|
String(role.tenantId) === '0'
|
||||||
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
||||||
))
|
))
|
||||||
|
const warehousePickerRoleIds = computed(() => new Set(
|
||||||
|
availableRoles.value.filter((role) => role.roleCode === 'WAREHOUSE_PICKER').map((role) => String(role.id)),
|
||||||
|
))
|
||||||
|
const hasWarehousePickerRole = computed(() => form.roleIds.some((roleId) => warehousePickerRoleIds.value.has(roleId)))
|
||||||
|
|
||||||
const formRules = computed<FormRules>(() => ({
|
const formRules = computed<FormRules>(() => ({
|
||||||
username: editingUser.value ? [] : [
|
username: editingUser.value ? [] : [
|
||||||
@@ -111,6 +122,9 @@ const formRules = computed<FormRules>(() => ({
|
|||||||
],
|
],
|
||||||
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||||
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
||||||
|
overseasWarehouseId: !hasWarehousePickerRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
||||||
|
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
|
||||||
|
],
|
||||||
password: editingUser.value && !form.resetPassword ? [] : [
|
password: editingUser.value && !form.resetPassword ? [] : [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
|
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
|
||||||
@@ -200,6 +214,7 @@ function openEdit(row: PlatformManagedUser) {
|
|||||||
email: row.email ?? '',
|
email: row.email ?? '',
|
||||||
tenantId: '0',
|
tenantId: '0',
|
||||||
roleIds: row.roleIds.map(String),
|
roleIds: row.roleIds.map(String),
|
||||||
|
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -209,6 +224,23 @@ function openEdit(row: PlatformManagedUser) {
|
|||||||
nextTick(() => formRef.value?.clearValidate())
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOverseasWarehouses() {
|
||||||
|
const requestId = ++warehouseRequestId
|
||||||
|
overseasWarehousesLoading.value = true
|
||||||
|
overseasWarehousesError.value = null
|
||||||
|
try {
|
||||||
|
const records = await listSharedReceivingOverseasWarehouseOptions()
|
||||||
|
if (requestId !== warehouseRequestId) return
|
||||||
|
overseasWarehouses.value = records
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== warehouseRequestId) return
|
||||||
|
overseasWarehouses.value = []
|
||||||
|
overseasWarehousesError.value = getApiErrorDetails(error, '共享海外收货仓加载失败,请重试')
|
||||||
|
} finally {
|
||||||
|
if (requestId === warehouseRequestId) overseasWarehousesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isCurrentUser(row: PlatformManagedUser | null) {
|
function isCurrentUser(row: PlatformManagedUser | null) {
|
||||||
if (!row) return false
|
if (!row) return false
|
||||||
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
|
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
|
||||||
@@ -231,6 +263,7 @@ async function saveUser() {
|
|||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
tenantId: '0',
|
tenantId: '0',
|
||||||
roleIds: form.roleIds,
|
roleIds: form.roleIds,
|
||||||
|
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
})
|
})
|
||||||
@@ -244,6 +277,7 @@ async function saveUser() {
|
|||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
tenantId: '0',
|
tenantId: '0',
|
||||||
roleIds: form.roleIds,
|
roleIds: form.roleIds,
|
||||||
|
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (canChangeStatus.value && form.status !== row.status) {
|
if (canChangeStatus.value && form.status !== row.status) {
|
||||||
@@ -296,6 +330,14 @@ function applyFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch([page, pageSize], () => void loadUsers())
|
watch([page, pageSize], () => void loadUsers())
|
||||||
|
watch(hasWarehousePickerRole, (selected) => {
|
||||||
|
if (!selected) {
|
||||||
|
form.overseasWarehouseId = ''
|
||||||
|
overseasWarehousesError.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadOverseasWarehouses()
|
||||||
|
})
|
||||||
onMounted(loadInitialData)
|
onMounted(loadInitialData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -377,6 +419,20 @@ onMounted(loadInitialData)
|
|||||||
<el-option label="禁用" value="DISABLED" />
|
<el-option label="禁用" value="DISABLED" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="hasWarehousePickerRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
||||||
|
<el-select v-model="form.overseasWarehouseId" filterable
|
||||||
|
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||||
|
placeholder="请选择平台共享海外收货仓">
|
||||||
|
<el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id"
|
||||||
|
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`"
|
||||||
|
:value="warehouse.id" />
|
||||||
|
</el-select>
|
||||||
|
<p v-if="overseasWarehousesError" class="field-error">
|
||||||
|
{{ overseasWarehousesError.message }}
|
||||||
|
<el-button link type="primary" :loading="overseasWarehousesLoading" @click="loadOverseasWarehouses">重新加载</el-button>
|
||||||
|
</p>
|
||||||
|
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item v-if="editingUser" label="最后登录">
|
<el-form-item v-if="editingUser" label="最后登录">
|
||||||
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
|
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -401,6 +457,9 @@ onMounted(loadInitialData)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.password-fields { margin-top: 16px; }
|
.password-fields { margin-top: 16px; }
|
||||||
|
.field-help, .field-error { margin: 6px 0 0; font-size: 12px; line-height: 1.5; }
|
||||||
|
.field-help { color: var(--cc-text-secondary); }
|
||||||
|
.field-error { color: var(--el-color-danger); }
|
||||||
:global(.internal-user-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
:global(.internal-user-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||||
:global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
:global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||||
:global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
:global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
|
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
|
||||||
updateUsTruckOrder,
|
updateUsTruckOrder,
|
||||||
} from './platformOverseasBusiness'
|
} from './platformOverseasBusiness'
|
||||||
|
import type { UpdateLocalTransportOrderRequest } from '../types'
|
||||||
|
|
||||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||||
const purchaseSummary = {
|
const purchaseSummary = {
|
||||||
@@ -41,7 +42,8 @@ describe('platform overseas business API adapter', () => {
|
|||||||
expect(await listOverseasPurchaseCustomerOptions(' 客户 ', 50)).toEqual([{ customerId: '10001', customerName: '客户一' }])
|
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(overseas.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/overseas-procurements/customer-options', { params: { keyword: '客户', limit: 50 } })
|
||||||
expect((await listOverseasPurchaseWarehouseOptions())[0]?.warehouseId).toBe('9007199254740995')
|
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' })
|
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))
|
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 listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45')
|
||||||
expect((await getUsTruckOrder('TR-1', '10002')).pallets[0]?.warehousePalletId).toBe('9007199254741003')
|
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' }
|
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('10002', create)
|
overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('9007199254741999', create)
|
||||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '10002' } })
|
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '9007199254741999' } })
|
||||||
const { idempotencyKey: _truckKey, ...truckFields } = create
|
const { idempotencyKey: _truckKey, ...truckFields } = create
|
||||||
const update = { ...truckFields, version: 2 }
|
const update = { ...truckFields, version: 2 }
|
||||||
overseas.patch.mockResolvedValue(envelope(detail)); await updateUsTruckOrder('TR-1', '10002', update)
|
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: '10002' } })
|
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 () => {
|
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))
|
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 listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
|
||||||
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
|
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' }
|
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('10003', create)
|
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
|
||||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '10003' } })
|
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
|
||||||
const { idempotencyKey: _looseKey, ...looseboardFields } = create
|
const { idempotencyKey: _looseKey, ...looseboardFields } = create
|
||||||
const update = { ...looseboardFields, version: 0 }
|
const update = { ...looseboardFields, version: 0 }
|
||||||
overseas.put.mockResolvedValue(envelope(detail)); await updateLocalLooseboardOrder('LB-1', '10003', update)
|
overseas.put.mockResolvedValue(envelope(detail)); await updateLocalLooseboardOrder('LB/1', '9007199254742001', update)
|
||||||
expect(overseas.put).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders/LB-1', update, { params: { customerId: '10003' } })
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe('overseas business real contract pages', () => {
|
|||||||
expect(api).toContain('updateUsTruckOrder')
|
expect(api).toContain('updateUsTruckOrder')
|
||||||
expect(api).toContain('createLocalLooseboardOrder')
|
expect(api).toContain('createLocalLooseboardOrder')
|
||||||
expect(api).toContain('updateLocalLooseboardOrder')
|
expect(api).toContain('updateLocalLooseboardOrder')
|
||||||
|
expect(api).not.toContain('createOverseasPurchase')
|
||||||
expect(api).not.toMatch(/\/merchant\/|receive-pallets|\/status/)
|
expect(api).not.toMatch(/\/merchant\/|receive-pallets|\/status/)
|
||||||
expect(list).toContain('listLocalLooseboardOrders')
|
expect(list).toContain('listLocalLooseboardOrders')
|
||||||
expect(form).toContain('listPlatformTenants')
|
expect(form).toContain('listPlatformTenants')
|
||||||
@@ -46,5 +47,7 @@ describe('overseas business real contract pages', () => {
|
|||||||
expect(transportList).toContain("row.status === 'PENDING_REVIEW'")
|
expect(transportList).toContain("row.status === 'PENDING_REVIEW'")
|
||||||
expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
|
expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
|
||||||
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
|
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
|
||||||
|
expect(transportForm).toContain('idempotencyKey: form.idempotencyKey')
|
||||||
|
expect(transportForm.match(/idempotencyKey:\s*newIdempotencyKey\(\)/g)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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('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('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('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('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('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('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('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 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('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('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() })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ export interface CreateLocalTransportOrderRequest {
|
|||||||
budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string
|
budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string
|
||||||
remark?: string | null; idempotencyKey: string
|
remark?: string | null; idempotencyKey: string
|
||||||
}
|
}
|
||||||
export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransportOrderRequest, 'idempotencyKey'> { 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 LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||||
export type PalletRequired = 'YES' | 'NO'
|
export type PalletRequired = 'YES' | 'NO'
|
||||||
|
|||||||
@@ -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 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。
|
||||||
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
|
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
|
||||||
- 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。
|
- 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。
|
||||||
|
|
||||||
剩余后端契约缺口:
|
当前明确边界:
|
||||||
|
|
||||||
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
|
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
|
||||||
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
|
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
|
||||||
- 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。
|
- 权限资源由总控统一写入 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` 的业务错误,不保存为文件。
|
||||||
|
|||||||
@@ -6,15 +6,15 @@
|
|||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 |
|
| 合作仓 `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`,不跨区域查询报价候选 |
|
| 合作仓 | 新增/编辑 | `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,不跨区拼装候选 |
|
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14860-14894` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 |
|
||||||
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list |
|
| 海外仓报价 | 详情 | `10-general.html:208-216`、`app.js:14896-14933` | 居中弹框 `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:177-204`、`app.js:14935-15055` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 |
|
||||||
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
|
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:15056-15070` | 启停、删除为列表行操作;导出在工具栏 | 操作列第一列 | 已接真实启停与 CSV 导出;服务端无删除端点,未展示删除 | quote:status/export 独立校验;不推断 DELETE |
|
||||||
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
|
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
|
||||||
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
|
| 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
|
||||||
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
|
| 车辆 `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:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
|
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
|
||||||
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
|
| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
|
||||||
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
|
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE |
|
||||||
|
|
||||||
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
|
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
|
||||||
|
|||||||
@@ -1,57 +1,145 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import {
|
import {
|
||||||
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
||||||
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||||
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
||||||
|
type QuoteCreate, type QuoteUpdate,
|
||||||
} from './overseasMasterData'
|
} from './overseasMasterData'
|
||||||
|
|
||||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
|
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 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())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
describe('overseas master data API', () => {
|
describe('overseas master data API', () => {
|
||||||
it('uses the overseas quote contract including server-owned partner options', async () => {
|
it('normalizes quote page/detail decimals and preserves long string IDs', async () => {
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: '1', size: '20', total: '0', pages: '0' }))
|
vi.mocked(overseasHttp.get)
|
||||||
await listQuotes({ page: 1, size: 20, keyword: 'V1' })
|
.mockResolvedValueOnce(ok({ records: [quoteSummary], page: '1', size: '20', total: '1', pages: '1' }))
|
||||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } })
|
.mockResolvedValueOnce(ok(quoteDetail))
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok([])); await listQuoteWarehouseOptions()
|
.mockResolvedValueOnce(ok([{ warehouseId: quoteSummary.warehouseId, warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓', countryCode: 'US' }]))
|
||||||
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')
|
const result = await listQuotes({ page: 1, size: 20, keyword: 'V1' })
|
||||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9223372036854775807')
|
expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
|
||||||
const payload = { name: '报价', warehouseId: '9', providerName: '服务商', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', warehouseFees: [], transitPrices: [], remark: null }
|
expect(result.records[0]).toMatchObject({ id: longId, warehouseId: quoteSummary.warehouseId, recordVersion: 7 })
|
||||||
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createQuote(payload)
|
const detail = await getQuote(longId)
|
||||||
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateQuote('9', { ...payload, recordVersion: 2 })
|
expect(detail.summary.id).toBe(longId)
|
||||||
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeQuoteStatus('9', 'INACTIVE', 2)
|
expect(detail.summary.recordVersion).toBe(7)
|
||||||
expect(overseasHttp.patch).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9/status', { status: 'INACTIVE', recordVersion: 2 })
|
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 () => {
|
it('keeps quote create/update DTOs distinct and sends recordVersion on writes', 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' })
|
const create: QuoteCreate = {
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ latestMonth: '2026-08' })); await getExchangeRateSummary()
|
name: '报价', warehouseId: quoteSummary.warehouseId, providerName: '服务商', currency: 'USD',
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ month: '2026-08' })); await getExchangeRate('2026-08')
|
effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1',
|
||||||
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.18, remark: null })
|
warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }],
|
||||||
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.19, remark: null })
|
transitPrices: [], 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: '停用' })
|
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 () => {
|
it('normalizes exchange-rate decimal/version and uses month as the stable key', 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)
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ id: '9223372036854775807' })); await getVehicle('9223372036854775807')
|
.mockResolvedValueOnce(ok({ records: [exchangeRate], page: '1', size: '20', total: '1', pages: '1' }))
|
||||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9223372036854775807')
|
.mockResolvedValueOnce(ok(exchangeRate))
|
||||||
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 }
|
.mockResolvedValueOnce(ok({ latestMonth: '2026-08', latestUsdToCnyAverage: '7.12345678', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }))
|
||||||
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createVehicle(payload)
|
const page = await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' })
|
||||||
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateVehicle('9', { ...payload, recordVersion: 1 })
|
const detail = await getExchangeRate('2026-08')
|
||||||
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeVehicleStatus('9', 'INACTIVE', 1)
|
const summary = await getExchangeRateSummary()
|
||||||
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9/status', { status: 'INACTIVE', recordVersion: 1 })
|
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 () => {
|
it('normalizes vehicle decimals/version while preserving long string IDs', async () => {
|
||||||
const blob = new Blob(['csv']); vi.mocked(overseasHttp.get).mockResolvedValue(blob)
|
vi.mocked(overseasHttp.get)
|
||||||
await exportQuotes({ status: 'ACTIVE' }); await exportExchangeRates({}); await exportVehicles({ ownership: 'OWNED' })
|
.mockResolvedValueOnce(ok({ records: [vehicle], page: '1', size: '20', total: '1', pages: '1' }))
|
||||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'blob' })
|
.mockResolvedValueOnce(ok(vehicle))
|
||||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'blob' })
|
const result = await listVehicles({ page: 1, size: 20, ownership: 'OWNED' })
|
||||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'blob' })
|
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: 'arraybuffer' })
|
||||||
|
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' })
|
||||||
|
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,42 +1,124 @@
|
|||||||
|
import axios from 'axios'
|
||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
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 {
|
import type {
|
||||||
MasterDataStatus, MonthlyExchangeRate, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
|
MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
|
||||||
VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus,
|
VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||||
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
|
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
|
||||||
const base = '/platform/v1/last-leg/master-data'
|
const base = '/platform/v1/last-leg/master-data'
|
||||||
function page<T>(value: PagePayload<T>): PageResult<T> { return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } }
|
type Numeric = number | string
|
||||||
async function getPage<T>(path: string, params: object) { return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<T>>, ApiEnvelope<PagePayload<T>>>(path, { params }))) }
|
type QuoteWire = Omit<OverseasWarehouseQuote, 'recordVersion'> & { recordVersion: Numeric }
|
||||||
async function download(path: string, params: object) { return overseasHttp.get<Blob, Blob>(path, { params, responseType: 'blob' }) }
|
type FeeWire = Omit<OverseasWarehouseFee, 'unitPrice' | 'minimumCharge'> & { unitPrice: Numeric; minimumCharge: Numeric | null }
|
||||||
|
type TransitPriceWire = Omit<OverseasTransitPrice, 'estimatedMiles' | 'price' | 'minimumCharge'> & { estimatedMiles: Numeric | null; price: Numeric; minimumCharge: Numeric | null }
|
||||||
|
type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouseFees' | 'transitPrices'> & { summary: QuoteWire; warehouseFees: FeeWire[]; transitPrices: TransitPriceWire[] }
|
||||||
|
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
|
||||||
|
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
|
||||||
|
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
|
||||||
|
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
|
||||||
|
|
||||||
|
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
|
||||||
|
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<T, W>(path: string, params: object, normalize: (record: W) => T) {
|
||||||
|
return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<W>>, ApiEnvelope<PagePayload<W>>>(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) })
|
||||||
|
|
||||||
|
type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string
|
||||||
|
|
||||||
|
async function downloadText(payload: DownloadPayload) {
|
||||||
|
if (typeof payload === 'string') return payload
|
||||||
|
if (payload instanceof Blob) return payload.text()
|
||||||
|
const bytes = payload instanceof ArrayBuffer
|
||||||
|
? new Uint8Array(payload)
|
||||||
|
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function csvError(payload: DownloadPayload, status: number) {
|
||||||
|
const contentType = payload instanceof Blob ? payload.type.toLowerCase() : ''
|
||||||
|
const text = await downloadText(payload)
|
||||||
|
if (!contentType.includes('json') && !/^\s*[\[{]/.test(text)) return null
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(text) as Partial<ApiEnvelope<unknown>>
|
||||||
|
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error('invalid envelope')
|
||||||
|
return new ApiResponseError(envelope.message || 'CSV 导出失败', envelope.code || 'DOWNLOAD_FAILED', status,
|
||||||
|
envelope.data, envelope.requestId, envelope.traceId)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiResponseError) return error
|
||||||
|
return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function csvBlob(payload: DownloadPayload) {
|
||||||
|
if (payload instanceof Blob) return payload
|
||||||
|
if (typeof payload === 'string') return new Blob([payload], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const bytes = payload instanceof ArrayBuffer
|
||||||
|
? new Uint8Array(payload)
|
||||||
|
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||||
|
const copy = new Uint8Array(bytes.byteLength)
|
||||||
|
copy.set(bytes)
|
||||||
|
return new Blob([copy.buffer], { type: 'text/csv;charset=utf-8' })
|
||||||
|
}
|
||||||
|
async function download(path: string, params: object) {
|
||||||
|
try {
|
||||||
|
const payload = await overseasHttp.get<DownloadPayload, DownloadPayload>(path, { params, responseType: 'arraybuffer' })
|
||||||
|
const error = await csvError(payload, 200)
|
||||||
|
if (error) throw error
|
||||||
|
return csvBlob(payload)
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = axios.isAxiosError(error) ? error : null
|
||||||
|
const responsePayload = axiosError?.response?.data as DownloadPayload | undefined
|
||||||
|
if (responsePayload !== undefined) {
|
||||||
|
const responseError = await csvError(responsePayload, axiosError?.response?.status || 0)
|
||||||
|
if (responseError) throw responseError
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
|
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
|
||||||
export type QuoteSave = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'effectiveEnd' | 'warehouseFees' | 'transitPrices' | 'remark'>
|
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
|
||||||
& { warehouseId?: string; versionCode?: string; recordVersion?: number }
|
type QuoteEditableFields = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
|
||||||
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote>(`${base}/overseas-quotes`, query)
|
& { effectiveEnd: string } & QuoteDetailFields
|
||||||
export async function getQuote(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`)) }
|
export type QuoteCreate = QuoteEditableFields & Pick<OverseasWarehouseQuote, 'warehouseId' | 'versionCode'>
|
||||||
|
export type QuoteUpdate = QuoteEditableFields & Pick<OverseasWarehouseQuote, 'recordVersion'>
|
||||||
|
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote, QuoteWire>(`${base}/overseas-quotes`, query, normalizeQuote)
|
||||||
|
export async function getQuote(id: string) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`))) }
|
||||||
export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PartnerWarehouseOption[]>, ApiEnvelope<PartnerWarehouseOption[]>>(`${base}/overseas-quotes/warehouse-options`)) }
|
export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PartnerWarehouseOption[]>, ApiEnvelope<PartnerWarehouseOption[]>>(`${base}/overseas-quotes/warehouse-options`)) }
|
||||||
export async function createQuote(payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes`, payload)) }
|
export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes`, payload))) }
|
||||||
export async function updateQuote(id: string, payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload)) }
|
export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) }
|
||||||
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
|
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
|
||||||
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
|
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
|
||||||
|
|
||||||
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
|
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
|
||||||
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate>(`${base}/exchange-rates`, query)
|
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate, ExchangeRateWire>(`${base}/exchange-rates`, query, normalizeExchangeRate)
|
||||||
export async function getExchangeRate(month: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`)) }
|
export async function getExchangeRate(month: string) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${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 }>, ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>>(`${base}/exchange-rates/summary`)) }
|
export async function getExchangeRateSummary(): Promise<ExchangeRateSummary> {
|
||||||
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates`, payload)) }
|
const value = unwrapApiResponse(await overseasHttp.get<ApiEnvelope<ExchangeRateSummaryWire>, ApiEnvelope<ExchangeRateSummaryWire>>(`${base}/exchange-rates/summary`))
|
||||||
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload)) }
|
return { ...value, latestUsdToCnyAverage: nullableNumeric(value.latestUsdToCnyAverage) }
|
||||||
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark })) }
|
}
|
||||||
|
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${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<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${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<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
|
||||||
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
|
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
|
||||||
|
|
||||||
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
||||||
export type VehicleSave = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'> & { status?: VehicleStatus; recordVersion?: number }
|
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
|
||||||
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail>(`${base}/vehicles`, query)
|
export type VehicleCreate = VehicleEditableFields & Pick<VehicleDetail, 'status'>
|
||||||
export async function getVehicle(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`)) }
|
export type VehicleUpdate = VehicleEditableFields & Pick<VehicleDetail, 'recordVersion'>
|
||||||
export async function createVehicle(payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles`, payload)) }
|
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail, VehicleWire>(`${base}/vehicles`, query, normalizeVehicle)
|
||||||
export async function updateVehicle(id: string, payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload)) }
|
export async function getVehicle(id: string) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`))) }
|
||||||
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
|
export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles`, payload))) }
|
||||||
|
export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) }
|
||||||
|
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
|
||||||
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
|
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -55,6 +55,19 @@ describe('overseas master data real contract pages', () => {
|
|||||||
expect(view).toContain('access.can(permission.')
|
expect(view).toContain('access.can(permission.')
|
||||||
expect(view).toContain('ElMessage.success')
|
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', () => {
|
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('/overseas-quotes/warehouse-options')
|
||||||
expect(gaps).toContain('浏览器不调用国内')
|
expect(gaps).toContain('浏览器不调用国内')
|
||||||
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')
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/></div>' }
|
||||||
|
const stubs = {
|
||||||
|
AdminPage: slot,
|
||||||
|
ListPageLayout: slot,
|
||||||
|
QueryToolbar: true,
|
||||||
|
LoadErrorState: true,
|
||||||
|
DataTable: {
|
||||||
|
props: ['data'],
|
||||||
|
template: '<div><slot name="toolbar-actions"/><slot v-if="data && data[0]" name="actions" :row="data[0]"/></div>',
|
||||||
|
},
|
||||||
|
AppDialog: {
|
||||||
|
props: ['modelValue'], emits: ['confirm', 'update:modelValue'],
|
||||||
|
template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="dialog-confirm" @click="$emit(\'confirm\')">confirm</button></div>',
|
||||||
|
},
|
||||||
|
ElButton: { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot/></button>' },
|
||||||
|
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<typeof mountView>, 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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -56,7 +56,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
|
|||||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
|
||||||
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
|
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
|
||||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
|
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
|
||||||
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template>
|
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
|
||||||
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
|
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
|
||||||
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
|
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
|
||||||
</DataTable></section>
|
</DataTable></section>
|
||||||
|
|||||||
@@ -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}}
|
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)
|
watch([page,pageSize],load);onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||||
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
||||||
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
|
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** 四期海外主数据页面模型,由模块 API 适配层从 Lastleg 稳定 DTO 转换。 */
|
||||||
* 四期原型展示模型。
|
|
||||||
*
|
|
||||||
* 报价、汇率和车辆主数据尚无已确认的海外服务端 DTO;这些类型不能用于猜测
|
|
||||||
* API 路径或直接序列化请求。真实接口落地后应按 Controller/OpenAPI 单独建立
|
|
||||||
* API DTO,并在适配层转换为以下页面模型。
|
|
||||||
*/
|
|
||||||
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
|
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
|
||||||
export type QuoteCurrency = 'USD' | 'CNY'
|
export type QuoteCurrency = 'USD' | 'CNY'
|
||||||
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
|
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
|
||||||
@@ -45,8 +39,6 @@ export interface OverseasWarehouseQuote {
|
|||||||
updatedBy: string
|
updatedBy: string
|
||||||
recordVersion: number
|
recordVersion: number
|
||||||
remark: string | null
|
remark: string | null
|
||||||
warehouseFees: OverseasWarehouseFee[]
|
|
||||||
transitPrices: OverseasTransitPrice[]
|
|
||||||
}
|
}
|
||||||
export interface OverseasWarehouseQuoteDetail { summary: OverseasWarehouseQuote; warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[]; createdBy: string; createdAt: string }
|
export interface OverseasWarehouseQuoteDetail { summary: OverseasWarehouseQuote; warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[]; createdBy: string; createdAt: string }
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
# 海外仓真实接口接入矩阵
|
# 海外仓真实接口接入矩阵
|
||||||
|
|
||||||
浏览器只通过共享 `overseasHttp` 请求海外网关,模块路径统一从 `/platform/v1/...` 开始。长整型 ID 全程按字符串处理。以下结论来自 `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service` 当前 Controller/DTO;三者当前没有静态 OpenAPI YAML,本轮未把 Springdoc 注解之外的路径推断为正式接口。
|
浏览器统一通过共享 `overseasHttp` 请求海外 Gateway,模块传入 `/platform/v1/...`;Controller 完整路径带 `/api` 前缀。长整型 ID 及业务单号均按字符串传递,path 参数使用 `encodeURIComponent`。接口事实已核对 Controller/DTO、Springdoc 与 `lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml`,不再沿用“无静态 OpenAPI”的旧结论。
|
||||||
|
|
||||||
| 原型页面 | 页面 ID | 当前真实接口 | 接入状态 | 说明 |
|
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
|
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
|
||||||
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
|
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 |
|
||||||
| 入库单列表/详情/FBA 预约 | `fba-inbound-order*` | `GET /platform/v1/last-leg/inbound-plans`、`GET /{planNo}`、`POST /fba-appointments/batch`、`GET/PUT /fba-appointments/{appointmentNo}` | 已接入 | 显式 `customerId`、真实分页和筛选;支持按入库明细勾选、同客户/同 FBA 仓校验、预计装车时间以及预约编辑。 |
|
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string`,不传客户 | `...:inbound-order:create`;不接补托/PDA |
|
||||||
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
|
| 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 |
|
||||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|balances/{id}/positions|ledger` | 已接入只读 | 列表、汇总、库位/托盘位置与流水已接入;余额返回 SKU/产品名数组,流水返回 SKU 和库位代码。 |
|
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 |
|
||||||
| 托盘列表/详情 | `fba-pallet*` | `GET/POST /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET/PUT/DELETE /{id}` | 已接入 | 所有请求显式携带 `customerId` 和仓库范围;新增、编辑、删除使用真实 DTO 和真实 Controller 端点。 |
|
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
|
||||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
|
||||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
|
||||||
|
| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` |
|
||||||
|
| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 |
|
||||||
|
| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户 | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship |
|
||||||
|
| 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 |
|
||||||
|
| 公共海外仓候选 | `GET /platform/v1/warehouse-admin` | `page=1,size=200`,不传 `customerId` | `...:warehouse:list`;仓储资源固定公共作用域 `customer_id=0` |
|
||||||
|
| 托盘列表/详情 | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`;`GET /pallets/{palletId}` | list:`page,size,keyword?,palletType?,status?`;无客户参数 | `...:pallet:list`;仓库/托盘长 ID 编码 |
|
||||||
|
| 托盘新增 | `POST /platform/v1/warehouse-admin/{warehouseId}/pallets` | body:`palletCode,palletType,fbaWarehouseCode,capacityCbm,unitPrice,status,locationId,remark`,明确不带 `version` | 前端 `...:pallet:create`;失败不模拟成功 |
|
||||||
|
| 托盘编辑 | `PUT /platform/v1/warehouse-admin/{warehouseId}/pallets/{palletId}` | 新增同字段,并必传详情返回的 `version:number` | `...:pallet:edit`;旧版本返回 `A0409`,页面保留真实冲突错误。原型未定义删除,模块不保留删除 adapter/入口 |
|
||||||
|
| 换标列表/详情 | `GET /platform/v1/last-leg/relabel-applications`;`GET /{relabelNo}` | list:`page,size,skuCode?,status?`;平台跨客户;详情 path 编码 | `...:relabel-order:list`;未返回字段不填演示值 |
|
||||||
|
| 换标创建 | `POST /platform/v1/last-leg/relabel-applications?customerId={positiveLong}` | `customerId` 只在 query;body:`customerOrderNo,overseasWarehouseId,labelDeadline,contactName,contactPhone,remark?,idempotencyKey,items[]` | `...:relabel-order:create`;显式 ACTIVE 客户→该客户 ACTIVE 海外仓;同一弹框会话重试复用同一幂等键,失败保留 |
|
||||||
|
| 换标状态 | `PUT /platform/v1/last-leg/relabel-applications/{relabelNo}/status` | body:`status,remark?` | `...:relabel-order:status`;仅允许 `PENDING_PROCESSING→PROCESSING/REJECTED/CANCELLED`、`PROCESSING→COMPLETED/REJECTED/CANCELLED` |
|
||||||
|
|
||||||
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
|
## 原型与范围
|
||||||
|
|
||||||
菜单、路由与操作按钮使用真实 Controller 权限:`platform:overseas-warehouse:*`(尾程、入库、换标、库存、仓库、库位和托盘资源)。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令。
|
- 页面 ID:尾程 `fba-last-mile-order*`、入库 `fba-inbound-order*`、换标 `fba-relabel-order*`、托盘 `fba-pallet*`、库存 `fba-inventory*`。排车没有独立海外仓原型页,只在入库页智能排车弹框及既有排车查询页承载。
|
||||||
|
- 托盘原型操作列仅查看、编辑,工具栏仅新增;后端即使存在 DELETE,Admin 也不暴露。所有业务表格操作列保持第一列并固定左侧。
|
||||||
当前正式路由只保留尾程订单、入库单、换标单、托盘管理和库存管理;箱号和产品库本期暂缓,不恢复正式路由。
|
- 箱号、产品库本期暂缓并从正式 routes 移除;海外出库、退货出库、海外派送、PDA、卸柜照片、内部事件以及库存离仓命令均排除。
|
||||||
|
- 1920×1080、浏览器 100% 的人工截图仍待验收。
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
# 四期海外仓前端待补契约
|
# 四期海外仓前端剩余契约与环境缺口
|
||||||
|
|
||||||
正式页面已开始接入真实海外接口。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。逐页现状见 `API_INTEGRATION_MATRIX.md`。
|
已接请求、权限、客户作用域和错误承载见 `API_INTEGRATION_MATRIX.md`。本文件只保留当前仍影响真实联调或原型完整度的差异,不再把 FBA 预约、换标写接口、托盘公共作用域或静态 OpenAPI 记为“未接/不存在”。
|
||||||
|
|
||||||
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
|
## 需后端或产品继续确认
|
||||||
|
|
||||||
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
|
- **换标原型字段**:列表 Summary 仍不含 `productNameZh/labelRequirement/relabelFile/budgetTotal/relabelCost`;本模块没有可用的换标文件上传端点。页面不伪造金额或文件名。创建 DTO 额外要求海外仓、截止时间、联系人、电话、货件号和新 SKU,这是完成真实创建的最小扩展。
|
||||||
- 入库单分页与详情已接入。详情 DTO 仍缺预约状态/时间、排车状态/时间、创建更新时间、备注和应收费用;创建、预约及补托只在原型承载和完整幂等/打印语义冻结后接入。
|
- **托盘新增服务端权限**:前端新增按钮按 `platform:overseas-warehouse:pallet:create` 控制,但当前审计的 `PlatformWarehouseResourceController#createPallet` 未见对应 `@PreAuthorize`;需后端补齐或确认由网关等价强制。原型要求“托盘重量”,当前 Save DTO 无该字段,页面不伪造或发送。
|
||||||
- 库存汇总、SKU 分页、库位位置和进销存已接入。流水 DTO 仍缺 SKU、产品名、客户名和海外仓名称;详情也没有按 `balanceId` 直接查询余额主记录的端点。
|
- **托盘状态语义**:原型只给闲置/占用,DTO 还允许 `SCRAPPED`。是否允许 Admin 在普通编辑中直接改为报废仍需确认;不新增独立停用/报废动作。PUT 已按冻结契约携带详情 `version`,旧版本 `A0409`。
|
||||||
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
|
- **库存导出**:后端已存在只读 `GET /inventory/balances/export` 与 `GET /inventory/ledger/export`(权限 `...:inventory:export`),当前模块页面/adapter 尚无二进制下载实现;原型承载与共享下载约定确认后再接,不能用 JSON 请求替代。
|
||||||
- 托盘分页、详情、新增、编辑已接入公共资源接口。剩余缺口是独立公共仓库候选接口、FBA 仓字段、业务绑定/关联箱数只读汇总;删除未在原型定义,不新增入口。
|
- **入库补充托盘**:`POST /inbound-plans/{planNo}/pallets/supplement` 已存在,但权限、幂等、打印及原型承载未整体冻结,Admin 不接。FBA 预约创建、详情和编辑已经接入。
|
||||||
- 箱号、产品库本期暂缓并已从模块正式路由移除;既有源码保留,相关平台查询/写接口不再作为本期交付阻断。
|
|
||||||
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
|
|
||||||
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
|
|
||||||
|
|
||||||
## 当前缺口的精确请求形状
|
## 真实环境仍需的数据与配置
|
||||||
|
|
||||||
- 入库详情:`GET /api/platform/v1/last-leg/inbound-plans/{planNo}` 需补预约/排车/时间/费用,或提供对应只读辅助接口;`sourceOrderId` 已按字符串接入。
|
- 可获得海外 Token 且持有矩阵 canonical 权限的 Admin 测试账号;失败响应需验证 `requestId/traceId`。
|
||||||
- 库存流水:`GET /api/platform/v1/last-leg/inventory/ledger` 需补 `skuCode/productName/customerName/overseasWarehouseName`,并支持 SKU 过滤,才能完整还原进销存和 SKU 详情流水。
|
- 至少一个 ACTIVE 客户及其 ACTIVE 海外仓;换标写验收还需允许关联的专用测试尾程订单/SKU,不能操作历史数据。
|
||||||
- 换标:当前列表响应需补 `productName,labelRequirement,relabelFile,budgetTotal/relabelCost`,或产品明确调整原型字段;写接口必须补正式 `create/edit` 权限。现有状态机为 `PENDING_PROCESSING -> PROCESSING/REJECTED/CANCELLED`、`PROCESSING -> COMPLETED/REJECTED/CANCELLED`。
|
- 带 `sourceOrderId`、明细和托盘的入仓计划,以及已有 FBA 预约号,用于详情长 ID 与预约编辑。
|
||||||
- 托盘:资源接口已固定公共作用域且不再传客户参数;仍缺无需客户遍历即可获取所有实际海外仓的公共候选接口,以及 FBA 仓和业务绑定汇总字段。
|
- 开启 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled`,并准备未锁候选托盘;验证 409 草稿锁冲突、关闭释放和预排回读。
|
||||||
- 权限:入仓、库存、仓库资源已切换 canonical `platform:overseas-warehouse:*`;前端已同步本模块权限常量。换标写弹框字段与后端 Create DTO 仍不一致,因此不开放提交。
|
- 至少一个公共海外仓(`customer_id=0`)及公共托盘;编辑需用专用测试资源验证正确 version 与旧 version `A0409`。
|
||||||
|
- 至少一条含 SKU/产品名、位置和 ledger 的库存事实,用于核对分页、汇总与长 ID;不调用离仓命令。
|
||||||
|
|
||||||
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
|
## 仍然排除
|
||||||
|
|
||||||
`lastleg-service` 已提供尾程订单、入仓计划、库存查询等 Controller,`warehouse-overseas-service` 已提供平台海外仓、库位和托盘 Controller;接入时以这些服务的 Controller/DTO/OpenAPI 为准。当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导未覆盖页面的分页包络、URL、请求参数、时间/金额精度或写接口。统一区域客户端和对应页面契约完成后再建立实际 API 适配层,并逐页替换空态数据源。
|
箱号平台、产品库、海外出库、退货出库、海外派送、PDA 扫描/拣货/卸柜照片、内部事件,以及库存 `allocate/unallocate/pick/ship`。不得为自动化覆盖扩展这些前端路径。
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { getDispatch, listDispatchCandidates, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches'
|
import { createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches'
|
||||||
|
|
||||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
|
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||||
|
|
||||||
describe('dispatch overseas API', () => {
|
describe('dispatch overseas API', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(overseasHttp.get).mockReset()
|
vi.mocked(overseasHttp.get).mockReset()
|
||||||
vi.mocked(overseasHttp.post).mockReset()
|
vi.mocked(overseasHttp.post).mockReset()
|
||||||
|
vi.mocked(overseasHttp.delete).mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the platform dispatch page without customer scope', async () => {
|
it('uses the platform dispatch page without customer scope', async () => {
|
||||||
@@ -66,4 +67,18 @@ describe('dispatch overseas API', () => {
|
|||||||
expect(overseasHttp.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
|
expect(overseasHttp.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
|
||||||
expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
|
expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads drafts and preserves long container ids in create and delete paths', async () => {
|
||||||
|
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
|
||||||
|
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 68, palletIds: [] } })
|
||||||
|
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||||
|
|
||||||
|
await listDispatchDrafts()
|
||||||
|
await createDispatchDraftContainer('draft/1', 68)
|
||||||
|
await deleteDispatchDraftContainer('draft/1', '190000000000000099')
|
||||||
|
|
||||||
|
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
|
||||||
|
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 68 })
|
||||||
|
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OverseasPageResult } from './lastMileOrders'
|
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||||
|
|
||||||
export interface DispatchQuery {
|
export interface DispatchQuery {
|
||||||
page: number
|
page: number
|
||||||
@@ -106,11 +106,11 @@ export interface DispatchItemDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listDispatches(query: DispatchQuery) {
|
export async function listDispatches(query: DispatchQuery) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<DispatchSummaryDto>>, ApiEnvelope<OverseasPageResult<DispatchSummaryDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>, ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>>(
|
||||||
'/platform/v1/last-leg/dispatches',
|
'/platform/v1/last-leg/dispatches',
|
||||||
{ params: query },
|
{ params: query },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDispatch(dispatchNo: string) {
|
export async function getDispatch(dispatchNo: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OverseasPageResult } from './lastMileOrders'
|
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||||
|
|
||||||
export interface InboundOrderQuery {
|
export interface InboundOrderQuery {
|
||||||
page: number
|
page: number
|
||||||
@@ -68,10 +68,10 @@ export interface FbaAppointmentDto {
|
|||||||
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
|
export type UpdateFbaAppointmentPayload = Omit<BatchFbaAppointmentPayload, 'lineIds' | 'appointmentNo'>
|
||||||
|
|
||||||
export async function listInboundOrders(query: InboundOrderQuery) {
|
export async function listInboundOrders(query: InboundOrderQuery) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundOrderSummaryDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>, ApiEnvelope<OverseasPagePayload<InboundOrderSummaryDto>>>(
|
||||||
'/platform/v1/last-leg/inbound-plans', { params: query },
|
'/platform/v1/last-leg/inbound-plans', { params: query },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) {
|
export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OverseasPageResult } from './lastMileOrders'
|
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||||
|
|
||||||
export interface InboundPlanQuery {
|
export interface InboundPlanQuery {
|
||||||
customerId: string
|
customerId: string
|
||||||
@@ -60,7 +60,7 @@ export interface InboundPlanDetailDto {
|
|||||||
|
|
||||||
export async function listInboundPlans(query: InboundPlanQuery) {
|
export async function listInboundPlans(query: InboundPlanQuery) {
|
||||||
const { customerId, ...params } = query
|
const { customerId, ...params } = query
|
||||||
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))
|
return normalizeOverseasPage(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InboundPlanSummaryDto>>, ApiEnvelope<OverseasPagePayload<InboundPlanSummaryDto>>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } })))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInboundPlan(planNo: string, customerId: string) {
|
export async function getInboundPlan(planNo: string, customerId: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OverseasPageResult } from './lastMileOrders'
|
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||||
|
|
||||||
export interface InventoryBalanceDto {
|
export interface InventoryBalanceDto {
|
||||||
id: string
|
id: string
|
||||||
@@ -110,10 +110,10 @@ export interface InventoryBalanceQuery {
|
|||||||
function params(query: InventoryBalanceQuery) { return { ...query } }
|
function params(query: InventoryBalanceQuery) { return { ...query } }
|
||||||
|
|
||||||
export async function listInventoryBalances(query: InventoryBalanceQuery) {
|
export async function listInventoryBalances(query: InventoryBalanceQuery) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>, ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InventoryBalanceDto>>, ApiEnvelope<OverseasPagePayload<InventoryBalanceDto>>>(
|
||||||
'/platform/v1/last-leg/inventory/balances', { params: params(query) },
|
'/platform/v1/last-leg/inventory/balances', { params: params(query) },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInventorySummary(query: InventoryBalanceQuery) {
|
export async function getInventorySummary(query: InventoryBalanceQuery) {
|
||||||
@@ -153,9 +153,9 @@ export interface InventoryLedgerQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listInventoryLedger(query: InventoryLedgerQuery) {
|
export async function listInventoryLedger(query: InventoryLedgerQuery) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>, ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<InventoryLedgerDto>>, ApiEnvelope<OverseasPagePayload<InventoryLedgerDto>>>(
|
||||||
'/platform/v1/last-leg/inventory/ledger',
|
'/platform/v1/last-leg/inventory/ledger',
|
||||||
{ params: { ...query } },
|
{ params: { ...query } },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ describe('last-mile order overseas API', () => {
|
|||||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||||
|
|
||||||
it('loads all platform orders without a customer scope', async () => {
|
it('loads all platform orders without a customer scope', async () => {
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
|
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
|
||||||
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toMatchObject({ page: 2 })
|
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 })
|
||||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
|
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
|
||||||
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' },
|
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ export interface OverseasPageResult<T> {
|
|||||||
pages: number
|
pages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OverseasPagePayload<T> = Omit<OverseasPageResult<T>, 'page' | 'size' | 'total' | 'pages'> & {
|
||||||
|
page: number | string
|
||||||
|
size: number | string
|
||||||
|
total: number | string
|
||||||
|
pages: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOverseasPage<T>(value: OverseasPagePayload<T>): OverseasPageResult<T> {
|
||||||
|
return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) }
|
||||||
|
}
|
||||||
|
|
||||||
export interface LastMileOrderQuery {
|
export interface LastMileOrderQuery {
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
@@ -118,11 +129,11 @@ export interface LastMileOrderDetailDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listLastMileOrders(query: LastMileOrderQuery) {
|
export async function listLastMileOrders(query: LastMileOrderQuery) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPagePayload<LastMileOrderSummaryDto>>>(
|
||||||
'/platform/v1/last-leg/orders',
|
'/platform/v1/last-leg/orders',
|
||||||
{ params: query },
|
{ params: query },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLastMileOrder(orderId: string) {
|
export async function getLastMileOrder(orderId: string) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { createPallet, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from './pallets'
|
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from './pallets'
|
||||||
|
|
||||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||||
|
|
||||||
describe('overseas pallet API', () => {
|
describe('overseas pallet API', () => {
|
||||||
beforeEach(() => { vi.clearAllMocks() })
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
@@ -12,22 +12,20 @@ describe('overseas pallet API', () => {
|
|||||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
|
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
|
||||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' })
|
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' })
|
||||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null })
|
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null })
|
||||||
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', data: null })
|
|
||||||
const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
|
const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }
|
||||||
|
|
||||||
await listCustomerWarehouses()
|
await listCustomerWarehouses()
|
||||||
await listPallets('200', { page: 1, size: 20, keyword: 'PLT' })
|
await listPallets('200', { page: 1, size: 20, keyword: 'PLT' })
|
||||||
await getPallet('200', '9')
|
await getPallet('200', '9')
|
||||||
await createPallet('200', payload)
|
await createPallet('200', payload)
|
||||||
await updatePallet('200', '9', payload)
|
await updatePallet('200', '9', { ...payload, version: 7 })
|
||||||
await deletePallet('200', '9')
|
|
||||||
|
|
||||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', { params: { page: 1, size: 200 } })
|
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', { params: { page: 1, size: 200 } })
|
||||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } })
|
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } })
|
||||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/warehouse-admin/200/pallets/9', {})
|
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/warehouse-admin/200/pallets/9', {})
|
||||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload)
|
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload)
|
||||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', payload)
|
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', { ...payload, version: 7 })
|
||||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ONT8' }))
|
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ONT8', version: 7 }))
|
||||||
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', {})
|
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', expect.not.objectContaining({ version: expect.anything() }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OverseasPageResult } from './lastMileOrders'
|
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||||
|
|
||||||
export interface CustomerWarehouseDto {
|
export interface CustomerWarehouseDto {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,6 +23,7 @@ export interface PalletDto {
|
|||||||
unitPrice: number
|
unitPrice: number
|
||||||
status: string
|
status: string
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
|
version: number
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
}
|
}
|
||||||
@@ -38,20 +39,24 @@ export interface SavePalletPayload {
|
|||||||
remark?: string | null
|
remark?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatePalletPayload extends SavePalletPayload {
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCustomerWarehouses() {
|
export async function listCustomerWarehouses() {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>, ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<CustomerWarehouseDto>>, ApiEnvelope<OverseasPagePayload<CustomerWarehouseDto>>>(
|
||||||
'/platform/v1/warehouse-admin',
|
'/platform/v1/warehouse-admin',
|
||||||
{ params: { page: 1, size: 200 } },
|
{ params: { page: 1, size: 200 } },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
|
export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<PalletDto>>, ApiEnvelope<OverseasPagePayload<PalletDto>>>(
|
||||||
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`,
|
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`,
|
||||||
{ params: { ...query } },
|
{ params: { ...query } },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPallet(warehouseId: string, palletId: string) {
|
export async function getPallet(warehouseId: string, palletId: string) {
|
||||||
@@ -69,18 +74,10 @@ export async function createPallet(warehouseId: string, payload: SavePalletPaylo
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePallet(warehouseId: string, palletId: string, payload: SavePalletPayload) {
|
export async function updatePallet(warehouseId: string, palletId: string, payload: UpdatePalletPayload) {
|
||||||
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(
|
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||||
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
|
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePallet(warehouseId: string, palletId: string) {
|
|
||||||
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
|
|
||||||
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
return unwrapApiResponse(response)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders } from './relabelOrders'
|
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders'
|
||||||
|
|
||||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||||
|
|
||||||
describe('relabel overseas API', () => {
|
describe('relabel overseas API', () => {
|
||||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
beforeEach(() => {
|
||||||
|
vi.mocked(overseasHttp.get).mockReset()
|
||||||
|
vi.mocked(overseasHttp.post).mockReset()
|
||||||
|
vi.mocked(overseasHttp.put).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the platform page endpoint without customer scope', async () => {
|
it('uses the platform page endpoint without customer scope', async () => {
|
||||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||||
@@ -23,9 +27,17 @@ describe('relabel overseas API', () => {
|
|||||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: {} })
|
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: {} })
|
||||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
|
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
|
||||||
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
|
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
|
||||||
await createRelabelOrder(payload)
|
await createRelabelOrder('10001', payload)
|
||||||
await changeRelabelOrderStatus('RL-1', 'PROCESSING', 'ok')
|
await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok')
|
||||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload)
|
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } })
|
||||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL-1/status', { status: 'PROCESSING', remark: 'ok' })
|
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1/status', { status: 'PROCESSING', remark: 'ok' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads ACTIVE overseas warehouses for the explicitly selected customer', async () => {
|
||||||
|
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } })
|
||||||
|
await listRelabelWarehouseOptions('10001')
|
||||||
|
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||||
|
params: { customerId: '10001', page: 1, size: 200, status: 'ACTIVE' },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OverseasPageResult } from './lastMileOrders'
|
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||||
|
|
||||||
export interface RelabelOrderQuery {
|
export interface RelabelOrderQuery {
|
||||||
page: number
|
page: number
|
||||||
@@ -37,6 +37,15 @@ export interface CreateRelabelPayload {
|
|||||||
items: CreateRelabelItemPayload[]
|
items: CreateRelabelItemPayload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelabelWarehouseOptionDto {
|
||||||
|
id: string
|
||||||
|
customerId: string
|
||||||
|
warehouseCode: string
|
||||||
|
warehouseName: string
|
||||||
|
warehouseType: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RelabelSummaryDto {
|
export interface RelabelSummaryDto {
|
||||||
id: string
|
id: string
|
||||||
relabelNo: string
|
relabelNo: string
|
||||||
@@ -83,10 +92,10 @@ export interface RelabelDetailDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
||||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>(
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
|
||||||
'/platform/v1/last-leg/relabel-applications', { params: query },
|
'/platform/v1/last-leg/relabel-applications', { params: query },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRelabelOrder(relabelNo: string) {
|
export async function getRelabelOrder(relabelNo: string) {
|
||||||
@@ -96,9 +105,17 @@ export async function getRelabelOrder(relabelNo: string) {
|
|||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRelabelOrder(payload: CreateRelabelPayload) {
|
export async function listRelabelWarehouseOptions(customerId: string) {
|
||||||
|
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<RelabelWarehouseOptionDto>>, ApiEnvelope<OverseasPagePayload<RelabelWarehouseOptionDto>>>(
|
||||||
|
'/platform/v1/warehouse-admin',
|
||||||
|
{ params: { customerId, page: 1, size: 200, status: 'ACTIVE' } },
|
||||||
|
)
|
||||||
|
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRelabelOrder(customerId: string, payload: CreateRelabelPayload) {
|
||||||
const response = await overseasHttp.post<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
|
const response = await overseasHttp.post<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
|
||||||
'/platform/v1/last-leg/relabel-applications', payload,
|
'/platform/v1/last-leg/relabel-applications', payload, { params: { customerId } },
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
|
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '../api/pallets'
|
||||||
|
import PalletsView from './PalletsView.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||||
|
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listCustomerWarehouses: vi.fn(), listPallets: vi.fn(), updatePallet: vi.fn() }))
|
||||||
|
|
||||||
|
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||||
|
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
afterAll(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('PalletsView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
|
vi.stubGlobal('localStorage', memoryStorage())
|
||||||
|
vi.mocked(listCustomerWarehouses).mockReset().mockResolvedValue({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', status: 'ACTIVE' } as never])
|
||||||
|
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
|
||||||
|
vi.mocked(listPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 10, total: 1, pages: 1 })
|
||||||
|
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
|
||||||
|
vi.mocked(createPallet).mockReset()
|
||||||
|
vi.mocked(updatePallet).mockReset().mockResolvedValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the current detail version and keeps the edit open on a conflict', async () => {
|
||||||
|
vi.mocked(updatePallet).mockRejectedValueOnce(Object.assign(new Error('version conflict'), { code: 'A0409' }))
|
||||||
|
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '200', code: '', type: '', status: '' })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button.el-button--small').filter((button) => button.text().includes('编辑')).at(-1)!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const dialog = wrapper.findComponent(AppDialog)
|
||||||
|
dialog.vm.$emit('confirm')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(getPallet).toHaveBeenCalledWith('200', '9007199254740993')
|
||||||
|
expect(updatePallet).toHaveBeenCalledWith('200', '9007199254740993', expect.objectContaining({
|
||||||
|
palletCode: 'PLT-1', fbaWarehouseCode: 'ONT8', version: 7,
|
||||||
|
}))
|
||||||
|
expect(dialog.props('modelValue')).toBe(true)
|
||||||
|
expect(createPallet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Eye, Pencil, Plus, Trash2 } from '@lucide/vue'
|
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||||
@@ -14,7 +14,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { createPallet, deletePallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
|
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
|
||||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||||
import type { PalletListItem } from '../types'
|
import type { PalletListItem } from '../types'
|
||||||
|
|
||||||
@@ -26,7 +26,6 @@ const rows = ref<PalletListItem[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const contextLoading = ref(false)
|
const contextLoading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const deletingId = ref('')
|
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||||
const loadErrorKind = ref<'context' | 'list'>('context')
|
const loadErrorKind = ref<'context' | 'list'>('context')
|
||||||
@@ -49,7 +48,7 @@ const columns: DataTableColumn[] = [
|
|||||||
]
|
]
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const dialogMode = ref<'create' | 'edit'>('create')
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' })
|
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 })
|
||||||
|
|
||||||
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||||||
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
||||||
@@ -93,14 +92,14 @@ function retryLoad() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
|
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEdit(source: object) {
|
async function openEdit(source: object) {
|
||||||
const row = source as PalletListItem; saving.value = true; mutationError.value = null
|
const row = source as PalletListItem; saving.value = true; mutationError.value = null
|
||||||
try {
|
try {
|
||||||
const detail = await getPallet(row.warehouseId, row.id)
|
const detail = await getPallet(row.warehouseId, row.id)
|
||||||
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '' }); dialogOpen.value = true
|
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); dialogOpen.value = true
|
||||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
|
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
@@ -110,23 +109,13 @@ async function save() {
|
|||||||
saving.value = true; mutationError.value = null
|
saving.value = true; mutationError.value = null
|
||||||
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
|
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
|
||||||
try {
|
try {
|
||||||
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, payload)
|
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, { ...payload, version: form.version })
|
||||||
else await createPallet(form.warehouseId, payload)
|
else await createPallet(form.warehouseId, payload)
|
||||||
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
|
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
|
||||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
|
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(source: object) {
|
|
||||||
const row = source as PalletListItem
|
|
||||||
try { await ElMessageBox.confirm(`确认删除托盘 ${row.code}?`, '删除托盘', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) }
|
|
||||||
catch { return }
|
|
||||||
deletingId.value = row.id; mutationError.value = null
|
|
||||||
try { await deletePallet(row.warehouseId, row.id); ElMessage.success('托盘已删除'); await load() }
|
|
||||||
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘删除失败,请重试') }
|
|
||||||
finally { deletingId.value = '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||||
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
|
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
|
||||||
onMounted(loadWarehouses)
|
onMounted(loadWarehouses)
|
||||||
@@ -137,8 +126,8 @@ onMounted(loadWarehouses)
|
|||||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
|
||||||
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouseId ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouseId ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||||
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
||||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.palletDelete)" size="small" plain type="danger" :loading="deletingId === (row as PalletListItem).id" @click="remove(row)"><Trash2 :size="15" />删除</el-button></template>
|
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button></template>
|
||||||
<template #pagination-left>共 {{ total }} 条托盘</template>
|
<template #pagination-left>共 {{ total }} 条托盘</template>
|
||||||
</DataTable></section>
|
</DataTable></section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { listPlatformTenants } from '@/modules/iam'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import {
|
||||||
|
changeRelabelOrderStatus,
|
||||||
|
createRelabelOrder,
|
||||||
|
listRelabelOrders,
|
||||||
|
listRelabelWarehouseOptions,
|
||||||
|
} from '../api/relabelOrders'
|
||||||
|
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||||
|
vi.mock('../api/relabelOrders', () => ({
|
||||||
|
changeRelabelOrderStatus: vi.fn(),
|
||||||
|
createRelabelOrder: vi.fn(),
|
||||||
|
listRelabelOrders: vi.fn(),
|
||||||
|
listRelabelWarehouseOptions: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||||
|
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||||
|
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done }); return { promise, resolve } }
|
||||||
|
|
||||||
|
function formItem(wrapper: VueWrapper<any>, label: string) {
|
||||||
|
const item = wrapper.findAllComponents({ name: 'ElFormItem' }).find((candidate) => candidate.props('label') === label)
|
||||||
|
if (!item) throw new Error(`Missing form item: ${label}`)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(wrapper: VueWrapper<any>, placeholder: string) {
|
||||||
|
const target = wrapper.findAllComponents({ name: 'ElSelect' }).find((candidate) => candidate.props('placeholder') === placeholder)
|
||||||
|
if (!target) throw new Error(`Missing select: ${placeholder}`)
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
afterAll(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('RelabelOrdersView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
|
vi.stubGlobal('localStorage', memoryStorage())
|
||||||
|
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
vi.mocked(listRelabelOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||||
|
vi.mocked(listRelabelWarehouseOptions).mockReset()
|
||||||
|
vi.mocked(createRelabelOrder).mockReset()
|
||||||
|
vi.mocked(changeRelabelOrderStatus).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps only the latest selected customer warehouse response', async () => {
|
||||||
|
const first = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
|
||||||
|
const second = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
|
||||||
|
vi.mocked(listRelabelWarehouseOptions).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||||
|
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const customer = select(wrapper, '请选择 ACTIVE 客户')
|
||||||
|
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
|
||||||
|
await flushPromises()
|
||||||
|
customer.vm.$emit('update:modelValue', '10002'); customer.vm.$emit('change', '10002')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
second.resolve({ records: [{ id: 'WH-B', customerId: '10002', warehouseCode: 'B', warehouseName: 'B仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
first.resolve({ records: [{ id: 'WH-A', customerId: '10001', warehouseCode: 'A', warehouseName: 'A仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||||
|
expect(labels).toContain('B仓 · B')
|
||||||
|
expect(labels).not.toContain('A仓 · A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses one idempotency key after a failed create retry', async () => {
|
||||||
|
vi.mocked(listRelabelWarehouseOptions).mockResolvedValue({ records: [{ id: 'WH-1', customerId: '10001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
vi.mocked(createRelabelOrder).mockRejectedValueOnce(new Error('temporary')).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1' }, items: [], history: [] })
|
||||||
|
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const customer = select(wrapper, '请选择 ACTIVE 客户')
|
||||||
|
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
|
||||||
|
await flushPromises()
|
||||||
|
const warehouse = select(wrapper, '请先选择客户,再选择其海外仓')
|
||||||
|
warehouse.vm.$emit('update:modelValue', 'WH-1')
|
||||||
|
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
|
||||||
|
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
|
||||||
|
formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '商品一')
|
||||||
|
formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||||||
|
formItem(wrapper, '截止时间').findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-30T10:00:00')
|
||||||
|
formItem(wrapper, '联系人').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '张三')
|
||||||
|
formItem(wrapper, '联系电话').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '13800000000')
|
||||||
|
formItem(wrapper, '贴标要求').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '换SKU标')
|
||||||
|
formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SHIP-1')
|
||||||
|
formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-2')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const dialog = wrapper.findComponent(AppDialog)
|
||||||
|
dialog.vm.$emit('confirm')
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialog.props('modelValue')).toBe(true)
|
||||||
|
dialog.vm.$emit('confirm')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(createRelabelOrder).toHaveBeenCalledTimes(2)
|
||||||
|
const firstKey = vi.mocked(createRelabelOrder).mock.calls[0]![1].idempotencyKey
|
||||||
|
const retryKey = vi.mocked(createRelabelOrder).mock.calls[1]![1].idempotencyKey
|
||||||
|
expect(firstKey).toBeTruthy()
|
||||||
|
expect(retryKey).toBe(firstKey)
|
||||||
|
expect(createRelabelOrder).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||||
|
customerOrderNo: 'CO-1', overseasWarehouseId: 'WH-1', idempotencyKey: firstKey,
|
||||||
|
items: [expect.objectContaining({ skuCode: 'SKU-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标' })],
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers only backend-allowed audit transitions', async () => {
|
||||||
|
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
|
||||||
|
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_PROCESSING' }],
|
||||||
|
page: 1, size: 10, total: 1, pages: 1,
|
||||||
|
})
|
||||||
|
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('审核'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const labels = formItem(wrapper, '目标状态').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||||
|
expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消']))
|
||||||
|
expect(labels).not.toContain('已完成')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
@@ -12,8 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders } from '../api/relabelOrders'
|
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
|
||||||
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
|
|
||||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||||
import type { RelabelOrderListItem } from '../types'
|
import type { RelabelOrderListItem } from '../types'
|
||||||
@@ -23,7 +23,11 @@ const router = useRouter()
|
|||||||
const rows = ref<RelabelOrderListItem[]>([])
|
const rows = ref<RelabelOrderListItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const warehouses = ref<CustomerWarehouseDto[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
|
const customersLoading = ref(false)
|
||||||
|
const warehouses = ref<RelabelWarehouseOptionDto[]>([])
|
||||||
|
const warehousesLoading = ref(false)
|
||||||
|
const warehouseRequestSequence = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -46,35 +50,90 @@ const columns: DataTableColumn[] = [
|
|||||||
]
|
]
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const dialogMode = ref<'create' | 'edit'>('create')
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
const form = reactive({ orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
const submitting = ref(false)
|
||||||
|
const editSourceStatus = ref('')
|
||||||
|
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||||
|
const createIdempotencyKey = ref('')
|
||||||
|
const allowedStatusOptions = computed(() => {
|
||||||
|
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
|
||||||
|
if (editSourceStatus.value === 'PROCESSING') return ['COMPLETED', 'REJECTED', 'CANCELLED']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
|
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
|
||||||
function openForm(mode: 'create' | 'edit', source?: object) {
|
function openForm(mode: 'create' | 'edit', source?: object) {
|
||||||
const row = source as RelabelOrderListItem | undefined
|
const row = source as RelabelOrderListItem | undefined
|
||||||
dialogMode.value = mode
|
dialogMode.value = mode
|
||||||
Object.assign(form, { orderNo: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||||
if (mode === 'edit' && row) Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: relabelStatusCode(row.status), labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
|
editSourceStatus.value = ''
|
||||||
if (mode === 'create') void loadWarehouses()
|
if (mode === 'edit' && row) {
|
||||||
|
editSourceStatus.value = relabelStatusCode(row.status)
|
||||||
|
Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: '', labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
|
||||||
|
}
|
||||||
|
if (mode === 'create') {
|
||||||
|
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
|
||||||
|
warehouses.value = []
|
||||||
|
void loadCustomers()
|
||||||
|
}
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouses() {
|
async function loadCustomers() {
|
||||||
try { warehouses.value = (await listCustomerWarehouses()).records }
|
customersLoading.value = true
|
||||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message) }
|
try {
|
||||||
|
const records: PlatformTenantResource[] = []
|
||||||
|
let currentPage = 1
|
||||||
|
let pages = 1
|
||||||
|
do {
|
||||||
|
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
|
||||||
|
records.push(...result.records.filter((item) => item.status === 'ACTIVE'))
|
||||||
|
pages = Math.max(1, result.pages)
|
||||||
|
currentPage += 1
|
||||||
|
} while (currentPage <= pages)
|
||||||
|
customers.value = records
|
||||||
|
} catch (error) { customers.value = []; ElMessage.error(getApiErrorDetails(error, '客户列表加载失败,请重试').message) }
|
||||||
|
finally { customersLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCustomerChange(customerId: string) {
|
||||||
|
const requestSequence = ++warehouseRequestSequence.value
|
||||||
|
form.warehouseId = ''
|
||||||
|
warehouses.value = []
|
||||||
|
if (!customerId) { warehousesLoading.value = false; return }
|
||||||
|
warehousesLoading.value = true
|
||||||
|
try {
|
||||||
|
const records = (await listRelabelWarehouseOptions(customerId)).records.filter((item) => item.status === 'ACTIVE')
|
||||||
|
if (requestSequence === warehouseRequestSequence.value && customerId === form.customerId) warehouses.value = records
|
||||||
|
} catch (error) {
|
||||||
|
if (requestSequence === warehouseRequestSequence.value) ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message)
|
||||||
|
} finally {
|
||||||
|
if (requestSequence === warehouseRequestSequence.value) warehousesLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitForm() {
|
async function submitForm() {
|
||||||
|
if (submitting.value) return
|
||||||
try {
|
try {
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await createRelabelOrder({ customerOrderNo: form.relatedOrderNo, overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName, contactPhone: form.contactPhone, remark: form.remark || undefined, idempotencyKey: `admin-${Date.now()}`, items: [{ skuCode: form.sku, productNameZh: form.productName, totalPackageCount: Number(form.cartons || 0), notifiedPackageCount: Number(form.cartons || 0), shipmentNo: form.shipmentNo, newSkuCode: form.newSkuCode || form.sku, labelRequirement: form.labelRequirement }] })
|
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请先选择客户和该客户的海外仓'); return }
|
||||||
|
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
|
||||||
|
ElMessage.warning('请完整填写换标单必填信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
|
||||||
ElMessage.success('换标单已创建')
|
ElMessage.success('换标单已创建')
|
||||||
} else {
|
} else {
|
||||||
|
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
|
||||||
|
submitting.value = true
|
||||||
await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined)
|
await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined)
|
||||||
ElMessage.success('状态已更新')
|
ElMessage.success('状态已更新')
|
||||||
}
|
}
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
|
createIdempotencyKey.value = ''
|
||||||
await load()
|
await load()
|
||||||
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
|
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
|
||||||
|
finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetail(source: object) {
|
function openDetail(source: object) {
|
||||||
@@ -90,6 +149,10 @@ function relabelStatusCode(value: unknown) {
|
|||||||
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 已完成: 'COMPLETED', 已驳回: 'REJECTED', 已取消: 'CANCELLED' }
|
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 已完成: 'COMPLETED', 已驳回: 'REJECTED', 已取消: 'CANCELLED' }
|
||||||
return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
|
return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
|
||||||
}
|
}
|
||||||
|
function canAudit(source: object) {
|
||||||
|
const row = source as RelabelOrderListItem
|
||||||
|
return ['PENDING_PROCESSING', 'PROCESSING'].includes(relabelStatusCode(row.status))
|
||||||
|
}
|
||||||
async function load(model: QueryModel = filters.value) {
|
async function load(model: QueryModel = filters.value) {
|
||||||
Object.assign(filters.value, model)
|
Object.assign(filters.value, model)
|
||||||
loading.value = true; loadError.value = null
|
loading.value = true; loadError.value = null
|
||||||
@@ -107,6 +170,7 @@ async function load(model: QueryModel = filters.value) {
|
|||||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -115,27 +179,28 @@ onMounted(load)
|
|||||||
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
|
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||||
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
||||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||||
</DataTable></section>
|
</DataTable></section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" @confirm="submitForm">
|
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
|
||||||
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
|
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
|
||||||
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
|
||||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="item.warehouseName" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户" @change="handleCustomerChange"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" placeholder="请输入商品SKU" /></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" :disabled="!form.customerId" placeholder="请先选择客户,再选择其海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col>
|
||||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></el-col>
|
||||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" /></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" /></el-form-item></el-col>
|
||||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" /></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" /></el-form-item></el-col>
|
||||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="状态"><el-select v-model="form.status"><el-option v-for="value in ['PROCESSING', 'COMPLETED', 'REJECTED', 'CANCELLED']" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="目标状态"><el-select v-model="form.status" placeholder="请选择目标状态"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelStatus(value)" :value="value" /></el-select></el-form-item></el-col>
|
||||||
</el-row><h3>作业要求</h3><el-row :gutter="16">
|
</el-row><h3>作业要求</h3><el-row :gutter="16">
|
||||||
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['全覆盖换标', '加贴新标', '移除原标+换标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
|
||||||
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
|
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" /></el-form-item></el-col>
|
||||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(USD)"><el-input-number v-model="form.relabelCost" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
|
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(只读)"><el-input-number v-model="form.relabelCost" disabled :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
|
||||||
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
|
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
|
||||||
</el-row></el-form>
|
</el-row></el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
|||||||
expect(inventoryList).not.toContain('请先选择客户后查询')
|
expect(inventoryList).not.toContain('请先选择客户后查询')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('connects pallet CRUD after the overseas contract is available', () => {
|
it('connects only the prototype-defined pallet create and edit actions', () => {
|
||||||
const value = source('PalletsView.vue')
|
const value = source('PalletsView.vue')
|
||||||
expect(value).toContain('<AppDialog')
|
expect(value).toContain('<AppDialog')
|
||||||
expect(value).toContain('760px')
|
expect(value).toContain('760px')
|
||||||
@@ -156,9 +156,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
|||||||
expect(value).toContain('placeholder="请选择海外仓"')
|
expect(value).toContain('placeholder="请选择海外仓"')
|
||||||
expect(value).toContain('!form.warehouseId')
|
expect(value).toContain('!form.warehouseId')
|
||||||
expect(value).toContain('updatePallet')
|
expect(value).toContain('updatePallet')
|
||||||
expect(value).toContain('deletePallet')
|
expect(value).not.toContain('deletePallet')
|
||||||
expect(value).toContain('<el-button type="primary" @click="openCreate">')
|
expect(value).toContain('v-if="access.can(permission.palletCreate)"')
|
||||||
expect(value).not.toContain('v-if="access.can(permission.palletCreate)"')
|
expect(value).toContain('version: detail.version')
|
||||||
|
expect(value).toContain('{ ...payload, version: form.version }')
|
||||||
expect(value).toContain('v-model="form.fbaWarehouseCode"')
|
expect(value).toContain('v-model="form.fbaWarehouseCode"')
|
||||||
expect(value).toContain("warehouseType: 'FBA'")
|
expect(value).toContain("warehouseType: 'FBA'")
|
||||||
expect(value).toContain('listAllPlatformWarehouses')
|
expect(value).toContain('listAllPlatformWarehouses')
|
||||||
|
|||||||
@@ -136,4 +136,19 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
|||||||
expect(second[2]).toEqual(first[2])
|
expect(second[2]).toEqual(first[2])
|
||||||
expect(second[2]).toMatchObject({ expectedVersion: 1, standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) })
|
expect(second[2]).toMatchObject({ expectedVersion: 1, standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires an estimated or actual time before calling the manual-node adapter', async () => {
|
||||||
|
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||||
|
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
|
||||||
|
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
|
||||||
|
wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'PICKUP_READY')
|
||||||
|
wrapper.findAllComponents({ name: 'ElInput' }).find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核验可提柜')
|
||||||
|
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('预计时间和实际时间至少填写一项')
|
||||||
|
expect(appendCarrierScheduleManualNode).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue'), 'utf8')
|
||||||
|
const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/api/platformCarrierScheduleQueries.ts'), 'utf8')
|
||||||
|
|
||||||
describe('CarrierScheduleQueryPanel', () => {
|
describe('CarrierScheduleQueryPanel', () => {
|
||||||
it('uses the existing relation filters and never creates or executes polling tasks', () => {
|
it('uses the existing relation filters and never creates or executes polling tasks', () => {
|
||||||
@@ -60,4 +61,14 @@ describe('CarrierScheduleQueryPanel', () => {
|
|||||||
expect(source).toContain('item.providerStatus')
|
expect(source).toContain('item.providerStatus')
|
||||||
expect(source).not.toContain('/' + 'manual-results')
|
expect(source).not.toContain('/' + 'manual-results')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps retired progress fields and forbidden write endpoints out of the panel and adapter', () => {
|
||||||
|
const productionSource = `${source}\n${apiSource}`
|
||||||
|
expect(productionSource).not.toContain(['latest', 'StandardNode'].join(''))
|
||||||
|
expect(productionSource).not.toContain(['latest', 'NodeObservedAt'].join(''))
|
||||||
|
expect(productionSource).not.toContain('/' + 'attempts')
|
||||||
|
expect(productionSource).not.toContain('/' + 'manual-results')
|
||||||
|
expect(productionSource).not.toContain('/' + 'scheduler')
|
||||||
|
expect(apiSource).toContain('/manual-nodes')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['scripts/phase4-development-frontend.acceptance.ts'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 120_000,
|
||||||
|
hookTimeout: 120_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user