Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c78fbd2d45 | ||
|
|
d761bff871 | ||
|
|
e10e079565 | ||
|
|
110d52fd5b | ||
|
|
bd76c977e0 | ||
|
|
f5f62af4a7 | ||
|
|
8d80074266 |
+4
-3
@@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。
|
||||
ENV NODE_OPTIONS=--max-old-space-size=2048
|
||||
|
||||
# Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。
|
||||
ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
|
||||
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||
@@ -21,9 +24,7 @@ RUN pnpm config set registry https://registry.npmmirror.com && \
|
||||
COPY . .
|
||||
ARG VITE_CLIENT_WEB_URL
|
||||
ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
|
||||
# Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。
|
||||
# pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest
|
||||
# 把 maxWorkers 当作位置参数,导致并发限制不生效。
|
||||
# 类型检查通过后才生成静态产物。
|
||||
RUN pnpm build
|
||||
|
||||
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
|
||||
|
||||
Vendored
+6
-2
@@ -3,6 +3,7 @@ pipeline {
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
timeout(time: 20, unit: 'MINUTES')
|
||||
}
|
||||
stages {
|
||||
stage('检查测试发布参数') {
|
||||
@@ -27,8 +28,11 @@ pipeline {
|
||||
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||
steps {
|
||||
sh '''set -eu
|
||||
docker compose -f docker-compose.test.yml build --pull=false
|
||||
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
|
||||
sh scripts/build-image.sh --pull=false \
|
||||
--build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
|
||||
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
|
||||
-t "admin-web-test:$IMAGE_TAG" .
|
||||
docker compose -f docker-compose.test.yml up -d --no-build --pull never --wait --wait-timeout 120
|
||||
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
|
||||
容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。
|
||||
|
||||
Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。
|
||||
|
||||
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||
|
||||
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.40",
|
||||
"vue-echarts": "^8.3.2",
|
||||
@@ -27,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
|
||||
Generated
+170
@@ -29,6 +29,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3))
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
sortablejs:
|
||||
specifier: ^1.15.7
|
||||
version: 1.15.7
|
||||
@@ -45,6 +48,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.10.0
|
||||
version: 24.13.3
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@types/sortablejs':
|
||||
specifier: ^1.15.9
|
||||
version: 1.15.9
|
||||
@@ -376,6 +382,9 @@ packages:
|
||||
'@types/node@24.13.3':
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/sortablejs@1.15.9':
|
||||
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
||||
|
||||
@@ -567,6 +576,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -575,6 +588,9 @@ packages:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -629,6 +645,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -640,6 +660,9 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -719,6 +742,10 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -744,6 +771,10 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -908,6 +939,10 @@ packages:
|
||||
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
@@ -990,6 +1025,18 @@ packages:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -999,6 +1046,10 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1036,6 +1087,10 @@ packages:
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
postcss@8.5.23:
|
||||
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1051,6 +1106,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -1058,10 +1118,17 @@ packages:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
rolldown@1.1.5:
|
||||
resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1079,6 +1146,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1394,6 +1464,9 @@ packages:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1404,6 +1477,10 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1419,11 +1496,22 @@ packages:
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
@@ -1674,6 +1762,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 24.13.3
|
||||
|
||||
'@types/sortablejs@1.15.9': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
@@ -1912,12 +2004,20 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -1967,12 +2067,16 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -2053,6 +2157,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
follow-redirects@1.16.0(debug@4.4.3):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
@@ -2075,6 +2184,8 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -2240,6 +2351,10 @@ snapshots:
|
||||
pkg-types: 2.3.1
|
||||
quansync: 0.2.11
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1):
|
||||
@@ -2303,6 +2418,16 @@ snapshots:
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
parse5@8.0.1:
|
||||
@@ -2311,6 +2436,8 @@ snapshots:
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
@@ -2346,6 +2473,8 @@ snapshots:
|
||||
exsolve: 1.1.0
|
||||
pathe: 2.0.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
@@ -2358,12 +2487,22 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
rolldown@1.1.5:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.139.0
|
||||
@@ -2393,6 +2532,8 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -2665,6 +2806,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -2674,6 +2817,12 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -2690,8 +2839,29 @@ snapshots:
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。
|
||||
available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo)
|
||||
case "$available_kib" in
|
||||
''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;;
|
||||
esac
|
||||
if [ "$available_kib" -lt 4194304 ]; then
|
||||
echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。
|
||||
# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。
|
||||
exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \
|
||||
docker build --memory=3g --memory-swap=3g \
|
||||
--cpu-period=100000 --cpu-quota=200000 --force-rm "$@"
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const wrapper = join(root, 'scripts/build-image.sh')
|
||||
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
|
||||
const shellAvailable = process.platform !== 'win32' || existsSync(bash)
|
||||
|
||||
// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。
|
||||
function runBuild(availableMemory, childStatus = '0') {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-'))
|
||||
try {
|
||||
writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 })
|
||||
writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 })
|
||||
writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 })
|
||||
return spawnSync(bash, ['-c',
|
||||
'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"',
|
||||
'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'),
|
||||
'--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], {
|
||||
encoding: 'utf8', timeout: 10000,
|
||||
env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus }
|
||||
})
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
|
||||
rmSync(directory, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('TEST and gray builds share the resource gate and deployment cannot rebuild implicitly', () => {
|
||||
const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8')
|
||||
const production = readFileSync(join(root, 'scripts/deploy-production.sh'), 'utf8')
|
||||
const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8')
|
||||
for (const source of [pipeline, production]) {
|
||||
assert.match(source, /sh scripts\/build-image\.sh/)
|
||||
assert.doesNotMatch(source, /docker (?:compose[^\n]* build|build\s)/)
|
||||
}
|
||||
assert.match(pipeline, /timeout\(time: 20, unit: 'MINUTES'\)/)
|
||||
assert.match(pipeline, /up -d --no-build --pull never --wait/)
|
||||
assert.ok(pipeline.includes(`-t "${basename(root)}-test:$IMAGE_TAG"`))
|
||||
assert.match(dockerfile, /^ENV NODE_OPTIONS=--max-old-space-size=2048$/m)
|
||||
})
|
||||
|
||||
test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => {
|
||||
const result = runBuild('4194304', '23')
|
||||
assert.equal(result.status, 23, result.stderr)
|
||||
assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/)
|
||||
assert.match(result.stdout, /builder=0\r?\nbuild/)
|
||||
for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) {
|
||||
assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument)
|
||||
}
|
||||
assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./)
|
||||
assert.equal(runBuild('8388608').status, 0)
|
||||
assert.equal(runBuild('8388608', '124').status, 124)
|
||||
})
|
||||
|
||||
test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => {
|
||||
for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) {
|
||||
const result = runBuild(memory)
|
||||
assert.equal(result.status, status, result.stderr)
|
||||
assert.equal(result.stdout, '')
|
||||
}
|
||||
})
|
||||
@@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d)
|
||||
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
|
||||
docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
|
||||
sh scripts/build-image.sh --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
|
||||
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
|
||||
--build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" .
|
||||
docker push "$image"
|
||||
|
||||
@@ -25,7 +25,8 @@ describe('InboundTaskList measurement scope', () => {
|
||||
expect(toolbar).toContain('</h1></slot>')
|
||||
expect(filters).toContain('<QueryToolbar')
|
||||
expect(filters).not.toContain('name="status-tabs"')
|
||||
expect(source).toContain('<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>')
|
||||
expect(source).toContain('<template #toolbar-actions>')
|
||||
expect(source).toContain('v-if="isReview && access.can(actionPermission)" type="success"')
|
||||
})
|
||||
|
||||
it('supports selecting only pending measurement reviews for batch approval', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const props = defineProps<{
|
||||
title?: string
|
||||
taskLoader?: (pagination: { page: number; size: number }) => Promise<InboundPageResult>
|
||||
}>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const emit = defineEmits<{ changed: []; selectionChange: [rows: InboundOrderSummary[]] }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
|
||||
@@ -169,7 +169,7 @@ async function loadOptions() {
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
selectedReviewRows.value = []
|
||||
handleReviewSelectionChange([])
|
||||
const serial = ++requestSerial
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
@@ -270,6 +270,7 @@ async function handleTaskChanged() {
|
||||
|
||||
function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
|
||||
selectedReviewRows.value = rows.filter((row) => row.measureReviewStatus === 'PENDING')
|
||||
emit('selectionChange', selectedReviewRows.value)
|
||||
}
|
||||
|
||||
async function batchApproveMeasurements() {
|
||||
@@ -399,8 +400,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
</template></template>
|
||||
|
||||
|
||||
<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>
|
||||
<el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="isReview && access.can(actionPermission)" type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
|
||||
<slot name="toolbar-actions" :loading="loading || batchReviewLoading" />
|
||||
</template>
|
||||
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
|
||||
|
||||
@@ -152,10 +152,10 @@ describe('platform overseas business API adapter', () => {
|
||||
})
|
||||
|
||||
it('supports looseboard list/detail/create and PUT update', async () => {
|
||||
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [], dispatchNos: ['PC-1', 'PC-2'] }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
|
||||
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
|
||||
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
|
||||
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ status: 'PENDING_ACCEPTANCE', dispatchNos: ['PC-1', 'PC-2'] })
|
||||
const create = { productName: '家居用品', estimatedPalletCount: 2, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
|
||||
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
|
||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
|
||||
@@ -180,7 +180,7 @@ describe('platform overseas business API adapter', () => {
|
||||
|
||||
it('preserves unknown pool pallet service and product name in list and detail responses', async () => {
|
||||
const summary = { ...looseboardSummary, palletRequired: null, productName: null }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [], dispatchNos: [] }))
|
||||
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, productName: null })
|
||||
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, productName: null })
|
||||
expect(overseas.get).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -27,7 +27,7 @@ interface TruckDetailPayload {
|
||||
interface LooseboardSummaryPayload extends Omit<LocalLooseboardOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface LooseboardDetailPayload {
|
||||
summary: LooseboardSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
|
||||
remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]
|
||||
remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]; dispatchNos: string[]
|
||||
}
|
||||
|
||||
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
|
||||
@@ -49,8 +49,9 @@ function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
|
||||
function looseboardSummary(value: LooseboardSummaryPayload): LocalLooseboardOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
}
|
||||
/** 详情保留按原单查询的全部排车,不依赖车队预订摘要。 */
|
||||
function looseboardDetail(value: LooseboardDetailPayload): LocalLooseboardDetail {
|
||||
return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [] }
|
||||
return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [], dispatchNos: value.dispatchNos }
|
||||
}
|
||||
|
||||
export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = 200) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { LocalLooseboardDetail } from '../types'
|
||||
import LooseboardOrderDetailView from './LooseboardOrderDetailView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
|
||||
const permissions = vi.hoisted(() => ({ can: vi.fn(() => false) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => permissions }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ getLocalLooseboardOrder: vi.fn() }))
|
||||
|
||||
@@ -21,7 +23,7 @@ function order(overrides: Partial<LocalLooseboardDetail> = {}): LocalLooseboardD
|
||||
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', packageCount: 20,
|
||||
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
|
||||
status: 'PENDING_ACCEPTANCE', version: 1, remark: '运输备注', createdBy: '7',
|
||||
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], ...overrides,
|
||||
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], dispatchNos: [], ...overrides,
|
||||
}
|
||||
}
|
||||
function mountDetail() {
|
||||
@@ -31,11 +33,33 @@ function mountDetail() {
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
permissions.can.mockReturnValue(false)
|
||||
routing.route = reactive({ params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order())
|
||||
})
|
||||
|
||||
describe('looseboard document fees', () => {
|
||||
it('shows all actual dispatches without a fleet booking and links only with dispatch permission', async () => {
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order({ dispatchNos: ['PC-1', 'PC-2'] }))
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.find('.looseboard-dispatches').text()).toContain('PC-1')
|
||||
expect(wrapper.find('.looseboard-dispatches').text()).toContain('PC-2')
|
||||
expect(wrapper.find('.looseboard-dispatches button').exists()).toBe(false)
|
||||
permissions.can.mockReturnValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '刷新订单')!.trigger('click')
|
||||
await flushPromises()
|
||||
const links = wrapper.findAll('.looseboard-dispatches button')
|
||||
expect(links).toHaveLength(2)
|
||||
await links[1]!.trigger('click')
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC-2' } })
|
||||
})
|
||||
|
||||
it('identifies a looseboard order that has not been dispatched', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('排车单号')
|
||||
expect(wrapper.text()).toContain('暂未排车')
|
||||
})
|
||||
|
||||
it('refreshes the current status when returning to a cached document tab', async () => {
|
||||
const visible = ref(true)
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND' }))
|
||||
|
||||
@@ -3,6 +3,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -19,13 +20,14 @@ import { poolOrderStatusLabels } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
|
||||
|
||||
const route = useRoute(), router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<LocalLooseboardDetail | null>(null), loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let requestSequence = 0
|
||||
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
const statusLabels: Record<string, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
|
||||
const statusLabels: Record<string, string> = { FUNDING_PENDING: '资金处理中', FUNDING_FAILED: '资金冻结失败', PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
|
||||
const statusLabel = (value: string | null) => value ? statusLabels[value] || value : '—'
|
||||
const fields = computed(() => detail.value ? [
|
||||
['客户编号', detail.value.customerId], ['商品名称', detail.value.productName], ['预计托数', detail.value.estimatedPalletCount],
|
||||
@@ -81,7 +83,10 @@ onActivated(() => {
|
||||
<template v-else-if="detail">
|
||||
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
|
||||
<section v-if="detail.deliveryMode === 'WAREHOUSE_PICKUP' && detail.transferNo" class="looseboard-detail-section"><el-alert title="上门取货需在对应取货排车的作业节点上传取货及到仓资料。" type="info" :closable="false" /><TransferPickupProgressPanel :transfer-no="detail.transferNo" :customer-id="detail.customerId" /></section>
|
||||
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border>
|
||||
<el-descriptions-item label="排车单号" :span="3"><div v-if="detail.dispatchNos.length" class="looseboard-dispatches"><template v-for="dispatchNo in detail.dispatchNos" :key="dispatchNo"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" link type="primary" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo } })">{{ dispatchNo }}</el-button><span v-else>{{ dispatchNo }}</span></template></div><span v-else>暂未排车</span></el-descriptions-item>
|
||||
<el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item>
|
||||
</el-descriptions></section>
|
||||
<section class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
|
||||
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
|
||||
</template>
|
||||
@@ -92,4 +97,5 @@ onActivated(() => {
|
||||
.looseboard-detail-header { flex-wrap: wrap; }
|
||||
.looseboard-detail-header .density-detail-heading { flex: 1; }
|
||||
.looseboard-detail-section { margin-bottom: var(--admin-space-6); }
|
||||
.looseboard-dispatches { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import { submitProcurementCommand } from '../api/procurementCommands'
|
||||
@@ -32,6 +33,10 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/warehouses', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@/modules/warehouses')>(),
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/procurementCommands')>(),
|
||||
@@ -61,7 +66,11 @@ const procurementDetail = (): PlatformProcurement => ({
|
||||
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', vehicleType: 'PALLET_15', origin: 'Long Beach', destination: 'Ontario', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', originContactName: 'Bob', originContactPhone: '10010', destinationContactName: 'Alice', destinationContactPhone: '10020', pickupAt: '2026-08-28T09:30:00', deliveryAt: '2026-08-28T15:45:00', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
const looseboardDetail: LocalLooseboardDetail = {
|
||||
...looseboard, originAddress: null, destinationAddress: 'Delivery street', remark: null, createdBy: '7', histories: [], dispatchNos: [],
|
||||
operatingWarehouseId: '20001', destinationPostalCode: '90001', deliveryContactName: '送仓联系人',
|
||||
deliveryContactPhone: '555-0100', estimatedArrivalAt: '2026-10-20T08:00:00',
|
||||
}
|
||||
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
|
||||
|
||||
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
|
||||
@@ -106,6 +115,7 @@ describe('overseas business mounted pages', () => {
|
||||
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
|
||||
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '可选客户', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [{ id: '20001', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', status: 'ACTIVE', contactName: '送仓联系人', contactPhone: '555-0100' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
})
|
||||
|
||||
|
||||
@@ -151,7 +161,7 @@ describe('overseas business mounted pages', () => {
|
||||
expect(document.body.textContent).toContain('池来源订单按实际运输履约推进')
|
||||
expect(document.body.textContent).toContain('FB-TEST'); expect(document.body.textContent).toContain('— / — / —')
|
||||
expect(listUsTruckOrders).toHaveBeenCalledOnce(); expect(getUsTruckOrder).not.toHaveBeenCalled()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '承运安排')!
|
||||
await dialog.findAll('.app-dialog__footer button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
@@ -405,9 +415,10 @@ describe('overseas business mounted pages', () => {
|
||||
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
|
||||
const routeLabels = domain === 'truck'
|
||||
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
|
||||
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
|
||||
: ['目的地邮编', '送货方式', '收货仓库', '到仓联系人', '到仓联系电话', '预计到货时间', '物流跟踪号', '目的地']
|
||||
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
|
||||
Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originAddress: ' Pickup street ', destinationAddress: ' Delivery street ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
|
||||
if (domain === 'looseboard') Object.assign(model, { deliveryMode: 'WAREHOUSE_PICKUP', operatingWarehouseId: '20001', destinationPostalCode: '90001', packageCount: 1 })
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
|
||||
await submit.trigger('click'); await flushPromises()
|
||||
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
@@ -445,6 +456,7 @@ describe('overseas business mounted pages', () => {
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
|
||||
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
|
||||
Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: 80, totalVolumeCbm: 1.8, deliveryAt: '2026-10-20T11:30:00' })
|
||||
Object.assign(model, { deliveryMode: 'WAREHOUSE_PICKUP', operatingWarehouseId: '20001', destinationPostalCode: '90001', originAddress: 'Pickup street', destinationAddress: 'Delivery street', budgetAmount: 50 })
|
||||
await wrapper.vm.$nextTick()
|
||||
const save = wrapper.findAll('button').find(button => button.text().includes('提交订单'))!
|
||||
await save.trigger('click'); await flushPromises()
|
||||
@@ -462,7 +474,31 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); 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() })
|
||||
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 model = wrapper.findComponent({ name: 'ElForm' }).props('model')
|
||||
Object.assign(model, {
|
||||
customerId: '10001', deliveryMode: 'WAREHOUSE_PICKUP', operatingWarehouseId: '20001', destinationPostalCode: '90001',
|
||||
originAddress: 'Pickup street', destinationAddress: 'Delivery street', budgetAmount: 50,
|
||||
productName: '家居用品', packageCount: 1, estimatedPalletCount: 1,
|
||||
originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2',
|
||||
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', totalWeightKg: 80, totalVolumeCbm: 1.8,
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]')
|
||||
const destination = wrapper.find('input[placeholder="城市 / 州"]')
|
||||
await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER')
|
||||
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()
|
||||
})
|
||||
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
|
||||
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
|
||||
@@ -42,7 +42,7 @@ const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
|
||||
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
|
||||
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { FUNDING_PENDING: '资金处理中', FUNDING_FAILED: '资金冻结失败', PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const statusLabels = computed<Record<string, string>>(() => isLooseboard.value ? looseboardStatusLabels : truckStatusLabels)
|
||||
const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning' | 'primary' | 'success' | 'danger' | 'info'> = {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
if (field === 'packageCount' && kind === 'truck') continue
|
||||
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||
}
|
||||
rules.budgetAmount = [{ required: kind === 'truck', type: 'number', min: kind === 'truck' ? 0.01 : 0, message: '请输入大于 0 的预算总价', trigger: 'change' }]
|
||||
rules.budgetAmount = [{ required: true, type: 'number', min: kind === 'truck' ? 0.01 : 0, message: kind === 'truck' ? '请输入大于 0 的预算总价' : '请输入预算总价,不能小于 0', trigger: 'change' }]
|
||||
const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
|
||||
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
|
||||
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
|
||||
@@ -110,6 +110,7 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
|
||||
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
|
||||
if (kind === 'truck' && (!Number.isSafeInteger(form.estimatedPalletCount) || form.estimatedPalletCount <= 0)) return '请输入大于 0 的托数'
|
||||
if (kind === 'truck' && (form.budgetAmount == null || !Number.isFinite(form.budgetAmount) || form.budgetAmount <= 0)) return '请输入大于 0 的预算总价'
|
||||
if (kind === 'looseboard' && form.budgetAmount == null) return '请输入预算总价'
|
||||
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
|
||||
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
|
||||
return undefined
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransp
|
||||
}
|
||||
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export type LocalLooseboardStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export interface LocalLooseboardQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
|
||||
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
|
||||
@@ -92,6 +92,7 @@ export interface LocalLooseboardOrder {
|
||||
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
|
||||
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
|
||||
histories: OverseasBusinessHistory[]
|
||||
dispatchNos: string[]
|
||||
}
|
||||
export interface CreateLocalLooseboardOrderRequest {
|
||||
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import Dialog from './LooseboardCargoDialog.vue'
|
||||
import { candidate, detail } from '../../overseas-warehouse/dispatchTestFixtures'
|
||||
import * as api from '@/modules/overseas-warehouse'
|
||||
|
||||
vi.mock('@/modules/overseas-warehouse', async () => ({
|
||||
getDispatch: vi.fn(), listLooseboardCargo: vi.fn(), addLooseboardCargo: vi.fn(),
|
||||
expandSelectionGroups: (await import('../../overseas-warehouse/dispatchContracts')).expandSelectionGroups,
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() } }))
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('selects one looseboard order and submits all original pallet selections together', async () => {
|
||||
const first = candidate({ orderNo: 'LB1', availablePackages: 9 })
|
||||
const second = candidate({ sourceKey: 'source-2', sourceVersion: 'version-2', selection: { ...first.selection, planPalletId: '2', sourceVersion: 'version-2' } })
|
||||
first.selectionGroup = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [...first.selectionGroup.pallets, { warehousePalletId: null, palletNo: 'P2', sourceKeys: [second.sourceKey], packageCount: 2 }] }
|
||||
const dispatch = detail(); dispatch.actions.ADD_CARGO = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(dispatch)
|
||||
vi.mocked(api.listLooseboardCargo).mockResolvedValue({ candidates: [first], total: 1, page: 1, size: 20, pages: 1, complete: true, snapshotAt: '2026-11-05T00:00:00Z' })
|
||||
const wrapper = shallowMount(Dialog, { props: { modelValue: false, dispatchNo: 'PC/1' }, global: { renderStubDefaultSlot: true, stubs: { ElAlert: true } } })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const table = wrapper.getComponent({ name: 'DataTable' })
|
||||
expect(table.props('data')).toHaveLength(1)
|
||||
expect(table.props('columns').map((column: { label?: string }) => column.label)).toContain('托数')
|
||||
expect(table.props('columns').map((column: { label?: string }) => column.label)).not.toContain('计划 / 实物托盘')
|
||||
table.vm.$emit('selection-change', [first]); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.addLooseboardCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection] }))
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -15,7 +15,7 @@ const page=ref(1), size=ref(20), total=ref(0), loading=ref(false), saving=ref(fa
|
||||
const columns:DataTableColumn[]=[
|
||||
{key:'selection',type:'selection',width:45}, {key:'orderNo',label:'订单号',minWidth:180},
|
||||
{key:'customerName',label:'客户',minWidth:140}, {key:'inboundCode',label:'入仓号',minWidth:150},
|
||||
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>!row.selection.warehousePalletId ? '申报待到仓' : '已实收'}, {key:'palletNo',label:'计划 / 实物托盘',minWidth:150}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
|
||||
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>row.selectionGroup?.sources.every(source=>source.selection.warehousePalletId) ? '已实收' : '申报待到仓'}, {key:'palletCount',label:'托数',width:80,formatter:(row:Candidate)=>row.selectionGroup?.pallets.length ?? '—'}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
|
||||
{key:'availablePackages',label:'计划 / 可用箱数',width:100},
|
||||
{key:'blockReasons',label:'资格',minWidth:200,formatter:(row:Candidate)=>row.blockReasons.length?row.blockReasons.join(';'):'可添加'},
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface OutsourcedBusinessQuery { page?: number; size?: number; planned
|
||||
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
|
||||
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
|
||||
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
|
||||
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
|
||||
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string | null; destinationKey: string; destinationKeys?: string[]; packages: string | null }[]; photos: OutsourcedBusinessPhoto[] }
|
||||
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
|
||||
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface OwnedBusinessQuery { page?: number; size?: number; plannedDepar
|
||||
export interface OwnedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
|
||||
export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
|
||||
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
|
||||
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
|
||||
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string | null; destinationKey: string; destinationKeys?: string[]; packages: string | null }[]; photos: OwnedBusinessPhoto[] }
|
||||
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
|
||||
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||
|
||||
@@ -23,7 +23,7 @@ const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), hist
|
||||
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
|
||||
const previewUrls = new Set<string>()
|
||||
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
|
||||
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
|
||||
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
|
||||
const taskFields = computed(() => {
|
||||
const row = detail.value?.row
|
||||
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
|
||||
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
|
||||
<template #state><LoadErrorState v-if="error" title="外配任务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
|
||||
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
|
||||
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
|
||||
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ (row.destinationKeys || [row.destinationKey]).map(destinationLabel).join('、') }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
|
||||
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
|
||||
</OutsourcedBusinessDetailLayout>
|
||||
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
|
||||
|
||||
@@ -30,6 +30,19 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('outsourced business views', () => {
|
||||
it('shows the complete destination set on a grouped looseboard order', async () => {
|
||||
const detail = outsourcedBusinessFixture()
|
||||
detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' })
|
||||
detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }]
|
||||
calls.detail.mockResolvedValue(detail)
|
||||
const wrapper = mount(OutsourcedBusinessDetailView, { global }); await flushPromises()
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises()
|
||||
const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))!
|
||||
expect(table.props('data')).toHaveLength(1)
|
||||
expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' })
|
||||
expect(table.text()).toContain('FBA-1、FBA-2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps an owned vehicle with pooled cargo in the outsourced task list', async () => {
|
||||
const detail = outsourcedBusinessFixture()
|
||||
detail.row.vehicle.ownership = 'OWNED'
|
||||
|
||||
@@ -23,7 +23,7 @@ const history = ref<OwnedBusinessHistory[]>([]), historyPage = ref(1), historySi
|
||||
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
|
||||
const previewUrls = new Set<string>()
|
||||
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
|
||||
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
|
||||
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
|
||||
const taskFields = computed(() => {
|
||||
const row = detail.value?.row
|
||||
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
|
||||
<template #actions><el-button v-if="detail && access.can(permission.ownedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.ownedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
|
||||
<template #state><LoadErrorState v-if="error" title="自有业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
|
||||
<template #task><dl v-if="detail" class="density-fields owned-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
|
||||
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
|
||||
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ (row.destinationKeys || [row.destinationKey]).map(destinationLabel).join('、') }}</template></DataTable></div><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
|
||||
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ ownedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><p>里程 {{ row.operations.actualDistanceMiles ?? '未登记' }} mile · 油耗 {{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }} L/100km</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
|
||||
</OwnedBusinessDetailLayout>
|
||||
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
|
||||
|
||||
@@ -30,6 +30,19 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('owned business views', () => {
|
||||
it('shows one looseboard order with all associated destinations and pallets', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' })
|
||||
detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }]
|
||||
calls.detail.mockResolvedValue(detail)
|
||||
const wrapper = mount(OwnedBusinessDetailView, { global }); await flushPromises()
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises()
|
||||
const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))!
|
||||
expect(table.props('data')).toHaveLength(1)
|
||||
expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' })
|
||||
expect(table.text()).toContain('FBA-1、FBA-2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
|
||||
const detail = ownedBusinessFixture()
|
||||
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'
|
||||
|
||||
@@ -54,7 +54,7 @@ it('hides the single delivery selector and saves receipt files to that delivery
|
||||
await wrapper.get('.save').trigger('click'); await flushPromises()
|
||||
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('modelValue')).toBe(true)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
||||
@@ -91,6 +91,18 @@ it('switches customer scope exactly and keeps unsaved delivery edits when switch
|
||||
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
|
||||
confirmation.mockRestore(); wrapper.unmount()
|
||||
})
|
||||
it('keeps the management dialog and draft open when saving fails', async () => {
|
||||
vi.mocked(saveDeliveryDocuments).mockRejectedValue(new Error('保存失败'))
|
||||
const wrapper = setup(); await flushPromises()
|
||||
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
|
||||
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
await wrapper.get('.save').trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
expect(wrapper.text()).toContain('保存失败')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not request delivery documents without delivery list permission', async () => {
|
||||
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
|
||||
const wrapper = setup(); await flushPromises()
|
||||
|
||||
@@ -55,7 +55,7 @@ async function canLeave() {
|
||||
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
|
||||
async function beforeClose(done: () => void) { if (await canLeave()) done() }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
|
||||
function saved() { emit('saved') }
|
||||
function saved() { open.value = false; emit('saved') }
|
||||
function pickupCompleted() { open.value = false; emit('saved') }
|
||||
async function loadingSaved() { emit('saved'); await load() }
|
||||
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchReturnCargo from './DispatchReturnCargo.vue'
|
||||
import { getDispatchReturnOptions } from '../api/dispatches'
|
||||
import type { DispatchReturnCandidate, DispatchReturnSelection } from '../dispatchContracts'
|
||||
|
||||
vi.mock('../api/dispatches', () => ({ getDispatchReturnOptions: vi.fn() }))
|
||||
|
||||
const candidates: DispatchReturnCandidate[] = [
|
||||
{ customerId: '101', customerName: '客户甲', deliveryNo: 'DL-1', lastMileOrderId: '11', lastMileOrderNo: '同号订单', sources: [
|
||||
{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, palletNo: 'P-1', skuCode: 'SKU-A', cartonMark: 'BOX-A', maxReturnPackages: 3 },
|
||||
] },
|
||||
{ customerId: '102', customerName: '客户乙', deliveryNo: 'DL-2', lastMileOrderId: '12', lastMileOrderNo: '同号订单', sources: [
|
||||
{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, palletNo: 'P-2', skuCode: 'SKU-B', cartonMark: 'BOX-B', maxReturnPackages: 5 },
|
||||
] },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(getDispatchReturnOptions).mockResolvedValue(structuredClone(candidates))
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
})
|
||||
|
||||
it('selects multiple customer orders, preserves independent quantities and removes only the deselected order', async () => {
|
||||
const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(getDispatchReturnOptions).toHaveBeenCalledExactlyOnceWith('PC-1')
|
||||
const select = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(select.props('multiple')).toBe(true)
|
||||
const values = wrapper.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))
|
||||
expect(new Set(values).size).toBe(2)
|
||||
/** 模拟选择控件的真实双向绑定和 change,验证用户操作后的提交模型。 */
|
||||
async function choose(selected: unknown[]) {
|
||||
select.vm.$emit('update:modelValue', selected)
|
||||
select.vm.$emit('change', selected)
|
||||
await flushPromises()
|
||||
}
|
||||
/** 数量控件先更新绑定,再触发提交模型重算。 */
|
||||
async function count(index: number, value: number) {
|
||||
const input = wrapper.findAllComponents({ name: 'ElInputNumber' })[index]!
|
||||
input.vm.$emit('update:modelValue', value)
|
||||
input.vm.$emit('change', value)
|
||||
await flushPromises()
|
||||
}
|
||||
/** 只核对公开的 v-model 输出,不依赖组件内部状态。 */
|
||||
function selected() { return wrapper.emitted('update:modelValue')!.at(-1)![0] as DispatchReturnSelection[] }
|
||||
await choose([values[0]])
|
||||
await count(0, 1)
|
||||
await choose(values)
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([1, undefined])
|
||||
await count(1, 2)
|
||||
expect(selected()).toEqual([
|
||||
{ deliveryNo: 'DL-1', lastMileOrderId: '11', items: [{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, packageCount: 1 }] },
|
||||
{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] },
|
||||
])
|
||||
await choose([values[1]])
|
||||
expect(selected()).toEqual([{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] }])
|
||||
await choose(values)
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([undefined, 2])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears stale selections and exposes a retry when return sources cannot be loaded', async () => {
|
||||
vi.mocked(getDispatchReturnOptions).mockRejectedValueOnce(new Error('来源读取失败'))
|
||||
const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false, modelValue: [{ deliveryNo: 'OLD', lastMileOrderId: 'OLD', items: [] }] }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('来源货物加载失败')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]])
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
|
||||
await wrapper.get('[role="alert"] button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(2)
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -6,20 +6,24 @@ import { getApiErrorDetails } from '@/shared/api/http'
|
||||
const props = defineProps<{ dispatchNo: string; disabled: boolean }>()
|
||||
const selections = defineModel<DispatchReturnSelection[]>({ default: () => [] })
|
||||
const candidates = ref<DispatchReturnCandidate[]>([]), loading = ref(false), error = ref('')
|
||||
const sourceId = ref(''), quantities = ref<number[]>([])
|
||||
const source = computed(() => candidates.value.find(row => row.lastMileOrderId === sourceId.value))
|
||||
/** 原单切换时清空旧货物,避免把另一单数量提交到当前来源。 */
|
||||
function choose() { quantities.value = []; selections.value = [] }
|
||||
/** 仅提交用户明确填写的原出库来源与退回箱数。 */
|
||||
const sourceIds = ref<string[]>([]), quantities = ref<Record<string, number[]>>({})
|
||||
/** 客户、派送单和原订单共同标识选项,避免同号货物串单。 */
|
||||
function sourceKey(row: DispatchReturnCandidate) { return JSON.stringify([row.customerId, row.deliveryNo, row.lastMileOrderId]) }
|
||||
const sources = computed(() => candidates.value.filter(row => sourceIds.value.includes(sourceKey(row))))
|
||||
/** 增选保留已填数量,取消选中的订单立即清除其退回数量与提交内容。 */
|
||||
function choose() {
|
||||
quantities.value = Object.fromEntries(sourceIds.value.map(key => [key, quantities.value[key] || []]))
|
||||
update()
|
||||
}
|
||||
/** 每单分别提交用户填写的原出库来源和退回箱数,不把其他订单数量带入本单。 */
|
||||
function update() {
|
||||
const row = source.value
|
||||
selections.value = row ? [{ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
|
||||
items: row.sources.flatMap((item, index) => quantities.value[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[index]! }] : []) }] : []
|
||||
selections.value = sources.value.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
|
||||
items: row.sources.flatMap((item, index) => quantities.value[sourceKey(row)]?.[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[sourceKey(row)]![index]! }] : []) }))
|
||||
}
|
||||
/** 每次打开按本车最新派送事实加载候选,失败明确阻断退回提交。 */
|
||||
async function load() {
|
||||
loading.value = true; error.value = ''; selections.value = []
|
||||
loading.value = true; error.value = ''; candidates.value = []; sourceIds.value = []; quantities.value = {}; selections.value = []
|
||||
try { candidates.value = await getDispatchReturnOptions(props.dispatchNo) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '来源货物加载失败').message }
|
||||
finally { loading.value = false }
|
||||
@@ -29,11 +33,14 @@ onMounted(load)
|
||||
<template>
|
||||
<section v-loading="loading" aria-label="选择退回货物">
|
||||
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试</el-button></p>
|
||||
<el-form-item label="来源订单" required><el-select v-model="sourceId" :disabled="disabled || loading" placeholder="请选择本车来源订单" @change="choose">
|
||||
<el-option v-for="row in candidates" :key="row.lastMileOrderId" :value="row.lastMileOrderId" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
|
||||
<el-form-item label="来源订单" required><el-select v-model="sourceIds" multiple :disabled="disabled || loading" placeholder="请选择本车来源订单(可多选)" @change="choose">
|
||||
<el-option v-for="row in candidates" :key="sourceKey(row)" :value="sourceKey(row)" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
|
||||
</el-select></el-form-item>
|
||||
<table v-if="source" class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
|
||||
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
|
||||
</tbody></table>
|
||||
<section v-for="source in sources" :key="sourceKey(source)" :aria-label="`退回货物 ${source.lastMileOrderNo} ${source.deliveryNo}`">
|
||||
<h4>{{ source.lastMileOrderNo }} · {{ source.customerName || source.customerId }} · {{ source.deliveryNo }}</h4>
|
||||
<table class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
|
||||
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[sourceKey(source)]![index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -124,6 +124,18 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it('shows the server grouped looseboard orders and their aggregated pallet and volume totals', async () => {
|
||||
const base = detail().orderDetails[0]!
|
||||
const orderDetails = [{ ...base, orderNo: 'LB202611050003', inboundCode: null, palletCount: 2, volumeCbm: 10, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050003', sourceKeys: ['PLAN:11', 'PLAN:12'] },
|
||||
{ ...base, sourceKey: 'PLAN:13', orderNo: 'LB202611050002', inboundCode: null, palletCount: 1, volumeCbm: 4, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050002', sourceKeys: ['PLAN:13'] }]
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails })
|
||||
expect(dialog.text()).toContain('关联订单 (2)')
|
||||
await dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!.trigger('click')
|
||||
const rows = dialog.get('.maintenance-orders').findAll('tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.findAll('td').map(cell => cell.text())).toEqual(['客户甲', 'LB202611050003', '—', '—', '2', 'ONT8', '10', '—'])
|
||||
expect(rows[1]!.text()).toContain('LB202611050002')
|
||||
})
|
||||
it('edits one number per destination and can save an incomplete second destination', async () => {
|
||||
const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }))
|
||||
const initial = detail()
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface DispatchPage { records: DispatchListRow[]; total: number; page:
|
||||
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||
export interface FbaAppointment { batchKey: string | null; batchNo: string | null; customerId: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null; shipmentNumbers: string[] }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; sourceKeys?: string[] }
|
||||
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
|
||||
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
|
||||
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
|
||||
|
||||
@@ -58,6 +58,19 @@ describe('DeliveryOrderDetailView', () => {
|
||||
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders document and receipt operation nodes in Chinese', async () => {
|
||||
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
|
||||
value.operations = ['FIRSTLEG_COMPLETION_PENDING', 'SAVE_DOCUMENTS', 'RECEIPT_AUTO_COMPLETE'].map((operationNode, index) => ({ id: String(index), operationNode, operatedAt: '2026-11-05T16:59:55', result: '已保存' }))
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
|
||||
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.find('.el-tabs__item:nth-child(4)').trigger('click')
|
||||
expect(wrapper.text()).toContain('来源货物签收待齐')
|
||||
expect(wrapper.text()).toContain('保存派送资料')
|
||||
expect(wrapper.text()).toContain('签收文件自动完成派送')
|
||||
for (const operation of value.operations) expect(wrapper.text()).not.toContain(operation.operationNode)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows frozen original coverage separately from new fees', async () => {
|
||||
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
|
||||
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
|
||||
|
||||
@@ -29,7 +29,7 @@ const methodLabels: Record<string, string> = { PICKUP: '普通自提', AMAZON_PI
|
||||
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
|
||||
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
|
||||
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
|
||||
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
|
||||
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费', SAVE_DOCUMENTS: '保存派送资料', RECEIPT_AUTO_COMPLETE: '签收文件自动完成派送', FIRSTLEG_COMPLETION_PENDING: '来源货物签收待齐' }
|
||||
const resultLabels: Record<string, string> = { CREATED: '已创建' }
|
||||
|
||||
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus, { ElMessageBox, ElRadioGroup } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { pdaReleaseRoutes } from '../routes'
|
||||
import { adminNavigation } from '@/core/navigation/adminNavigation'
|
||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||
import { copyText } from '@/shared/utils/copyText'
|
||||
import { listPdaReleases, publishPdaRelease, withdrawPdaRelease, type PdaRelease } from '../api/pdaReleases'
|
||||
import PdaReleasesView from './PdaReleasesView.vue'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/pdaReleases', () => ({
|
||||
listPdaReleases: vi.fn(), publishPdaRelease: vi.fn(), uploadPdaRelease: vi.fn(), withdrawPdaRelease: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/shared/utils/copyText', () => ({ copyText: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8')
|
||||
|
||||
@@ -18,7 +36,8 @@ describe('PDA release workbench', () => {
|
||||
it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => {
|
||||
expect(page).toContain('value="domestic"')
|
||||
expect(page).toContain('value="overseas"')
|
||||
expect(page).toContain('row.packageType === \'apk\' && row.downloadUrl')
|
||||
expect(page).toContain('aria-label="最新 APK 下载"')
|
||||
expect(page).not.toContain('@click="download(asRelease(row))"')
|
||||
expect(page).toContain('v-if="canManage"')
|
||||
expect(page).toContain('row.status === \'DRAFT\'')
|
||||
expect(page).toContain('row.current')
|
||||
@@ -35,10 +54,207 @@ describe('PDA release workbench', () => {
|
||||
const toolbar = page.match(/<template #toolbar-start>([\s\S]*?)<\/template>/)?.[1]
|
||||
expect(toolbar).toContain('class="pda-release-context query-toolbar workbench-list-tabs"')
|
||||
expect(toolbar).toContain('<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">')
|
||||
expect(toolbar).toContain('当前 APK')
|
||||
expect(toolbar).toContain('当前 WGT')
|
||||
expect(page).not.toContain('<template #filters>')
|
||||
expect(page).not.toContain('<h1>PDA版本</h1>')
|
||||
expect(pdaReleaseRoutes[0]?.meta?.showPageHeading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const apk: PdaRelease = {
|
||||
id: 'apk-current', app: 'domestic', appid: 'domestic-pda', packageType: 'apk',
|
||||
versionName: '0.1.0', versionCode: 10, minAppVersionCode: 10, mandatory: false,
|
||||
releaseNotes: '更新扫码', status: 'PUBLISHED', current: true, sha256: 'checksum', sizeBytes: 1024,
|
||||
downloadUrl: '/gateway-api/pda/domestic/current.apk', createdAt: '2026-11-06T14:00:00', publishedAt: '2026-11-06T14:10:00',
|
||||
}
|
||||
const apkUrl = new URL(apk.downloadUrl!, window.location.href).href
|
||||
const overseasApk: PdaRelease = { ...apk, id: 'overseas-current', app: 'overseas', appid: 'overseas-pda',
|
||||
downloadUrl: 'https://downloads.example.com/pda/overseas/current.apk' }
|
||||
|
||||
const TableStub = defineComponent({
|
||||
props: { data: { type: Array as PropType<PdaRelease[]>, default: () => [] } },
|
||||
template: `<div><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="notices" />
|
||||
<div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>`,
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
return mount(PdaReleasesView, {
|
||||
global: { plugins: [ElementPlus], stubs: { DataTable: TableStub, AppDialog: true } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('latest APK download', () => {
|
||||
beforeEach(() => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(listPdaReleases).mockReset().mockImplementation(async (targetApp) => targetApp === 'domestic' ? [apk] : [overseasApk])
|
||||
vi.mocked(publishPdaRelease).mockReset().mockResolvedValue(apk)
|
||||
vi.mocked(withdrawPdaRelease).mockReset().mockResolvedValue({ ...apk, current: false, status: 'WITHDRAWN' })
|
||||
vi.spyOn(QRCode, 'toString')
|
||||
})
|
||||
|
||||
it('encodes the current published APK as an absolute URL and shares it with read-only users', async () => {
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(listPdaReleases).mockResolvedValue([
|
||||
{ ...apk, id: 'apk-history', current: false, downloadUrl: '/old.apk' },
|
||||
{ ...apk, id: 'apk-draft', current: false, status: 'DRAFT', versionCode: 20, downloadUrl: '/draft.apk' },
|
||||
{ ...apk, id: 'wgt-current', packageType: 'wgt', versionCode: 30, downloadUrl: '/current.wgt' },
|
||||
overseasApk, apk,
|
||||
])
|
||||
const downloads: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this) })
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(QRCode.toString).toHaveBeenCalledTimes(2)
|
||||
expect(QRCode.toString).toHaveBeenCalledWith(apkUrl, expect.objectContaining({ type: 'svg', margin: 4, width: 160 }))
|
||||
expect(QRCode.toString).toHaveBeenCalledWith(overseasApk.downloadUrl, expect.objectContaining({ type: 'svg' }))
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const overseas = wrapper.get('[data-app="overseas"]')
|
||||
expect(domestic.findAll('img')).toHaveLength(1)
|
||||
expect(overseas.findAll('img')).toHaveLength(1)
|
||||
expect(wrapper.get('.list-page-layout__toolbar .pda-apk-downloads').exists()).toBe(true)
|
||||
expect(wrapper.get('.workbench-table').find('.pda-apk-downloads').exists()).toBe(false)
|
||||
const image = domestic.get('.pda-apk-qr img')
|
||||
expect(image.attributes('alt')).toBe('国内 PDA最新APK下载二维码')
|
||||
expect(decodeURIComponent(image.attributes('src').split(',')[1]!)).toContain('<svg')
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
expect(domestic.findComponent(CopyButton).props('value')).toBe(apkUrl)
|
||||
await domestic.get('[aria-label="复制国内 PDA APK下载链接"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(copyText).toHaveBeenCalledWith(apkUrl)
|
||||
await domestic.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
|
||||
expect(downloads[0]?.href).toBe(apkUrl)
|
||||
expect(downloads[0]?.download).toBe('domestic-pda-0.1.0.apk')
|
||||
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
|
||||
await flushPromises()
|
||||
await overseas.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
|
||||
expect(downloads[1]?.href).toBe(overseasApk.downloadUrl)
|
||||
expect(downloads[1]?.download).toBe('overseas-pda-0.1.0.apk')
|
||||
expect(wrapper.text()).not.toContain('上传版本')
|
||||
})
|
||||
|
||||
it('offers no download for drafts, withdrawn APKs or WGT-only releases', async () => {
|
||||
vi.mocked(listPdaReleases).mockResolvedValue([
|
||||
{ ...apk, status: 'DRAFT', current: true },
|
||||
{ ...apk, id: 'withdrawn', status: 'WITHDRAWN', current: true },
|
||||
{ ...apk, id: 'wgt', packageType: 'wgt' },
|
||||
])
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('尚未发布 APK')
|
||||
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(false)
|
||||
expect(wrapper.find('.pda-apk-qr').exists()).toBe(false)
|
||||
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(QRCode.toString).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an unavailable state when a published APK has no download URL', async () => {
|
||||
vi.mocked(listPdaReleases).mockResolvedValue([{ ...apk, downloadUrl: null }])
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('当前 APK 下载地址暂不可用')
|
||||
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(QRCode.toString).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps both download cards independent of the selected list tab, including pending QR generation', async () => {
|
||||
let finishDomestic!: (svg: string) => void
|
||||
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishDomestic = resolve }))
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const overseas = wrapper.get('[data-app="overseas"]')
|
||||
const overseasImage = overseas.get('.pda-apk-qr img').attributes('src')
|
||||
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
|
||||
await flushPromises()
|
||||
|
||||
finishDomestic('<svg>domestic QR</svg>')
|
||||
await flushPromises()
|
||||
expect(overseas.get('.pda-apk-qr img').attributes('src')).toBe(overseasImage)
|
||||
expect(domestic.get('.pda-apk-qr img').attributes('alt')).toBe('国内 PDA最新APK下载二维码')
|
||||
expect(overseas.get('.pda-apk-qr img').attributes('alt')).toBe('海外 PDA最新APK下载二维码')
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
expect(overseas.get('.pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
|
||||
expect(wrapper.findComponent(TableStub).props('data')).toEqual([overseasApk])
|
||||
expect(listPdaReleases).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a previous QR generation after refreshing the latest APK', async () => {
|
||||
let finishOld!: (svg: string) => void
|
||||
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishOld = resolve }))
|
||||
const newerApk = { ...apk, id: 'newer-apk', downloadUrl: '/newer.apk' }
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? [newerApk] : [overseasApk])
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const newerImage = domestic.get('.pda-apk-qr img').attributes('src')
|
||||
finishOld('<svg>old QR</svg>')
|
||||
await flushPromises()
|
||||
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(new URL('/newer.apk', window.location.href).href)
|
||||
expect(domestic.get('.pda-apk-qr img').attributes('src')).toBe(newerImage)
|
||||
expect(listPdaReleases).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('shows the QR only after publishing and removes it on withdrawal without selecting history', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const domesticResponses: PdaRelease[][] = [
|
||||
[{ ...apk, status: 'DRAFT', current: false }], [apk],
|
||||
[{ ...apk, status: 'WITHDRAWN', current: false }, { ...apk, id: 'history', current: false }],
|
||||
]
|
||||
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? domesticResponses.shift()! : [overseasApk])
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const overseasImage = wrapper.get('[data-app="overseas"] img').attributes('src')
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '发布')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(publishPdaRelease).toHaveBeenCalledWith(apk.id)
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '停发')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(withdrawPdaRelease).toHaveBeenCalledWith(apk.id)
|
||||
expect(domestic.find('.pda-apk-qr').exists()).toBe(false)
|
||||
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-app="overseas"] img').attributes('src')).toBe(overseasImage)
|
||||
expect(listPdaReleases).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('clears the previous download when refresh fails and recovers on retry', async () => {
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
vi.mocked(listPdaReleases).mockRejectedValueOnce(new Error('network error'))
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
|
||||
await flushPromises()
|
||||
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(domestic.get('[role="alert"]').text()).toBeTruthy()
|
||||
expect(wrapper.get('[data-app="overseas"] .pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('reports QR generation failure while preserving the APK link and allows refresh to retry', async () => {
|
||||
vi.mocked(QRCode.toString).mockImplementationOnce(() => Promise.reject(new Error('QR unavailable')))
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe('下载二维码生成失败,请刷新后重试')
|
||||
expect(wrapper.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { FileUp, Plus, RefreshCw } from '@lucide/vue'
|
||||
import QRCode from 'qrcode'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -18,9 +20,14 @@ import {
|
||||
const access = useAccessPreviewStore()
|
||||
const canManage = computed(() => access.can('platform:pda-release:manage'))
|
||||
const app = ref<PdaApp>('domestic')
|
||||
const rows = ref<PdaRelease[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const apps: PdaApp[] = ['domestic', 'overseas']
|
||||
const releaseLists = reactive<Record<PdaApp, { rows: PdaRelease[]; loading: boolean; error: string; request: number }>>({
|
||||
domestic: { rows: [], loading: false, error: '', request: 0 },
|
||||
overseas: { rows: [], loading: false, error: '', request: 0 },
|
||||
})
|
||||
const rows = computed(() => releaseLists[app.value].rows)
|
||||
const loading = computed(() => releaseLists[app.value].loading)
|
||||
const refreshing = computed(() => apps.some((targetApp) => releaseLists[targetApp].loading))
|
||||
const actionId = ref('')
|
||||
const uploadOpen = ref(false)
|
||||
const uploading = ref(false)
|
||||
@@ -42,27 +49,65 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' },
|
||||
]
|
||||
|
||||
const currentApk = computed(() => rows.value.find((row) => row.current && row.packageType === 'apk'))
|
||||
const apkDownloads = apps.map((targetApp) => {
|
||||
const list = releaseLists[targetApp]
|
||||
const release = computed(() => list.rows.find((row) => row.app === targetApp
|
||||
&& row.current && row.status === 'PUBLISHED' && row.packageType === 'apk'))
|
||||
const url = computed(() => {
|
||||
if (list.loading || list.error || !release.value?.downloadUrl) return ''
|
||||
return new URL(release.value.downloadUrl, window.location.href).href
|
||||
})
|
||||
const qrImage = ref('')
|
||||
const qrLoading = ref(false)
|
||||
const qrError = ref('')
|
||||
|
||||
watch(url, async (downloadUrl, _previous, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => { cancelled = true })
|
||||
qrImage.value = ''
|
||||
qrError.value = ''
|
||||
qrLoading.value = Boolean(downloadUrl)
|
||||
if (!downloadUrl) return
|
||||
try {
|
||||
const svg = await QRCode.toString(downloadUrl, { type: 'svg', margin: 4, width: 160 })
|
||||
if (!cancelled) qrImage.value = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
} catch {
|
||||
if (!cancelled) qrError.value = '下载二维码生成失败,请刷新后重试'
|
||||
} finally {
|
||||
if (!cancelled) qrLoading.value = false
|
||||
}
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
|
||||
return reactive({ app: targetApp, label: targetApp === 'domestic' ? '国内 PDA' : '海外 PDA',
|
||||
release, url, qrImage, qrLoading, qrError, loading: computed(() => list.loading), error: computed(() => list.error) })
|
||||
})
|
||||
|
||||
const currentApk = computed(() => apkDownloads.find((card) => card.app === app.value)?.release)
|
||||
const currentWgt = computed(() => rows.value.find((row) => row.current && row.packageType === 'wgt'))
|
||||
|
||||
function asRelease(row: unknown) { return row as PdaRelease }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const requestedApp = app.value
|
||||
async function load(requestedApp: PdaApp) {
|
||||
const list = releaseLists[requestedApp]
|
||||
const request = ++list.request
|
||||
list.loading = true
|
||||
list.error = ''
|
||||
try {
|
||||
const result = await listPdaReleases(requestedApp)
|
||||
if (app.value === requestedApp) rows.value = result
|
||||
if (request === list.request) list.rows = result
|
||||
} catch (cause) {
|
||||
if (app.value === requestedApp) error.value = getApiErrorMessage(cause, '版本列表加载失败')
|
||||
if (request === list.request) list.error = getApiErrorMessage(cause, '版本列表加载失败')
|
||||
} finally {
|
||||
if (app.value === requestedApp) loading.value = false
|
||||
if (request === list.request) list.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(app, () => { rows.value = []; void load() })
|
||||
async function refreshAll() {
|
||||
await Promise.all(apps.map(load))
|
||||
}
|
||||
|
||||
watch(() => form.packageType, () => { form.file = null })
|
||||
onMounted(() => { void load() })
|
||||
onMounted(() => { void refreshAll() })
|
||||
|
||||
function openUpload() {
|
||||
Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1,
|
||||
@@ -83,11 +128,12 @@ async function upload() {
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
const uploadedApp = app.value
|
||||
try {
|
||||
await uploadPdaRelease(app.value, { ...form, file: form.file })
|
||||
await uploadPdaRelease(uploadedApp, { ...form, file: form.file })
|
||||
uploadOpen.value = false
|
||||
ElMessage.success('版本草稿已上传,请核对后发布')
|
||||
await load()
|
||||
await load(uploadedApp)
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, '更新包上传失败'))
|
||||
} finally { uploading.value = false }
|
||||
@@ -104,18 +150,19 @@ async function changeStatus(row: PdaRelease, action: 'publish' | 'withdraw') {
|
||||
if (action === 'publish') await publishPdaRelease(row.id)
|
||||
else await withdrawPdaRelease(row.id)
|
||||
ElMessage.success(`版本已${label}`)
|
||||
await load()
|
||||
await load(row.app)
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`))
|
||||
} finally { actionId.value = '' }
|
||||
}
|
||||
|
||||
function download(row: PdaRelease) {
|
||||
if (!row.downloadUrl) return
|
||||
function downloadApk(targetApp: PdaApp) {
|
||||
const card = apkDownloads.find((item) => item.app === targetApp)
|
||||
if (!card?.url || !card.release) return
|
||||
const link = document.createElement('a')
|
||||
link.href = row.downloadUrl
|
||||
link.href = card.url
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.download = `${row.app}-pda-${row.versionName}.${row.packageType}`
|
||||
link.download = `${targetApp}-pda-${card.release.versionName}.apk`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
@@ -125,6 +172,29 @@ function download(row: PdaRelease) {
|
||||
<template>
|
||||
<AdminPage class="admin-density pda-releases-page admin-workbench">
|
||||
<ListPageLayout aria-label="PDA版本">
|
||||
<template #toolbar>
|
||||
<div class="pda-apk-downloads" aria-label="最新 APK 下载">
|
||||
<section v-for="card in apkDownloads" :key="card.app" v-loading="card.loading || card.qrLoading" class="pda-apk-download" :data-app="card.app" :aria-label="`${card.label}下载`" :aria-busy="card.loading || card.qrLoading">
|
||||
<div v-if="card.release?.downloadUrl" class="pda-apk-qr">
|
||||
<img v-if="card.qrImage" :src="card.qrImage" :alt="`${card.label}最新APK下载二维码`" width="160" height="160" />
|
||||
</div>
|
||||
<div class="pda-apk-download-content">
|
||||
<h2>{{ card.label }} · 扫码下载 APK</h2>
|
||||
<template v-if="card.url">
|
||||
<p>{{ card.release?.versionName }} ({{ card.release?.versionCode }})</p>
|
||||
<a class="pda-apk-download-url" :href="card.url" rel="noopener noreferrer">{{ card.url }}</a>
|
||||
<div class="density-actions">
|
||||
<el-button @click="downloadApk(card.app)">下载 APK</el-button>
|
||||
<CopyButton :value="card.url" :label="`${card.label} APK下载链接`" button-text="复制链接" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="card.error" role="alert">{{ card.error }}</p>
|
||||
<p v-else-if="!card.loading">{{ card.release ? '当前 APK 下载地址暂不可用' : '尚未发布 APK' }}</p>
|
||||
<p v-if="card.qrError" role="alert">{{ card.qrError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" preference-key="pda-releases-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无版本记录">
|
||||
<template #toolbar-start>
|
||||
<div class="pda-release-context query-toolbar workbench-list-tabs">
|
||||
@@ -133,21 +203,16 @@ function download(row: PdaRelease) {
|
||||
<el-radio-button value="overseas">海外 PDA</el-radio-button>
|
||||
</el-radio-group>
|
||||
<dl class="pda-release-current">
|
||||
<div><dt>当前 APK</dt><dd>{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</dd></div>
|
||||
<div><dt>当前 WGT</dt><dd>{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" /></template>
|
||||
|
||||
|
||||
<template #toolbar-actions>
|
||||
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button :loading="refreshing" @click="refreshAll"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="pda-release-actions density-actions row-actions admin-text-actions">
|
||||
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))">下载 APK</el-button>
|
||||
<el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
|
||||
<el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
|
||||
</div>
|
||||
@@ -190,12 +255,19 @@ function download(row: PdaRelease) {
|
||||
|
||||
<style scoped>
|
||||
.pda-release-panel { min-width: 0; overflow: hidden; }
|
||||
.pda-release-error { border: 0; border-bottom: 1px solid var(--el-color-danger-light-7); border-radius: 0; }
|
||||
.pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); }
|
||||
.pda-release-current { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); margin: 0; }
|
||||
.pda-release-current > div { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2); }
|
||||
.pda-release-current dt { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.pda-release-current dd { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); font-variant-numeric: tabular-nums; }
|
||||
.pda-apk-downloads { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: var(--admin-space-5); }
|
||||
.pda-apk-download { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: 4px; background: var(--cc-surface); }
|
||||
.pda-apk-qr { display: grid; width: 160px; height: 160px; flex-shrink: 0; place-items: center; background: #fff; }
|
||||
.pda-apk-qr img { display: block; }
|
||||
.pda-apk-download-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.pda-apk-download-content h2 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section); }
|
||||
.pda-apk-download-content p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
|
||||
.pda-apk-download-url { color: var(--el-color-primary); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.pda-release-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
|
||||
.pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.pda-release-options { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 var(--admin-space-6); }
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('@/modules/inbounds', async () => {
|
||||
name: 'InboundTaskList', props: ['mode', 'title', 'taskLoader'],
|
||||
methods: { reload() { return this.taskLoader({ page: 1, size: 20 }) } },
|
||||
mounted() { void this.reload() },
|
||||
template: '<section><slot name="filters" :loading="false" /><slot name="status-tabs" /></section>',
|
||||
template: '<section><slot name="filters" :loading="false" /><slot name="status-tabs" /><slot name="toolbar-actions" :loading="false" /></section>',
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -77,6 +77,32 @@ function mountView() {
|
||||
}
|
||||
|
||||
describe('UnifiedOrdersView list request coordination', () => {
|
||||
it('shares fulfillment actions with measurement reviews and uses only that tab selection', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const reviewPage = { ...emptyPage(), records: [
|
||||
{ orderId: 'review-1', inboundId: 'inbound-1', tenantId: '8', mainStatus: 'WAIT_MEASURE', receivingWarehouseId: '9', channelCode: 'CHANNEL' },
|
||||
{ orderId: 'review-2', inboundId: 'inbound-2', tenantId: '8', mainStatus: 'WAIT_MEASURE', receivingWarehouseId: '9', channelCode: 'CHANNEL' },
|
||||
], total: 2, pages: 1 } as Awaited<ReturnType<typeof listPlatformUnifiedOrders>>
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValueOnce(reviewPage)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text().startsWith('测量数据审核'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const action = (label: string) => wrapper.findAll('button').find(button => button.text().startsWith(label))!
|
||||
expect(action('合柜报关').attributes('disabled')).toBeDefined()
|
||||
expect(action('打印箱唛').attributes('disabled')).toBeDefined()
|
||||
expect(action('智能装柜').exists()).toBe(true)
|
||||
const reviewList = wrapper.getComponent({ name: 'InboundTaskList' })
|
||||
reviewList.vm.$emit('selection-change', [{ id: 'inbound-1' }, { id: 'inbound-2' }])
|
||||
await flushPromises()
|
||||
expect(action('合柜报关').attributes('disabled')).toBeUndefined()
|
||||
expect(action('打印箱唛').text()).toContain('2')
|
||||
expect(action('打印箱唛').attributes('disabled')).toBeUndefined()
|
||||
reviewList.vm.$emit('selection-change', [])
|
||||
await flushPromises()
|
||||
expect(action('打印箱唛').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockReset().mockResolvedValue({ counts: {} })
|
||||
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
|
||||
|
||||
@@ -747,7 +747,7 @@ function selectStatus(status: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW'
|
||||
if (selectedTab.value === status) return
|
||||
ordersRequestSerial += 1
|
||||
measurementReviewSelected.value = status === 'MEASUREMENT_REVIEW'
|
||||
if (measurementReviewSelected.value) selectedOrders.value = []
|
||||
handleOrderSelectionChange([])
|
||||
if (status === 'MEASUREMENT_REVIEW') {
|
||||
return
|
||||
}
|
||||
@@ -1670,11 +1670,23 @@ function tabCount(value: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
|
||||
return value ? statusCounts.value[value] : undefined
|
||||
}
|
||||
|
||||
const measurementReviewOrders = ref<UnifiedOrderResource[]>([])
|
||||
let measurementReviewRequestSerial = 0
|
||||
|
||||
/** 保留审核列表对应的原始订单,工具栏操作复用同一客户、入库单和业务资格。 */
|
||||
async function loadMeasurementReviewTasks(pagination: { page: number; size: number }) {
|
||||
const serial = ++measurementReviewRequestSerial
|
||||
const result = await listPlatformUnifiedOrders({ ...orderCountFilters(), mainStatus: 'MEASUREMENT_REVIEW', ...pagination })
|
||||
if (serial === measurementReviewRequestSerial) measurementReviewOrders.value = result.records
|
||||
return { ...result, records: result.records.map(toMeasurementReviewTask) }
|
||||
}
|
||||
|
||||
/** 审核列表勾选映射回原始订单,避免混用其他页签的勾选数据。 */
|
||||
function handleMeasurementReviewSelection(rows: Array<{ id: string }>) {
|
||||
const ids = new Set(rows.map(row => String(row.id)))
|
||||
handleOrderSelectionChange(measurementReviewOrders.value.filter(row => ids.has(String(row.inboundId))))
|
||||
}
|
||||
|
||||
async function loadOrders(refreshCounts = true) {
|
||||
const serial = ++ordersRequestSerial
|
||||
if (refreshCounts) void loadStatusCounts()
|
||||
@@ -2862,7 +2874,14 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
|
||||
<template v-else>
|
||||
<div v-if="measurementReviewSelected" id="unified-orders-results" class="orders-status-toolbar" role="tabpanel">
|
||||
<InboundTaskList ref="measurementReviewList" :task-loader="loadMeasurementReviewTasks" mode="review" title="订单列表" class="is-embedded" @changed="loadStatusCounts">
|
||||
<InboundTaskList ref="measurementReviewList" :task-loader="loadMeasurementReviewTasks" mode="review" title="订单列表" class="is-embedded" @changed="loadStatusCounts" @selection-change="handleMeasurementReviewSelection">
|
||||
<template #toolbar-actions="{ loading: reviewLoading }">
|
||||
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
|
||||
<el-button type="primary" plain :loading="coLoadSaving" :disabled="reviewLoading || !canCreateCoLoadGroup" aria-label="合柜报关" @click="openCoLoadCreate"><Link2 :size="16" />合柜报关<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
|
||||
</div>
|
||||
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="reviewLoading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
|
||||
</template>
|
||||
<template #status-tabs>
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user