Compare commits
22
Commits
00169b9d7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9eb896a82 | ||
|
|
a9373e468a | ||
|
|
f69ea1476d | ||
|
|
74ffecb3dd | ||
|
|
b70d88eb0a | ||
|
|
c78fbd2d45 | ||
|
|
5ee196bdf3 | ||
|
|
d761bff871 | ||
|
|
e10e079565 | ||
|
|
110d52fd5b | ||
|
|
bd76c977e0 | ||
|
|
f5f62af4a7 | ||
|
|
8d80074266 | ||
|
|
40095a749b | ||
|
|
a1721371e4 | ||
|
|
d075ac7e03 | ||
|
|
525392963c | ||
|
|
2d1bb38ad5 | ||
|
|
b5f9537e21 | ||
|
|
f54d3ae8a5 | ||
|
|
1150f245be | ||
|
|
c17d355a69 |
+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"
|
||||
|
||||
@@ -124,13 +124,16 @@ it('preserves real FBA fields and entered values when expanding the create dialo
|
||||
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(creationBody().text()).toContain('AP-001')
|
||||
})
|
||||
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
|
||||
it('shows saved dates, confirmed offsets and long remarks without planned time ranges', async () => {
|
||||
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
|
||||
const wrapper = await open(); await create(wrapper)
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
const receipt = creationBody().get('[aria-label="预约保存回执"]')
|
||||
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
|
||||
for (const text of ['AP-001', saved.plannedDate, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
|
||||
expect(receipt.text()).not.toMatch(/计划开始时段|计划结束时段/)
|
||||
expect(receipt.text()).not.toContain(saved.plannedAt)
|
||||
expect(receipt.text()).not.toContain(saved.plannedEndAt)
|
||||
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
|
||||
})
|
||||
|
||||
@@ -32,9 +32,9 @@ it('names the missing material and supplements the canonical booking inside the
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
|
||||
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
|
||||
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
|
||||
expect(wrapper.text()).not.toContain(source.orderId)
|
||||
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
|
||||
expect(wrapper.find('input[type=number]').exists()).toBe(false)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
|
||||
@@ -44,6 +44,22 @@ it('names the missing material and supplements the canonical booking inside the
|
||||
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||
})
|
||||
it('allows missing or changed quantities to be verified before saving shipment references', async () => {
|
||||
for (const cargo of [
|
||||
{ ...context, effectivePackages: null },
|
||||
{ ...context, sourceChanged: true },
|
||||
]) {
|
||||
vi.mocked(getCargoVerification).mockResolvedValue(cargo)
|
||||
vi.mocked(saveCargoVerification).mockClear()
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20')
|
||||
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'] }))
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
expect(resolvePlanningOrder).not.toHaveBeenCalled()
|
||||
|
||||
@@ -66,7 +66,6 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
|
||||
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
|
||||
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
|
||||
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
|
||||
<tr v-for="row in rows" :key="key(row)">
|
||||
|
||||
@@ -129,7 +129,6 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
|
||||
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
|
||||
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
|
||||
<tr v-if="createdForm.appointmentAt || createdForm.appointmentEndAt"><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
|
||||
<tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
|
||||
|
||||
@@ -59,14 +59,13 @@ onMounted(() => load())
|
||||
<template>
|
||||
<section aria-label="货物核实资料">
|
||||
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
||||
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
|
||||
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取货物资料…</p>
|
||||
<template v-if="context">
|
||||
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
|
||||
<dl v-if="!confirmation" class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
|
||||
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
||||
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
|
||||
<label v-if="!confirmation || context.effectivePackages == null || context.sourceChanged">{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
|
||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
|
||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
|
||||
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
||||
|
||||
@@ -63,7 +63,7 @@ onMounted(load)
|
||||
</tbody></table>
|
||||
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||
</section>
|
||||
<FbaAppointmentFields continuous hide-confirmation-times ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<FbaAppointmentFields continuous date-only ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
||||
</template>
|
||||
|
||||
@@ -43,12 +43,15 @@ describe('LoginView authentication lifecycle', () => {
|
||||
expect(loginSource).not.toContain('demo-tip')
|
||||
})
|
||||
|
||||
it('uses the shared blue-white theme and preserves the existing compact brand layout', () => {
|
||||
it('preserves the dark brand panel and white login card independently of workbench typography', () => {
|
||||
expect(loginSource).toContain("import '@/styles/adminWorkbench.css'")
|
||||
expect(loginSource).toContain('class="login-page admin-workbench"')
|
||||
expect(loginSource).toContain('<h1 class="workbench-title">柏悦物流管理后台</h1>')
|
||||
expect(loginSource).toContain('class="login-mobile-brand workbench-brand-mark"')
|
||||
expect(loginSource).toContain('class="login-card workbench-panel"')
|
||||
expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
|
||||
expect(loginSource).toContain('class="login-mobile-brand"')
|
||||
expect(loginSource).toContain('class="login-card"')
|
||||
expect(loginSource).toContain('--login-brand-background: #172a46')
|
||||
expect(loginSource).toContain('background: var(--login-brand-background); color: #fff;')
|
||||
expect(loginSource).not.toMatch(/class="[^"]*workbench-(?:title|heading|panel|brand-mark)/)
|
||||
expect(loginSource).toContain('class="login-error workbench-notice workbench-tone" data-tone="rose"')
|
||||
expect(loginSource).toContain('aria-label="平台员工登录"')
|
||||
expect(loginSource).toContain('label-position="top" size="large"')
|
||||
|
||||
@@ -57,23 +57,23 @@ async function submit() {
|
||||
<template>
|
||||
<div class="login-page admin-workbench">
|
||||
<section class="login-intro">
|
||||
<div class="login-brand workbench-brand-mark"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||
<div class="workbench-heading">
|
||||
<h1 class="workbench-title">柏悦物流管理后台</h1>
|
||||
<p class="login-intro-copy workbench-note">平台内部运营与管理系统</p>
|
||||
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||
<div>
|
||||
<h1>柏悦物流管理后台</h1>
|
||||
<p class="login-intro-copy">平台内部运营与管理系统</p>
|
||||
</div>
|
||||
<div class="login-security-note workbench-meta">
|
||||
<div class="login-security-note">
|
||||
<ShieldCheck :size="22" aria-hidden="true" />
|
||||
<span>平台内部系统,请使用授权账号登录</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel">
|
||||
<div class="login-card workbench-panel">
|
||||
<div class="login-mobile-brand workbench-brand-mark"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||
<div class="login-card-icon workbench-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
|
||||
<h2 class="workbench-title">登录管理后台</h2>
|
||||
<p class="workbench-note">使用平台内部员工账号登录</p>
|
||||
<div class="login-card">
|
||||
<div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||
<div class="login-card-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
|
||||
<h2>登录管理后台</h2>
|
||||
<p>使用平台内部员工账号登录</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" aria-label="平台员工登录" @submit.prevent="submit">
|
||||
<el-form-item label="账号" prop="account" required>
|
||||
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus />
|
||||
@@ -92,17 +92,20 @@ async function submit() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page { display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--workbench-surface); color: var(--workbench-text); }
|
||||
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); }
|
||||
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; padding: 8px 20px; }
|
||||
/* Brand layout is local to login; workbench styles only supply shared controls and errors. */
|
||||
.login-page { --login-brand-background: #172a46; display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--cc-surface); color: var(--cc-text); }
|
||||
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--login-brand-background); color: #fff; }
|
||||
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; }
|
||||
.login-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
|
||||
.login-intro h1 { max-width: 560px; }
|
||||
.login-intro-copy { max-width: 560px; }
|
||||
.login-intro h1 { max-width: 560px; margin: 16px 0 14px; font-size: 40px; line-height: 1.2; overflow-wrap: anywhere; }
|
||||
.login-intro-copy { max-width: 560px; margin: 0; color: #cbd5e1; font-size: 15px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.login-security-note { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 13px; line-height: 1.6; }
|
||||
.login-security-note > svg { flex: 0 0 auto; }
|
||||
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--workbench-surface-tint); }
|
||||
.login-card.workbench-panel { width: min(410px, 100%); min-width: 0; padding: 32px; }
|
||||
.login-card h2 { margin-top: 22px; }
|
||||
.login-card > p { margin: 8px 0 30px; }
|
||||
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--cc-surface-muted); }
|
||||
.login-card { width: min(410px, 100%); min-width: 0; padding: 32px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); box-shadow: var(--cc-shadow-md); }
|
||||
.login-card-icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: var(--cc-radius-lg); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
|
||||
.login-card h2 { margin: 22px 0 8px; font-size: 28px; line-height: 1.3; overflow-wrap: anywhere; }
|
||||
.login-card > p { margin: 0 0 30px; color: var(--cc-muted); font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.login-mobile-brand { display: none; }
|
||||
.login-submit { width: 100%; min-height: 44px; }
|
||||
.login-submit :deep(> span) { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -114,14 +117,14 @@ async function submit() {
|
||||
.login-page { grid-template-columns: 1fr; }
|
||||
.login-intro { display: none; }
|
||||
.login-panel { align-items: start; padding: 24px; }
|
||||
.login-card.workbench-panel { width: min(440px, 100%); margin: auto; padding: 28px; }
|
||||
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; }
|
||||
.login-card { width: min(440px, 100%); margin: auto; padding: 28px; }
|
||||
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; border-radius: var(--cc-radius-lg); background: var(--login-brand-background); }
|
||||
.login-mobile-brand img { display: block; width: 154px; height: 48px; object-fit: contain; object-position: left center; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.login-panel { padding: 12px; }
|
||||
.login-card.workbench-panel { padding: 20px; }
|
||||
.login-card { padding: 20px; }
|
||||
.login-mobile-brand { margin-bottom: 24px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,6 +69,22 @@ function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
|
||||
}
|
||||
|
||||
describe('ContainerCustomsPanel', () => {
|
||||
it('locks pre-entry uploads after customer confirmation before broker execution', async () => {
|
||||
const value = customs('DECLARING', true)
|
||||
value.documents[0]!.receiptStatus = 'CONFIRMED'
|
||||
api.get.mockResolvedValue(value)
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('客户已确认预录单,待发送报关行执行')
|
||||
for (const label of ['选择预录单', '提交预录单']) {
|
||||
expect(wrapper.findAll('button').find(button => button.text() === label)?.attributes('disabled')).toBeDefined()
|
||||
}
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '发送报关行执行')?.attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getDeclarationBatches.mockResolvedValue([])
|
||||
@@ -264,7 +280,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
|
||||
expect(api.upload).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.
|
||||
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
|
||||
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
|
||||
const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true)
|
||||
const receiptUploadLocked = computed(() => brokerSubmitted.value || customs.value?.documents.some((document) => document.receiptStatus === 'CONFIRMED') === true)
|
||||
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
|
||||
const statusText: Record<ContainerCustomsStatus, string> = {
|
||||
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
|
||||
@@ -85,7 +86,7 @@ function formatDateTime(value: string | null | undefined) {
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function selectReceipt(upload: UploadFile) {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || receiptUploadLocked.value || actionLoading.value) return
|
||||
receiptFile.value = upload.raw || null
|
||||
actionError.value = null
|
||||
}
|
||||
@@ -186,7 +187,7 @@ async function start() {
|
||||
}
|
||||
|
||||
async function uploadReceipt() {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || receiptUploadLocked.value || actionLoading.value) return
|
||||
const selected = receiptFile.value
|
||||
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
|
||||
const documents = customs.value.documents
|
||||
@@ -325,12 +326,12 @@ onMounted(load)
|
||||
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div v-if="customs.documents.length" class="compact-table-wrap">
|
||||
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
|
||||
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="actionLoading || !brokerSubmission || receiptUploadLocked" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="actionLoading || !brokerSubmission || receiptUploadLocked">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="actionLoading || !brokerSubmission || receiptUploadLocked || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
|
||||
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>报关行状态</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
|
||||
<tr v-for="document in customs.documents" :key="document.documentId">
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td>
|
||||
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
|
||||
<td>{{ brokerSubmitted ? '已发送报关行执行' : document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单,待发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
|
||||
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
|
||||
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td><td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
|
||||
</tr>
|
||||
|
||||
+10
-9
@@ -65,15 +65,16 @@ describe('ContainerDetailView empty return', () => {
|
||||
expect(value).toBe('2026-10-23(默认推算)')
|
||||
expect(value).not.toContain('00:00')
|
||||
})
|
||||
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
|
||||
const wrapper = await mountDetail('DOMESTIC')
|
||||
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
||||
expect(panel.props('container').id).toBe('9001')
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never)
|
||||
panel.vm.$emit('saved'); await flushPromises()
|
||||
expect(getContainer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('REASSIGNED / V2')
|
||||
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1')
|
||||
it.each(['DOMESTIC', 'OVERSEAS'] as const)('keeps voyage information without the temporary schedule maintenance entry in %s', async (workbench) => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'VESSEL', voyageNo: 'V2', currentPortScheduleId: 'S1', estimatedSailingAt: '2026-11-05T10:55:00', estimatedPortArrivalAt: '2026-11-08T00:00:00' } as never)
|
||||
const wrapper = await mountDetail(workbench)
|
||||
const section = wrapper.get('[aria-labelledby="container-voyage-heading"]')
|
||||
expect(section.text()).toContain('船名航次信息')
|
||||
expect(section.text()).toContain('VESSEL / V2')
|
||||
expect(section.text()).toContain('2026-11-05 10:55')
|
||||
expect(section.text()).toContain('2026-11-08 00:00')
|
||||
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).exists()).toBe(false)
|
||||
expect(section.text()).not.toContain('船期维护与历史')
|
||||
})
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
|
||||
@@ -16,7 +16,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
|
||||
import {
|
||||
getContainerOutboundDispatch,
|
||||
@@ -648,7 +648,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
|
||||
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
@@ -245,6 +245,7 @@ const allColumns: DataTableColumn[] = [
|
||||
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常', afterColumn: 'containerNo' },
|
||||
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
|
||||
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: ContainerBookingResource) => formatTime(row.createdAt), afterColumn: 'cabinetNo' },
|
||||
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
|
||||
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
@@ -267,8 +268,8 @@ const allColumns: DataTableColumn[] = [
|
||||
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
|
||||
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
|
||||
]
|
||||
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
|
||||
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
|
||||
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'createdAt', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
|
||||
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'createdAt', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
|
||||
const columns = computed(() => allColumns
|
||||
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))
|
||||
// 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
@@ -367,6 +367,7 @@ describe('InboundFeePanel', () => {
|
||||
|
||||
it('confirms and then sends a calculated fee through the implemented backend transitions', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
|
||||
vi.mocked(getInboundMeasurement).mockRejectedValue(new ApiResponseError('无测量查看权限', 'A0403', 403))
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
@@ -379,10 +380,47 @@ describe('InboundFeePanel', () => {
|
||||
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('changed')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['confirm', 'push'])('shows the backend review rejection for the %s entry point without sending', async (entry) => {
|
||||
const message = '测量数据尚未审核通过,请先完成测量审核后再确认费用'
|
||||
const showError = vi.spyOn(ElMessage, 'error')
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
|
||||
vi.mocked(confirmInboundFee).mockRejectedValue(new ApiResponseError(message, 'A0409', 200))
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const button = entry === 'confirm'
|
||||
? wrapper.get('[data-testid="confirm-fee-button"]')
|
||||
: wrapper.findAll('button').find(button => button.text().includes('确认推送费用'))!
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmInboundFee).toHaveBeenCalledTimes(1)
|
||||
expect(sendInboundFee).not.toHaveBeenCalled()
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(showError).toHaveBeenCalledWith(expect.objectContaining({ message }))
|
||||
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the backend review rejection when sending an already confirmed fee', async () => {
|
||||
const message = '测量数据尚未审核通过,请先完成测量审核后再发送客户'
|
||||
const showError = vi.spyOn(ElMessage, 'error')
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED' })
|
||||
vi.mocked(sendInboundFee).mockRejectedValue(new ApiResponseError(message, 'A0409', 200))
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sendInboundFee).toHaveBeenCalledTimes(1)
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
expect(showError).toHaveBeenCalledWith(expect.objectContaining({ message }))
|
||||
})
|
||||
|
||||
it('does not expose fee transitions without their independent permissions', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
|
||||
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust'])
|
||||
@@ -392,6 +430,20 @@ describe('InboundFeePanel', () => {
|
||||
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms and publishes without requiring measurement-view permission', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
|
||||
vi.mocked(getInboundMeasurement).mockRejectedValue(new ApiResponseError('无测量查看权限', 'A0403', 403))
|
||||
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee'])
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('确认推送费用'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-confirm-/))
|
||||
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
|
||||
const wrapper = mountPanel()
|
||||
|
||||
@@ -98,6 +98,13 @@ const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
|
||||
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
|
||||
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate'))
|
||||
const isPrivateAddress = computed(() => props.destinationType === 'PRIVATE')
|
||||
const pricingHelp = computed(() => isPrivateAddress.value ? {
|
||||
workflow: '私人地址完成测量审核后,请先进行人工头程报价,再确认预装。',
|
||||
quantity: '计费量按客户计费方式和实际测量确定,人工填写单价,金额以服务端快照为准。',
|
||||
} : {
|
||||
workflow: '测量审核完成后,系统会自动生成初算费用,人工确认后发布。',
|
||||
quantity: '实际重量与体积重量取大后计费,金额以服务端快照为准。',
|
||||
})
|
||||
const canManualQuote = computed(() => access.can('firstleg:inbound:confirm-send-fee'))
|
||||
const canRunTrial = computed(() => isPrivateAddress.value
|
||||
? canManualQuote.value && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED'
|
||||
@@ -459,11 +466,11 @@ defineExpose({ reload: loadFee })
|
||||
<template>
|
||||
<section class="inbound-fee-panel admin-workbench" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
||||
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
|
||||
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
|
||||
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">{{ isPrivateAddress ? '头程人工报价' : '头程自动初算' }} <InlineHelp title="计费说明"><p>{{ pricingHelp.workflow }}</p><p>{{ pricingHelp.quantity }}</p></InlineHelp></h3>
|
||||
<div v-else class="fee-heading-copy">
|
||||
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
||||
<div>
|
||||
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
|
||||
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>{{ pricingHelp.workflow }}</p><p>{{ pricingHelp.quantity }}</p></InlineHelp></h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-heading-actions">
|
||||
@@ -569,7 +576,7 @@ defineExpose({ reload: loadFee })
|
||||
<el-button size="small" @click="loadFee()">重新加载</el-button>
|
||||
</el-alert>
|
||||
|
||||
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
||||
<el-empty v-else-if="notFound" :image-size="64" :description="isPrivateAddress ? '私人地址待人工头程报价' : '尚未生成费用快照'">
|
||||
</el-empty>
|
||||
|
||||
<div v-else-if="snapshot" class="fee-content">
|
||||
|
||||
@@ -425,7 +425,7 @@ describe('InboundMeasurementPanel', () => {
|
||||
it('preserves the hidden actual goods name without overriding declaration facts', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
expect(wrapper.text()).not.toContain('实际品名')
|
||||
await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
|
||||
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -442,6 +442,60 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(payload).not.toHaveProperty('originalVolumeCbm')
|
||||
})
|
||||
|
||||
it('preserves a 255-character goods name without exposing correction controls', async () => {
|
||||
const actualGoodsName = '品'.repeat(255)
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ actualGoodsName }))
|
||||
const wrapper = await mountPanel()
|
||||
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
|
||||
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
|
||||
await wrapper.findAll('button').find(button => button.text().includes('保存测量数据'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ actualGoodsName }))
|
||||
})
|
||||
|
||||
it('blocks an overlong or cleared goods name and keeps its correction editable until a successful retry', async () => {
|
||||
const actualGoodsName = '品'.repeat(256)
|
||||
vi.mocked(getInboundMeasurement)
|
||||
.mockResolvedValueOnce(measurement({ actualGoodsName }))
|
||||
.mockResolvedValueOnce(measurement({ actualGoodsName: '精简后的品名', version: 3 }))
|
||||
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(new ApiResponseError('资料保存失败', 'S0503'))
|
||||
const wrapper = await mountPanel()
|
||||
const input = () => wrapper.get<HTMLInputElement>('input[aria-label="实际品名"]')
|
||||
const save = () => wrapper.findAll('button').find(button => button.text().includes('保存测量数据'))!
|
||||
expect(input().element.value).toBe(actualGoodsName)
|
||||
expect(wrapper.get('.measurement-panel__footer').text()).toContain('实际品名超过255字')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
|
||||
await (wrapper.vm as unknown as { submitForReview(): Promise<void> }).submitForReview()
|
||||
expect(submitInboundMeasurement).not.toHaveBeenCalled()
|
||||
|
||||
await input().setValue('')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.measurement-panel__footer').text()).toContain('请填写精简后的实际品名')
|
||||
|
||||
await input().setValue(' 精简后的品名 ')
|
||||
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.measurement-declaration').text()).toContain('蓝牙音箱')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ actualGoodsName: '精简后的品名' }))
|
||||
expect(input().element.value).toBe(' 精简后的品名 ')
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledTimes(2)
|
||||
expect(updateConfirmedPackageCount).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not expose goods name correction without measurement edit permission', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ actualGoodsName: '品'.repeat(256) }))
|
||||
const wrapper = await mountPanel(['platform:firstleg:inbound:measure:view'])
|
||||
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
|
||||
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows saving one warehouse outer dimension against the measured sequence', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
expect(wrapper.text()).toContain('001')
|
||||
|
||||
@@ -149,12 +149,21 @@ const packageCountDifference = computed(() => expectedPackageCount.value == null
|
||||
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
|
||||
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
|
||||
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
// 以服务端原值决定修正入口,避免输入缩短后立即收起;保存成功重读后才隐藏。
|
||||
const needsGoodsNameCorrection = computed(() => (detail.value?.actualGoodsName?.trim().length ?? 0) > 255)
|
||||
const goodsNameCorrectionError = computed(() => {
|
||||
const length = form.actualGoodsName.trim().length
|
||||
if (length > 255) return '实际品名超过255字,请先精简后再保存'
|
||||
if (needsGoodsNameCorrection.value && !length) return '请填写精简后的实际品名,最多255字'
|
||||
return ''
|
||||
})
|
||||
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
|
||||
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
|
||||
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !goodsNameCorrectionError.value && !loading.value && !saving.value)
|
||||
const submissionBlockReason = computed(() => {
|
||||
if (!canSubmit.value || !detail.value || loading.value) return ''
|
||||
if (detail.value.processStatus === 'PREPARING') return '订单尚待客服审核,审核通过后才能提交测量审核'
|
||||
if (!submitEditableProcess.value) return `当前${processStatusLabel(detail.value.processStatus)},不能提交测量审核`
|
||||
if (goodsNameCorrectionError.value) return goodsNameCorrectionError.value
|
||||
if (!form.measurementId) return hasConfirmedWeightResult.value ? '请先完成箱规测量并保存测量数据' : '请先完成箱规测量,并上传确认至少一张称重图片'
|
||||
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
|
||||
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
|
||||
@@ -416,6 +425,7 @@ async function reloadLatest() {
|
||||
function validateDraft() {
|
||||
const errors: Record<string, string[]> = {}
|
||||
const general: string[] = []
|
||||
if (goodsNameCorrectionError.value) general.push(goodsNameCorrectionError.value)
|
||||
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录')
|
||||
if (isDropship.value && (!Number.isInteger(form.expectedPalletCount) || Number(form.expectedPalletCount) < 1 || Number(form.expectedPalletCount) > 1000)) general.push('一件代发预计托数必须是 1 至 1,000 的整数')
|
||||
else if (isDropship.value && Number(form.expectedPalletCount) > confirmedPackageCount.value) general.push('一件代发预计托数不能超过实测箱数')
|
||||
@@ -736,7 +746,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
|
||||
</el-alert>
|
||||
|
||||
<div class="workbench-evidence"><table class="measurement-comparison" aria-label="客户申报与平台实测对比">
|
||||
<table class="measurement-comparison" aria-label="客户申报与平台实测对比">
|
||||
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="metric in comparisonMetrics" :key="metric.label">
|
||||
@@ -748,7 +758,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>
|
||||
<div class="measurement-extra-facts">
|
||||
<span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
|
||||
<span>计费材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
|
||||
@@ -782,7 +792,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>平台实测箱规</h3></div>
|
||||
|
||||
<div v-if="isDropship" class="measurement-data-grid measurement-platform-fields">
|
||||
<div v-if="isDropship || (needsGoodsNameCorrection && mayMaintain)" class="measurement-data-grid measurement-platform-fields">
|
||||
<label v-if="needsGoodsNameCorrection && mayMaintain" class="measurement-goods-name"><span>实际品名(最多255字)</span><el-input v-model="form.actualGoodsName" aria-label="实际品名" placeholder="请精简汇总品名后保存" maxlength="255" show-word-limit :disabled="saving" /></label>
|
||||
<label v-if="isDropship && mayMaintain" class="measurement-expected-pallets"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
|
||||
<div v-else-if="isDropship" class="measurement-readonly-field measurement-expected-pallets"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
|
||||
</div>
|
||||
@@ -927,9 +938,9 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<style scoped>
|
||||
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
|
||||
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.measurement-comparison thead th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
|
||||
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
|
||||
.measurement-comparison thead th:nth-child(3) { background: var(--admin-surface, #fff); color: #18795d; }
|
||||
.measurement-comparison thead th { background: var(--admin-label-bg); color: var(--workbench-text); }
|
||||
.measurement-comparison thead th:nth-child(2) { background: var(--admin-dialog-source-bg); color: #465fc4; }
|
||||
.measurement-comparison thead th:nth-child(3) { background: var(--admin-dialog-summary-bg); color: #18795d; }
|
||||
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
|
||||
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.measurement-extra-facts strong { color: var(--workbench-text); }
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/* Measurement workspace: keep all sections inside the panel's content bounds. */
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||
/* Approved measurement-only palette; shared dialog styling must preserve these sections and gutters. */
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-summary-border); border-left: 4px solid #20a477; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-summary-bg); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-left: 4px solid #c79643; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
|
||||
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-left: 4px solid #647fec; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-table th.el-table__cell { background: var(--admin-label-bg); color: var(--workbench-muted); }
|
||||
|
||||
@media (min-width: 961px) {
|
||||
.inbound-density.admin-density-dialog .measurement-dialog-weight { padding-bottom: var(--admin-space-4, 10px); }
|
||||
@@ -19,9 +20,6 @@
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header h4,
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header h4 { flex-shrink: 0; margin: 0; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header { display: flex; align-items: baseline; gap: var(--admin-space-6, 16px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number { display: flex; flex: 1; align-items: baseline; gap: var(--admin-space-3, 8px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number > span { flex-shrink: 0; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number strong { min-width: 0; min-height: 0; padding: 0; }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section .measurement-section__heading { flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields { display: flex; flex: 1 1 520px; align-items: center; flex-wrap: wrap; margin: 0; gap: var(--admin-space-5, 12px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > label,
|
||||
|
||||
@@ -226,7 +226,7 @@ onMounted(() => {
|
||||
<ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
|
||||
</template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
|
||||
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
|
||||
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">{{ isPreload ? '编辑' : '查看' }}</el-button>
|
||||
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
|
||||
</div></template>
|
||||
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项仅在智能排车建单时选择,默认不勾选;维护车辆不提供发布修改,预约日历建单不自动发布,重发先取消原排车再重新创建,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
|
||||
- 散板加货从上述列表的 `components/LooseboardCargoDialog.vue` 进入,通过 `../overseas-warehouse` 公开的 `listLooseboardCargo/addLooseboardCargo` 调用 `dispatch/controller/LooseboardCargoController.java`;区别于客户本土散板下单及排车提货/派送执行。
|
||||
|
||||
- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('driver APIs', () => {
|
||||
|
||||
it('uploads a real multipart file with the version and leaves multipart boundary to the browser', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(ok(record))
|
||||
const file = new File(['%PDF-1.7'], 'license.pdf', { type: 'application/pdf' })
|
||||
const file = new File(['image'], 'license.png', { type: 'image/png' })
|
||||
await uploadDriverLicense(id, 2, file)
|
||||
const [url, body] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(url).toBe(`${base}/${id}/license`)
|
||||
@@ -50,6 +50,18 @@ describe('driver APIs', () => {
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
})
|
||||
|
||||
it.each(['application/pdf', 'image/gif', 'text/plain'])('rejects %s driver license uploads before sending a request', async type => {
|
||||
await expect(uploadDriverLicense(id, 2, new File(['content'], 'license', { type }))).rejects.toThrow('JPG 或 PNG')
|
||||
expect(http.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty and oversized driver license images before sending a request', async () => {
|
||||
const empty = new File([], 'empty.png', { type: 'image/png' })
|
||||
const oversized = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'large.jpg', { type: 'image/jpeg' })
|
||||
for (const file of [empty, oversized]) await expect(uploadDriverLicense(id, 2, file)).rejects.toThrow('最大 10MiB')
|
||||
expect(http.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes task qualification from server and keeps history snapshots distinct', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(ok(record.qualification)).mockResolvedValueOnce(ok({ page: '1', size: '20', total: '1', pages: '1', records: [{ recordVersion: '1', action: 'UPLOAD_LICENSE', snapshot: { ...record, recordVersion: '1', reviewStatus: 'PENDING' } }] }))
|
||||
const expectedEnd = '2026-10-20T18:00:00Z'
|
||||
|
||||
@@ -67,6 +67,9 @@ export async function submitDriver(id: string, recordVersion: number): Promise<D
|
||||
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/submit`, { recordVersion })))
|
||||
}
|
||||
export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise<DriverDetail> {
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) {
|
||||
throw new Error('请选择非空 JPG 或 PNG,最大 10MiB')
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('recordVersion', String(recordVersion))
|
||||
form.append('file', file)
|
||||
|
||||
@@ -43,8 +43,8 @@ watch([open, () => props.driver?.id], ([value]) => {
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] || null
|
||||
if (file && (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
|
||||
ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
|
||||
if (file && (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
|
||||
ElMessage.warning('请选择非空 JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
|
||||
}
|
||||
selectedFile.value = file
|
||||
}
|
||||
@@ -107,7 +107,7 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
|
||||
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<h3 class="full-width driver-section-heading">证照附件<el-tag v-if="current?.licenseFileId" size="small" type="info">已有附件</el-tag></h3>
|
||||
<div class="full-width">
|
||||
<label v-if="access.can(permission.driverUpload)">驾驶证<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
|
||||
<label v-if="access.can(permission.driverUpload)">驾驶证(JPG / PNG)<input ref="fileInput" type="file" accept="image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
|
||||
</div>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -307,6 +307,23 @@ describe('driver page workflows', () => {
|
||||
expect(api.getDriverQualification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts image selection and clears it when a PDF is selected instead', async () => {
|
||||
vi.mocked(api.updateDriver).mockResolvedValue(structuredClone(driver))
|
||||
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
expect(picker.attributes('accept')).toBe('image/jpeg,image/png')
|
||||
Object.defineProperty(picker.element, 'files', { value: [new File(['image'], 'license.png', { type: 'image/png' })], configurable: true })
|
||||
await picker.trigger('change')
|
||||
expect(ElMessage.warning).not.toHaveBeenCalled()
|
||||
Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'license.pdf', { type: 'application/pdf' })], configurable: true })
|
||||
await picker.trigger('change')
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith('请选择非空 JPG 或 PNG,最大 10MiB')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDriver).toHaveBeenCalledTimes(1)
|
||||
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create a second draft when attachment upload fails after saving', async () => {
|
||||
vi.mocked(api.createDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 0 })
|
||||
vi.mocked(api.uploadDriverLicense).mockRejectedValueOnce(new Error('文件服务不可用')).mockResolvedValueOnce({ ...structuredClone(driver), recordVersion: 1 })
|
||||
@@ -317,7 +334,7 @@ describe('driver page workflows', () => {
|
||||
inputs[1]!.vm.$emit('update:modelValue', '123')
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects.at(-1)!.vm.$emit('update:modelValue', 'UTC')
|
||||
const file = new File(['%PDF-test'], 'license.pdf', { type: 'application/pdf' })
|
||||
const file = new File(['image'], 'license.png', { type: 'image/png' })
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [file] })
|
||||
await picker.trigger('change')
|
||||
@@ -342,7 +359,7 @@ describe('driver page workflows', () => {
|
||||
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.anything())
|
||||
await wrapper.setProps({ modelValue: false }); await flushPromises()
|
||||
await wrapper.setProps({ driver: other, modelValue: true }); await flushPromises()
|
||||
const file = new File(['%PDF-other'], 'other-driver.pdf', { type: 'application/pdf' })
|
||||
const file = new File(['image'], 'other-driver.jpg', { type: 'image/jpeg' })
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
|
||||
await picker.trigger('change')
|
||||
@@ -365,7 +382,7 @@ describe('driver page workflows', () => {
|
||||
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'driver.pdf', { type: 'application/pdf' })] })
|
||||
Object.defineProperty(picker.element, 'files', { value: [new File(['image'], 'driver.jpg', { type: 'image/jpeg' })] })
|
||||
await picker.trigger('change')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
| 仓储转运审核、审核通过 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `permissions.ts` 的 `warehouseTransferApprove`;仅 `WAREHOUSE_TRANSFER + PENDING_REVIEW` 行开放。当前弹框仅提交 `APPROVE`,携带该行客户和转运单号;无驳回按钮 |
|
||||
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 入仓单、私人地址计划与 FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 散板提货/派送执行与取证 | `components/DispatchDocumentsDialog.vue` → `DispatchLooseboardExecution.vue` | `api/dispatchLooseboardExecution.ts`;Backend `dispatch/controller/DispatchLooseboardExecutionController.java`,区别于客户散板下单 |
|
||||
| 派送异常回仓、退回货物与实收入库 | `components/DispatchReturnCargo.vue`、`pages/ExceptionOrdersView.vue` | 候选 `api/dispatches.ts`,退回建单 `api/exceptionOrders.ts` 的 `generateExceptionReturn`;PDA 实收查 `ExceptionReturnWork.vue` 与 Lastleg 异常回仓契约 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
|
||||
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
|
||||
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
@@ -41,7 +43,7 @@
|
||||
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
|
||||
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。出库/派送建单时机按[排车现行契约](../../../../../docs/海外仓业务/排车列表统一作业改造契约.md)执行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
|
||||
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
|
||||
- 异常回仓先从原出库事实选择可退货物,由平台生成关联异常的退回尾程单/计划;建单不恢复库存。PDA 经异常回仓任务分批实收并恢复原来源库存,不走普通接柜或客户退货预报链。版本、证据、目标资源及完成状态以工作区 `baiyue-firstleg/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml` 为准;回访仍走 `api/customerVisits.ts`。
|
||||
|
||||
## 作业细则按需入口
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
|
||||
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 |
|
||||
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 |
|
||||
| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
| 入库单 | 确认收货异常 | 用户确认短少、超收及实际归属的处理要求;Admin规范“批量预览、对照与选择” | 详情托盘区展示本柜应收、现场清点及已归属摘要;1100px居中核对弹框,显示全柜涉及单号、逐托清点及逐票逐SKU归属,原因必填,已完成行只读 | `InboundShortageConfirmation.vue`;全部待差异核对计划具有编辑权限时可进入,提交受当前快照、服务端门禁及逐托合计校验约束 | 本轮4个定向文件34项测试及类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
|
||||
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
|
||||
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
|
||||
@@ -476,3 +476,13 @@
|
||||
|
||||
|
||||
2026-10-29 用户截图调整:排车列表查看路线更名查看轨迹。已完成本土卡车详情补充货物数量卡片、起讫地址与联系人、司机/车辆及证照照片、实际发车和完成时间。维护车辆/司机候选排除其他车次实际占用,当前车次已选资源允许保留。尾程查询占位提示字体12px。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
|
||||
|
||||
## 2026-11-05 PDA拆柜收货与维护车辆问题修复
|
||||
|
||||
| 页面/问题 | 修复 | 验收 |
|
||||
|---|---|---|
|
||||
| PDA拆柜收货按钮重叠 | 底部操作区改为随内容正常排列,解析、校验、保存及结束按钮全部保留;照片上传后返回页面不再清理同一签收会话的未签收草稿。 | PDA定向35项通过,App资源构建通过;真实设备截图待确认 |
|
||||
| 维护车辆字段与附件 | 移除不可维护的发车时间及主档拖车公司展示;保留可编辑的外配拖车公司字段;预约号回显已登记编号;驾驶证展示优先本趟上传文件,避免保存后重复主档或空白图。 | Admin定向67项通过,修改文件Scoped TypeScript检查通过;真实页面截图待确认 |
|
||||
| 后端预约号 | 已登记预约号可回显,只有有效收货确认提供确认时间。 | Lastleg定向8项通过;未部署 |
|
||||
|
||||
本轮修改未部署到测试或生产环境;需在真实PDA和Admin页面进行人工截图及业务数据验收。
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans'
|
||||
import { confirmReceivingReconciliation, getReceivingReconciliation, getInboundPlan, listInboundPlans } from './inboundPlans'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
describe('inbound plan overseas API', () => {
|
||||
beforeEach(() => vi.mocked(http.get).mockReset())
|
||||
it('confirms the current shortage with customer, version and idempotency key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } })
|
||||
const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' }
|
||||
await confirmInboundShortage('IP/1', payload, 'request-key')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } })
|
||||
beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() })
|
||||
it('reads the whole receiving scope with the explicit customer and encoded plan number', async () => {
|
||||
const data = { snapshotHash: 'current-snapshot', expectedPackageCount: 21, countedPackageCount: 22, attributedPackageCount: 20 }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data })
|
||||
expect(await getReceivingReconciliation('IP/1', '190000000000000001')).toEqual(data)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/receiving-reconciliation', { params: { customerId: '190000000000000001' } })
|
||||
})
|
||||
it('confirms explicit pallet and SKU quantities with the current snapshot and idempotency key', async () => {
|
||||
const data = { planNo: 'IP/1', status: 'COMPLETED', confirmedPackageCount: 22, reconciledPlanNos: ['IP/1'] }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data })
|
||||
const payload = { customerId: '190000000000000001', snapshotHash: 'current-snapshot', reason: '逐托核实超收1箱',
|
||||
pallets: [{ palletId: '9007199254741001', countedPackageCount: 22 }],
|
||||
receipts: [{ customerId: '190000000000000001', planNo: 'IP/1', lineId: '9007199254741002', palletId: '9007199254741001', skuCode: 'SKU-1', receivedPackageCount: 22 }] }
|
||||
expect(await confirmReceivingReconciliation('IP/1', payload, 'request-key')).toEqual(data)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/receiving-reconciliation', payload, { headers: { 'Idempotency-Key': 'request-key' } })
|
||||
})
|
||||
it('pages plans with explicit customer scope', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
|
||||
@@ -82,17 +82,67 @@ export async function getInboundPlan(planNo: string, customerId: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
|
||||
}
|
||||
|
||||
export interface ConfirmInboundShortagePayload {
|
||||
customerId: string
|
||||
version: number
|
||||
expectedPackageCount: number
|
||||
receivedPackageCount: number
|
||||
reason: string
|
||||
export interface ReceivingReconciliationPallet {
|
||||
palletId: string
|
||||
palletNo?: string | null
|
||||
groupKey: string
|
||||
countedPackageCount: number
|
||||
attributedPackageCount: number
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export async function confirmInboundShortage(planNo: string, payload: ConfirmInboundShortagePayload, idempotencyKey: string) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<{ status: string }>, ApiEnvelope<{ status: string }>>(
|
||||
`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/confirm-shortage`, payload,
|
||||
export interface ReceivingReconciliationReceipt {
|
||||
customerId: string
|
||||
planNo: string
|
||||
lineId: string
|
||||
inboundCode?: string | null
|
||||
palletId: string
|
||||
palletNo?: string | null
|
||||
skuCode: string
|
||||
productName?: string | null
|
||||
expectedPackageCount: number
|
||||
receivedPackageCount: number
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface ReceivingReconciliationDto {
|
||||
planNo: string
|
||||
containerNo?: string | null
|
||||
snapshotHash: string
|
||||
expectedPackageCount: number
|
||||
countedPackageCount: number
|
||||
attributedPackageCount: number
|
||||
canConfirm: boolean
|
||||
blockedReason?: string | null
|
||||
plans: Array<{ customerId: string; planNo: string; customerName?: string | null; status: string; expectedPackageCount: number; attributedPackageCount: number }>
|
||||
pallets: ReceivingReconciliationPallet[]
|
||||
receipts: ReceivingReconciliationReceipt[]
|
||||
}
|
||||
|
||||
export interface ConfirmReceivingReconciliationPayload {
|
||||
customerId: string
|
||||
snapshotHash: string
|
||||
reason: string
|
||||
pallets: Array<{ palletId: string; countedPackageCount: number }>
|
||||
receipts: Array<{ customerId: string; planNo: string; lineId: string; palletId: string; skuCode: string; receivedPackageCount: number }>
|
||||
}
|
||||
|
||||
export interface ReceivingReconciliationResult {
|
||||
planNo: string
|
||||
status: string
|
||||
confirmedPackageCount: number
|
||||
reconciledPlanNos: string[]
|
||||
}
|
||||
|
||||
export async function getReceivingReconciliation(planNo: string, customerId: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ReceivingReconciliationDto>, ApiEnvelope<ReceivingReconciliationDto>>(
|
||||
`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/receiving-reconciliation`, { params: { customerId } },
|
||||
))
|
||||
}
|
||||
|
||||
export async function confirmReceivingReconciliation(planNo: string, payload: ConfirmReceivingReconciliationPayload, idempotencyKey: string) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<ReceivingReconciliationResult>, ApiEnvelope<ReceivingReconciliationResult>>(
|
||||
`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/receiving-reconciliation`, payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -23,13 +23,33 @@ it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed up
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
|
||||
it('uses the uploaded license before the master fallback and restores the fallback after removal', async () => {
|
||||
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
|
||||
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
|
||||
expect(downloadDriverLicense).not.toHaveBeenCalled()
|
||||
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '301')
|
||||
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
|
||||
expect(wrapper.findAll('figure img')).toHaveLength(2); wrapper.unmount()
|
||||
expect(wrapper.findAll('figure img')).toHaveLength(1)
|
||||
await wrapper.setProps({ modelValue: [] }); await flushPromises()
|
||||
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
|
||||
expect(wrapper.findAll('figure img')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps saved trip licenses without an additional master image or changes to stored references', async () => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['33', '301'], referenceIds: ['33'], fallbackFileId: '33' } }); await flushPromises()
|
||||
expect(wrapper.findAll('figure img')).toHaveLength(1)
|
||||
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['tiny', 'broken'])('shows a recovery message instead of a blank %s thumbnail', async failure => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
|
||||
const image = wrapper.get('figure img')
|
||||
Object.defineProperties(image.element, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } })
|
||||
await image.trigger(failure === 'tiny' ? 'load' : 'error')
|
||||
expect(wrapper.find('figure img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain(failure === 'tiny' ? '原图仅 1 × 1 像素' : '照片加载失败')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
|
||||
|
||||
@@ -11,14 +11,21 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
|
||||
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
||||
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } from '@/modules/overseas-master-data'
|
||||
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; fallbackFileId?: string | null; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
|
||||
const ids = defineModel<string[]>({ required: true })
|
||||
const profilePhotoId = ref<string | null>(null)
|
||||
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
|
||||
// 主档仅作缺图时的默认来源;已有本趟驾驶证时不再额外展示主档图片。
|
||||
const selectedIds = computed(() => {
|
||||
const values = [...new Set([...(props.referenceIds || []), ...ids.value])]
|
||||
const fallback = props.fallbackFileId || props.masterLicense?.fileId
|
||||
return props.kind === 'DRIVER' && fallback && values.some(id => id !== fallback) ? values.filter(id => id !== fallback) : values
|
||||
})
|
||||
const allIds = computed(() => [...new Set([...selectedIds.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
|
||||
const emit = defineEmits<{ busy: [value: boolean]; available: [value: boolean] }>()
|
||||
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
|
||||
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
||||
const thumbnails = ref<Record<string, string>>({})
|
||||
const thumbnailFailures = ref<Record<string, string>>({})
|
||||
let masterBlob: { fileId: string; blob: Blob } | null = null
|
||||
const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
|
||||
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
|
||||
@@ -45,9 +52,9 @@ function imageFailed(event: Event) {
|
||||
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
|
||||
}
|
||||
async function load() {
|
||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])]
|
||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...selectedIds.value]
|
||||
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
|
||||
thumbnails.value = {}
|
||||
thumbnails.value = {}; thumbnailFailures.value = {}
|
||||
closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false)
|
||||
if (!no) return
|
||||
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。
|
||||
@@ -89,6 +96,14 @@ async function load() {
|
||||
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
|
||||
}))
|
||||
}
|
||||
function thumbnailLoaded(id: string, event: Event) {
|
||||
const image = event.target as HTMLImageElement
|
||||
if (image.getAttribute('src') !== thumbnails.value[id]) return
|
||||
if (image.naturalWidth <= 1 && image.naturalHeight <= 1) thumbnailFailures.value[id] = '原图仅 1 × 1 像素,请替换有效照片'
|
||||
}
|
||||
function thumbnailFailed(id: string, event: Event) {
|
||||
if ((event.target as HTMLImageElement).getAttribute('src') === thumbnails.value[id]) thumbnailFailures.value[id] = '照片加载失败,请重新读取或替换'
|
||||
}
|
||||
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
|
||||
async function upload(event: Event) {
|
||||
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
|
||||
@@ -133,7 +148,7 @@ async function preview(id: string) {
|
||||
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
|
||||
finally { if (current === previewGeneration) previewLoading.value = false }
|
||||
}
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true })
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.fallbackFileId, props.masterLicense, props.masterResource]), load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
@@ -142,7 +157,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
<figure v-if="photoCard" class="evidence-photo-card">
|
||||
<div v-if="allIds.length" class="evidence-photo-list">
|
||||
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
|
||||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
|
||||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id] && !thumbnailFailures[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName" @load="thumbnailLoaded(id, $event)" @error="thumbnailFailed(id, $event)"><span v-else>{{ thumbnailFailures[id] || (metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载') }}</span></button>
|
||||
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,24 @@ it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous ma
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
|
||||
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
|
||||
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
|
||||
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
|
||||
expect(wrapper.text()).toContain('预计任务结束')
|
||||
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it.each(['MASTER', 'EXTERNAL'] as const)('lets a %s driver fill a missing trip end and respects the disabled state', async mode => {
|
||||
const value = resources(); value.driver.mode = mode; value.driver.evidence!.expectedTaskEndAt = null
|
||||
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
|
||||
const endField = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '预计任务结束')!
|
||||
const picker = endField.getComponent({ name: 'ElDatePicker' })
|
||||
picker.vm.$emit('update:modelValue', '2030-01-01T17:00:00Z'); await flushPromises()
|
||||
expect(value.driver.evidence!.expectedTaskEndAt).toBe('2030-01-01T17:00:00Z')
|
||||
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).some(item => item.props('label') === '驾驶证到期日')).toBe(mode === 'EXTERNAL')
|
||||
await wrapper.setProps({ disabled: true })
|
||||
expect(picker.props('disabled')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.driver.mode = 'MASTER'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -43,8 +56,8 @@ it('shows the newly selected authorized vehicle company without copying it into
|
||||
const selected = wrapper.emitted('update:vehicle')!.at(-1)![0] as typeof value.vehicle
|
||||
expect(selected).toMatchObject({ vehicleId: 'vehicle-2', carrierName: null })
|
||||
await wrapper.setProps({ vehicle: selected })
|
||||
const company = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '拖车公司名称')!.getComponent({ name: 'ElInput' })
|
||||
expect(company.props('modelValue')).toBe('新承运公司'); expect(company.props('readonly')).toBe(true); wrapper.unmount()
|
||||
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
|
||||
|
||||
@@ -10,7 +10,6 @@ const driver = defineModel<Driver>('driver', { required: true })
|
||||
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
|
||||
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
|
||||
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
|
||||
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
|
||||
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
|
||||
@@ -63,7 +62,6 @@ function selectDriver(id: string) {
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
</template>
|
||||
<tr v-if="vehicle.mode === 'MASTER'"><td colspan="2"><el-form-item label="拖车公司名称"><el-input :model-value="masterCarrierName" readonly /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
|
||||
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,7 @@ function confirmedAt(appointment: DispatchDestinationAppointment) {
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" aria-label="本车资源登记">
|
||||
<h3 class="density-dialog-section__title">车辆与司机</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /><DispatchTimeZonePreview :value="detail.dispatchInfo.departedAt" label="发车时间" /></el-form-item></td></tr></tbody></table>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td colspan="2"><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td></tr></tbody></table>
|
||||
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车时间登记">
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('dispatch workflow native layout', () => {
|
||||
const fields = dialog.findComponent(DispatchResourceFields)
|
||||
const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } }
|
||||
fields.vm.$emit('update:driver', selected); await flushPromises()
|
||||
expect(dialog.text()).toContain('预计任务结束')
|
||||
const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
|
||||
expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片')
|
||||
expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33')
|
||||
@@ -70,6 +71,17 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(license().find('img').exists()).toBe(false)
|
||||
expect(license().text()).toContain('暂无照片')
|
||||
})
|
||||
it('saves the entered task end for a master driver in the maintenance command', async () => {
|
||||
const saved = resources()
|
||||
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, expectedTaskEndAt: null } }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: saved })
|
||||
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01T17:00:00Z')
|
||||
await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({
|
||||
driver: expect.objectContaining({ mode: 'MASTER', driverId: '22', evidence: expect.objectContaining({ expectedTaskEndAt: '2030-01-01T17:00:00Z' }) }),
|
||||
}))
|
||||
})
|
||||
it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => {
|
||||
const saved = resources()
|
||||
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } }
|
||||
@@ -112,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()
|
||||
@@ -151,7 +175,8 @@ describe('dispatch workflow native layout', () => {
|
||||
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...source.dispatchInfo,
|
||||
departedAt: '2026-07-01T15:00:00Z', plannedDepartureAt: '2026-07-01T16:00:00Z', estimatedArrivalAt: '2026-07-01T17:00:00Z',
|
||||
} })
|
||||
expect(field(dialog, '发车时间').get('[aria-label="发车时间时区换算"]').text()).toBe('美东2026-07-01 11:00:00美西2026-07-01 08:00:00')
|
||||
expect(dialog.find('[aria-label="发车时间时区换算"]').exists()).toBe(false)
|
||||
expect(field(dialog, '发车时间')).toBeUndefined()
|
||||
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 12:00:00美西2026-07-01 09:00:00')
|
||||
expect(field(dialog, '预计到达时间').get('[aria-label="预计到达时间时区换算"]').text()).toBe('美东2026-07-01 13:00:00美西2026-07-01 10:00:00')
|
||||
expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
|
||||
@@ -178,7 +203,8 @@ describe('dispatch workflow native layout', () => {
|
||||
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
|
||||
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
|
||||
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(field(dialog, '发车时间')).toBeUndefined()
|
||||
expect(field(dialog, '排车单号').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
@@ -202,7 +228,7 @@ describe('dispatch workflow native layout', () => {
|
||||
await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED')
|
||||
dialog.vm.$emit('confirm');await flushPromises()
|
||||
const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
|
||||
expect(dialog.text()).toContain('workflow-request')
|
||||
expect((wrapper.vm as unknown as { error: { requestId: string } }).error.requestId).toBe('workflow-request')
|
||||
expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined()
|
||||
expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
|
||||
await field(dialog,'车牌号').get('input').setValue('CA-CHANGED')
|
||||
|
||||
@@ -275,7 +275,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车牌图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :fallback-file-id="masterLicenseReference" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
</template>
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
|
||||
@@ -3,9 +3,9 @@ import { expect, it } from 'vitest'
|
||||
import { createAppointmentForm } from '../appointmentForm'
|
||||
import FbaAppointmentFields from './FbaAppointmentFields.vue'
|
||||
|
||||
it('removes confirmation time and offset inputs only for the new appointment flow while retaining planned dates', async () => {
|
||||
it('keeps only the planned date for new appointments and allows time fields in other flows', async () => {
|
||||
const wrapper = mount(FbaAppointmentFields, {
|
||||
props: { model: createAppointmentForm(), continuous: true, hideConfirmationTimes: true, timezone: 'America/New_York' },
|
||||
props: { model: createAppointmentForm(), continuous: true, dateOnly: true, timezone: 'America/New_York' },
|
||||
global: { stubs: {
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
|
||||
@@ -16,10 +16,11 @@ it('removes confirmation time and offset inputs only for the new appointment flo
|
||||
})
|
||||
expect(wrapper.text()).toContain('外部约号')
|
||||
expect(wrapper.text()).toContain('计划送仓日期')
|
||||
expect(wrapper.text()).toContain('补充计划时段')
|
||||
expect(wrapper.findAll('[data-picker]')).toHaveLength(3)
|
||||
expect(wrapper.text()).not.toMatch(/对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
|
||||
await wrapper.setProps({ hideConfirmationTimes: false })
|
||||
expect(wrapper.findAll('[data-picker]')).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toMatch(/补充计划时段|计划开始时段|计划结束时段|对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
|
||||
await wrapper.setProps({ dateOnly: false })
|
||||
expect(wrapper.text()).toContain('计划开始时段')
|
||||
expect(wrapper.text()).toContain('计划结束时段')
|
||||
expect(wrapper.text()).toContain('对方确认开始时间')
|
||||
expect(wrapper.findAll('[data-picker]')).toHaveLength(5)
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import type { AppointmentForm } from '../appointmentForm'
|
||||
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; hideConfirmationTimes?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; dateOnly?: boolean; sources?: PlanningSourceLookup[] }>()
|
||||
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
|
||||
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
|
||||
watchEffect(() => {
|
||||
@@ -13,7 +13,7 @@ watchEffect(() => {
|
||||
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
|
||||
</script>
|
||||
<template>
|
||||
<p v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
|
||||
<p v-if="!dateOnly && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
|
||||
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
|
||||
<div v-if="continuous" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
|
||||
@@ -22,13 +22,13 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
|
||||
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item></td></tr>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
|
||||
<tr v-if="!hideConfirmationTimes"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
|
||||
<tr v-if="!dateOnly"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
|
||||
</template>
|
||||
<tr><td colspan="2"><el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
<details><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
|
||||
<details v-if="!dateOnly"><summary>补充计划时段与夏令时信息</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item></td><td><el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
|
||||
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
|
||||
</tbody></table></details></el-form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -39,15 +39,15 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
|
||||
<el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
||||
<el-form-item v-if="!hideConfirmationTimes" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||
<el-form-item v-if="!hideConfirmationTimes" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
|
||||
<el-form-item v-if="!dateOnly" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||
<el-form-item v-if="!dateOnly" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item>
|
||||
<details class="appointment-fields__extra"><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary>
|
||||
<details v-if="!dateOnly" class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
|
||||
<div class="appointment-fields__extra-grid">
|
||||
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
|
||||
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
|
||||
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
|
||||
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
|
||||
</template>
|
||||
|
||||
@@ -1,68 +1,348 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import {
|
||||
confirmReceivingReconciliation, getReceivingReconciliation,
|
||||
type InboundPlanDetailDto, type ReceivingReconciliationDto,
|
||||
} from '../api/inboundPlans'
|
||||
import InboundShortageConfirmation from './InboundShortageConfirmation.vue'
|
||||
|
||||
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access }))
|
||||
vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() }))
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<div v-if="modelValue"><slot /><button data-confirm :disabled="confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认并按实收入库</button></div>' }
|
||||
vi.mock('../api/inboundPlans', () => ({ confirmReceivingReconciliation: vi.fn(), getReceivingReconciliation: vi.fn() }))
|
||||
const dialog = {
|
||||
name: 'AppDialog', props: ['modelValue', 'title', 'loading', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'],
|
||||
template: '<div v-if="modelValue"><h2>{{ title }}</h2><slot /><button data-confirm :disabled="loading || confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认收货异常</button></div>',
|
||||
}
|
||||
const numberInput = {
|
||||
props: ['modelValue', 'min', 'max', 'disabled'], emits: ['update:modelValue'],
|
||||
template: '<input type="number" :min="min" :max="max" :disabled="disabled" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value === \'\' ? undefined : Number($event.target.value))" />',
|
||||
}
|
||||
function detail(overrides: Partial<InboundPlanDetailDto> = {}): InboundPlanDetailDto {
|
||||
return { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
|
||||
orderType: 'FIRST_LEG', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
|
||||
lineCount: 1, palletCount: 2, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0,
|
||||
lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 2 }],
|
||||
lineCount: 1, palletCount: 1, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0,
|
||||
lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 1 }],
|
||||
pallets: [], allocations: [], ...overrides }
|
||||
}
|
||||
function render(value = detail()) {
|
||||
return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } })
|
||||
function snapshot(overrides: Partial<ReceivingReconciliationDto> = {}): ReceivingReconciliationDto {
|
||||
return { planNo: 'IP-1', containerNo: 'CONT-1', snapshotHash: 'snapshot-1', expectedPackageCount: 21,
|
||||
countedPackageCount: 20, attributedPackageCount: 20, canConfirm: true, blockedReason: null,
|
||||
plans: [{ customerId: '9007199254741001', planNo: 'IP-1', customerName: '本轮客户', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, attributedPackageCount: 20 }],
|
||||
pallets: [{ palletId: '9007199254741002', palletNo: 'PLT-1', groupKey: 'G-1', countedPackageCount: 20, attributedPackageCount: 20, editable: true }],
|
||||
receipts: [{ customerId: '9007199254741001', planNo: 'IP-1', lineId: '9007199254741003', inboundCode: 'IN-1',
|
||||
palletId: '9007199254741002', palletNo: 'PLT-1', skuCode: 'SKU-1', productName: '货物', expectedPackageCount: 21, receivedPackageCount: 20, editable: true }], ...overrides }
|
||||
}
|
||||
describe('入库短少确认', () => {
|
||||
beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) })
|
||||
it('requires a reason and submits actual counts without overwriting the expected count', async () => {
|
||||
function render(value = detail()) {
|
||||
return mount(InboundShortageConfirmation, { props: { detail: value }, global: {
|
||||
plugins: [ElementPlus], stubs: { AppDialog: dialog, ElInputNumber: numberInput },
|
||||
} })
|
||||
}
|
||||
async function open(wrapper: ReturnType<typeof render>) {
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
const palletInput = 'input[aria-label="托盘PLT-1确认清点箱数"]'
|
||||
const receiptInput = 'input[aria-label="IP-1托盘PLT-1SKUSKU-1确认归属箱数"]'
|
||||
|
||||
describe('全柜收货异常确认', () => {
|
||||
beforeEach(() => {
|
||||
access.can.mockReturnValue(true)
|
||||
vi.mocked(getReceivingReconciliation).mockReset().mockResolvedValue(snapshot())
|
||||
vi.mocked(confirmReceivingReconciliation).mockReset().mockResolvedValue({ planNo: 'IP-1', status: 'PARTIAL_INBOUND', confirmedPackageCount: 20, reconciledPlanNos: ['IP-1'] })
|
||||
})
|
||||
|
||||
it('shows separate whole-container expected, counted and attributed facts on the detail', async () => {
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(snapshot({ countedPackageCount: 22, attributedPackageCount: 19 }))
|
||||
const wrapper = render()
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.text()).toContain('21')
|
||||
expect(wrapper.text()).toContain('20')
|
||||
await flushPromises()
|
||||
const summary = wrapper.get('[aria-label="本柜原始收货数量"]')
|
||||
expect(summary.text()).toContain('原应收箱数21')
|
||||
expect(summary.text()).toContain('现场清点箱数22')
|
||||
expect(summary.text()).toContain('已归属箱数19')
|
||||
expect(getReceivingReconciliation).toHaveBeenCalledWith('IP-1', '9007199254741001')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('requires a reason and submits the exact current snapshot, pallet and SKU identities', async () => {
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
expect(wrapper.text()).toContain('确认收货异常 · IP-1')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('textarea').setValue('现场清点短少1箱')
|
||||
await wrapper.get('textarea').setValue(' 逐托核实短少1箱 ')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String))
|
||||
expect(confirmReceivingReconciliation).toHaveBeenCalledWith('IP-1', {
|
||||
customerId: '9007199254741001', snapshotHash: 'snapshot-1', reason: '逐托核实短少1箱',
|
||||
pallets: [{ palletId: '9007199254741002', countedPackageCount: 20 }],
|
||||
receipts: [{ customerId: '9007199254741001', planNo: 'IP-1', lineId: '9007199254741003', palletId: '9007199254741002', skuCode: 'SKU-1', receivedPackageCount: 20 }],
|
||||
}, expect.any(String))
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => {
|
||||
vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化'))
|
||||
|
||||
it.each([0, 21, 22])('allows an explicit confirmed actual count of %s', async count => {
|
||||
const wrapper = render(detail({ pickedPackageCount: count, version: undefined, orderType: 'ONE_CLICK_DROPSHIP' }))
|
||||
await open(wrapper)
|
||||
await wrapper.get(palletInput).setValue(count)
|
||||
await wrapper.get(receiptInput).setValue(count)
|
||||
await wrapper.get('textarea').setValue('已逐项核实现场数量及归属')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined()
|
||||
if (count === 22) expect(wrapper.text()).toContain('超收1')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmReceivingReconciliation).toHaveBeenCalledWith('IP-1', expect.objectContaining({
|
||||
pallets: [{ palletId: '9007199254741002', countedPackageCount: count }],
|
||||
receipts: [expect.objectContaining({ receivedPackageCount: count })],
|
||||
}), expect.any(String))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens every pending reconciliation without inferring eligibility from the old detail quantities or line type', async () => {
|
||||
const wrapper = render(detail({ version: undefined, expectedPackageCount: undefined, pickedPackageCount: undefined,
|
||||
lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 1, plannedPalletCount: 1 },
|
||||
{ id: '4', orderId: '2', fbaWarehouseCode: null, requiredPackageCount: 1, plannedPalletCount: 1 }] }))
|
||||
await open(wrapper)
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a blocked plan inspectable and shows the server reason', async () => {
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(snapshot({ canConfirm: false, blockedReason: '该票冻结SKU缺失' }))
|
||||
const wrapper = render()
|
||||
await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱')
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱')
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('核对资料')
|
||||
expect(wrapper.text()).toContain('该票冻结SKU缺失')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
expect(confirmReceivingReconciliation).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('never distributes an ambiguous multi-SKU physical count and validates each pallet separately', async () => {
|
||||
const data = snapshot()
|
||||
data.pallets[0]!.countedPackageCount = 22
|
||||
data.receipts[0]!.receivedPackageCount = 10
|
||||
data.receipts.push({ ...data.receipts[0]!, skuCode: 'SKU-2', receivedPackageCount: 10 })
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(data)
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
expect((wrapper.get(receiptInput).element as HTMLInputElement).value).toBe('10')
|
||||
await wrapper.get('textarea').setValue('现场逐SKU核实')
|
||||
expect(wrapper.text()).toContain('逐SKU归属合计须等于确认清点箱数')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get(receiptInput).setValue(12)
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows all affected plan numbers and preserves completed rows as read-only facts', async () => {
|
||||
const data = snapshot()
|
||||
data.plans.push({ customerId: '9007199254741004', planNo: 'IP-2', customerName: '同柜客户', status: 'COMPLETED', expectedPackageCount: 2, attributedPackageCount: 2 })
|
||||
data.pallets[0]!.countedPackageCount = 22
|
||||
data.receipts.push({ ...data.receipts[0]!, customerId: '9007199254741004', planNo: 'IP-2', lineId: '9007199254741005', receivedPackageCount: 2, editable: false })
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(data)
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
expect(wrapper.get('[aria-label="本柜涉及入库单"]').text()).toContain('IP-2')
|
||||
expect(wrapper.get('[aria-label="逐票逐SKU实际归属"]').text()).toContain('2(只读)')
|
||||
expect(wrapper.find('input[aria-label="IP-2托盘PLT-1SKUSKU-1确认归属箱数"]').exists()).toBe(false)
|
||||
await wrapper.get('textarea').setValue('全柜逐票归属已核实')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
const payload = vi.mocked(confirmReceivingReconciliation).mock.calls[0]?.[1]
|
||||
expect(payload?.receipts).toHaveLength(2)
|
||||
expect(payload?.receipts[1]).toMatchObject({ planNo: 'IP-2', receivedPackageCount: 2 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejects cross-pallet misattribution even when the whole-container totals match', async () => {
|
||||
const data = snapshot()
|
||||
data.pallets[0]!.countedPackageCount = 10
|
||||
data.pallets.push({ ...data.pallets[0]!, palletId: '9007199254741010', palletNo: 'PLT-2' })
|
||||
data.receipts[0]!.receivedPackageCount = 11
|
||||
data.receipts.push({ ...data.receipts[0]!, palletId: '9007199254741010', palletNo: 'PLT-2', receivedPackageCount: 9 })
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(data)
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('核对逐托归属')
|
||||
expect(wrapper.text()).toContain('托盘PLT-1的逐SKU归属合计须等于确认清点箱数')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
expect(confirmReceivingReconciliation).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('submits completed read-only pallet and SKU quantities above 66 unchanged', async () => {
|
||||
const data = snapshot()
|
||||
data.plans.push({ customerId: '9007199254741004', planNo: 'IP-2', status: 'COMPLETED', expectedPackageCount: 80, attributedPackageCount: 80 })
|
||||
data.pallets.push({ ...data.pallets[0]!, palletId: '9007199254741010', palletNo: 'PLT-2', countedPackageCount: 80, attributedPackageCount: 80, editable: false })
|
||||
data.receipts.push({ ...data.receipts[0]!, planNo: 'IP-2', palletId: '9007199254741010', palletNo: 'PLT-2', receivedPackageCount: 80, editable: false })
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(data)
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('保留已完成票据归属,核对当前票据')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('80(只读)')
|
||||
expect(wrapper.get(palletInput).attributes('max')).toBe('66')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
const payload = vi.mocked(confirmReceivingReconciliation).mock.calls[0]?.[1]
|
||||
expect(payload?.pallets[1]).toEqual({ palletId: '9007199254741010', countedPackageCount: 80 })
|
||||
expect(payload?.receipts[1]).toMatchObject({ planNo: 'IP-2', receivedPackageCount: 80 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['pallet', 'receipt'])('rejects a negative read-only %s quantity', async field => {
|
||||
const data = snapshot()
|
||||
if (field === 'pallet') {
|
||||
data.pallets[0]!.editable = false
|
||||
data.pallets[0]!.countedPackageCount = -1
|
||||
} else {
|
||||
data.receipts[0]!.editable = false
|
||||
data.receipts[0]!.receivedPackageCount = -1
|
||||
}
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(data)
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('核对数量')
|
||||
expect(wrapper.text()).toContain('清点及归属须为非负整数')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the entry available when the first reconciliation read fails', async () => {
|
||||
vi.mocked(getReceivingReconciliation).mockRejectedValueOnce(new Error('暂时无法读取'))
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const action = wrapper.findAll('button').find(button => button.text() === '确认收货异常')!
|
||||
await action.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
expect(getReceivingReconciliation).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['', '-1', '1.5', '67'])('blocks an invalid quantity %s', async value => {
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get(palletInput).setValue(value)
|
||||
await wrapper.get('textarea').setValue('核对数量')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('范围为0至66')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves human input after failure, reads a fresh hash and requires a new click and key', async () => {
|
||||
vi.mocked(confirmReceivingReconciliation).mockRejectedValueOnce(new Error('现场记录已变化'))
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get(palletInput).setValue(19)
|
||||
await wrapper.get(receiptInput).setValue(19)
|
||||
await wrapper.get('textarea').setValue('核对后短少2箱')
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(snapshot({ snapshotHash: 'snapshot-2' }))
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ detail: detail({ version: 5 }) })
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
const calls = vi.mocked(confirmInboundShortage).mock.calls
|
||||
expect(calls[1]?.[1].version).toBe(5)
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('核对后短少2箱')
|
||||
expect((wrapper.get(palletInput).element as HTMLInputElement).value).toBe('19')
|
||||
expect((wrapper.get(receiptInput).element as HTMLInputElement).value).toBe('19')
|
||||
expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
const calls = vi.mocked(confirmReceivingReconciliation).mock.calls
|
||||
expect(calls[1]?.[1]).toMatchObject({ snapshotHash: 'snapshot-2', pallets: [{ countedPackageCount: 19 }] })
|
||||
expect(calls[1]?.[2]).not.toBe(calls[0]?.[2])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('hides the action without edit permission', () => {
|
||||
access.can.mockReturnValue(false)
|
||||
const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('allows ordinary overseas carton shortage confirmation with the same versioned payload', async () => {
|
||||
const wrapper = render(detail({ lines: [{ id: '3', orderId: '2', fbaWarehouseCode: null, requiredPackageCount: 21, plannedPalletCount: 2 }] }))
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.get('textarea').setValue('逐箱核对短少1箱')
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', expect.objectContaining({
|
||||
expectedPackageCount: 21, receivedPackageCount: 20, reason: '逐箱核对短少1箱',
|
||||
}), expect.any(String))
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
|
||||
it('disables resubmission when the new snapshot cannot be loaded', async () => {
|
||||
vi.mocked(confirmReceivingReconciliation).mockRejectedValueOnce(new Error('快照已失效'))
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('现场已核实')
|
||||
vi.mocked(getReceivingReconciliation).mockRejectedValue(new Error('网络失败'))
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('现场已核实')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => {
|
||||
const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
|
||||
|
||||
it('does not duplicate an in-flight confirmation', async () => {
|
||||
let finish!: (value: Awaited<ReturnType<typeof confirmReceivingReconciliation>>) => void
|
||||
vi.mocked(confirmReceivingReconciliation).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('已确认')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(1)
|
||||
finish({ planNo: 'IP-1', status: 'PARTIAL_INBOUND', confirmedPackageCount: 20, reconciledPlanNos: ['IP-1'] })
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ changed: 'plan', outcome: 'success' }, { changed: 'plan', outcome: 'error' },
|
||||
{ changed: 'customer', outcome: 'success' }, { changed: 'customer', outcome: 'error' },
|
||||
])('ignores a late $outcome after the $changed changes and preserves the new submission', async ({ changed, outcome }) => {
|
||||
type Result = Awaited<ReturnType<typeof confirmReceivingReconciliation>>
|
||||
let finishOld!: (value: Result) => void
|
||||
let failOld!: (error: Error) => void
|
||||
let finishNew!: (value: Result) => void
|
||||
const oldRequest = new Promise<Result>((resolve, reject) => { finishOld = resolve; failOld = reject })
|
||||
const newRequest = new Promise<Result>(resolve => { finishNew = resolve })
|
||||
vi.mocked(confirmReceivingReconciliation).mockReturnValueOnce(oldRequest).mockReturnValueOnce(newRequest)
|
||||
const wrapper = render()
|
||||
await open(wrapper)
|
||||
await wrapper.get('textarea').setValue('旧单原因')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
|
||||
const nextDetail = detail(changed === 'plan' ? { planNo: 'IP-NEW' } : { customerId: '9007199254741099' })
|
||||
const nextSnapshot = snapshot({ planNo: nextDetail.planNo, snapshotHash: 'new-target-snapshot' })
|
||||
nextSnapshot.plans = nextSnapshot.plans.map(row => ({ ...row, planNo: nextDetail.planNo, customerId: nextDetail.customerId }))
|
||||
nextSnapshot.receipts = nextSnapshot.receipts.map(row => ({ ...row, planNo: nextDetail.planNo, customerId: nextDetail.customerId }))
|
||||
vi.mocked(getReceivingReconciliation).mockResolvedValue(nextSnapshot)
|
||||
await wrapper.setProps({ detail: nextDetail })
|
||||
await open(wrapper)
|
||||
expect(getReceivingReconciliation).toHaveBeenLastCalledWith(nextDetail.planNo, nextDetail.customerId)
|
||||
await wrapper.get('textarea').setValue('新单原因')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
expect(confirmReceivingReconciliation).toHaveBeenCalledTimes(2)
|
||||
expect(confirmReceivingReconciliation).toHaveBeenLastCalledWith(nextDetail.planNo,
|
||||
expect.objectContaining({ customerId: nextDetail.customerId, snapshotHash: 'new-target-snapshot' }), expect.any(String))
|
||||
const readsBeforeOldResponse = vi.mocked(getReceivingReconciliation).mock.calls.length
|
||||
|
||||
if (outcome === 'success') finishOld({ planNo: 'IP-1', status: 'COMPLETED', confirmedPackageCount: 20, reconciledPlanNos: ['IP-1'] })
|
||||
else failOld(new Error('旧单返回失败'))
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('refresh')).toBeUndefined()
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('新单原因')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('旧单返回失败')
|
||||
expect(getReceivingReconciliation).toHaveBeenCalledTimes(readsBeforeOldResponse)
|
||||
|
||||
finishNew({ planNo: nextDetail.planNo, status: 'COMPLETED', confirmedPackageCount: 20, reconciledPlanNos: [nextDetail.planNo] })
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides the action without edit permission and for completed plans', async () => {
|
||||
access.can.mockReturnValue(false)
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('现场清点箱数')
|
||||
access.can.mockReturnValue(true)
|
||||
await wrapper.setProps({ detail: detail({ status: 'COMPLETED' }) })
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,68 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import {
|
||||
confirmReceivingReconciliation, getReceivingReconciliation, type InboundPlanDetailDto,
|
||||
type ReceivingReconciliationDto, type ReceivingReconciliationPallet, type ReceivingReconciliationReceipt,
|
||||
} from '../api/inboundPlans'
|
||||
import { overseasWarehousePermissions } from '../permissions'
|
||||
import { inboundPlanStatusLabel } from '../lastMilePresentation'
|
||||
|
||||
type PalletDraft = ReceivingReconciliationPallet & { confirmedCount: number | undefined }
|
||||
type ReceiptDraft = ReceivingReconciliationReceipt & { confirmedCount: number | undefined }
|
||||
|
||||
const props = defineProps<{ detail: InboundPlanDetailDto }>()
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const open = ref(false)
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const reason = ref('')
|
||||
const snapshot = ref<ReceivingReconciliationDto | null>(null)
|
||||
const pallets = ref<PalletDraft[]>([])
|
||||
const receipts = ref<ReceiptDraft[]>([])
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const expected = computed(() => props.detail.expectedPackageCount)
|
||||
const received = computed(() => props.detail.pickedPackageCount)
|
||||
const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0))
|
||||
const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit)
|
||||
&& props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null
|
||||
&& props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0
|
||||
&& (props.detail.lines.every(line => !!line.fbaWarehouseCode) || props.detail.lines.every(line => !line.fbaWarehouseCode))
|
||||
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
let loadSequence = 0
|
||||
const pending = computed(() => props.detail.status === 'PENDING_RECONCILIATION')
|
||||
const eligible = computed(() => pending.value && access.can(overseasWarehousePermissions.inboundOrderEdit))
|
||||
const confirmedTotal = computed(() => receipts.value.reduce((sum, row) => sum + (row.confirmedCount ?? 0), 0))
|
||||
const confirmedCountedTotal = computed(() => pallets.value.reduce((sum, row) => sum + (row.confirmedCount ?? 0), 0))
|
||||
const difference = computed(() => confirmedTotal.value - (snapshot.value?.expectedPackageCount ?? 0))
|
||||
const validationMessage = computed(() => {
|
||||
if (!snapshot.value) return ''
|
||||
if (pallets.value.some(row => !validCount(row.confirmedCount, row.editable))
|
||||
|| receipts.value.some(row => !validCount(row.confirmedCount, row.editable))) {
|
||||
return '清点及归属须为非负整数,可编辑箱数范围为0至66'
|
||||
}
|
||||
const mismatch = pallets.value.find(pallet => receipts.value
|
||||
.filter(row => row.palletId === pallet.palletId)
|
||||
.reduce((sum, row) => sum + (row.confirmedCount ?? 0), 0) !== pallet.confirmedCount)
|
||||
return mismatch ? `托盘${mismatch.palletNo || '—'}的逐SKU归属合计须等于确认清点箱数` : ''
|
||||
})
|
||||
const canSubmit = computed(() => eligible.value && !loading.value && !saving.value && !loadError.value
|
||||
&& !!snapshot.value?.snapshotHash && snapshot.value.canConfirm && !validationMessage.value && !!reason.value.trim())
|
||||
|
||||
function validCount(value: number | undefined, editable: boolean): value is number {
|
||||
return value != null && Number.isInteger(value) && value >= 0 && (!editable || value <= 66)
|
||||
}
|
||||
function receiptKey(row: ReceivingReconciliationReceipt) {
|
||||
return JSON.stringify([row.customerId, row.planNo, row.lineId, row.palletId, row.skuCode])
|
||||
}
|
||||
|
||||
// 保存失败后仅保留同一归属行的人工输入,已完成行始终采用最新只读事实。
|
||||
async function loadSnapshot(preserveInputs = false) {
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await getReceivingReconciliation(props.detail.planNo, props.detail.customerId)
|
||||
if (sequence !== loadSequence) return
|
||||
const oldPallets = new Map(pallets.value.map(row => [row.palletId, row]))
|
||||
const oldReceipts = new Map(receipts.value.map(row => [receiptKey(row), row]))
|
||||
snapshot.value = result
|
||||
pallets.value = result.pallets.map(row => ({ ...row, confirmedCount: preserveInputs && row.editable && oldPallets.has(row.palletId)
|
||||
? oldPallets.get(row.palletId)?.confirmedCount : row.countedPackageCount }))
|
||||
receipts.value = result.receipts.map(row => ({ ...row, confirmedCount: preserveInputs && row.editable && oldReceipts.has(receiptKey(row))
|
||||
? oldReceipts.get(receiptKey(row))?.confirmedCount : row.receivedPackageCount }))
|
||||
} catch (cause) {
|
||||
if (sequence === loadSequence) loadError.value = getApiErrorDetails(cause, '收货核对资料加载失败,请重新加载')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!eligible.value || saving.value) return
|
||||
reason.value = ''; error.value = null; open.value = true
|
||||
if (!eligible.value || saving.value || open.value) return
|
||||
reason.value = ''
|
||||
error.value = null
|
||||
open.value = true
|
||||
void loadSnapshot()
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!open.value || saving.value || !eligible.value || !reason.value.trim()
|
||||
|| props.detail.version == null || expected.value == null || received.value == null) return
|
||||
saving.value = true; error.value = null
|
||||
if (!canSubmit.value || !snapshot.value) return
|
||||
const target = { planNo: props.detail.planNo, customerId: props.detail.customerId }
|
||||
let sequence = loadSequence
|
||||
// 切换单据或客户后,旧命令的回执不得影响当前详情或新命令状态。
|
||||
const current = () => sequence === loadSequence
|
||||
&& target.planNo === props.detail.planNo && target.customerId === props.detail.customerId
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await confirmInboundShortage(props.detail.planNo, {
|
||||
customerId: props.detail.customerId, version: props.detail.version,
|
||||
expectedPackageCount: expected.value, receivedPackageCount: received.value, reason: reason.value.trim(),
|
||||
const result = await confirmReceivingReconciliation(target.planNo, {
|
||||
customerId: target.customerId, snapshotHash: snapshot.value.snapshotHash, reason: reason.value.trim(),
|
||||
pallets: pallets.value.map(row => ({ palletId: row.palletId, countedPackageCount: row.confirmedCount! })),
|
||||
receipts: receipts.value.map(row => ({ customerId: row.customerId, planNo: row.planNo, lineId: row.lineId,
|
||||
palletId: row.palletId, skuCode: row.skuCode, receivedPackageCount: row.confirmedCount! })),
|
||||
}, crypto.randomUUID())
|
||||
ElMessage.success(`已确认短少,${received.value} 箱实收已入库`)
|
||||
if (!current()) return
|
||||
ElMessage.success(`已确认收货异常,实际归属${result.confirmedPackageCount}箱,涉及${result.reconciledPlanNos.length}张入库单`)
|
||||
open.value = false
|
||||
emit('refresh')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '短少确认失败,请核对最新收货记录后重试')
|
||||
emit('refresh')
|
||||
} finally { saving.value = false }
|
||||
if (!current()) return
|
||||
error.value = getApiErrorDetails(cause, '收货异常确认失败,请核对最新资料后重新确认')
|
||||
// 失败不重放旧快照,重新加载后由用户再次确认保留的清点及归属输入。
|
||||
saving.value = false
|
||||
const reload = loadSnapshot(true)
|
||||
sequence = loadSequence
|
||||
await reload
|
||||
if (current()) emit('refresh')
|
||||
} finally {
|
||||
if (current()) saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.detail.customerId, () => props.detail.planNo, () => props.detail.status], () => {
|
||||
loadSequence++
|
||||
open.value = false
|
||||
snapshot.value = null
|
||||
pallets.value = []
|
||||
receipts.value = []
|
||||
reason.value = ''
|
||||
error.value = null
|
||||
loadError.value = null
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
if (pending.value) void loadSnapshot()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { loadSequence++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认短少入库</el-button>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="确认短少入库" width="560px"
|
||||
confirm-text="确认并按实收入库" :confirm-loading="saving" :confirm-disabled="!eligible || !reason.trim()" @confirm="confirm">
|
||||
<section v-if="pending" class="receiving-reconciliation" aria-label="本柜收货核对">
|
||||
<div class="reconciliation-heading">
|
||||
<strong>本柜收货数量</strong>
|
||||
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认收货异常</el-button>
|
||||
</div>
|
||||
<table v-loading="loading" class="density-table" aria-label="本柜原始收货数量"><tbody><tr>
|
||||
<th scope="row">原应收箱数</th><td>{{ snapshot?.expectedPackageCount ?? '—' }}</td>
|
||||
<th scope="row">现场清点箱数</th><td>{{ snapshot?.countedPackageCount ?? '—' }}</td>
|
||||
<th scope="row">已归属箱数</th><td>{{ snapshot?.attributedPackageCount ?? '—' }}</td>
|
||||
</tr></tbody></table>
|
||||
<LoadErrorState v-if="loadError && !open" compact title="收货核对资料加载失败" :message="loadError.message" :loading="loading" @retry="loadSnapshot()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
</section>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="`确认收货异常 · ${detail.planNo}`"
|
||||
width="min(1100px, calc(100vw - 48px))" fullscreenable :loading="loading"
|
||||
confirm-text="确认收货异常" :confirm-loading="saving" :confirm-disabled="!canSubmit" @confirm="confirm">
|
||||
<div class="density-dialog-sections">
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">入库单号</th><td>{{ detail.planNo }}</td></tr>
|
||||
<tr><th scope="row">应收箱数</th><td>{{ expected }}</td></tr>
|
||||
<tr><th scope="row">实收入库箱数</th><td>{{ received }}</td></tr>
|
||||
<tr><th scope="row">确认短少箱数</th><td>{{ shortage }}</td></tr>
|
||||
</tbody></table>
|
||||
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving">
|
||||
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="短少确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="error" compact title="短少确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" compact title="收货核对资料加载失败" :message="loadError.message" :loading="loading" @retry="loadSnapshot(true)"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-if="snapshot">
|
||||
<table class="density-table" aria-label="本柜收货核对摘要"><tbody>
|
||||
<tr><th scope="row">核对范围</th><td colspan="5">本柜全部涉及入库单 · {{ snapshot.containerNo || '—' }}</td></tr>
|
||||
<tr><th scope="row">原应收箱数</th><td>{{ snapshot.expectedPackageCount }}</td><th scope="row">现场清点箱数</th><td>{{ snapshot.countedPackageCount }}</td><th scope="row">已归属箱数</th><td>{{ snapshot.attributedPackageCount }}</td></tr>
|
||||
<tr><th scope="row">本次确认清点</th><td>{{ confirmedCountedTotal }}</td><th scope="row">本次实际归属</th><td>{{ confirmedTotal }}</td><th scope="row">实际与应收差异</th><td>{{ difference > 0 ? `超收${difference}` : difference < 0 ? `短少${-difference}` : '一致' }}</td></tr>
|
||||
</tbody></table>
|
||||
<table class="density-table" aria-label="本柜涉及入库单"><thead><tr><th scope="col">客户</th><th scope="col">入库单号</th><th scope="col">当前状态</th><th scope="col">原应收箱数</th><th scope="col">已归属箱数</th></tr></thead><tbody>
|
||||
<tr v-for="plan in snapshot.plans" :key="`${plan.customerId}-${plan.planNo}`"><td>{{ plan.customerName || '—' }}</td><td>{{ plan.planNo }}</td><td>{{ inboundPlanStatusLabel(plan.status) }}</td><td>{{ plan.expectedPackageCount }}</td><td>{{ plan.attributedPackageCount }}</td></tr>
|
||||
</tbody></table>
|
||||
<div class="reconciliation-table-wrap"><table class="density-table density-table--editable" aria-label="逐托现场清点"><thead><tr><th scope="col">托盘号</th><th scope="col">现场清点箱数</th><th scope="col">已归属箱数</th><th scope="col">确认清点箱数</th></tr></thead><tbody>
|
||||
<tr v-for="pallet in pallets" :key="pallet.palletId"><td>{{ pallet.palletNo || '—' }}</td><td>{{ pallet.countedPackageCount }}</td><td>{{ pallet.attributedPackageCount }}</td><td>
|
||||
<el-input-number v-if="pallet.editable" v-model="pallet.confirmedCount" :min="0" :max="66" :step="1" :precision="0" :disabled="saving || loading" :aria-label="`托盘${pallet.palletNo || '—'}确认清点箱数`" />
|
||||
<span v-else>{{ pallet.confirmedCount }}(只读)</span>
|
||||
</td></tr>
|
||||
</tbody></table></div>
|
||||
<div class="reconciliation-table-wrap"><table class="density-table density-table--editable" aria-label="逐票逐SKU实际归属"><thead><tr><th scope="col">入库单号/入仓码</th><th scope="col">托盘号</th><th scope="col">SKU/品名</th><th scope="col">本票SKU原应收</th><th scope="col">确认归属箱数</th></tr></thead><tbody>
|
||||
<tr v-for="receipt in receipts" :key="receiptKey(receipt)"><td>{{ receipt.planNo }}<br>{{ receipt.inboundCode || '—' }}</td><td>{{ receipt.palletNo || '—' }}</td><td>{{ receipt.skuCode || '—' }}<br>{{ receipt.productName || '—' }}</td><td>{{ receipt.expectedPackageCount }}</td><td>
|
||||
<el-input-number v-if="receipt.editable" v-model="receipt.confirmedCount" :min="0" :max="66" :step="1" :precision="0" :disabled="saving || loading" :aria-label="`${receipt.planNo}托盘${receipt.palletNo || '—'}SKU${receipt.skuCode}确认归属箱数`" />
|
||||
<span v-else>{{ receipt.confirmedCount }}(只读)</span>
|
||||
</td></tr>
|
||||
</tbody></table></div>
|
||||
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving || loading">
|
||||
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="收货异常确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="snapshot.blockedReason || !snapshot.canConfirm" compact title="当前收货核对未满足确认条件" :message="snapshot.blockedReason || '请先补齐收货核对资料'" :show-retry="false" />
|
||||
<LoadErrorState v-if="validationMessage" compact title="请核对本次清点与归属" :message="validationMessage" :show-retry="false" />
|
||||
</template>
|
||||
<LoadErrorState v-if="error" compact title="收货异常确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.receiving-reconciliation { margin-bottom: 12px; }
|
||||
.reconciliation-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.reconciliation-table-wrap { overflow-x: auto; }
|
||||
.reconciliation-table-wrap td { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -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 || '—' }
|
||||
|
||||
@@ -57,7 +57,6 @@ beforeEach(() => {
|
||||
})
|
||||
async function listPage() { const wrapper = mount(InboundOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
|
||||
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
|
||||
async function openEditPlan() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '编辑')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑入库单') } }
|
||||
async function openEditAppointment() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑预约') } }
|
||||
async function openMove() { const wrapper = mount(InboundOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '托盘移库') } }
|
||||
|
||||
@@ -140,40 +139,17 @@ describe('inbound appointment native dialogs', () => {
|
||||
})
|
||||
|
||||
describe('inbound plan and pallet move native dialogs', () => {
|
||||
it('shows plan identity and saves the selected master warehouse with the internal version through fullscreen', async () => {
|
||||
const { dialog } = await openEditPlan()
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('入库单号')
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).not.toContain('版本')
|
||||
const select = dialog.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', { customerId: 'C-1', overseasWarehouseId: warehouse.id, warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', version: 0 })
|
||||
})
|
||||
it('locks a pending plan edit and keeps the warehouse and diagnostic context after failure', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
vi.mocked(updateInboundPlan).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
|
||||
const { dialog } = await openEditPlan()
|
||||
const select = dialog.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateInboundPlan).toHaveBeenCalledTimes(1)
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(select.find('input').attributes('disabled')).toBeDefined()
|
||||
reject(new ApiResponseError('版本已变化', 'A0409', 409, null, 'plan-popup-request')); await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(select.props('modelValue')).toBe(warehouse.id)
|
||||
expect(dialog.text()).toContain('plan-popup-request')
|
||||
})
|
||||
it('does not save while the plan warehouse candidates are loading', async () => {
|
||||
let resolve!: (value: Awaited<ReturnType<typeof listReceivingOverseasWarehouses>>) => void
|
||||
vi.mocked(listReceivingOverseasWarehouses).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { dialog } = await openEditPlan()
|
||||
expect(dialog.props('loading')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateInboundPlan).not.toHaveBeenCalled()
|
||||
resolve({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(dialog.props('loading')).toBe(false)
|
||||
it.each(['ONE_CLICK_DROPSHIP', 'FIRST_LEG', 'RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'RETURN_RELABEL', 'DELIVERY_RETURN', 'LOCAL_LOOSEBOARD', 'CARGO_DISCARD', 'REJECTED', 'EXPRESS', 'FUTURE_TYPE', null])('hides plan editing for inbound order type %s even with edit permission', async (orderType) => {
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order, orderType }], total: 1, page: 1, size: 10, pages: 1 })
|
||||
const wrapper = await listPage()
|
||||
const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())
|
||||
expect(actions).not.toContain('编辑')
|
||||
expect(actions).toContain('查看')
|
||||
expect(actions).toContain('查看预约')
|
||||
expect(actions).toContain('预约')
|
||||
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title').startsWith('编辑入库单'))).toBe(false)
|
||||
expect(listReceivingOverseasWarehouses).not.toHaveBeenCalled()
|
||||
expect(updateInboundPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
it('loads only physical usable locations and preserves a valid zero pallet version in the command', async () => {
|
||||
const { dialog } = await openMove()
|
||||
|
||||
@@ -8,9 +8,9 @@ vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, q
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } }))
|
||||
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-reconciliation @click="$emit(\'refresh\')">确认收货异常</button>' } }))
|
||||
|
||||
it('provides the scoped plan facts and reloads after shortage confirmation', async () => {
|
||||
it('provides the scoped plan facts and reloads after receiving reconciliation', async () => {
|
||||
const detail = { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
|
||||
status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
|
||||
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] }
|
||||
@@ -19,7 +19,8 @@ it('provides the scoped plan facts and reloads after shortage confirmation', asy
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail)
|
||||
expect(wrapper.text()).toContain('待差异核对')
|
||||
await wrapper.get('[data-shortage]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('确认收货异常')
|
||||
await wrapper.get('[data-reconciliation]').trigger('click'); await flushPromises()
|
||||
expect(getInboundPlan).toHaveBeenCalledTimes(2)
|
||||
expect(getInboundPlan).toHaveBeenLastCalledWith('IP-1', '9007199254741001')
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -129,7 +129,7 @@ onMounted(load)
|
||||
<template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap">
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><div class="detail-table-wrap">
|
||||
<LoadErrorState v-if="printError" compact title="托盘标签打印失败" :message="printError.message" :show-retry="false"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
|
||||
<div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><RowActions :actions="palletActions(row as typeof palletRows[number])" text-actions @action="action => action === 'print' ? printPalletLabel(row as typeof palletRows[number]) : openMove(row as typeof palletRows[number])" /></template></DataTable></div></div></section>
|
||||
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
|
||||
|
||||
@@ -19,8 +19,7 @@ import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
|
||||
import type { PlanningSourceLookup } from '@/modules/appointment-calendar'
|
||||
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
|
||||
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
@@ -68,7 +67,6 @@ const appointmentViewOnly = ref(false)
|
||||
const appointmentReplacementNos = ref<string[]>([])
|
||||
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
|
||||
const appointmentTitle = computed(() => `${appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约'} · ${editingAppointmentNo.value || `${appointmentSelectedCount.value} 条入库单`}`)
|
||||
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
|
||||
function selectableRow(source: object) {
|
||||
const row = source as InboundOrderListItem
|
||||
@@ -234,8 +232,6 @@ async function load(model: QueryModel = filters.value) {
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
|
||||
async function submitPlanEdit() { if (planEditSaving.value || planEditLoading.value || !access.can(permission.inboundOrderEdit)) return; const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
@@ -250,7 +246,7 @@ onMounted(() => { void load() })
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="canCreateDispatch" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></div></section>
|
||||
</ListPageLayout>
|
||||
@@ -263,11 +259,6 @@ onMounted(() => { void load() })
|
||||
</div>
|
||||
</AppDialog>
|
||||
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
|
||||
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
</div></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
|
||||
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110, afterColumn: 'orderNo' },
|
||||
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '出库排车状态', width: 110, afterColumn: 'orderNo' },
|
||||
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180, afterColumn: 'orderNo' },
|
||||
{ key: 'status', prop: 'status', label: '当前状态', width: 110, afterColumn: 'orderNo' },
|
||||
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100, afterColumn: 'orderNo' },
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -91,7 +91,7 @@ function feePreview(overrides: Partial<InboundFeePreview> = {}): InboundFeePrevi
|
||||
}
|
||||
|
||||
function savedOuterMeasurement() {
|
||||
return { actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
|
||||
return { measureReviewStatus: 'APPROVED', actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
|
||||
items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52,
|
||||
outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
|
||||
calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53,
|
||||
@@ -266,6 +266,22 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED'])('blocks fee publication before measurement approval: %s', async (measureReviewStatus) => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm & { feeConfirmOpen: boolean }
|
||||
vm.measurementDetail = { ...savedOuterMeasurement(), measureReviewStatus }
|
||||
|
||||
await vm.openFeeConfirmDialog()
|
||||
expect(vm.feeConfirmOpen).toBe(false)
|
||||
await vm.executeConfirmAndSendFee()
|
||||
expect(previewInboundFee).not.toHaveBeenCalled()
|
||||
expect(calculateInboundFee).not.toHaveBeenCalled()
|
||||
expect(confirmInboundFee).not.toHaveBeenCalled()
|
||||
expect(sendInboundFee).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([3.894, 4.25])('defaults billing to the saved outer volume and sends the confirmed value %s', async (confirmedVolume) => {
|
||||
const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' }
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never)
|
||||
@@ -359,7 +375,7 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as FeeConfirmVm
|
||||
vm.measurementDetail = {
|
||||
actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
|
||||
measureReviewStatus: 'APPROVED', actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
|
||||
items: [{ id: '9001', itemNo: 'B1', packageCount: 10, lengthCm: 50, widthCm: 40, heightCm: 30,
|
||||
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
|
||||
calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34,
|
||||
|
||||
@@ -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()
|
||||
@@ -1741,6 +1753,10 @@ async function openFeeConfirmDialog() {
|
||||
ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用')
|
||||
return
|
||||
}
|
||||
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
|
||||
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
|
||||
return
|
||||
}
|
||||
if (!hasActualMeasurementData.value) {
|
||||
ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
|
||||
return
|
||||
@@ -1866,6 +1882,10 @@ async function executeConfirmAndSendFee() {
|
||||
if (confirmFeeSending.value) return
|
||||
const summary = detail.value?.summary
|
||||
if (!summary?.inboundId) return
|
||||
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
|
||||
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
|
||||
return
|
||||
}
|
||||
const selectedWeightKg = Number(feeConfirmWeight.value)
|
||||
const selectedVolumeCbm = Number(feeConfirmVolume.value)
|
||||
const selectedUnitPrice = Number(feeConfirmUnitPrice.value)
|
||||
@@ -2854,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
|
||||
|
||||
@@ -431,7 +431,11 @@ describe('approved Admin density ownership', () => {
|
||||
expect(rules.get(':is(.admin-density, .admin-density-dialog) .density-table--editable .el-form-item.is-error')).toMatchObject({ 'margin-bottom': 'var(--admin-space-5)' })
|
||||
for (const selector of rules.keys()) if (selector.includes('density-dialog-section')) expect(selector.startsWith(':is(.admin-density-dialog, .admin-density-drawer:where(.admin-density-drawer--detail, .admin-density-drawer--editor)) ')).toBe(true)
|
||||
const measured = read('modules/inbounds/measurement/measurementDensity.css')
|
||||
expect(measured).not.toMatch(/border-left(?:-color|-width)?\s*:/)
|
||||
cssRoot(measured).walkRules(rule => {
|
||||
rule.walkDecls(/^border-left/, () => {
|
||||
expect(rule.selector).toMatch(/^\.inbound-measurement-workspace-dialog\.inbound-density \.measurement-(summary-section|declaration|items-section)$/)
|
||||
})
|
||||
})
|
||||
for (const color of ['#f4fbf8', '#f7f9ff', '#fffbf4']) expect(measured).not.toContain(color)
|
||||
for (const file of ['unified-orders/pages/UnifiedOrdersView.vue', 'unified-orders/components/OrderDeclarationEditorDialog.vue', 'unified-orders/components/InboundFeeDetailManager.vue', 'inbounds/measurement/InboundMeasurementPanel.vue', 'inbounds/weight/components/WeightOcrPanel.vue', 'domestic-containers/pages/DomesticContainersView.vue', 'sailing-calendar/pages/SailingCalendarView.vue', 'ocean-bookings/components/OceanBookingPrintDialog.vue', 'ocean-bookings/pages/OceanBookingDetailView.vue', 'trucking/components/TruckAttachmentDialog.vue', 'container-loading/pages/ContainersView.vue', 'container-loading/pages/ContainerDetailView.vue', 'container-loading/components/OceanBookingLinkDialog.vue']) {
|
||||
const source = read('modules/' + file)
|
||||
@@ -1302,20 +1306,27 @@ describe('approved Admin density ownership', () => {
|
||||
sectionStyles.set(rule.selector, declarations)
|
||||
})
|
||||
for (const [section, background] of [
|
||||
['summary-section', 'var(--admin-surface, #fff)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-surface, #fff)'],
|
||||
['summary-section', 'var(--admin-dialog-summary-bg)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-dialog-work-bg)'],
|
||||
]) {
|
||||
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
|
||||
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
|
||||
background,
|
||||
})
|
||||
}
|
||||
for (const [section, accent] of [['summary-section', '#20a477'], ['declaration', '#647fec'], ['items-section', '#c79643']]) {
|
||||
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)?.['border-left']).toBe(`4px solid ${accent}`)
|
||||
}
|
||||
expect(differenceStyles.get('.measurement-comparison thead th')).toMatchObject({ background: 'var(--admin-label-bg)' })
|
||||
expect(differenceStyles.get('.measurement-comparison thead th:nth-child(2)')).toMatchObject({ background: 'var(--admin-dialog-source-bg)' })
|
||||
expect(differenceStyles.get('.measurement-comparison thead th:nth-child(3)')).toMatchObject({ background: 'var(--admin-dialog-summary-bg)' })
|
||||
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-table th.el-table__cell')).toMatchObject({ background: 'var(--admin-label-bg)', color: 'var(--workbench-muted)' })
|
||||
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert')?.background).toBe('var(--admin-surface, #fff)')
|
||||
// Shared density must not reset the sections' horizontal gutters.
|
||||
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
|
||||
'padding-block': 'var(--admin-space-4, 10px)',
|
||||
})
|
||||
expect(layout).toContain('.measurement-document-number { display: flex; flex: 1;')
|
||||
expect(layout).toContain('.measurement-platform-fields { display: flex; flex: 1 1 520px;')
|
||||
expect(layout).toContain('.measurement-platform-fields > .measurement-goods-name { flex: 1 1 340px; }')
|
||||
expect(layout).toContain('.measurement-platform-fields > .measurement-expected-pallets { flex: 0 0 180px; }')
|
||||
for (const style of dialog.styles) {
|
||||
expect(style.content).not.toMatch(/\.measurement-(summary-section|metrics|declaration|items-section)\b/)
|
||||
|
||||
Reference in New Issue
Block a user