Merge origin/release/test and preserve storage rent checks with request unlock
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
# Only compiled H5 assets and image configuration enter the build context.
|
||||
**
|
||||
!deploy/
|
||||
!deploy/Dockerfile.h5
|
||||
!deploy/nginx.conf
|
||||
!dist/
|
||||
!dist/build/
|
||||
!dist/build/h5/
|
||||
!dist/build/h5/**
|
||||
|
||||
!deploy/Dockerfile.h5.production
|
||||
!deploy/nginx.production.conf.template
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
## 服务与发布边界
|
||||
|
||||
- H5只上传Nginx站点,禁止容器发布及重跑旧灰度脚本;步骤见`README.md`。
|
||||
- Auth 唯一签发账号/Token;认证/业务共用 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 用 VITE_H5_API_BASE_URL。
|
||||
- 登录/刷新/会话校验Token平台主体、成员、有效期、冻结仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 仅读存Auth登录/刷新顶层快照,禁解析JWT权限。快照缺失/无效/过期/跨主体或撤权均fail-closed清会话,不能只藏入口。
|
||||
- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。
|
||||
@@ -35,7 +36,7 @@
|
||||
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` |
|
||||
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example` |
|
||||
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` |
|
||||
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` |
|
||||
| 海外仓工作台与作业入口 | `src/pages/home/home.vue`;首页直接进入各作业,页面刷新后返回首页 |
|
||||
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` |
|
||||
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
|
||||
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js` |
|
||||
@@ -55,8 +56,7 @@
|
||||
## 海外业务接口边界
|
||||
|
||||
- 库内/换标/收货/排车/出库/查询仅用 `/api/pda/**` API/DTO,经 `gateway` 显式PDA路由,不直连Lastleg/借平台、客户、内部接口。Gateway/服务端校验Token、`lastleg:pda:operate`、冻结仓、客户/资源归属;工作台 `available`、本地客户/仓ID不代替授权。
|
||||
- 仅展示服务端业务标识/资格;写操作留稳定幂等键,失败/409重读权威状态,超时不判未执行/成功。
|
||||
- 收货/退货/移库/托盘/换标/发车/照片/恢复变更须读 `docs/business-index.md` 对应页面行及“海外业务接口边界”,执行专项门禁。
|
||||
- 仅展示服务端业务标识/资格;写请求携当前版本及本次幂等键,仅请求中防连点,结束即释放操作态。保留表单,不持久化或重放失败命令,下次点击按当前输入新建请求;409/结果不明先刷新权威状态,超时不判未执行/成功。Token刷新单次重放仍沿认证规则。
|
||||
- 仓储转运逐托存箱数、长宽高(cm)、毛重(kg),本托尺寸算体积;逐SKU核对件数差异,不以预报箱数强制核平。沿用客户预报箱唛,不造箱号。测量完成前客户/平台可增整单预报箱数、不选SKU,箱内SKU以PDA实点为准;刷新合并新箱、留本机草稿,清点传forecastPackageCount拦旧快照。生成托盘号锁该卡测量,服务端绑定确认且至少一张现场照片才可完成打托;完成传稳定幂等键、照片文件ID,SKU实收差异在完成弹窗必填原因,不重传测量。重进从详情恢复托盘/鉴权照片;`labelDownloadPath` 下载PDF,打开不等于真机打印。
|
||||
|
||||
- 换标目标确认即提交拆合托,无底部二次提交。下述组件按本轮最新 `PALLET_BIND` 逐箱改绑,扫新目标弹窗确认,经 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不写。服务端约束库存/托盘版本及幂等,已排车/出库禁改绑。回归 `tests/relabel-pallet-work.test.mjs`,离线非真机验收。
|
||||
@@ -81,8 +81,8 @@
|
||||
- 请求身份/资源范围仅取Token/current,不传 `customerId/planNo/allocationId/warehouseId/locationId`;409引导账号恢复当前柜,完成前禁切柜。
|
||||
- 签收照片三角色各一当前文件;上传后以服务端 `downloadPath` 会话内鉴权读。换图失败留原 `fileId`/可提交状态,无原图失败槽不计 `photosReady`;临时路径仅本次预览。
|
||||
- 柜号签收入仓用Lastleg `/api/pda/v1/last-leg/receiving-containers/**`,退货签收/合箱分别用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,入口见上表。
|
||||
- 理货审核沿既有排车详情;规则/回归见业务索引排车条目。
|
||||
- 排车/出库网络失败留原命令和键;资格、阶段、恢复及装车资料API见业务索引排车条目。`src/components/DispatchLoadingDocuments.vue` 支持装车中同步上传,装车与资料均完成后自动派送,无独立发车入口。
|
||||
- 排车资格、阶段、理货审核及装车资料 API/回归查业务索引排车条目。
|
||||
- `src/components/DispatchLoadingDocuments.vue` 支持装车中上传,装车与资料均完成后自动派送,无独立发车入口。
|
||||
|
||||
## 质量门禁
|
||||
|
||||
|
||||
@@ -75,32 +75,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
|
||||
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
||||
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
||||
|
||||
## TEST H5 静态托管
|
||||
## H5 静态托管(TEST 与灰度)
|
||||
|
||||
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
|
||||
国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。
|
||||
|
||||
执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
|
||||
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。
|
||||
|
||||
`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。
|
||||
执行 `npm run build:h5` 导出 Web 静态文件;`npm run build:app` 不属于 H5 发布。
|
||||
|
||||
从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-overseas-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。
|
||||
发布步骤:
|
||||
|
||||
独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
|
||||
1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。
|
||||
2. 将 `dist/build/h5/` **目录内的文件**上传到该 PDA 站点现有 `root`。先上传 `assets/`、`static/` 等资源,最后替换 `index.html`,保留站点证书验证目录和站点配置;不要把外层构建目录一起嵌套上传。
|
||||
3. 页面 `location /` 使用 `try_files $uri $uri/ =404` 直接读取静态文件,设置 `Cache-Control: no-store`;不得再转发到旧 PDA 容器。接口 `/gateway-api/` 独立转发到当前环境统一 Gateway `/api/`,保留 Authorization 和上传能力。
|
||||
4. 首次切换前备份实际生效站点配置,合并下述片段并执行 `nginx -t`,成功后才重载。已有独立 API 规则先核对并复用,不能直接追加重复 `location`。后续仅更新静态文件时无需新建容器或重载 Nginx。
|
||||
5. 检查域名首页及实际引用的 JS/CSS 均返回 200,并将响应内容的 SHA-256 与本次产物逐项比较;确认响应不是旧容器内容。再检查登录和受影响页面。首页 200 不等于接口或业务验收通过。
|
||||
|
||||
部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
|
||||
### 站点配置片段
|
||||
|
||||
构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
|
||||
`deploy/nginx.conf.template` 是合并到已有 `server` 的参考片段,不是完整站点配置。保留现有域名、HTTPS、证书与宝塔 include;替换原有 root/index 及同名 location。环境地址、端口及网站目录只在部署侧配置,不提交到源码。
|
||||
|
||||
在部署机器上将 `PDA_SITE_ROOT` 设置为该站点真实根目录,`PDA_GATEWAY_ORIGIN` 设置为宿主机可达的当前环境 Gateway 源地址(协议、主机、端口,不含末尾 `/` 或 `/api`)。不要使用仅 Docker 网络可解析的服务名。显式只渲染这两个变量,保留 `$uri`、`$host` 和代理请求头变量:
|
||||
|
||||
```bash
|
||||
envsubst '${PDA_SITE_ROOT} ${PDA_GATEWAY_ORIGIN}' < deploy/nginx.conf.template
|
||||
```
|
||||
|
||||
输出仅供核对和合并,不直接覆盖完整站点。若机器没有 `envsubst`,手工替换这两个占位符;未替换的模板不能直接加载。
|
||||
|
||||
### 两端统一 API 配置
|
||||
|
||||
两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
|
||||
App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。
|
||||
|
||||
两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
|
||||
站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。
|
||||
|
||||
```nginx
|
||||
location ^~ /gateway-api/ {
|
||||
proxy_pass http://gateway:9000/api/;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
client_max_body_size 11m;
|
||||
}
|
||||
```
|
||||
部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
|
||||
FROM ${NGINX_BASE_IMAGE}
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY dist/build/h5/ /srv/pda-h5/
|
||||
EXPOSE 80
|
||||
@@ -1,5 +0,0 @@
|
||||
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
|
||||
FROM ${NGINX_BASE_IMAGE}
|
||||
COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY dist/build/h5/ /srv/pda-h5/
|
||||
EXPOSE 80
|
||||
@@ -1,29 +0,0 @@
|
||||
name: firstleg-production-pda-overseas-h5
|
||||
services:
|
||||
pda-overseas-h5:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.h5.production
|
||||
args:
|
||||
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: pda-overseas-h5-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_UPSTREAM: "http://gateway:9000"
|
||||
ports:
|
||||
- "127.0.0.1:${HOST_PORT:-29006}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
networks:
|
||||
backend:
|
||||
aliases:
|
||||
- pda-overseas-h5-prod
|
||||
networks:
|
||||
backend:
|
||||
external: true
|
||||
name: firstleg-production-network
|
||||
@@ -1,27 +0,0 @@
|
||||
name: firstleg-test-pda-overseas-h5
|
||||
services:
|
||||
pda-overseas-h5:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.h5
|
||||
args:
|
||||
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: pda-overseas-h5-test
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
networks:
|
||||
test-backend:
|
||||
aliases:
|
||||
- pda-overseas-h5-test
|
||||
networks:
|
||||
test-backend:
|
||||
external: true
|
||||
name: firstleg-test-network
|
||||
@@ -1,16 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /srv/pda-h5;
|
||||
|
||||
location = /healthz {
|
||||
default_type text/plain;
|
||||
add_header Cache-Control "no-store" always;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
location / {
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Merge into the existing site's server block; preserve TLS and panel includes.
|
||||
# Render only PDA_SITE_ROOT and PDA_GATEWAY_ORIGIN (see README).
|
||||
# Replace existing root/index and matching locations; do not append duplicates.
|
||||
root ${PDA_SITE_ROOT};
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location ^~ /gateway-api/ {
|
||||
proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /srv/pda-h5;
|
||||
|
||||
location = /healthz {
|
||||
default_type text/plain;
|
||||
add_header Cache-Control "no-store" always;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
location /gateway-api/ {
|
||||
client_max_body_size 55m;
|
||||
proxy_pass ${API_UPSTREAM}/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location / {
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@
|
||||
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | Android/iOS 尺寸映射由 `tests/app-icons.test.mjs` 锁定;独立 AppID、签名和发布通道不得复用国内 PDA |
|
||||
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example`、`src/manifest.json` | `VITE_PDA_UPDATE_URL` 使用海外独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 |
|
||||
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
|
||||
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
|
||||
| 海外仓工作台与作业入口 | `src/pages/home/home.vue` | `src/data/overseas-workbench.js` 是分组与作业可用性入口;首页直接进入各作业,页面刷新后返回首页;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
|
||||
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
|
||||
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `A0` 共享溢出位提示,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
|
||||
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
|
||||
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;刷新时服务端实收已变化须清空旧实物输入,未变化保留可编辑草稿;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
|
||||
| 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` | 扫描箱号或托盘号定位清点任务;各箱 SKU 汇总与签收数量核平,支持恢复当前任务;页面注册见 `src/pages.json` |
|
||||
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | 工作台键 `warehouseTransfer`;候选→详情→逐托测量草稿→生成新托盘号→扫码绑定→照片与完成打托。API 模块路径 `/pda/v1/last-leg/warehouse-transfers` 经统一海外请求层处理;不是 Admin 转运审核或排车转派送。回归 `tests/warehouse-transfer-api.test.mjs` |
|
||||
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
|
||||
@@ -26,7 +26,7 @@
|
||||
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
|
||||
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
|
||||
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五张整车照片,照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
|
||||
| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一张排车单代表一趟车,使用 `dispatchNo` 与公开 `recordVersion`(0 合法),保留完整原命令和 `idempotencyKey`。审核和现场作业均恢复校验原单号、动作、请求键、结果及版本;理货审核通过结果须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;已确认结果重读失败不再 POST,首个明确拒绝可手动重读、网络未知保留完整原命令且不以新版本覆盖。`LOADING_COMPLETE` 结果须为 `stage=AFTER_LOADING|DELIVERING`(result 仍为 Loaded,输入为原版本、manifestHash和幂等键,不再填报逐托测量),重读任务且版本不早于已确认结果后再清除原命令。司机证照、资源及各客户派送资格由服务端校验。正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不再使用独立车次发车请求或以车次状态代替排车公开状态 |
|
||||
| 装车确认、自动派送与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一单一趟,使用 `dispatchNo`、公开 `recordVersion`(0 合法)及本次 `idempotencyKey`。只在请求中锁定,结束即释放;保留表单,不持久化或重放失败命令,下次点击按当前输入和版本生成新键。结果不明或 409 先刷新权威任务,不以超时推断未执行或成功。结果核对单号、动作、请求键和版本;理货审核通过须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;`LOADING_COMPLETE` 返回 `stage=AFTER_LOADING|DELIVERING`、Loaded 结果,提交版本、manifestHash 和幂等键,不逐托填测量。成功后重读任务,版本不得早于已确认结果;重读失败只刷新,不自动重复 POST。司机证照、资源及各客户派送资格由服务端校验;正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不使用独立车次发车请求 |
|
||||
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
|
||||
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
||||
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
|
||||
@@ -58,8 +58,8 @@
|
||||
- `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。
|
||||
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
|
||||
- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
|
||||
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
|
||||
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写请求携带本次幂等键,结束即释放操作态,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
|
||||
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
|
||||
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
||||
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
||||
- 排车拣货、普通出库、整托出库和废弃确认在实际写库存前,先用原扫码 `idempotencyKey` 调用 `POST /api/pda/v1/last-leg/storage-rent/preparations`。四种模式分别为 `DISPATCH/OUTBOUND/OUTBOUND_PALLET/DISCARD`,货源、客户、计租范围和可用资金由服务端校验。仅明确 `ready=true` 且状态为 `NOT_REQUIRED/READY/CONSUMED` 才继续原扫码命令;待冻结、失败、过期、响应异常或网络未知均不发库存写请求。保留完整原命令与键,再次提交同一预校验接口恢复,不能换新键绕过费用确认;已消费的预校验只用于恢复原命令结果,不重复收费。定向回归为 `tests/storage-rent-preflight.test.mjs`,离线通过不代表实际冻结、扣款和出库联调已验收。
|
||||
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
||||
- 排车拣货、普通出库、整托出库和废弃确认在实际写库存前,先用原扫码 `idempotencyKey` 调用 `POST /api/pda/v1/last-leg/storage-rent/preparations`。四种模式分别为 `DISPATCH/OUTBOUND/OUTBOUND_PALLET/DISCARD`,货源、客户、计租范围和可用资金由服务端校验。仅明确 `ready=true` 且状态为 `NOT_REQUIRED/READY/CONSUMED` 才继续原扫码命令;待冻结、失败、过期、响应异常或网络未知均不发库存写请求。预校验与紧随其后的库存写请求使用同一个键和同一份货源参数;请求结束释放操作态,保留表单但不自动重放失败命令。再次点击按当前输入和版本生成新键,仍必须重新通过服务端费用预校验;结果不明时先刷新权威任务,不以换键跳过费用确认。定向回归为 `tests/storage-rent-preflight.test.mjs`,离线通过不代表实际冻结、扣款和出库联调已验收。
|
||||
|
||||
@@ -21,6 +21,5 @@ export function normalizeLoadingDocuments(value, no) {
|
||||
}
|
||||
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
|
||||
export function uploadLoadingPhoto(no, slot, filePath, key) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}`, filePath) }
|
||||
export function recoverLoadingPhoto(no, key) { return overseasRequest('GET', `${path(no)}/uploads/${encodeURIComponent(key)}`) }
|
||||
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
|
||||
export function readLoadingPhoto(no, photo) { return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: 'image/' }) }
|
||||
|
||||
+12
-2
@@ -135,12 +135,22 @@ function rawUpload(path, filePath, name, token) {
|
||||
const request = new XMLHttpRequest()
|
||||
request.open('POST', url)
|
||||
request.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
request.timeout = 60000
|
||||
request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
|
||||
request.onerror = () => reject(new Error('海外作业文件上传失败'))
|
||||
request.ontimeout = () => reject(new Error('照片上传超时,请重试'))
|
||||
request.onabort = () => reject(new Error('照片上传已中断,请重试'))
|
||||
request.send(form)
|
||||
})
|
||||
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
|
||||
return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 60000)
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form, signal: controller.signal })
|
||||
return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted) throw new Error('照片上传超时,请重试')
|
||||
throw cause
|
||||
} finally { clearTimeout(timeout) }
|
||||
})
|
||||
.catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) })
|
||||
}
|
||||
|
||||
@@ -8,21 +8,20 @@
|
||||
<text v-if="snapshot?.stage === 'LOADING'" class="section-hint">可边装车边上传;确认装车完成且照片齐全后自动进入派送中。</text>
|
||||
<text v-if="snapshot?.stage === 'AFTER_LOADING'" class="section-hint">上传并保存 5 张装车照片后,本车自动进入派送中。</text>
|
||||
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
|
||||
<view v-if="pending" class="pending-notice"><text>有装车照片尚未确认保存。</text><button class="recover-button" :disabled="busy || !editable" @tap="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</button></view>
|
||||
<view class="photo-list">
|
||||
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
|
||||
<view class="photo-card-header">
|
||||
<view class="photo-card-main">
|
||||
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
|
||||
<view class="photo-card-title"><text class="photo-label">装车照片 {{ index + 1 }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
|
||||
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
|
||||
</view>
|
||||
<button v-if="editable" class="photo-upload" :disabled="busy || Boolean(pending)" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
|
||||
<button v-if="editable" class="photo-upload" :aria-label="`${snapshot?.photos.some(item => item.slot === slot) ? '更换' : '上传'}${slotLabels[slot]}`" :disabled="busy" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
|
||||
</view>
|
||||
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
|
||||
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
|
||||
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览装车照片 ${index + 1}`" @tap="preview(images[photo.fileId])" />
|
||||
<button v-else class="photo-read-retry" :disabled="busy" @tap="load">图片读取失败,重试</button>
|
||||
<text v-if="images[photo.fileId]" class="photo-preview-caption" @tap="preview(images[photo.fileId])">查看大图</text>
|
||||
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
|
||||
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
|
||||
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -32,28 +31,18 @@
|
||||
<script>
|
||||
import { session } from '@/stores/session.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { loadingPhotoSlots, getLoadingDocuments, uploadLoadingPhoto, recoverLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
|
||||
import { loadingPhotoSlots, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
|
||||
|
||||
export default {
|
||||
props: { dispatchNo: { type: String, required: true } },
|
||||
emits: ['saved', 'busy-change'],
|
||||
data() { return { slots: loadingPhotoSlots, snapshot: null, pending: null, busy: false, error: null, images: {}, active: true, corrupted: false,
|
||||
data() { return { slots: loadingPhotoSlots, slotLabels: { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' }, snapshot: null, busy: false, error: null, images: {}, active: true, revision: 0,
|
||||
scope: `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`, number: this.dispatchNo } },
|
||||
computed: {
|
||||
storageKey() { return `dispatch-loading-documents:${this.scope}:${this.number}` },
|
||||
editable() { return !this.corrupted && this.snapshot?.editable === true }
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(this.storageKey)
|
||||
if (raw) { const value = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!this.slots.includes(value.slot) || !value.key) throw new Error('原装车照片请求无法读取'); this.pending = value }
|
||||
} catch (cause) { this.corrupted = true; this.error = toDispatchError(cause, '原请求无法读取') }
|
||||
if (!this.corrupted) this.load()
|
||||
},
|
||||
beforeUnmount() { this.active = false },
|
||||
computed: { editable() { return this.snapshot?.editable === true } },
|
||||
mounted() { this.load() },
|
||||
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
|
||||
methods: {
|
||||
ensureScope() { if (!this.active || this.number !== this.dispatchNo || this.scope !== `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`) throw new Error('作业身份已变化,请重新进入任务') },
|
||||
persist() { if (this.pending) uni.setStorageSync(this.storageKey, JSON.stringify(this.pending)); else uni.removeStorageSync(this.storageKey) },
|
||||
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
|
||||
async previews() {
|
||||
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
|
||||
@@ -62,49 +51,29 @@ export default {
|
||||
}))
|
||||
},
|
||||
async load() { await this.run(async () => { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; await this.previews() }) },
|
||||
async bind() {
|
||||
const item = this.pending
|
||||
if (!item?.photo || !this.snapshot) throw new Error('原上传尚未确认')
|
||||
if (item.photo.slot !== item.slot || !item.photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: this.snapshot.recordVersion, idempotencyKey: this.key(), fileId: item.photo.fileId }; this.persist() }
|
||||
try {
|
||||
const value = await bindLoadingPhoto(this.number, item.slot, item.binding); this.ensureScope()
|
||||
this.snapshot = value; this.pending = null; this.persist(); await this.previews()
|
||||
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
|
||||
} catch (cause) { if (this.pending && /^(?:A0|HTTP_)?409$/.test(String(cause.code))) { this.pending.conflict = true; this.persist() }; throw cause }
|
||||
},
|
||||
async choose(slot) {
|
||||
if (!this.editable || this.pending || this.busy) return
|
||||
if (this.busy || !this.editable) return
|
||||
await this.run(async () => {
|
||||
const chosen = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
|
||||
const filePath = chosen.tempFilePaths?.[0]; if (!filePath) return
|
||||
if (chosen.tempFiles?.[0]?.size > 10 * 1024 * 1024) throw new Error('请选择不超过10MiB的图片')
|
||||
this.pending = { slot, key: this.key() }; this.persist()
|
||||
let photo
|
||||
try { photo = await uploadLoadingPhoto(this.number, slot, filePath, this.pending.key) }
|
||||
catch (cause) {
|
||||
this.ensureScope()
|
||||
if (/^(?:A0|HTTP_)?400$/.test(String(cause.code))) { this.pending = null; this.persist() }
|
||||
throw cause
|
||||
}
|
||||
this.ensureScope()
|
||||
this.pending.photo = photo; this.persist(); await this.bind(); return true
|
||||
}, true)
|
||||
},
|
||||
async recover() {
|
||||
if (!this.pending || !this.editable) return
|
||||
await this.run(async () => {
|
||||
if (this.pending.conflict) { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; this.pending.binding = null; this.pending.conflict = false; this.persist() }
|
||||
if (!this.pending.photo) { const photo = await recoverLoadingPhoto(this.number, this.pending.key); this.ensureScope(); this.pending.photo = photo; this.persist() }
|
||||
await this.bind(); return true
|
||||
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
|
||||
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
||||
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
|
||||
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
|
||||
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId }); this.ensureScope()
|
||||
this.snapshot = value; await this.previews()
|
||||
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
|
||||
return true
|
||||
}, true)
|
||||
},
|
||||
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
|
||||
async run(work, notify = false) {
|
||||
if (this.busy) return
|
||||
const revision = ++this.revision
|
||||
this.busy = true; this.$emit('busy-change', true); this.error = null; let saved = false
|
||||
try { this.ensureScope(); saved = await work() === true }
|
||||
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) this.error = toDispatchError(cause, '装车资料操作失败') }
|
||||
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) { this.error = toDispatchError(cause, '装车资料操作失败,请重新操作') } }
|
||||
finally { if (this.active) { this.busy = false; this.$emit('busy-change', false) } }
|
||||
if (saved && notify && this.active) this.$emit('saved')
|
||||
}
|
||||
@@ -137,8 +106,6 @@ export default {
|
||||
.photo-thumbnail { display: block; width: 100%; height: 136rpx; border-radius: 12rpx; background: #e7edf5; }
|
||||
.photo-preview-caption { display: block; overflow: hidden; margin-top: 8rpx; color: #64748b; font-size: 18rpx; line-height: 28rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.photo-read-retry { display: flex; box-sizing: border-box; width: 100%; min-height: 136rpx; margin: 0; padding: 12rpx; align-items: center; justify-content: center; border: 0; border-radius: 12rpx; color: #174d91; font-size: 20rpx; line-height: 30rpx; background: #e7edf5; }
|
||||
.pending-notice { margin-top: 16rpx; padding: 16rpx; border: 2rpx solid #f2d398; border-radius: 14rpx; color: #8a5a00; font-size: 22rpx; line-height: 34rpx; background: #fff7df; }
|
||||
.recover-button { min-height: 80rpx; margin: 12rpx 0 0; padding: 0 18rpx; border: 2rpx solid #d4aa54; border-radius: 14rpx; color: #8a5a00; font-size: 24rpx; line-height: 80rpx; background: #fff; }
|
||||
.error { margin-top: 16rpx; padding: 12rpx 16rpx; border-radius: 14rpx; color: #b91c1c; background: #fff1f0; }
|
||||
.error text { display: block; font-size: 22rpx; line-height: 34rpx; overflow-wrap: anywhere; }
|
||||
.heading-refresh::after, .photo-upload::after, .photo-read-retry::after, .recover-button::after { border: 0; }
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<text class="receiving-summary-status">{{ statusLabel }}</text>
|
||||
</view>
|
||||
<view class="receiving-summary-context">
|
||||
<text class="receiving-summary-warehouse">海外接柜仓:{{ receivingWarehouseLabel }}</text>
|
||||
<view class="receiving-summary-destinations"><text class="destination-label">目的仓:</text><view class="destination-tags"><text v-for="value in destinationValues" :key="value">{{ value }}</text></view></view>
|
||||
</view>
|
||||
<view class="receiving-summary-metrics">
|
||||
@@ -33,7 +32,6 @@
|
||||
props: { session: { type: Object, default: null } },
|
||||
computed: {
|
||||
planFlags() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds : [] },
|
||||
receivingWarehouseLabel() { const code = String(this.session?.overseasWarehouseCode || '').trim(); const name = String(this.session?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' },
|
||||
inboundCodeCount() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds.length : 0 },
|
||||
plannedPalletCount() { return (this.session?.inbounds || []).reduce((sum, item) => sum + Math.max(0, Number(item?.plannedPalletCount || 0)), 0) },
|
||||
destinationValues() { const values = (this.session?.inbounds || []).map((item) => item?.fbaWarehouseCode || item?.destinationWarehouseName || item?.destinationWarehouseCode).filter(Boolean); const unique = [...new Set(values)]; return unique.length ? unique : ['—'] },
|
||||
@@ -51,7 +49,7 @@
|
||||
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
|
||||
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
|
||||
.receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-warehouse{display:block}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
|
||||
.receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
|
||||
.receiving-summary-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:7px;padding:7px 2px;border-radius:9px;background:rgba(255,255,255,.8)}.receiving-summary-metrics>view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #e3eaf2}.receiving-summary-metrics>view:last-child{border-right:0}
|
||||
.receiving-plan-flags{margin-top:7px;padding:3px 8px;border-radius:9px;background:rgba(255,255,255,.82)}.receiving-plan-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 0;border-bottom:1px solid #e9eef5}.receiving-plan-row:last-child{border-bottom:0}.receiving-plan-code{min-width:0;color:#17324d;font-size:10px;font-weight:750;overflow-wrap:anywhere}.receiving-plan-tags{display:flex;flex-shrink:0;gap:3px}.receiving-plan-tags text{padding:2px 5px;border-radius:99px;font-size:8px;line-height:12px}.flag-positive{color:#08705f;background:#e2f6f0}.flag-option{color:#1668dc;background:#e7f0ff}.flag-urgent{color:#b54708;background:#fff0dc}.flag-muted{color:#718095;background:#edf1f5}
|
||||
.metric-value{color:#17324d;font-size:17px;font-weight:850;line-height:21px}.metric-pending{color:#b66a08}.metric-label{margin-top:1px;color:#718095;font-size:8px;line-height:12px;text-align:center}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
|
||||
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
import { warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
components: { ScanTargetInput },
|
||||
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
|
||||
@@ -128,7 +128,6 @@ export default {
|
||||
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
|
||||
await this.run(async () => {
|
||||
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
this.message = `托盘 ${pallet.palletCode} 实测已保存`
|
||||
this.$emit('saved')
|
||||
})
|
||||
@@ -172,7 +171,6 @@ export default {
|
||||
const payload = { relabelNo: this.task.relabelNo, source: { palletCode: source.palletCode, expectedVersion: source.palletVersion }, target: { palletCode: target.palletCode, expectedVersion: target.palletVersion }, cartonMarks: [binding.mark], reason: 'PDA 已绑定箱唛确认改绑托盘' }
|
||||
const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
|
||||
this.savePalletTotals(await bindRelabelPallet({ ...payload, idempotencyKey: key.value }))
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
this.rebindSelection = null; this.targetCode = ''; this.target = null
|
||||
this.$emit('saved')
|
||||
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
|
||||
@@ -306,7 +304,7 @@ export default {
|
||||
if (measuredCartons.length) payload.measuredCartons = measuredCartons
|
||||
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
|
||||
let saved = false
|
||||
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
|
||||
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
|
||||
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
|
||||
else if (this.error) this.showBindingMessage('绑定失败', this.error)
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
|
||||
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
components: { ScanTargetInput },
|
||||
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
|
||||
@@ -143,11 +143,12 @@ export default {
|
||||
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity
|
||||
const items=Object.entries(totals).map(([skuCode,quantity])=>({skuCode,quantity}))
|
||||
const box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
|
||||
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark,sourceDeductions,...box }
|
||||
const expectedSourceQuantities = this.sources.flatMap(source => (source.items || []).map(item => ({ cartonMark: source.cartonMark, skuCode: item.skuCode, quantity: Number(item.quantity) })))
|
||||
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark, sourceDeductions, expectedSourceQuantities, ...box }
|
||||
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
|
||||
await this.run(async () => {
|
||||
const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
|
||||
releaseWarehouseKey('returnPacking', key.signature); this.$emit('completed', { ...result, targetMode: this.mode })
|
||||
this.$emit('completed', { ...result, targetMode: this.mode })
|
||||
})
|
||||
},
|
||||
async run(work) { this.busy = true; this.$emit('busy-change', true); try { await work() } catch (error) { this.$emit('error', error) } finally { this.busy = false; this.$emit('busy-change', false) } }
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<script>
|
||||
import { packReturnRelabelCartons } from '@/api/relabel.js'
|
||||
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
|
||||
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
import { warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'],
|
||||
data() { return { busy: false, dimensions: { length: '', width: '', height: '', weight: '' }, fields: [{ key: 'length', label: '长 cm' }, { key: 'width', label: '宽 cm' }, { key: 'height', label: '高 cm' }, { key: 'weight', label: '重量 kg' }] } },
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
const payload = { sourceCartonMarks: [...new Set(this.deductions.map(row => row.cartonMark))], targetCartonMark: null, sourceDeductions: this.deductions, ...Object.fromEntries(this.fields.map(field => [field.key, Number(this.dimensions[field.key])])) }
|
||||
const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
|
||||
this.busy = true; this.$emit('busy', true)
|
||||
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { releaseWarehouseKey('relabel', key.signature); this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
|
||||
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
|
||||
},
|
||||
print(label) {
|
||||
// #ifdef H5
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"pages": [
|
||||
{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柏悦物流海外版", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
|
||||
|
||||
+118
-40
@@ -3,28 +3,44 @@
|
||||
<PortalHeader title="排车作业" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view>
|
||||
<view v-if="pendingCommand" class="fulfillment-card gate-card"><text>排车 {{ pendingCommand.dispatchNo }} 的原操作结果待核对,请保留原请求。</text><button :disabled="busy || loading" @tap="recoverCommand">核对并重试原请求</button><button v-if="pendingCommand.rejected" :disabled="busy" @tap="discardRejected">按明确拒绝结果重新核对</button></view>
|
||||
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业">
|
||||
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading || Boolean(pendingCommand)" @tap="selectTab('picking')">拣货作业</button>
|
||||
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading || Boolean(pendingCommand)" @tap="selectTab('photos')">装车拍照</button>
|
||||
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading" @tap="selectTab('picking')">拣货作业</button>
|
||||
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading" @tap="selectTab('photos')">装车拍照</button>
|
||||
</view>
|
||||
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card"><ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" /><button :disabled="busy || loading || !scanCode || Boolean(pendingCommand)" @tap="handleScan(scanCode)">匹配扫描</button></view>
|
||||
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card dispatch-scan-card">
|
||||
<text class="card-title">{{ current ? '扫码拣货' : '扫码查询排车' }}</text>
|
||||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="busy || loading || !scanCode" @tap="handleScan(scanCode)">匹配扫描</button>
|
||||
</view>
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
|
||||
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,原请求和作业草稿已保留。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
||||
<template v-if="!current && !loading"><view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view><view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view><button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button></template>
|
||||
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,请稍后重新操作。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
||||
<template v-if="!current && !loading">
|
||||
<view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button>
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="current">
|
||||
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="busy = $event" @saved="openTask(current.dispatchNo)" /></view>
|
||||
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" role="tabpanel" aria-labelledby="dispatch-picking-tab">
|
||||
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="photoBusy = $event" @saved="openTask(current.dispatchNo)" /></view>
|
||||
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" class="picking-workspace" role="tabpanel" aria-labelledby="dispatch-picking-tab">
|
||||
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
|
||||
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view><text class="task-meta">任务号 {{ current.taskNo || '—' }}</text><text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value">{{ remainingPackages }}</text><text>未拣箱数</text></view></view></view>
|
||||
<view class="fulfillment-card summary-card">
|
||||
<text class="card-title">任务摘要</text>
|
||||
<view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ current.taskNo || '—' }}</text>
|
||||
<text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text>
|
||||
<text class="picking-section-label">拣货进度</text>
|
||||
<view class="metrics" aria-label="拣货进度"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>未拣箱数</text></view></view>
|
||||
</view>
|
||||
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
|
||||
<text class="card-title">理货员审核</text>
|
||||
<text class="task-meta">车牌号:{{ current.resources?.vehicle?.plate || '—' }} · 托车公司:{{ current.resources?.vehicle?.carrierName || '—' }}</text>
|
||||
<text class="task-meta">司机:{{ current.resources?.driver?.name || '—' }} · 电话:{{ current.resources?.driver?.phone || '—' }}</text>
|
||||
<text class="task-meta">计划发车(北京时间):{{ reviewTime(current.plannedDepartureAt) }}</text>
|
||||
<text class="task-meta">预计送达(北京时间):{{ reviewTime(current.estimatedArrivalAt) }}</text>
|
||||
<text v-for="(appointment, index) in current.fbaAppointments || []" :key="appointment.batchKey + '-' + index" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || appointment.batchNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
|
||||
<text v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
|
||||
<text class="task-meta">驾驶证号:{{ current.resources?.driver?.evidence?.licenseNo || '—' }} · 有效期:{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text>
|
||||
<text class="task-meta">调度审核:{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ current.reviews?.dispatchReview?.reviewedAt || '—' }}</text>
|
||||
<text v-if="current.reviews?.dispatchReview?.reason" class="task-meta">调度意见:{{ current.reviews.dispatchReview.reason }}</text>
|
||||
@@ -32,17 +48,31 @@
|
||||
<template v-if="canReview">
|
||||
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
|
||||
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
|
||||
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading || Boolean(pendingCommand)" @input="updateReviewReason" />
|
||||
<view class="count-row"><button :disabled="busy || loading || Boolean(pendingCommand) || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || Boolean(pendingCommand) || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
|
||||
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
|
||||
<view class="count-row"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
|
||||
</template>
|
||||
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
|
||||
</view>
|
||||
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)"><view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }}箱</text></view><text class="task-meta">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }} {{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · {{ routeLabel(item) }}</text><text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text><view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view></view>
|
||||
<text v-if="current.items.length" class="picking-section-title">托盘与货物来源</text>
|
||||
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card picking-item" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
|
||||
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text v-if="selectedItem?.dispatchItemId === item.dispatchItemId" class="picking-selected">当前选择</text></view>
|
||||
<view class="picking-fact"><text class="picking-fact-label">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }}</text><text>{{ item.sourceBusinessNo || '—' }}</text></view>
|
||||
<view class="picking-fact"><text class="picking-fact-label">客户</text><text>{{ item.customerName || '—' }}</text></view>
|
||||
<view class="picking-fact"><text class="picking-fact-label">目的地</text><text>{{ routeLabel(item) }}</text></view>
|
||||
<text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta picking-carton">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
|
||||
<text class="picking-item-progress">已拣 {{ item.pickedPackages }} / {{ item.scheduledPackages }} 箱</text>
|
||||
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
|
||||
</view>
|
||||
<view v-if="!current.items.length" class="fulfillment-state">本车暂无货物,空单不能核平、装车或发车。</view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading || Boolean(pendingCommand)" @tap="retry">刷新托盘实收信息</button></view>
|
||||
<view v-if="sourceChoices.length > 1" class="fulfillment-card"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy || Boolean(pendingCommand)" @tap="chooseSource(item)">{{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · 可拣{{ pickablePackages(item) }}箱</button></view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}</text><text v-if="selectedItem.returnCartons.length">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text><view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy || Boolean(pendingCommand)" @input="updatePackages" /><text>最多{{ selectedRemaining }}箱</text></view><button class="primary-button" :disabled="!canPick || busy || Boolean(pendingCommand)" @tap="submitPick">确认真实拣货</button></view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card"><text class="card-title">确认装车完成</text><text>{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy || Boolean(pendingCommand)" @tap="prepareLoading">确认装车完成</button></view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading" @tap="retry">刷新托盘实收信息</button></view>
|
||||
<view v-if="sourceChoices.length > 1" class="fulfillment-card picking-source-choices"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy" @tap="chooseSource(item)"><text class="business-code">{{ item.sourceBusinessNo || '—' }}</text><text class="task-meta">客户:{{ item.customerName || '—' }}</text><text class="picking-item-progress">可拣 {{ pickablePackages(item) }} 箱</text></button></view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card picking-current">
|
||||
<text class="card-title">当前拣货</text><text class="business-code">{{ selectedItem.palletNo || '来源箱唛' }}</text>
|
||||
<text v-if="selectedItem.returnCartons.length" class="picking-instruction">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text>
|
||||
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
|
||||
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
|
||||
</view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text class="card-title">确认装车完成</text><text class="picking-instruction">{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
|
||||
</view>
|
||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
||||
</view></scroll-view>
|
||||
@@ -51,7 +81,7 @@
|
||||
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车完成</text><text class="loading-dialog-number">{{ loadingPreview.dispatchNo }}</text></view>
|
||||
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
|
||||
<text class="loading-confirm-question">本车货物已全部装车?</text><text class="loading-confirm-note">照片可在“装车拍照”中继续上传</text>
|
||||
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy || Boolean(pendingCommand)" :loading="busy" @tap="completeLoading">确认装车</button></view>
|
||||
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认装车</button></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -61,17 +91,18 @@ import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
|
||||
import DispatchLoadingDocuments from '@/components/DispatchLoadingDocuments.vue'
|
||||
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, getDispatchCommand, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js'
|
||||
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js'
|
||||
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, busy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
||||
computed: {
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, packages() { return fulfillment.dispatch.packages },
|
||||
busy() { return this.requestBusy || this.photoBusy },
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
||||
selectedItem() { return this.current?.items.find(row => row.dispatchItemId === fulfillment.dispatch.dispatchItemId && row.sourceKey === fulfillment.dispatch.sourceKey) || null },
|
||||
selectedReturnCarton() { const matches = this.selectedItem?.returnCartons.filter(row => row.cartonMark === fulfillment.dispatch.cartonCode && row.status === 'OUTBOUND_PENDING') || []; return matches.length === 1 ? matches[0] : null },
|
||||
selectedPosition() { return this.selectedItem && this.selectedItem.scheduledPackages > this.selectedItem.pickedPackages ? this.selectedItem.positions.find(row => row.availablePackages > 0) || null : null },
|
||||
@@ -88,17 +119,16 @@ export default {
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
|
||||
methods: {
|
||||
selectTab(tab) { if (this.busy || this.loading || this.pendingCommand || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
|
||||
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
|
||||
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
||||
updatePackages(event) { if (this.pendingCommand) return; const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
||||
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
||||
async loadTasks() { const generation = ++this.generation; await this.run(async () => { const result = await listDispatchTasks(); if (generation === this.generation) { this.tasks = result; this.current = null } }, true) },
|
||||
async openTask(no) { if (this.pendingCommand && this.pendingCommand.dispatchNo !== no) return this.setError(new Error('请先核对原排车请求,不能切到另一张单执行'), '操作未完成'); const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; if (this.current?.dispatchNo !== no || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) this.activeTab = 'picking'; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
|
||||
async openTask(no) { const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; if (this.current?.dispatchNo !== no || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) this.activeTab = 'picking'; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
|
||||
closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
||||
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
||||
if (this.pendingCommand) return this.setError(new Error('请先核对原排车操作结果'), '原请求未完成')
|
||||
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
||||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||
@@ -112,7 +142,7 @@ export default {
|
||||
this.chooseSource(matches[0])
|
||||
},
|
||||
chooseSource(item) {
|
||||
if (this.pendingCommand || this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return
|
||||
if (this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return
|
||||
const cartonCode = this.choiceCartonCode
|
||||
this.selectPallet(item)
|
||||
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
||||
@@ -124,33 +154,55 @@ export default {
|
||||
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + row.availablePackages, 0))
|
||||
},
|
||||
selectPallet(item) {
|
||||
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
|
||||
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
|
||||
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细')
|
||||
const actualPackages = item.returnCartons.length ? 1 : Math.min(item.scheduledPackages - item.pickedPackages, item.positions.find(row => row.availablePackages > 0)?.availablePackages || 0)
|
||||
updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: actualPackages })
|
||||
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
|
||||
},
|
||||
async submitPick() { if (!this.canPick || this.pendingCommand) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; beginDispatchCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }); await this.recoverCommand() } catch (cause) { this.setError(cause, '拣货未提交') } },
|
||||
async prepareLoading() { if (!this.canComplete || this.pendingCommand) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
|
||||
async completeLoading() { if (!this.loadingPreview || this.pendingCommand || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); beginDispatchCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }); await this.recoverCommand() } catch (cause) { this.setError(cause, '装车完成未确认') } },
|
||||
updateReviewReason(event) { if (this.busy || this.loading || this.pendingCommand) return; this.reviewReason = String(event.detail.value || '') },
|
||||
async submitPick() { if (!this.canPick) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } },
|
||||
async prepareLoading() { if (!this.canComplete) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
|
||||
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } },
|
||||
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') },
|
||||
async submitReview(decision) {
|
||||
if (!this.canReview || this.busy || this.loading || this.pendingCommand || !['APPROVED', 'REJECTED'].includes(decision)) return
|
||||
if (!this.canReview || this.busy || this.loading || !['APPROVED', 'REJECTED'].includes(decision)) return
|
||||
if (decision === 'APPROVED' && this.current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED') return this.setError(new Error(this.current.actions.LOADING_REVIEW.blockReason), '预约及时间尚未核对')
|
||||
const reason = this.reviewReason.trim()
|
||||
if (decision === 'REJECTED' && !reason) return this.setError(new Error('请填写驳回原因,退回客服处理'), '审核未提交')
|
||||
try {
|
||||
const no = this.current.dispatchNo, version = requireDispatchVersion(this.current.recordVersion)
|
||||
const confirmation = await uni.showModal({ title: decision === 'APPROVED' ? '确认理货审核通过' : '确认退回客服处理', content: decision === 'APPROVED' ? '请确认车辆、司机和货物已核对,通过后进入装车中。' : `驳回原因:${reason}。本单将回到客服处理(车辆信息),重新提交后再逐级审核。` })
|
||||
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview || this.pendingCommand) return
|
||||
beginDispatchCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
|
||||
await this.recoverCommand()
|
||||
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview) return
|
||||
await this.submitCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
|
||||
} catch (cause) { this.setError(cause, '理货审核未提交') }
|
||||
},
|
||||
async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && !cause.storageRentPreparation && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? latest.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
|
||||
discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() },
|
||||
async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } },
|
||||
async submitCommand(no, action, body) {
|
||||
if (this.busy || this.loading) return
|
||||
const scope = fulfillment.scopeKey
|
||||
await this.run(async () => {
|
||||
const ensureScope = () => { if (scope !== fulfillment.scopeKey || this.current?.dispatchNo !== no) throw new Error('作业身份已变化,请重新进入任务') }
|
||||
ensureScope()
|
||||
const { dispatchItemId, ...payload } = body
|
||||
payload.idempotencyKey = `pda-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const result = action === 'PICK' ? await pickDispatchItem(no, dispatchItemId, payload)
|
||||
: action === 'LOADING_COMPLETE' ? await completeDispatchLoading(no, payload) : await reviewDispatchLoading(no, payload)
|
||||
ensureScope()
|
||||
if (!result || result.dispatchNo !== no || result.action !== action || result.idempotencyKey !== payload.idempotencyKey || !result.result) throw new Error('操作结果未确认,请刷新任务查看')
|
||||
requireDispatchVersion(result.recordVersion)
|
||||
if (action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || action === 'LOADING_REVIEW' && result.stage !== (body.decision === 'REJECTED' ? 'BEFORE_LOADING' : 'LOADING')) throw new Error('返回结果未确认本次操作,请刷新任务查看')
|
||||
this.loadingPreview = null
|
||||
if (action === 'LOADING_REVIEW') this.reviewReason = ''
|
||||
const current = await getDispatchTask(no); ensureScope()
|
||||
if (current.dispatchNo !== no) throw new Error('任务与当前排车单不一致')
|
||||
if (!dispatchVersionAtLeast(current.recordVersion, result.recordVersion)) throw new Error('详情版本早于已确认操作,请刷新任务查看')
|
||||
this.current = current
|
||||
updateFulfillmentDraft('dispatch', { currentNo: no, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 })
|
||||
this.successMessage = action === 'PICK' ? '本次真实拣货已记录' : action === 'LOADING_COMPLETE' ? '装车完成已确认' : body.decision === 'REJECTED' ? '已退回客服处理' : '理货审核已提交'
|
||||
playSuccessSound()
|
||||
})
|
||||
},
|
||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
|
||||
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
||||
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
||||
@@ -161,6 +213,32 @@ export default {
|
||||
</script>
|
||||
<style scoped>
|
||||
@import './fulfillment.scss';
|
||||
.dispatch-scan-card .card-title { margin-bottom: 12px; font-size: 16px; }
|
||||
.picking-workspace .card-title { font-size: 16px; }
|
||||
.picking-workspace .task-head { flex-wrap: wrap; align-items: flex-start; margin-top: 10px; }
|
||||
.picking-workspace .business-code { min-width: 0; font-size: 16px; line-height: 1.5; }
|
||||
.picking-workspace .task-meta { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.picking-workspace .status-badge { font-size: 12px; }
|
||||
.picking-workspace .gate-warning { font-size: 13px; line-height: 1.6; }
|
||||
.picking-section-label, .picking-section-title { display: block; margin: 16px 0 10px; color: #536982; font-size: 14px; font-weight: 700; }
|
||||
.picking-workspace .metrics { margin-top: 0; padding: 14px 0; }
|
||||
.picking-workspace .metrics view { font-size: 12px; }
|
||||
.picking-workspace .metric-value { font-size: 26px; }
|
||||
.picking-workspace .picking-item { margin-bottom: 12px; }
|
||||
.picking-item .task-head { margin-top: 0; }
|
||||
.picking-selected { padding: 4px 8px; border-radius: 6px; color: #135ee8; background: #e6f0ff; font-size: 12px; }
|
||||
.picking-fact { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; color: #243c58; font-size: 14px; line-height: 1.6; }
|
||||
.picking-fact-label { flex: 0 0 74px; color: #718095; }
|
||||
.picking-fact > text:last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.picking-item-progress { display: block; margin-top: 12px; color: #135ee8; font-size: 14px; font-weight: 700; }
|
||||
.picking-source-choices button { min-height: 48px; margin-top: 12px; padding: 12px; text-align: left; line-height: 1.5; background: #f3f7ff; }
|
||||
.picking-source-choices .business-code { display: block; }
|
||||
.picking-current { border-color: #9dc0f4; }
|
||||
.picking-current > .business-code { display: block; margin-top: 10px; }
|
||||
.picking-instruction { display: block; margin-top: 10px; color: #536982; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.picking-quantity input { box-sizing: border-box; width: 100%; height: 48px; margin-top: 10px; padding: 0 12px; border: 2px solid #135ee8; border-radius: 8px; color: #17314f; font-size: 18px; background: #fff; }
|
||||
.picking-workspace .primary-button, .dispatch-scan-card .secondary-button { min-height: 48px; font-size: 16px; line-height: 48px; }
|
||||
.picking-completion { margin-top: 16px; }
|
||||
.stage-switch { position: sticky; top: 0; z-index: 2; display: flex; gap: 8rpx; margin-bottom: 20rpx; padding: 8rpx; border: 1px solid #dbe5f0; border-radius: 20rpx; background: #eef2f7; }
|
||||
.stage-tab { flex: 1; margin: 0; min-height: 84rpx; padding: 0 16rpx; border-radius: 14rpx; color: #64748b; font-size: 28rpx; font-weight: 600; line-height: 84rpx; background: transparent; }
|
||||
.stage-tab::after { border: 0; }
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<view v-if="user && currentModule" class="page-shell portal-page">
|
||||
<PortalHeader title="业务模块" :user="user" />
|
||||
<view class="portal-content">
|
||||
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view><text class="module-hero-count">{{ availableCount }}/{{ currentModule.operations.length }} 可用</text></view>
|
||||
<view class="section-heading"><text class="group-title">业务功能</text></view>
|
||||
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-ready">可处理</text><text v-else class="operation-locked">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
|
||||
<view v-if="availableCount < currentModule.operations.length" class="module-empty-note">灰色功能尚未开放,仅可进入已接入真实接口的作业。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { MODULES } from '@/data/overseas-workbench.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() { return { user: session.currentUser, moduleKey: 'inbound' } },
|
||||
computed: {
|
||||
currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] },
|
||||
availableCount() { return this.currentModule.operations.filter((operation) => operation.available).length }
|
||||
},
|
||||
onLoad(options) { this.moduleKey = options.key || 'inbound' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser },
|
||||
methods: {
|
||||
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || `/pages/operation/operation?module=${this.moduleKey}&op=${operation.key}` }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="商品移库" :user="user" fallback-url="/pages/module/module?key=inventory" />
|
||||
<PortalHeader title="商品移库" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">整托移库</text></view>
|
||||
<view v-if="restoring" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在恢复未完成的移库草稿…</text></view>
|
||||
@@ -29,7 +29,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } },
|
||||
async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } },
|
||||
async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } },
|
||||
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('move', key.signature); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { releaseWarehouseKey('move', key.signature); await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } },
|
||||
resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,12 +3,11 @@
|
||||
<PortalHeader title="出库作业" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y>
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text></view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view v-if="!discardCurrent" class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? (discardTask?.scanMode === 'CARTON' ? '核对废弃箱唛' : '打开废弃托盘') : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
||||
@@ -17,14 +16,14 @@
|
||||
<template v-if="!loading && !current && !discardCurrent">
|
||||
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
|
||||
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.outboundNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">{{ taskType(task) }} · 任务号 {{ task.taskNo || '—' }}</text><text class="task-enter">查看并开始出库 ›</text></button>
|
||||
</view>
|
||||
<view v-else-if="!error && !discardTasks.length" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
|
||||
<view v-if="discardTasks.length" class="discard-section"><view class="list-toolbar"><view><text class="list-title">废弃订单</text><text class="list-count">{{ discardOrderCount }} 个订单</text></view></view><view class="task-list"><button v-for="order in discardOrders" :key="order.discardNo" class="task-card" @tap="openDiscardOrder(order.discardNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ order.discardNo || '—' }}</text></view><text class="status-badge">废弃订单</text></view><text v-if="order.outboundNo" class="task-meta">出库单 {{ order.outboundNo }} · 废弃出库</text><text class="task-meta">托盘 {{ order.palletCount }} 个 · 按箱废弃 {{ order.cartonCount }} 箱 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入废弃出库作业 ›</text></button></view></view>
|
||||
</template>
|
||||
|
||||
<template v-if="current">
|
||||
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
|
||||
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.outboundNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">{{ taskType(current) }} · 任务号 {{ current.taskNo || '—' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
|
||||
|
||||
<view v-if="current.items.length" class="item-list">
|
||||
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
|
||||
@@ -60,7 +59,7 @@
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { listCargoDiscardPhotos, uploadCargoDiscardPhoto, downloadCargoDiscardPhoto, completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
@@ -196,7 +195,7 @@
|
||||
// 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。
|
||||
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
|
||||
updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.error = null; this.successMessage = ''; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId)); const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo: this.discardCurrent.discardNo, palletNo: this.discardTask.palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: this.discardAllCartons }); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, photoFileIds, this.discardScannedMarks, this.discardAllCartons, { idempotencyKey: key.value }); releaseFulfillmentKey('outbound', key.signature); this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); this.successMessage = '废弃出库作业及照片已提交,已完成'; playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.error = null; this.successMessage = ''; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId)); const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo: this.discardCurrent.discardNo, palletNo: this.discardTask.palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: this.discardAllCartons }); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, photoFileIds, this.discardScannedMarks, this.discardAllCartons, { idempotencyKey: key.value }); this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); this.successMessage = '废弃出库作业及照片已提交,已完成'; playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
|
||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||
@@ -215,17 +214,17 @@
|
||||
async submitPick() {
|
||||
if (!this.canPick) return
|
||||
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
},
|
||||
async submitPalletPick(palletNo) {
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
},
|
||||
async completeTask() {
|
||||
if (!this.canComplete) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
},
|
||||
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
async submitOperation() {
|
||||
if (!this.canSubmit) return
|
||||
this.submitting = true; this.error = null; this.result = null
|
||||
if (!this.pendingKey) this.pendingKey = idempotencyKey(this.mode)
|
||||
this.pendingKey = idempotencyKey(this.mode)
|
||||
try {
|
||||
const source = expectation(this.sources[0])
|
||||
const target = this.target ? expectation(this.target) : null
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=query" />
|
||||
<PortalHeader :title="title" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ subtitle }};查询范围固定为 Token 绑定的海外仓</text></view>
|
||||
<view class="work-heading"><text class="work-title">{{ title }}</text></view>
|
||||
<view class="work-card"><ScanTargetInput name="query" v-model="code" :label="label" :placeholder="placeholder" :active="true" @submit="search" /><button class="primary" :disabled="!code.trim() || loading" @tap="search(code)">{{ loading ? '查询中…' : '查询' }}</button></view>
|
||||
<view v-if="loading" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在读取最新库存事实…</text></view>
|
||||
<template v-else-if="searched && mode === 'inventory'">
|
||||
@@ -26,13 +26,13 @@
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
const MODES = {
|
||||
inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '查看可用、冻结和收货中库存' },
|
||||
locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码', subtitle: '查看库位状态、容量和占用' },
|
||||
palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码', subtitle: '查看托盘状态和绑定库存' }
|
||||
inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码' },
|
||||
locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码' },
|
||||
palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码' }
|
||||
}
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '', loading: false, searched: false, result: null, error: null } },
|
||||
data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', loading: false, searched: false, result: null, error: null } },
|
||||
computed: {
|
||||
storeField() { return `${this.mode}Code` },
|
||||
code: { get() { return warehouseWork.query[this.storeField] || '' }, set(value) { updateWarehouseWork('query', { [this.storeField]: value }) } },
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
|
||||
<PortalHeader :title="title" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view v-if="!showCurrent" class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
|
||||
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
|
||||
|
||||
<template v-if="!showCurrent">
|
||||
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<view class="row-head candidate-toolbar"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
||||
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
||||
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
|
||||
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(item)"><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">查看并复核 ›</text></view></view></view>
|
||||
<view v-else-if="stage !== 'review'" class="task-list">
|
||||
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)">
|
||||
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
|
||||
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
|
||||
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">进入 ›</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="task-list">
|
||||
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" @tap="openReview(item)">
|
||||
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
|
||||
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text></view>
|
||||
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">查看并复核 ›</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</template>
|
||||
<view v-if="showCurrent" class="work-card">
|
||||
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text>
|
||||
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
|
||||
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">所属公司:{{ current.customerName || candidates.find(item => item.relabelNo === current.relabelNo)?.customerName || '未提供' }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
|
||||
<view class="destination-summary"><text class="meta-line">FBA 目的仓:</text><text v-for="destination in fbaDestinations" :key="destination" class="destination-tag">{{ destination }}</text><text v-if="!fbaDestinations.length" class="meta-line">待维护</text><view class="summary-metrics"><text class="summary-metric">应处理 {{ current.expectedPackages }}</text><text class="summary-metric">已换标 {{ current.relabelledPackages }}</text><text class="summary-metric">跳过 {{ current.skippedPackages }}</text><text class="summary-metric">异常 {{ current.exceptionPackages }}</text></view></view>
|
||||
<view class="section-gap"><view class="row-head"><text class="card-title">标签文件</text><button class="link-button" :disabled="busy" @tap="loadDocuments">刷新文件</button></view><text v-if="!documents.length" class="meta-line">暂无标签文件</text><view v-for="file in documents" :key="`${file.scope}:${file.fileId}`" class="row-head"><text class="meta-line">{{ file.scope === 'CARTON_TOTAL' ? '总箱唛' : file.scope === 'SKU_TOTAL' ? '总 SKU 标签' : '明细标签' }}:{{ file.fileName }}</text><button class="secondary" :disabled="busy" @tap="printDocument(file)">查看 / 打印</button></view></view>
|
||||
<template v-if="stage === 'execute'">
|
||||
@@ -80,7 +91,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
@@ -125,7 +136,6 @@
|
||||
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
|
||||
},
|
||||
showCurrent() { if (!this.current) return false; if (this.stage === 'review') return this.current.taskStatus === 'WAIT_REVIEW'; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
|
||||
stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '退回重做单置顶优先处理;同一账号一次只能执行一票' },
|
||||
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
|
||||
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
|
||||
skuVerified() { if (!this.selectedItem || this.selectedItem.operationType === 'CARTON_LABEL') return true; const expected = this.skuDetails(this.selectedItem); return expected.length > 0 && expected.every((sku, index) => { const actual = this.unitDraft.skuVerifications[index]; return actual && actual.skuCode.trim() === sku.skuCode && actual.newSkuCode.trim() === sku.newSkuCode && Number(actual.quantity) > 0 && Number(actual.quantity) === Number(sku.quantity) }) },
|
||||
@@ -197,8 +207,7 @@
|
||||
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('sku-scan', async () => {
|
||||
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
|
||||
this.applyCurrent(value); releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||||
this.applyCurrent(value); const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||||
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
|
||||
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
|
||||
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||||
@@ -236,7 +245,7 @@
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件'))
|
||||
const payload = { itemId: item.itemId, cartonMark: item.cartonMark, skuCode: item.skuCode, quantity, expectedVersion: this.current.version }
|
||||
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
|
||||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
|
||||
},
|
||||
async loadPhoto(fileId) {
|
||||
const no = this.current?.relabelNo
|
||||
@@ -283,7 +292,6 @@
|
||||
await this.run('carton-scan', async () => {
|
||||
const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value })
|
||||
this.applyCurrent(result)
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmedItem = result.items.find((row) => row.itemId === item.itemId)
|
||||
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
|
||||
this.quickCarton = ''
|
||||
@@ -308,7 +316,7 @@
|
||||
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
|
||||
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; const completed = item.operationType === 'CARTON_AND_SKU' ? Number(processed || 0) : Number(processed ?? item.relabelledPackages); return Math.max(0, item.expectedPackages - completed - item.skippedPackages - item.exceptionPackages) },
|
||||
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
|
||||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||||
async promptActiveTask(active) {
|
||||
if (this.busy) return
|
||||
this.busy = true
|
||||
@@ -350,20 +358,20 @@
|
||||
if (!confirmed) return
|
||||
this.taskCode = item.relabelNo
|
||||
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
|
||||
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key)
|
||||
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); claimed = true }, true, key)
|
||||
if (claimed) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
|
||||
},
|
||||
async openReview(item) { if (this.busy || this.stage !== 'review' || item.taskStatus !== 'WAIT_REVIEW') return; await this.openTask(item.relabelNo) },
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||||
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
|
||||
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
|
||||
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
|
||||
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
|
||||
async reloadCurrent() { if (!this.current?.relabelNo) return; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback = '换标作业失败') {
|
||||
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
|
||||
playErrorSound()
|
||||
@@ -376,9 +384,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-tag{display:inline-flex;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;line-height:18px;background:#d94343;color:#fff}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.item-card>input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #d7e1ef;border-radius:8px;margin:8px 0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
|
||||
.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
|
||||
.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
|
||||
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
|
||||
.candidate-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px}.candidate-customer{color:#52647c;font-size:10px;line-height:16px;overflow-wrap:anywhere}</style>
|
||||
.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
|
||||
.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page">
|
||||
<PortalHeader title="退货箱内清点" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<PortalHeader title="退货箱内清点" :user="user" />
|
||||
<view class="work-content counting-content">
|
||||
<view class="work-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view class="actions"><button v-if="task || scanResult" class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view>
|
||||
<view v-if="error" class="work-card error-card"><text>{{ error.message }}</text><text v-if="error.code" class="hint">错误码:{{ error.code }}</text><text v-if="error.requestId" class="hint">请求号:{{ error.requestId }}</text><text v-if="error.traceId" class="hint">跟踪号:{{ error.traceId }}</text></view>
|
||||
@@ -15,7 +15,7 @@
|
||||
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
||||
</view>
|
||||
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||
<view class="work-card"><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
@@ -88,6 +88,7 @@ export default {
|
||||
<style scoped lang="scss">
|
||||
@import '../warehouse/operations.scss';
|
||||
.counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0}
|
||||
.counting-content .primary{display:flex;align-items:center;justify-content:center;padding:0 12px;line-height:normal;text-align:center}
|
||||
.actions{display:flex;align-items:center;gap:8px;margin-top:10px}.small{width:auto;min-height:32px;margin:0;padding:0 12px;font-size:12px;line-height:32px}.search{width:100%;font-size:12px}.pager{justify-content:center;font-size:12px}.entry,.success{color:#135ee8;font-size:12px}.total-row{display:flex;justify-content:space-between;gap:12px;font-size:11px;margin-top:8px;color:#c67716}.total-row.matched{color:#22804a}.box-mark{display:block;color:#65768b;font-size:11px;margin:6px 0;word-break:break-all}.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:11px;word-break:break-all}.quantity{flex:none;width:70px;height:32px;text-align:center;font-size:12px}.box-card.selected{border-color:#135ee8}.row-head .card-title{font-size:13px}
|
||||
.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin:10px 0 6px}.measure-grid label text{display:block;margin-bottom:4px;color:#65768b;font-size:10px}.measure-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #cbd9e8;border-radius:8px;color:#203750;font-size:13px;text-align:center;background:#fbfdff}.volume-chip{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin:4px 0 8px;color:#728197;font-size:10px}.volume-chip strong{color:#135ee8;font-size:14px}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="退货合箱" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<PortalHeader title="退货合箱" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<button v-if="!result && (activePlan || mode === 'BOX')" class="link-button" :disabled="busy" @tap="leavePlan">返回扫码 / 计划列表</button>
|
||||
<ReturnBoxMergeWork v-if="mode === 'BOX' && !result" ref="boxWork" :key="activePlan?.planNo || initialSource?.cartonMark || 'direct'" :plan="activePlan" :initial-source="initialSource" @busy-change="busy = $event" @completed="packingCompleted" @error="setError($event, '箱合并失败')" />
|
||||
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card direct-merge-card"><text class="card-title">仓库现场直接合箱</text><ScanTargetInput name="stock" v-model="searchCode" label="SKU 或箱号" placeholder="扫描 SKU 或箱号,查询可合箱库存" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
|
||||
<view v-if="!result && !activePlan && plans.length" class="work-card"><view class="row-head"><text class="card-title">平台合箱计划</text><text class="meta-line">审核通过后推送</text></view><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button></view>
|
||||
<view v-if="!result && !activePlan && plans.length" class="work-card">
|
||||
<view class="row-head"><text class="card-title">平台合箱计划</text><text class="meta-line">审核通过后推送</text></view>
|
||||
<view class="task-list">
|
||||
<button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="customerCandidates.length && !selectedCustomer && !result" class="section-gap"><view class="work-card"><text class="card-title">选择客户</text><text class="hint">该 SKU 在多个客户有可用退货库存,请先选择客户</text><view class="task-list"><button v-for="(customer, index) in customerCandidates" :key="index" class="task-card candidate-card" @tap="selectCustomer(customer)"><view class="row-head"><view><text class="business-code">{{ customerLabel(customer, index) }}</text><text class="meta-line">可用 SKU {{ customer.skus.length }} 个 · 退货单 {{ customer.orders.length }} 个</text></view><text class="select-link">选择 ›</text></view></button></view></view></view>
|
||||
<view v-if="candidates !== null && !customerCandidates.length && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
|
||||
@@ -46,7 +51,7 @@
|
||||
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
|
||||
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
@@ -108,7 +113,7 @@
|
||||
const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo })
|
||||
await this.run(async () => {
|
||||
const value = this.activePlan ? await completePackingPlan(this.activePlan.planNo, { boxes: payload.boxes, idempotencyKey: key.value }) : await packReturnSkus({ ...payload, idempotencyKey: key.value })
|
||||
this.packingCompleted(value); releaseWarehouseKey('returnPacking', key.signature); await this.loadPlans()
|
||||
this.packingCompleted(value); await this.loadPlans()
|
||||
}, '退货合箱失败')
|
||||
},
|
||||
distributeBoxes() { updateWarehouseWork('returnPacking', { targetBoxes: defaultPackingBoxes(Object.entries(this.packing.quantities || {}).map(([skuCode,quantity]) => ({skuCode,quantity}))) }) },
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="退货入库" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<PortalHeader title="退货入库" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view v-if="!current && !result && !receiptBatch" class="work-card">
|
||||
<view v-if="!current && !result" class="work-card">
|
||||
<text class="card-title">扫描 SKU 69 码</text>
|
||||
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
|
||||
|
||||
</view>
|
||||
|
||||
<view v-if="!current && candidates !== null && !result && !receiptBatch" class="section-gap">
|
||||
<view v-if="!current && candidates !== null && !result" class="section-gap">
|
||||
<view class="row-head candidate-heading"><text class="card-title">匹配退货单</text><text class="meta-line">{{ candidates.length }} 个候选</text></view>
|
||||
<view v-if="!candidates.length" class="empty-box"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
|
||||
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
|
||||
</view>
|
||||
|
||||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成{{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '按箱' }}签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} {{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '箱' }}。请继续本批签收,已确认的包裹/箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||||
<template v-if="current && !result && !receiptBatch">
|
||||
<template v-if="current && !result">
|
||||
<view class="work-card receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
|
||||
|
||||
<view class="work-card">
|
||||
@@ -55,7 +53,7 @@
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -71,9 +69,8 @@
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
|
||||
data() { return { user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
|
||||
computed: {
|
||||
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
|
||||
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
|
||||
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
|
||||
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
|
||||
@@ -89,7 +86,7 @@
|
||||
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
|
||||
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
|
||||
@@ -114,12 +111,12 @@
|
||||
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { if (this.result) return; if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
|
||||
handleScan(value) { if (this.result) return; if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
|
||||
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
|
||||
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
|
||||
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
|
||||
async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
|
||||
async openOrderValue(returnNo, startedBy, feedback = true) { const previous = this.current; const value = await startReturnReceipt(returnNo); if (!value) { this.resetReceiptInput(); this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } if (!previous || previous.returnNo !== value.returnNo || JSON.stringify(previous.expectedItems) !== JSON.stringify(value.expectedItems)) this.resetReceiptInput(); this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
|
||||
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
|
||||
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
|
||||
boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) },
|
||||
@@ -132,39 +129,35 @@
|
||||
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
|
||||
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
|
||||
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
|
||||
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
|
||||
async confirmReceipt() { if (this.busy) return; const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
|
||||
await this.run(async () => {
|
||||
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
|
||||
let expectedReceivedQuantity = this.current.expectedItems.reduce((sum, item) => sum + Number(item.receivedQuantity || 0), 0)
|
||||
const entries = rows.map((box, index) => {
|
||||
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' || this.unitType === 'BOX' ? undefined : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : Number(box.length), width: this.unitType === 'PALLET' ? Number(this.palletWidth) : Number(box.width), height: this.unitType === 'PALLET' ? Number(this.palletHeight) : Number(box.height), weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : Number(box.weight), originalPackaging: this.unitType === 'PALLET' ? undefined : box.originalPackaging, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
|
||||
payload.expectedReceivedQuantity = expectedReceivedQuantity
|
||||
expectedReceivedQuantity += payload.items.reduce((sum, item) => sum + item.receivedQuantity, 0)
|
||||
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
|
||||
return { request: { ...payload, idempotencyKey: key.value }, result: null }
|
||||
})
|
||||
if (this.unitType !== 'PALLET') {
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
|
||||
await this.resumeReceiptBatch()
|
||||
} else {
|
||||
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
|
||||
}
|
||||
}, '退货入库失败')
|
||||
},
|
||||
async resumeReceiptBatch() {
|
||||
const batch = this.receiptBatch
|
||||
const scopeKey = warehouseWork.scopeKey
|
||||
if (!batch) return
|
||||
for (let index = 0; index < batch.entries.length; index++) {
|
||||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||||
if (batch.entries[index].result) continue
|
||||
// Persist the immutable request before every attempt; storage failures must stop submission.
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
|
||||
const result = await confirmReturnReceipt(batch.entries[index].request)
|
||||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||||
if (!result) throw new Error('服务端未返回签收结果,请重试本批签收')
|
||||
batch.entries[index].result = result
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
|
||||
}
|
||||
this.finishReceipt(batch.entries.map(entry => entry.result))
|
||||
},
|
||||
const scopeKey = warehouseWork.scopeKey
|
||||
for (const entry of entries) {
|
||||
const result = await confirmReturnReceipt(entry.request)
|
||||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||||
if (!result) throw new Error('服务端未返回签收结果')
|
||||
this.receiptResults.push(result)
|
||||
if (this.unitType !== 'PALLET') {
|
||||
this.boxRows.shift()
|
||||
for (const received of entry.request.items) {
|
||||
const item = this.current.expectedItems.find(item => item.skuCode === received.skuCode)
|
||||
if (item) item.receivedQuantity = Number(item.receivedQuantity || 0) + received.receivedQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
this.finishReceipt(this.receiptResults)
|
||||
this.receiptResults = []
|
||||
}, '退货入库失败')
|
||||
},
|
||||
finishReceipt(results) {
|
||||
const last = results[results.length - 1]
|
||||
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.flatMap(item => item.labels?.length ? item.labels : item.printableHtml && item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml }] : []) }
|
||||
@@ -193,10 +186,17 @@
|
||||
},
|
||||
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
|
||||
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
|
||||
// 服务端实收变化后重新录入下一份实物,不能给旧草稿换上新库存快照。
|
||||
resetReceiptInput() {
|
||||
this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
|
||||
this.unitCode = ''; this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
|
||||
this.palletCheck = null; this.newPalletHtml = ''
|
||||
updateWarehouseWork('returnReceipt', { quantities: {}, boxCounts: {}, palletCode: '', trackingNo: '' })
|
||||
},
|
||||
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
|
||||
clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
clearCurrent() { if (this.busy) return; this.receiptResults = []; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
continueScanning() {
|
||||
if (this.busy || this.receiptBatch) return
|
||||
if (this.busy) return
|
||||
this.clearCurrent()
|
||||
this.result = null
|
||||
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="库存盘点" :user="user" fallback-url="/pages/module/module?key=inventory" />
|
||||
<PortalHeader title="库存盘点" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">库存盘点</text></view>
|
||||
<view class="work-card">
|
||||
@@ -38,7 +38,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) },
|
||||
async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } },
|
||||
changeScope(value) { this.scopeType = value; this.scopeCode = '' },
|
||||
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
|
||||
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
|
||||
async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) },
|
||||
applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) },
|
||||
validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 },
|
||||
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
|
||||
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
|
||||
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
|
||||
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
|
||||
async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) },
|
||||
async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('stocktake', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' },
|
||||
statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page transfer-page">
|
||||
<PortalHeader :title="entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业'" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<PortalHeader :title="entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业'" :user="user" />
|
||||
<view v-if="entryMode === 'work'" class="transfer-work-toolbar">
|
||||
<button class="back-to-list" @tap="backToCandidates"><uni-icons type="back" color="#135ee8" size="18" /><text>返回列表</text></button>
|
||||
<button class="refresh-button" :disabled="loading" aria-label="刷新当前转运单" @tap="refreshCurrentTransfer"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '加载中' : '刷新' }}</text></button>
|
||||
@@ -196,7 +196,7 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
countDiscrepancyReason() { this.saveCartonDraft() },
|
||||
cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
|
||||
cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason }) } },
|
||||
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
|
||||
},
|
||||
onShow() { if (requireSession()) { this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.loadCandidates().then(() => { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); const taskNo = this.pendingTaskNo || activeNo; if (this.entryMode === 'work' && taskNo) { const active = this.candidates.find((item) => String(item.transferNo) === taskNo); if (active) { const fromExplicitTask = Boolean(this.pendingTaskNo); this.pendingTaskNo = ''; void this.select(active, fromExplicitTask) } } }) } },
|
||||
@@ -277,7 +277,7 @@ export default {
|
||||
if (!target || target.received === false || !this.validCartonValues(source)) return;
|
||||
this.copyCartonValues(source, target); this.saveCartonDraft();
|
||||
},
|
||||
saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
|
||||
saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason }) },
|
||||
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
|
||||
restoreCartons() {
|
||||
const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: box.quantity ?? '' }] }));
|
||||
@@ -286,7 +286,7 @@ export default {
|
||||
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
|
||||
if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || (this.selected?.cartons || []).some(row => row.cartonMark === box.cartonMark))) {
|
||||
const knownMarks = new Set(draft.forecastMarks || draft.boxes.map(box => box.cartonMark));
|
||||
this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = draft.key || ''; this.cartonSignature = draft.signature || '';
|
||||
this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
|
||||
} else this.cartonBoxes = rows;
|
||||
this.cartonBoxes.forEach(box => { if (this.cartonsCounted || (box.status === 'COUNTED' && this.validCartonValues(box))) box.confirmedSignature = this.cartonConfirmationSignature(box) });
|
||||
this.receivingPalletId = this.generatedPallets[0]?.draftId || ''; this.cartonPalletFilter = ''; this.batchPalletId = this.receivingPalletId; this.batchCountOpen = false; this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.batchSku = this.batchSkuCodes[0] || ''; this.batchBox = newBatchBox(); this.batchBox.items[0].skuCode = this.batchSku;
|
||||
@@ -353,8 +353,8 @@ export default {
|
||||
try {
|
||||
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
|
||||
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||
if (!this.cartonRequestKey || signature !== this.cartonSignature) { this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
|
||||
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
|
||||
{ this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
|
||||
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason });
|
||||
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
|
||||
uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
|
||||
@@ -544,7 +544,7 @@ export default {
|
||||
this.countDiscrepancyReason = reason
|
||||
this.saveCartonDraft()
|
||||
}
|
||||
if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds], discrepancyReason: this.hasSkuReceiptDifference ? this.countDiscrepancyReason : null }
|
||||
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
|
||||
} catch (e) { this.error = e } finally { this.submitting = false }
|
||||
|
||||
@@ -4,8 +4,8 @@ const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v2'
|
||||
|
||||
const blankState = () => ({
|
||||
scopeKey: '',
|
||||
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1, pendingCommand: null },
|
||||
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
|
||||
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 },
|
||||
outbound: { currentTaskNo: '', scanCode: '', packages: 1 }
|
||||
})
|
||||
|
||||
export const fulfillment = reactive(blankState())
|
||||
@@ -14,13 +14,7 @@ function storageAvailable() {
|
||||
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
|
||||
}
|
||||
|
||||
function safeDomain(value, fallback) {
|
||||
return {
|
||||
...fallback,
|
||||
...(value || {}),
|
||||
...(Object.hasOwn(fallback, 'pendingKeys') ? { pendingKeys: { ...(value?.pendingKeys || {}) } } : {})
|
||||
}
|
||||
}
|
||||
function safeDomain(value, fallback) { return Object.fromEntries(Object.keys(fallback).map(key => [key, value?.[key] ?? fallback[key]])) }
|
||||
|
||||
export function persistFulfillmentDraft() {
|
||||
if (!storageAvailable() || !fulfillment.scopeKey) return
|
||||
@@ -55,49 +49,9 @@ function randomKey(domain, action) {
|
||||
return `pda-${domain}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
|
||||
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
|
||||
if (domain === 'dispatch') throw new Error('排车必须保存完整原命令,不能只生成请求键')
|
||||
const signature = `${action}:${JSON.stringify(payload)}`
|
||||
const pendingKeys = fulfillment[domain].pendingKeys
|
||||
if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action)
|
||||
persistFulfillmentDraft()
|
||||
return { signature, value: pendingKeys[signature] }
|
||||
}
|
||||
|
||||
export function releaseFulfillmentKey(domain, signature) {
|
||||
if (!fulfillment[domain]) return
|
||||
if (domain === 'dispatch') throw new Error('排车原命令只能在服务端事实核对后清除')
|
||||
delete fulfillment[domain].pendingKeys[signature]
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
|
||||
export function beginDispatchCommand(dispatchNo, action, body) {
|
||||
if (!fulfillment.scopeKey) throw new Error('当前作业身份未恢复')
|
||||
if (fulfillment.dispatch.pendingCommand) throw new Error('请先核对原排车操作结果')
|
||||
const idempotencyKey = randomKey('dispatch', action)
|
||||
fulfillment.dispatch.pendingCommand = { dispatchNo, action, body: JSON.parse(JSON.stringify({ ...body, idempotencyKey })), attempted: false, rejected: false, result: null }
|
||||
persistFulfillmentDraft()
|
||||
return fulfillment.dispatch.pendingCommand
|
||||
}
|
||||
|
||||
export function patchDispatchCommand(patch) {
|
||||
if (!fulfillment.dispatch.pendingCommand) throw new Error('原排车请求不存在')
|
||||
Object.assign(fulfillment.dispatch.pendingCommand, patch)
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
|
||||
export function finishDispatchCommand(key) {
|
||||
if (fulfillment.dispatch.pendingCommand?.body.idempotencyKey !== key) throw new Error('核对结果与原请求键不一致')
|
||||
fulfillment.dispatch.pendingCommand = null
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
|
||||
export function discardRejectedDispatchCommand() {
|
||||
if (fulfillment.dispatch.pendingCommand?.rejected === true) {
|
||||
fulfillment.dispatch.pendingCommand = null
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
export function fulfillmentIdempotencyKey(domain, action, _payload = {}) {
|
||||
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
|
||||
return { value: randomKey(domain, action) }
|
||||
}
|
||||
|
||||
export function resetFulfillmentDomain(domain) {
|
||||
|
||||
@@ -3,13 +3,13 @@ import { reactive } from 'vue'
|
||||
const STORAGE_KEY = 'overseas-pda-warehouse-work-v1'
|
||||
const blankState = () => ({
|
||||
scopeKey: '',
|
||||
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
|
||||
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
|
||||
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
|
||||
returnReceipt: { pendingBatch: null, entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
|
||||
returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {}, pendingKeys: {} },
|
||||
returnPackingPlan: { pendingKeys: {} },
|
||||
warehouseTransfer: { currentNo: '', pendingKeys: {} },
|
||||
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '' },
|
||||
move: { sourcePalletCode: '', targetLocationCode: '', reason: '' },
|
||||
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [] },
|
||||
returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {} },
|
||||
returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {} },
|
||||
returnPackingPlan: {},
|
||||
warehouseTransfer: { currentNo: '' },
|
||||
query: { inventoryCode: '', locationCode: '', palletCode: '' }
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ function storageAvailable() {
|
||||
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
|
||||
}
|
||||
|
||||
function domain(value, fallback) { return { ...fallback, ...(value || {}), pendingKeys: { ...(value?.pendingKeys || {}) } } }
|
||||
function domain(value, fallback) { return Object.fromEntries(Object.keys(fallback).map(key => [key, value?.[key] ?? fallback[key]])) }
|
||||
|
||||
export function persistWarehouseWork() {
|
||||
if (!storageAvailable() || !warehouseWork.scopeKey) return
|
||||
@@ -54,21 +54,10 @@ export function updateWarehouseWork(name, patch) {
|
||||
persistWarehouseWork()
|
||||
}
|
||||
|
||||
export function warehouseIdempotencyKey(name, action, payload = {}) {
|
||||
const target = warehouseWork[name]
|
||||
if (!target?.pendingKeys) throw new Error('当前作业不支持写操作')
|
||||
const signature = `${action}:${JSON.stringify(payload)}`
|
||||
if (!target.pendingKeys[signature]) target.pendingKeys[signature] = `pda-${name}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
persistWarehouseWork()
|
||||
return { signature, value: target.pendingKeys[signature] }
|
||||
export function warehouseIdempotencyKey(name, action, _payload = {}) {
|
||||
if (!warehouseWork[name]) throw new Error('当前作业不支持写操作')
|
||||
return { value: `pda-${name}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` }
|
||||
}
|
||||
|
||||
export function releaseWarehouseKey(name, signature) {
|
||||
if (!warehouseWork[name]?.pendingKeys) return
|
||||
delete warehouseWork[name].pendingKeys[signature]
|
||||
persistWarehouseWork()
|
||||
}
|
||||
|
||||
export function resetWarehouseDomain(name) {
|
||||
const fresh = blankState()
|
||||
if (!fresh[name]) return
|
||||
|
||||
+2
-41
@@ -699,10 +699,6 @@
|
||||
}
|
||||
|
||||
.portal-title,
|
||||
.module-hero-title,
|
||||
.module-hero-desc,
|
||||
.operation-name,
|
||||
.operation-desc,
|
||||
.summary-number,
|
||||
.summary-label,
|
||||
.profile-name,
|
||||
@@ -780,50 +776,15 @@
|
||||
|
||||
.group-title { font-size: 16px; font-weight: 700; }
|
||||
|
||||
.module-hero-icon, .operation-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.operation-info { display: flex; min-width: 0; flex: 1; flex-direction: column; margin-left: 11px; }
|
||||
|
||||
.inline-back { height: 48px; padding: 0 4px; color: #42617f; font-size: 13px; line-height: 48px; background: transparent; }
|
||||
.module-hero { position: relative; display: flex; align-items: center; margin-top: 5px; padding: 17px; overflow: hidden; border: 1px solid rgba(23, 105, 232, 0.09); border-radius: 16px; background: #e7f0ff; box-shadow: 0 5px 16px rgba(32, 67, 111, 0.07); }
|
||||
.module-hero-inbound { background: #e1f7f0; }
|
||||
.module-hero-inventory { background: #fff3da; }
|
||||
.module-hero-relabel { background: #f1eafe; }
|
||||
.module-hero-query { background: #f0e9ff; }
|
||||
.module-hero-icon { width: 52px; height: 52px; flex-shrink: 0; margin-right: 13px; border-radius: 14px; color: #ffffff; font-size: 18px; background: $primary; }
|
||||
.module-hero-inbound .module-hero-icon { background: #0d9488; }
|
||||
.module-hero-inventory .module-hero-icon { background: #d68c0c; }
|
||||
.module-hero-relabel .module-hero-icon { background: #7c3aed; }
|
||||
.module-hero-query .module-hero-icon { background: #6d55c7; }
|
||||
.module-hero-copy { min-width: 0; flex: 1; }
|
||||
.module-hero-title { font-size: 18px; font-weight: 700; }
|
||||
.module-hero-desc { margin-top: 4px; color: #617187; font-size: 11px; line-height: 17px; }
|
||||
.module-hero-count { flex-shrink: 0; margin-left: 8px; padding: 4px 7px; border-radius: 99px; color: #49627d; font-size: 9px; line-height: 15px; background: rgba(255, 255, 255, 0.72); }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; margin: 22px 2px 11px; }
|
||||
.section-heading .group-title { margin: 0; }
|
||||
.section-heading-hint { color: #8190a3; font-size: 10px; line-height: 16px; }
|
||||
.group-title { display: block; margin: 22px 2px 11px; }
|
||||
.operation-list { overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 4px 15px rgba(32, 67, 111, 0.06); }
|
||||
.operation-item, .history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
|
||||
.operation-item:active { background: #f2f7ff; }
|
||||
.operation-item-disabled, .operation-item-disabled[disabled] { color: #8d98a6; opacity: 1; background: #f2f4f6; }
|
||||
.operation-item-disabled .operation-icon { color: #8b97a5; background: #e3e7eb; }
|
||||
.operation-item-disabled .operation-desc { color: #929daa; }
|
||||
.operation-item:last-child, .history-item:last-child { border-bottom: 0; }
|
||||
.operation-icon { width: 39px; height: 39px; flex-shrink: 0; border-radius: 10px; color: #366cae; font-size: 12px; background: #edf4fd; }
|
||||
.operation-name { font-size: 13px; font-weight: 700; }
|
||||
.operation-desc { margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; }
|
||||
.operation-ready, .operation-locked { flex-shrink: 0; padding: 3px 6px; border-radius: 99px; font-size: 9px; line-height: 15px; }
|
||||
.operation-ready { color: #0b6f65; background: #dff7f0; }
|
||||
.operation-locked { color: #7c8795; background: #e5e9ee; }
|
||||
.history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
|
||||
.history-item:last-child { border-bottom: 0; }
|
||||
.operation-count { min-width: 22px; height: 22px; border-radius: 11px; color: #ffffff; font-size: 11px; line-height: 22px; text-align: center; background: $danger; }
|
||||
.operation-prototype { flex-shrink: 0; padding: 5px 7px; border-radius: 5px; color: #737f8d; font-size: 11px; background: #e1e5e9; }
|
||||
.operation-arrow { margin-left: 8px; color: #9aa5b1; font-size: 21px; }
|
||||
.module-empty-note { margin-top: 14px; padding: 12px; border-radius: 9px; color: #758194; font-size: 11px; line-height: 18px; text-align: center; background: #e9eef5; }
|
||||
|
||||
.operation-page { padding-top: 12px; }
|
||||
.operation-page-title-row, .operation-scan-row, .task-toolbar, .prototype-task-head, .prototype-task-meta, .prototype-task-foot { display: flex; align-items: center; }
|
||||
|
||||
@@ -8,7 +8,7 @@ const interpolations = (source) => [...template(source).matchAll(/\{\{([\s\S]*?)
|
||||
|
||||
test('all registered user-facing pages avoid rendering internal identifiers', async () => {
|
||||
const paths = [
|
||||
'../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
|
||||
'../src/pages/login/login.vue', '../src/pages/home/home.vue',
|
||||
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
|
||||
'../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
|
||||
'../src/pages/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.vue',
|
||||
@@ -86,7 +86,6 @@ test('missing user-visible relationships use explicit empty labels instead of ID
|
||||
read('../src/components/ReceivingSessionSummary.vue'),
|
||||
read('../src/pages/pallet/pallet.vue')
|
||||
])
|
||||
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
|
||||
assert.match(summary, /unique\.length \? unique : \['—'\]/)
|
||||
assert.match(pallet, /inboundCode \|\| '无入仓码'/)
|
||||
assert.match(pallet, /locationCode \|\| '未绑定库位'/)
|
||||
|
||||
@@ -18,6 +18,18 @@ function component(overrides = {}) {
|
||||
for (const [key, getter] of Object.entries(definition.computed)) Object.defineProperty(state, key, { get: () => getter.call(state) })
|
||||
return { state, events, values, definition }
|
||||
}
|
||||
|
||||
test('five existing slots carry their business names into upload, replace and preview controls', async () => {
|
||||
const { state } = component()
|
||||
assert.deepEqual(state.slots, ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'])
|
||||
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' })
|
||||
const template = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).split('<script>')[0]
|
||||
assert.match(template, /class="photo-label">\{\{ slotLabels\[slot\] \}\}/)
|
||||
assert.match(template, /:aria-label="`\$\{snapshot\?\.photos.some\(item => item.slot === slot\) \? '更换' : '上传'\}\$\{slotLabels\[slot\]\}`"/)
|
||||
assert.match(template, /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/)
|
||||
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
|
||||
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
|
||||
})
|
||||
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
|
||||
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
|
||||
@@ -34,44 +46,45 @@ test('PDA upload preserves the original request key through the authenticated up
|
||||
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
|
||||
assert.equal(calls[0].filePath, 'local-image')
|
||||
})
|
||||
|
||||
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
|
||||
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
|
||||
assert.throws(() => normalizeLoadingDocuments(data({ photos: [photo, photo] }), 'PC/1'), /重复/)
|
||||
assert.throws(() => normalizeLoadingDocuments(data({ recordVersion: null }), 'PC/1'), /不一致/)
|
||||
})
|
||||
test('unknown binding keeps the same file, version and key and does not reupload', async () => {
|
||||
let uploads = 0; const bindings = []
|
||||
const { state, events } = component({ uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async (_no, _slot, body) => { bindings.push(structuredClone(body)); if (bindings.length === 1) throw { network: true, message: 'unknown' }; return data({ stage: 'DELIVERING', complete: true, recordVersion: '5' }) } })
|
||||
await state.load(); await state.choose('LOADING_SCENE')
|
||||
assert.ok(state.pending); assert.equal(events.some(event => event[0] === 'saved'), false)
|
||||
await state.recover()
|
||||
assert.equal(uploads, 1); assert.deepEqual(bindings[0], bindings[1]); assert.equal(state.pending, null)
|
||||
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||
})
|
||||
test('a failed upload does not replace a saved photo or advance the stage', async () => {
|
||||
const { state } = component({ getLoadingDocuments: async () => data({ photos: [photo] }), uploadLoadingPhoto: async () => { throw { network: true, message: 'unknown' } } })
|
||||
await state.load(); await state.choose('LOADING_SCENE')
|
||||
assert.equal(state.snapshot.photos[0].fileId, '1001'); assert.equal(state.snapshot.stage, 'AFTER_LOADING')
|
||||
assert.ok(state.pending.key); assert.equal(state.pending.photo, undefined)
|
||||
})
|
||||
|
||||
test('definitive file rejection allows selecting a new image while retaining the saved one', async () => {
|
||||
let uploads = 0
|
||||
const { state } = component({ getLoadingDocuments: async () => data({ photos: [photo] }), uploadLoadingPhoto: async () => {
|
||||
if (++uploads === 1) throw { code: 'A0400', message: '图片内容无效' }
|
||||
return photo
|
||||
} })
|
||||
await state.load(); await state.choose('LOADING_SCENE')
|
||||
assert.equal(state.pending, null); assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
|
||||
assert.equal(state.error.message, '图片内容无效')
|
||||
await state.choose('LOADING_SCENE'); assert.equal(uploads, 2); assert.equal(state.pending, null)
|
||||
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0422', message: 'invalid' }]) test('upload failure leaves saved photos and unlocks current selection: ' + failure.message, async () => {
|
||||
const uploads = []; let reads = 0, choice = 0
|
||||
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ photos: [photo] }) }, uploadLoadingPhoto: async (...args) => { uploads.push(args); throw failure } })
|
||||
uni.chooseImage = async () => ({ tempFilePaths: ['image-' + ++choice] })
|
||||
await state.load(); await state.choose(photo.slot)
|
||||
assert.equal(state.busy, false); assert.equal(values.size, 0); assert.equal(reads, 1)
|
||||
assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
|
||||
await state.choose(photo.slot)
|
||||
assert.equal(uploads.length, 2); assert.equal(uploads[1][2], 'image-2'); assert.notEqual(uploads[0][3], uploads[1][3])
|
||||
assert.deepEqual(events.at(-1), ['busy-change', false])
|
||||
})
|
||||
|
||||
test('loading documents can be saved during LOADING without claiming departure', async () => {
|
||||
assert.equal(normalizeLoadingDocuments(data({ stage: 'LOADING' }), 'PC/1').stage, 'LOADING')
|
||||
assert.throws(() => normalizeLoadingDocuments(data({ stage: 'BEFORE_LOADING' }), 'PC/1'), /不一致/)
|
||||
const { state, events } = component({ getLoadingDocuments: async () => data({ stage: 'LOADING' }), bindLoadingPhoto: async () => data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }) })
|
||||
await state.load(); await state.choose('LOADING_SCENE')
|
||||
assert.equal(state.snapshot.stage, 'LOADING'); assert.equal(state.pending, null)
|
||||
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||
test('binding failure releases immediately and the next selection uses a new file, key and current version', async () => {
|
||||
const binds = []; let uploads = 0, version = '0', reads = 0
|
||||
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ recordVersion: version }) }, uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++uploads) }), bindLoadingPhoto: async (_no, _slot, body) => { binds.push(body); if (binds.length === 1) throw { network: true }; return data({ photos: [photo] }) } })
|
||||
await state.load(); await state.choose(photo.slot)
|
||||
assert.equal(state.busy, false); assert.equal(reads, 2); assert.equal(values.size, 0)
|
||||
version = '7'; await state.choose(photo.slot)
|
||||
assert.equal(uploads, 2); assert.equal(binds[1].recordVersion, '7'); assert.notEqual(binds[1].fileId, binds[0].fileId)
|
||||
assert.notEqual(binds[1].idempotencyKey, binds[0].idempotencyKey); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||
})
|
||||
test('manual refresh after a lost binding response reflects server success without another write', async () => {
|
||||
let binds = 0, latest = data()
|
||||
const { state } = component({ getLoadingDocuments: async () => latest, bindLoadingPhoto: async () => { binds++; latest = data({ editable: false, complete: true, stage: 'DELIVERING', photos: [photo] }); throw { network: true } } })
|
||||
await state.load(); await state.choose(photo.slot); assert.equal(state.busy, false)
|
||||
await state.load(); assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(binds, 1); assert.equal(state.editable, false)
|
||||
})
|
||||
test('in-flight upload blocks duplicate clicks and unmount releases the parent busy flag', async () => {
|
||||
let resolveUpload, uploads = 0
|
||||
const { state, events, definition } = component({ uploadLoadingPhoto: () => { uploads++; return new Promise(resolve => { resolveUpload = resolve }) } })
|
||||
await state.load(); const first = state.choose(photo.slot)
|
||||
while (!resolveUpload) await new Promise(resolve => setImmediate(resolve))
|
||||
await state.choose(photo.slot); assert.equal(uploads, 1)
|
||||
definition.beforeUnmount.call(state); assert.deepEqual(events.at(-1), ['busy-change', false])
|
||||
resolveUpload(photo); await first; assert.equal(events.some(event => event[0] === 'saved'), false)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { parse, compileTemplate } from 'vue/compiler-sfc'
|
||||
|
||||
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
|
||||
const photos = await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')
|
||||
|
||||
for (const [filename, source] of [['dispatch.vue', page], ['DispatchLoadingDocuments.vue', photos]]) {
|
||||
test(`${filename} compiles its actual presentation template`, () => {
|
||||
const { descriptor, errors } = parse(source, { filename })
|
||||
assert.deepEqual(errors, [])
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename, id: 'dispatch-presentation' })
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
})
|
||||
}
|
||||
|
||||
test('picking separates source facts, current quantity and completion while retaining scanner and action guards', () => {
|
||||
const { descriptor } = parse(page)
|
||||
const template = descriptor.template.content
|
||||
for (const title of ['扫码拣货', '任务摘要', '拣货进度', '托盘与货物来源', '当前拣货', '确认装车完成']) assert.ok(template.includes(title), title)
|
||||
assert.match(template, /class="picking-fact-label">客户<\/text>/)
|
||||
assert.match(template, /class="picking-fact-label">目的地<\/text>/)
|
||||
assert.match(template, /aria-label="本次拣货箱数"[^>]+:disabled="busy"[^>]+@input="updatePackages"/)
|
||||
assert.match(template, /name="dispatch"[^>]+:active="activeTab === 'picking'"[^>]+@submit="handleScan"/)
|
||||
assert.match(template, /:disabled="!canPick \|\| busy" @tap="submitPick"/)
|
||||
assert.match(template, /:disabled="!canComplete \|\| busy" @tap="prepareLoading"/)
|
||||
assert.match(template, /@tap="selectPallet\(item\)"/)
|
||||
assert.match(page, /\.picking-fact > text:last-child \{[^}]*overflow-wrap: anywhere/)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
|
||||
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
|
||||
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
|
||||
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
|
||||
@@ -23,23 +23,26 @@ test('invalid, missing and unsafe native quantities fail at every item, position
|
||||
})
|
||||
const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', pickingReconciled: false, actions: { PICK: { allowed: true }, LOADING_COMPLETE: { allowed: true } }, items: [item()], ...overrides })
|
||||
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
|
||||
let lastCommand
|
||||
function page(dependencies = {}) {
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
|
||||
for (const [api, action] of [['pickDispatchItem', 'PICK'], ['completeDispatchLoading', 'LOADING_COMPLETE'], ['reviewDispatchLoading', 'LOADING_REVIEW']]) {
|
||||
const original = deps[api]; if (!original) continue
|
||||
deps[api] = (...args) => { lastCommand = { dispatchNo: args[0], action, body: args.at(-1) }; return original(...args) }
|
||||
}
|
||||
// Only bind dependencies actually imported by the page; injecting every mock
|
||||
// would hide missing production imports behind successful workflow tests.
|
||||
const importedNames = new Set([...source.matchAll(/^\s*import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+/gm)]
|
||||
.flatMap(match => (match[1] || match[2]).split(',').map(name => name.trim().split(/\s+as\s+/).at(-1))))
|
||||
const bindings = Object.entries(deps).filter(([name]) => importedNames.has(name))
|
||||
const definition = new Function(...bindings.map(([name]) => name), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...bindings.map(([, value]) => value))
|
||||
const state = { ...definition.data(), current: task() }
|
||||
for (const [name, method] of Object.entries(definition.methods)) state[name] = method.bind(state)
|
||||
for (const [name, getter] of Object.entries(definition.computed)) Object.defineProperty(state, name, { get: () => getter.call(state) })
|
||||
return state
|
||||
}
|
||||
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
|
||||
test('full pending command survives restoration, rejects another operation, and isolates the next operator', () => {
|
||||
storage(); const pending = beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
|
||||
const original = structuredClone(JSON.parse(JSON.stringify(pending)))
|
||||
fulfillment.dispatch.pendingCommand = null; restoreFulfillmentDraft('operator:warehouse')
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch.pendingCommand)), original)
|
||||
assert.throws(() => beginDispatchCommand('PC/2', 'LOADING_COMPLETE', { recordVersion: 2 }), /原排车/)
|
||||
restoreFulfillmentDraft('another:warehouse'); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
})
|
||||
|
||||
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
|
||||
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
|
||||
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
|
||||
@@ -50,35 +53,16 @@ test('real picking requires the native gate, physical source and current stage;
|
||||
})
|
||||
test('return carton pick keeps its exact item, quantity and explicit version zero without inventing a position', async () => {
|
||||
storage(); let sent
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
|
||||
state.handleScan('RC-1'); await state.submitPick()
|
||||
assert.equal(sent[0], 'PC/1'); assert.equal(sent[1], 'item-1')
|
||||
assert.deepEqual({ ...sent[2], idempotencyKey: 'key' }, { recordVersion: 0, positionId: null, packages: 1, productQuantity: 5, cartonMark: 'RC-1', remark: null, idempotencyKey: 'key' })
|
||||
assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
})
|
||||
test('unknown POST queries the original key and retries the unchanged payload; a subsequent equal physical pick gets a new key', async () => {
|
||||
storage(); const sent = []; let fail = true
|
||||
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push({ no, id, body }); if (fail) { fail = false; throw { network: true, message: 'offline' } } return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.ok(fulfillment.dispatch.pendingCommand)
|
||||
state.current.recordVersion = 80; await state.recoverCommand(); assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].body.recordVersion, 0)
|
||||
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.notEqual(sent[2].body.idempotencyKey, sent[1].body.idempotencyKey)
|
||||
})
|
||||
test('proven loading completion already delivered to the driver survives a failed GET without another POST', async () => {
|
||||
storage(); let posts = 0, reads = 0
|
||||
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
|
||||
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'AFTER_LOADING', recordVersion: 3 }) } })
|
||||
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'AFTER_LOADING')
|
||||
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
})
|
||||
test('later rejection cannot erase an earlier unknown write and older details cannot release a proven result', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 }); patchDispatchCommand({ attempted: true })
|
||||
const state = page({ getDispatchCommand: async () => { throw { code: '404' } }, completeDispatchLoading: async () => { throw { code: 'A0409', message: 'conflict' } } })
|
||||
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
|
||||
state.discardRejected(); assert.ok(fulfillment.dispatch.pendingCommand)
|
||||
patchDispatchCommand({ result: proof(fulfillment.dispatch.pendingCommand, '9007199254740993') })
|
||||
const stale = page({ getDispatchTask: async () => task({ recordVersion: '9007199254740992' }) }); await stale.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
|
||||
assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
})
|
||||
|
||||
|
||||
|
||||
test('actual loading confirmation leaves reference measurements blank and blocks missing unloading mapping', async () => {
|
||||
storage(); const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: 'pallet-1', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: null, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: false, sourceType: 'ORDER_SNAPSHOT_ALLOCATION' }] }] }
|
||||
let posts = 0; const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async () => { posts++ } })
|
||||
@@ -88,14 +72,10 @@ test('actual loading confirmation leaves reference measurements blank and blocks
|
||||
})
|
||||
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
|
||||
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
|
||||
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
})
|
||||
test('a first server error remains unknown and cannot be discarded as a definite business rejection', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
|
||||
const state = page({ pickDispatchItem: async () => { throw { code: 'A0500', message: 'server failure' } } })
|
||||
await state.recoverCommand(); state.discardRejected(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
|
||||
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
})
|
||||
|
||||
test('position picking uses available stock and this source remaining instead of warehouse allocation or picked buckets', () => {
|
||||
storage(); const state = page(); state.current = task({ items: [item({ scheduledPackages: '3', pickedPackages: '1', positions: [{ positionId: 'empty-position', availablePackages: '0', allocatedPackages: '20', pickedPackages: '0' }, { positionId: 'actual-position', availablePackages: '20', allocatedPackages: '0', pickedPackages: '7' }] })] })
|
||||
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 2)
|
||||
@@ -115,12 +95,12 @@ for (const scenario of [
|
||||
state.selectPallet(state.current.items[0]); state.updatePackages({ detail: { value: scenario.packages } })
|
||||
assert.equal(state.selectedPosition?.positionId ?? null, scenario.position); assert.equal(state.selectedRemaining, scenario.maximum)
|
||||
assert.equal(state.pickablePackages(state.current.items[0]), scenario.maximum); assert.equal(state.canPick, false)
|
||||
await state.submitPick(); assert.equal(posts, 0); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
await state.submitPick(); assert.equal(posts, 0); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
})
|
||||
test('a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity', async () => {
|
||||
storage(); const rows = [item({ scheduledPackages: 3 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', customerName: '客户乙', scheduledPackages: 5, positions: [{ positionId: 'position-2', availablePackages: 5, allocatedPackages: 0, pickedPackages: 0 }] })]
|
||||
let sent
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
|
||||
state.current = task({ items: rows }); state.handleScan('PALLET-1')
|
||||
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.deepEqual(state.sourceChoices.map(row => [row.sourceBusinessNo, row.customerId, state.pickablePackages(row)]), [['RL-1', 'customer-1', 2], ['RL-2', 'customer-2', 5]])
|
||||
state.chooseSource(state.sourceChoices[1]); state.updatePackages({ detail: { value: '5' } }); await state.submitPick()
|
||||
@@ -129,7 +109,7 @@ test('a shared pallet exposes both relabel sources and picking the second preser
|
||||
test('an ambiguous source carton requires explicit source selection and never picks the first matching item', async () => {
|
||||
storage(); let sent
|
||||
const rows = [item({ returnCartons: [{ returnCartonId: 'carton-1', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 2 }] }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', returnCartons: [{ returnCartonId: 'carton-2', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 7 }] })]
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
|
||||
state.current = task({ items: rows }); state.handleScan('SHARED-MARK')
|
||||
assert.equal(state.sourceChoices.length, 2); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
|
||||
state.chooseSource(state.sourceChoices[1]); await state.submitPick()
|
||||
@@ -144,7 +124,7 @@ for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${so
|
||||
const raw = JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', taskNo: 'TASK-1', recordVersion: '0', status: 'ACTIVE', stage: 'LOADING', actions: { PICK: { allowed: true } }, pickingReconciled: false, items: [item({ sourceKey: `${sourceType}:source-1`, sourceBusinessType: null, sourceBusinessNo: null, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] })] }))
|
||||
const normalized = normalizeDispatchTask(raw)
|
||||
assert.equal(normalized.items[0].sourceBusinessType, null); assert.equal(normalized.items[0].sourceBusinessNo, null)
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
|
||||
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
|
||||
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true); assert.equal(state.selectedRemaining, 1)
|
||||
assert.equal(state.pickablePackages(state.current.items[0]), 1)
|
||||
state.updatePackages({ detail: { value: '1' } }); await state.submitPick()
|
||||
@@ -156,7 +136,7 @@ test('shared relabel pallet with real string 1/0/0 positions requires a source c
|
||||
let rows = ['1', '2'].map(number => item({ dispatchItemId: `item-${number}`, sourceKey: `source-${number}`, sourceBusinessNo: `RL-${number}`, customerId: `customer-${number}`, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: `position-${number}`, availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] }))
|
||||
const state = page({ pickDispatchItem: async (no, itemId, body) => {
|
||||
sent.push({ no, itemId, body }); rows = rows.map(row => row.dispatchItemId === itemId ? { ...row, pickedPackages: '1', positions: row.positions.map(position => ({ ...position, availablePackages: '0' })) } : row)
|
||||
return proof(fulfillment.dispatch.pendingCommand, ++version)
|
||||
return proof(lastCommand, ++version)
|
||||
}, getDispatchTask: async () => task({ recordVersion: version, items: rows, pickingReconciled: rows.every(row => row.pickedPackages === '1') }) })
|
||||
state.current = task({ recordVersion: version, items: rows }); state.handleScan('PALLET-1')
|
||||
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
|
||||
@@ -168,7 +148,7 @@ test('shared relabel pallet with real string 1/0/0 positions requires a source c
|
||||
assert.deepEqual(sent.map(entry => [entry.no, entry.itemId, entry.body.positionId, entry.body.packages, entry.body.recordVersion]), [['PC/1', 'item-2', 'position-2', 1, 5], ['PC/1', 'item-1', 'position-1', 1, 6]])
|
||||
assert.notEqual(sent[0].body.idempotencyKey, sent[1].body.idempotencyKey)
|
||||
assert.ok(sent.every(entry => !('customerId' in entry.body) && !('warehouseId' in entry.body)))
|
||||
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
|
||||
assert.equal('pendingCommand' in fulfillment.dispatch, false); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
|
||||
})
|
||||
test('RELABEL_ORDER still requires its true business number and all sources require sourceKey', () => {
|
||||
storage(); const state = page()
|
||||
@@ -184,7 +164,7 @@ test('two picked logical sources use the single authoritative physical loading r
|
||||
storage(); const rows = [item({ pickedPackages: 2 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', pickedPackages: 2 })]
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'one-physical-pallet', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: 1, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: true }, { field: 'volumeCbm', actual: true }] }] }
|
||||
let sent
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
|
||||
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
|
||||
await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.equal('measurements' in sent[1], false)
|
||||
})
|
||||
@@ -192,7 +172,7 @@ test('two picked logical sources use the single authoritative physical loading r
|
||||
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
|
||||
test('missing appointment blocks approval while preserving rejection to customer service', async () => {
|
||||
storage(); const sent = []
|
||||
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
|
||||
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
|
||||
assert.equal(state.reviewTime(null), '未填写')
|
||||
@@ -210,52 +190,31 @@ test('review task is reachable in the existing list and blocks actual source sel
|
||||
})
|
||||
test('reject requires a nonblank reason and approval uses original zero version then refreshes real pick eligibility', async () => {
|
||||
storage(); let sent
|
||||
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = reviewTask(); state.reviewReason = ' '; await state.submitReview('REJECTED')
|
||||
assert.match(state.error.message, /原因/); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
assert.match(state.error.message, /原因/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
state.reviewReason = ' 已核对车辆和货物 '; await state.submitReview('APPROVED')
|
||||
assert.deepEqual({ ...sent[1], idempotencyKey: 'key' }, { recordVersion: 0, decision: 'APPROVED', reason: '已核对车辆和货物', idempotencyKey: 'key' })
|
||||
assert.equal(state.current.stage, 'LOADING'); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
|
||||
assert.equal(state.reviewReason, ''); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
assert.equal(state.reviewReason, ''); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
})
|
||||
test('rejected loading review returns to customer service with its reason and never enables picking', async () => {
|
||||
storage(); let sent
|
||||
const rejected = reviewTask({ recordVersion: 1, actions: { LOADING_REVIEW: { allowed: false } }, reviews: { dispatchReview: { status: 'NOT_SUBMITTED', invalidatedReason: '理货审核驳回,请客服修改后重新提交调度审核' }, loadingReview: { status: 'REJECTED', reason: '车牌不符' } } })
|
||||
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(fulfillment.dispatch.pendingCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
|
||||
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(lastCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
|
||||
state.current = reviewTask(); state.reviewReason = '车牌不符'; await state.submitReview('REJECTED')
|
||||
assert.equal(sent.decision, 'REJECTED'); assert.equal(state.rejectionReason, '车牌不符'); assert.equal(state.canReview, false)
|
||||
assert.equal(state.stageLabel(state.current), '已退回客服处理'); assert.equal(state.canPick, false); assert.match(state.successMessage, /客服处理/)
|
||||
})
|
||||
test('review network uncertainty preserves exact decision reason version and key across restore and replay', async () => {
|
||||
storage(); const sent = []; let fail = true
|
||||
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = reviewTask(); state.reviewReason = '现场已核对'; await state.submitReview('APPROVED')
|
||||
assert.equal(fulfillment.dispatch.pendingCommand.rejected, false); restoreFulfillmentDraft('operator:warehouse')
|
||||
state.current.recordVersion = 99; state.reviewReason = '不得覆盖原文'; await state.recoverCommand()
|
||||
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(sent[1].reason, '现场已核对'); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
})
|
||||
test('Admin wins a review race: definite conflict preserves command for explicit refresh and no automatic new approval', async () => {
|
||||
storage(); let writes = 0
|
||||
const state = page({ reviewDispatchLoading: async () => { writes++; throw { code: 'A0409', message: '版本已变化' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = reviewTask(); await state.submitReview('APPROVED')
|
||||
assert.equal(fulfillment.dispatch.pendingCommand.rejected, true); assert.equal(writes, 1)
|
||||
state.discardRejected(); await new Promise(resolve => setTimeout(resolve, 0))
|
||||
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canReview, false); assert.equal(writes, 1)
|
||||
})
|
||||
|
||||
|
||||
test('review confirmation cannot submit a different version or a revoked gate', async () => {
|
||||
for (const change of [state => { state.current.recordVersion = 2 }, state => { state.current.actions.LOADING_REVIEW.allowed = false }]) {
|
||||
storage(); const state = page(); state.current = reviewTask(); uni.showModal = async () => { change(state); return { confirm: true } }
|
||||
await state.submitReview('APPROVED'); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
await state.submitReview('APPROVED'); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
}
|
||||
})
|
||||
test('wrong review result stage or stale refresh retains original command until authoritative confirmation', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_REVIEW', { recordVersion: 0, decision: 'REJECTED', reason: '车牌不符' })
|
||||
const state = page({ reviewDispatchLoading: async () => proof(fulfillment.dispatch.pendingCommand) })
|
||||
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
|
||||
patchDispatchCommand({ result: { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'BEFORE_LOADING' } })
|
||||
const stale = page({ getDispatchTask: async () => reviewTask({ recordVersion: 2 }) }); await stale.recoverCommand()
|
||||
assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
|
||||
})
|
||||
|
||||
test('review UI exposes authoritative resources and reason without reviewer database IDs or extra dispatch review action', async () => {
|
||||
const template = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).split('<script>')[0]
|
||||
for (const field of ['resources?.vehicle?.plate', 'resources?.driver?.name', 'resources?.driver?.phone', 'reviews?.dispatchReview?.reason', 'rejectionReason']) assert.ok(template.includes(field))
|
||||
@@ -263,33 +222,15 @@ test('review UI exposes authoritative resources and reason without reviewer data
|
||||
assert.doesNotMatch(template, /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/)
|
||||
})
|
||||
|
||||
test('loading completion keeps original manifest through a network replay and immediately enters delivery', async () => {
|
||||
storage(); let fail = true; const sent = []
|
||||
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest' })
|
||||
const state = page({ completeDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: '404' } }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
|
||||
await state.recoverCommand(); restoreFulfillmentDraft('operator:warehouse'); state.current.recordVersion = 9; await state.recoverCommand()
|
||||
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.canComplete, false); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
await state.completeLoading(); await state.recoverCommand(); assert.equal(sent.length, 2)
|
||||
})
|
||||
test('loading completion requires an after-loading result and does not accept an intermediate stage as success', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
|
||||
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
|
||||
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
|
||||
})
|
||||
|
||||
test('documents already complete allow loading confirmation to return DELIVERING and recover without reposting', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash' })
|
||||
let posts = 0, reads = 0
|
||||
const state = page({ completeDispatchLoading: async () => { posts++; return { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'DELIVERING' } }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'DELIVERING', recordVersion: 3 }) } })
|
||||
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'DELIVERING')
|
||||
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
assert.match(state.successMessage, /已进入派送中/)
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
test('scanning received pallets defaults to real nine and eleven boxes without manually reentering estimates', async () => {
|
||||
storage(); const sent = []
|
||||
let rows = [9, 11].map((quantity, index) => item({ dispatchItemId: `received-${index}`, sourceKey: `PLAN:${index}`, sourceBusinessType: null, sourceBusinessNo: null, palletNo: `PALLET-${index}`, scheduledPackages: quantity, positions: [{ positionId: `position-${index}`, availablePackages: quantity, allocatedPackages: 0, pickedPackages: 0 }] }))
|
||||
const state = page({ pickDispatchItem: async (number, id, body) => { sent.push(body.packages); rows = rows.map(row => row.dispatchItemId === id ? { ...row, pickedPackages: body.packages, positions: [] } : row); return proof(fulfillment.dispatch.pendingCommand, sent.length) }, getDispatchTask: async () => task({ items: rows, recordVersion: sent.length }) })
|
||||
const state = page({ pickDispatchItem: async (number, id, body) => { sent.push(body.packages); rows = rows.map(row => row.dispatchItemId === id ? { ...row, pickedPackages: body.packages, positions: [] } : row); return proof(lastCommand, sent.length) }, getDispatchTask: async () => task({ items: rows, recordVersion: sent.length }) })
|
||||
state.current = task({ items: rows })
|
||||
state.handleScan('PALLET-0'); assert.equal(state.packages, 9); assert.equal(state.canPick, true); await state.submitPick()
|
||||
state.handleScan('PALLET-1'); assert.equal(state.packages, 11); assert.equal(state.canPick, true); await state.submitPick()
|
||||
@@ -312,16 +253,46 @@ test('same-task photo refresh stays on photos while a new task starts on picking
|
||||
})
|
||||
|
||||
test('tab changes cannot interrupt an upload or pending command, or open photos before loading', () => {
|
||||
storage(); const state = page(); state.busy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
state.busy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
state.current.stage = 'LOADING'; beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0 })
|
||||
storage(); const state = page(); state.photoBusy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
state.photoBusy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
state.current.stage = 'LOADING'; state.requestBusy = true
|
||||
state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
})
|
||||
|
||||
test('thirty scanned pallets confirm once without weight volume or pallet entry', async () => {
|
||||
storage(); let sent
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'thirty', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: Array.from({ length: 30 }, (_, i) => ({ warehousePalletId: String(i), palletNo: `PALLET-${i}`, deliveryIndex: 1, pickedPackages: 10, weightKg: null, volumeCbm: null, evidence: [] })) }
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading(); await state.completeLoading()
|
||||
assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
|
||||
})
|
||||
|
||||
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }, { storageRentPreparation: true, code: 'A0409', message: '仓租费用尚未确认' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => {
|
||||
const sent = []; let reads = 0
|
||||
storage()
|
||||
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } })
|
||||
state.selectPallet(state.current.items[0]); await state.submitPick()
|
||||
assert.equal(state.busy, false); assert.equal(reads, 0); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
state.current.recordVersion = 7; state.updatePackages({ detail: { value: 1 } }); await state.submitPick()
|
||||
assert.equal(sent.length, 2); assert.equal(sent[1].recordVersion, 7); assert.equal(sent[1].packages, 1)
|
||||
assert.notEqual(sent[0].idempotencyKey, sent[1].idempotencyKey)
|
||||
state.selectTab('photos'); assert.equal(state.activeTab, 'photos')
|
||||
})
|
||||
test('failed review keeps editable reason and releases the request without a readback gate', async () => {
|
||||
storage(); const sent = []
|
||||
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); throw { network: true } } })
|
||||
state.current = reviewTask(); state.reviewReason = 'first'; await state.submitReview('REJECTED')
|
||||
assert.equal(state.busy, false); state.updateReviewReason({ detail: { value: 'changed' } }); await state.submitReview('REJECTED')
|
||||
assert.equal(sent[1].reason, 'changed'); assert.notEqual(sent[0].idempotencyKey, sent[1].idempotencyKey)
|
||||
})
|
||||
test('in-flight pick blocks a second click and releases after failure', async () => {
|
||||
storage(); let reject, writes = 0
|
||||
const state = page({ pickDispatchItem: () => { writes++; return new Promise((_, failure) => { reject = failure }) } })
|
||||
state.selectPallet(state.current.items[0]); const first = state.submitPick(); await state.submitPick()
|
||||
assert.equal(writes, 1); reject({ network: true }); await first; assert.equal(state.busy, false)
|
||||
})
|
||||
test('invalid completion stage reports an error and still releases the page', async () => {
|
||||
storage(); const state = page({ completeDispatchLoading: async () => ({ ...proof(lastCommand), stage: 'LOADING' }) })
|
||||
await state.submitCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 })
|
||||
assert.match(state.error.message, /返回结果未确认/); assert.equal(state.busy, false)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
|
||||
import {
|
||||
reviewDispatchLoading, completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, getDispatchTask, getOutboundTask,
|
||||
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
|
||||
@@ -23,6 +23,15 @@ test('dispatch network failures show recovery guidance and retain diagnostics',
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('outbound list and detail lead with the outbound number and label the task number separately', async () => {
|
||||
const page = await read('../src/pages/outbound/outbound.vue')
|
||||
assert.match(page, /class="business-code">\{\{ task\.outboundNo \|\| '—' \}\}/)
|
||||
assert.match(page, /class="business-code">\{\{ current\.outboundNo \|\| '—' \}\}/)
|
||||
assert.match(page, /任务号 \{\{ task\.taskNo \|\| '—' \}\}/)
|
||||
assert.match(page, /任务号 \{\{ current\.taskNo \|\| '—' \}\}/)
|
||||
assert.match(page, /@tap="openTask\(task\.taskNo\)"/)
|
||||
})
|
||||
|
||||
test('native dispatch versions preserve explicit zero and reject missing or invalid versions', () => {
|
||||
assert.equal(requireDispatchVersion(0), 0)
|
||||
assert.equal(requireDispatchVersion('9007199254740993'), '9007199254740993')
|
||||
@@ -162,17 +171,16 @@ test('dispatch and outbound adapters retain stable business identifiers and coun
|
||||
assert.equal(outbound.items[0].positions[0].availablePackages, 3)
|
||||
})
|
||||
|
||||
test('outbound draft restoration retains its retry key and stays scoped to operator and warehouse', () => {
|
||||
test('outbound draft restoration discards request keys and stays scoped to operator and warehouse', () => {
|
||||
storageMock(); resetFulfillmentSession(); restoreFulfillmentDraft('operator-a:warehouse-a')
|
||||
updateFulfillmentDraft('outbound', { currentTaskNo: 'OUT-001' })
|
||||
const first = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 })
|
||||
assert.equal(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
|
||||
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
|
||||
fulfillment.outbound.currentTaskNo = ''; assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true)
|
||||
assert.equal(fulfillment.outbound.currentTaskNo, 'OUT-001')
|
||||
releaseFulfillmentKey('outbound', first.signature)
|
||||
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
|
||||
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false); assert.equal(fulfillment.outbound.currentTaskNo, '')
|
||||
assert.throws(() => fulfillmentIdempotencyKey('dispatch', 'pick', {}), /完整原命令/)
|
||||
assert.equal('pendingKeys' in fulfillment.outbound, false)
|
||||
})
|
||||
|
||||
test('pages retain loading, offline, diagnostics and native loading completion followed by shared loading documents', async () => {
|
||||
@@ -182,10 +190,10 @@ test('pages retain loading, offline, diagnostics and native loading completion f
|
||||
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
|
||||
}
|
||||
assert.doesNotMatch(dispatch, /departDispatch|canDepart|确认整车发车|等待后台安排派送/); assert.match(dispatch, /DispatchLoadingDocuments/); assert.match(dispatch, /completeDispatchLoading/); assert.match(dispatch, /pickingReconciled === true/)
|
||||
assert.match(dispatch, /beginDispatchCommand/); assert.match(dispatch, /await getDispatchTask[\s\S]*finishDispatchCommand/)
|
||||
assert.doesNotMatch(dispatch, /beginDispatchCommand|pendingCommand/); assert.match(dispatch, /submitCommand/)
|
||||
assert.doesNotMatch(dispatch, /tripDepartureRequest|completeDispatchPicking|fulfillmentIdempotencyKey/)
|
||||
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
|
||||
assert.match(outbound, /outboundPickPayload/); assert.match(outbound, /await getOutboundTask[\s\S]*releaseFulfillmentKey/)
|
||||
assert.match(outbound, /outboundPickPayload/); assert.match(outbound, /await getOutboundTask/); assert.doesNotMatch(outbound, /releaseFulfillmentKey/)
|
||||
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,43 +1,28 @@
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
test('H5 image copies only compiled assets and static nginx configuration', async () => {
|
||||
const dockerfile = await read('../deploy/Dockerfile.h5')
|
||||
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
|
||||
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/'])
|
||||
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
|
||||
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
|
||||
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
|
||||
const ignore = await read('../.dockerignore')
|
||||
assert.ok(ignore.split(/\r?\n/).includes('**'))
|
||||
assert.ok(ignore.includes('!dist/build/h5/**'))
|
||||
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
|
||||
test('PDA H5 has no container publishing entry', async () => {
|
||||
const files = await readdir(new URL('../deploy/', import.meta.url))
|
||||
assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), [])
|
||||
await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' })
|
||||
})
|
||||
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
|
||||
const nginx = await read('../deploy/nginx.conf')
|
||||
assert.match(nginx, /listen 80;/)
|
||||
assert.ok(nginx.includes('location /'))
|
||||
assert.match(nginx, /root \/srv\/pda-h5;/)
|
||||
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
|
||||
assert.match(nginx, /Cache-Control "no-store" always/)
|
||||
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
|
||||
assert.match(nginx, /location = \/healthz/)
|
||||
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
|
||||
})
|
||||
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
|
||||
const compose = await read('../deploy/docker-compose.h5.test.yml')
|
||||
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
|
||||
assert.match(compose, /context: \.\./)
|
||||
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
|
||||
assert.match(compose, /container_name: pda-overseas-h5-test/)
|
||||
assert.match(compose, /aliases:\s*\n\s*- pda-overseas-h5-test/)
|
||||
assert.match(compose, /external: true/)
|
||||
assert.match(compose, /name: firstleg-test-network/)
|
||||
assert.match(compose, /expose:\s*\n\s*- "80"/)
|
||||
assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/'))
|
||||
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
|
||||
|
||||
test('host nginx serves uploaded files and proxies only the API prefix', async () => {
|
||||
const nginx = await read('../deploy/nginx.conf.template')
|
||||
assert.ok(nginx.includes('root ${PDA_SITE_ROOT};'))
|
||||
const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1]
|
||||
assert.ok(page)
|
||||
assert.match(page, /try_files \$uri \$uri\/ =404;/)
|
||||
assert.match(page, /Cache-Control "no-store" always/)
|
||||
assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/)
|
||||
assert.match(nginx, /location \^~ \/gateway-api\//)
|
||||
assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;'))
|
||||
assert.equal((nginx.match(/proxy_pass/g) || []).length, 1)
|
||||
assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/)
|
||||
assert.match(nginx, /client_max_body_size 55m;/)
|
||||
assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/)
|
||||
})
|
||||
|
||||
test('only H5 production overrides the toolchain asset base', async () => {
|
||||
@@ -72,15 +57,3 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
|
||||
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
|
||||
const compose = await read('../deploy/docker-compose.h5.production.yml')
|
||||
const nginx = await read('../deploy/nginx.production.conf.template')
|
||||
const docker = await read('../deploy/Dockerfile.h5.production')
|
||||
assert.match(compose, /name: firstleg-production-network/)
|
||||
assert.match(compose, /127\.0\.0\.1:/)
|
||||
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
|
||||
assert.match(nginx, /location \/gateway-api\//)
|
||||
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
|
||||
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@ test('container UI locks and signs once, then renders the resolved destination g
|
||||
for (const fact of ['已分配', '未分配', '可合托·非强制', '不合托', '加急', '普通']) assert.match(summary, new RegExp(fact))
|
||||
assert.doesNotMatch(operation, /v-for="[^"]*inbound[^"]*"/)
|
||||
assert.match(operation, /v-if="activeInbound"[\s\S]*activeInbound\.inboundCodes[\s\S]*activeInbound\.pendingPackages/)
|
||||
assert.match(operation, /activeInbound\.operationMode === 'FBA_GROUP'[\s\S]*按 FBA 仓成组收货/)
|
||||
assert.match(operation, /class="mode-banner"[^\n]*activeInbound\.destinationType === 'FBA'[^\n]*FBA 按箱数收货[^\n]*海外仓逐箱收货/)
|
||||
assert.doesNotMatch(operation, /activeInbound\.customer|客户/)
|
||||
assert.match(operation, /resolveCurrentCarton\(cartonMark\)/)
|
||||
assert.match(operation, /operationMode !== 'DROPSHIP_PALLET'[\s\S]*receiveCurrentWholePallet/)
|
||||
@@ -121,7 +121,6 @@ test('container UI locks and signs once, then renders the resolved destination g
|
||||
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
|
||||
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
|
||||
assert.match(operation, /prepareReceivingContainer[\s\S]*validateReceivingWarehouse/)
|
||||
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
|
||||
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) {
|
||||
assert.doesNotMatch(inboundApi, new RegExp(forbidden))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { MODULES } from '../src/data/overseas-workbench.js'
|
||||
import { navigateBackOr, navigateToUnique } from '../src/utils/navigation.js'
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
const component = (source, dependencies) => new Function(...Object.keys(dependencies), source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
|
||||
|
||||
test('navigateBackOr returns through the stack and falls back when the stack is empty', () => {
|
||||
const calls = []
|
||||
globalThis.uni = {
|
||||
@@ -36,3 +41,46 @@ test('navigateToUnique does not stack the current utility page twice', () => {
|
||||
navigateToUnique('/pages/history/history')
|
||||
assert.deepEqual(calls, [{ url: '/pages/history/history' }])
|
||||
})
|
||||
|
||||
test('every registered page header returns home after a reload without page history', async (t) => {
|
||||
const previousUni = globalThis.uni
|
||||
const previousPages = globalThis.getCurrentPages
|
||||
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
|
||||
const { pages } = JSON.parse(await read('../src/pages.json'))
|
||||
const header = component(await read('../src/components/PortalHeader.vue'), { PopupHeader: {}, navigateBackOr, navigateToUnique })
|
||||
const calls = []
|
||||
globalThis.uni = { reLaunch: (options) => calls.push(options), navigateBack: () => assert.fail('a reloaded page has no back stack') }
|
||||
let checked = 0
|
||||
for (const { path } of pages) {
|
||||
const source = await read(`../src/${path}.vue`)
|
||||
const tag = source.match(/<PortalHeader\b[^>]*\/>/)?.[0]
|
||||
if (!tag) continue
|
||||
assert.doesNotMatch(tag, /:fallback-url=/, path)
|
||||
const fallbackUrl = tag.match(/\bfallback-url="([^"]*)"/)?.[1] || header.props.fallbackUrl.default
|
||||
assert.equal(fallbackUrl, '/pages/home/home', path)
|
||||
globalThis.getCurrentPages = () => [{ route: path }]
|
||||
header.methods.goBack.call({ fallbackUrl })
|
||||
assert.deepEqual(calls.at(-1), { url: '/pages/home/home' }, path)
|
||||
checked++
|
||||
}
|
||||
assert.ok(checked > 0)
|
||||
assert.equal(calls.length, checked)
|
||||
})
|
||||
|
||||
test('home opens every enabled operation directly on a registered page', async (t) => {
|
||||
const previousUni = globalThis.uni
|
||||
t.after(() => { globalThis.uni = previousUni })
|
||||
const { pages } = JSON.parse(await read('../src/pages.json'))
|
||||
const registered = new Set(pages.map(({ path }) => `/${path}`))
|
||||
const home = component(await read('../src/pages/home/home.vue'), { PortalHeader: {}, MODULES })
|
||||
const calls = []
|
||||
globalThis.uni = { navigateTo: (options) => calls.push(options) }
|
||||
for (const group of MODULES) {
|
||||
for (const operation of group.operations.filter((item) => item.available)) {
|
||||
const count = calls.length
|
||||
home.methods.openOperation.call({ groups: MODULES }, operation)
|
||||
assert.equal(calls.length, count + 1, operation.name)
|
||||
assert.ok(registered.has(calls.at(-1).url.split('?')[0]), operation.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ const deferred = () => { let resolve; const promise = new Promise(done => { reso
|
||||
function page(overrides = {}) {
|
||||
const calls = []
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
|
||||
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
|
||||
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }),
|
||||
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
|
||||
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
|
||||
...overrides }
|
||||
@@ -257,19 +257,19 @@ test('partial bound pallet confirmation sends only selected marks and preserves
|
||||
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }])
|
||||
})
|
||||
|
||||
test('discard confirmation retains its original request key until a definite successful response', async () => {
|
||||
const released = []; const submitted = []
|
||||
test('discard confirmation preserves inputs after preflight failure and starts a new checked request', async () => {
|
||||
const submitted = []
|
||||
const retry = deferred()
|
||||
let attempt = 0
|
||||
const { vm } = page({
|
||||
confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('费用确认中'); await retry.promise },
|
||||
releaseFulfillmentKey: (...args) => released.push(args), listCargoDiscardTasks: async () => []
|
||||
fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
|
||||
})
|
||||
vm.discardCurrent = { discardNo: 'CD-RETRY' }
|
||||
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
await vm.confirmDiscard()
|
||||
assert.equal(released.length, 0)
|
||||
assert.equal(vm.discardBusy, false)
|
||||
assert.ok(vm.discardTask)
|
||||
assert.equal(vm.error.message, '费用确认中')
|
||||
vm.successMessage = '上次作业已完成'
|
||||
@@ -277,8 +277,9 @@ test('discard confirmation retains its original request key until a definite suc
|
||||
assert.equal(vm.error, null)
|
||||
assert.equal(vm.successMessage, '')
|
||||
retry.resolve(); await retried
|
||||
assert.deepEqual(submitted[0], submitted[1])
|
||||
assert.deepEqual(released, [['outbound', 'signature']])
|
||||
assert.deepEqual(submitted[0].slice(0, 6), submitted[1].slice(0, 6))
|
||||
assert.notEqual(submitted[0][6].idempotencyKey, submitted[1][6].idempotencyKey)
|
||||
assert.equal(vm.discardBusy, false)
|
||||
assert.equal(vm.error, null)
|
||||
assert.match(vm.successMessage, /已完成/)
|
||||
})
|
||||
|
||||
@@ -274,6 +274,49 @@ test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile
|
||||
}
|
||||
})
|
||||
|
||||
test('H5 XMLHttpRequest timeout and abort reject once so the page can retry without an automatic upload replay', async () => {
|
||||
prepareSession()
|
||||
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest }
|
||||
globalThis.window = {}; globalThis.location = { protocol: 'http:' }
|
||||
globalThis.fetch = async () => new Response(new Blob(['photo'], { type: 'image/png' }))
|
||||
const requests = []
|
||||
globalThis.XMLHttpRequest = class {
|
||||
constructor() { requests.push(this) }
|
||||
open() {} setRequestHeader() {}
|
||||
send() { queueMicrotask(() => requests.length === 1 ? this.ontimeout() : this.onabort()) }
|
||||
}
|
||||
try {
|
||||
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
|
||||
assert.equal(requests.length, 1); assert.equal(requests[0].timeout, 60000)
|
||||
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /中断/.test(error.message))
|
||||
assert.equal(requests.length, 2)
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
|
||||
}
|
||||
})
|
||||
|
||||
test('H5 fetch upload aborts a stalled transfer, clears its timer, and permits a later upload', async () => {
|
||||
prepareSession()
|
||||
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }
|
||||
let timeoutCallback, posts = 0, cleared = 0
|
||||
globalThis.window = {}; globalThis.location = { protocol: 'http:' }; delete globalThis.XMLHttpRequest
|
||||
globalThis.setTimeout = (callback, delay) => { assert.equal(delay, 60000); timeoutCallback = callback; return 'upload-timer' }
|
||||
globalThis.clearTimeout = handle => { assert.equal(handle, 'upload-timer'); cleared++ }
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
|
||||
if (++posts === 1) return new Promise((_resolve, reject) => { options.signal.addEventListener('abort', () => reject(new Error('aborted'))); queueMicrotask(timeoutCallback) })
|
||||
return new Response(JSON.stringify({ code: '200', data: { fileId: 'retried-photo' } }), { status: 200 })
|
||||
}
|
||||
try {
|
||||
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
|
||||
assert.equal(posts, 1); assert.equal(cleared, 1)
|
||||
assert.deepEqual(await overseasUpload('/pda/photo', 'blob:photo'), { fileId: 'retried-photo' })
|
||||
assert.equal(posts, 2); assert.equal(cleared, 2)
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
|
||||
}
|
||||
})
|
||||
|
||||
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
|
||||
const value = prepareSession()
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
@@ -5,14 +5,13 @@ import { readFile } from 'node:fs/promises'
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('pallet management is enabled through its dedicated real PDA route', async () => {
|
||||
const [workbench, pages, home, module] = await Promise.all([
|
||||
const [workbench, pages, home] = await Promise.all([
|
||||
read('../src/data/overseas-workbench.js'), read('../src/pages.json'),
|
||||
read('../src/pages/home/home.vue'), read('../src/pages/module/module.vue')
|
||||
read('../src/pages/home/home.vue')
|
||||
])
|
||||
assert.match(workbench, /key: 'pallet'[\s\S]*available: true[\s\S]*route: '\/pages\/pallet\/pallet'/)
|
||||
assert.match(pages, /pages\/pallet\/pallet/)
|
||||
assert.match(home, /operation\.route \|\|/)
|
||||
assert.match(module, /operation\.route \|\|/)
|
||||
})
|
||||
|
||||
test('pallet API maps only the frozen overseas PDA operation endpoints', async () => {
|
||||
|
||||
@@ -90,9 +90,9 @@ test('login has no committed demo credentials and inbound operation implements t
|
||||
})
|
||||
|
||||
test('warehouse operations use dedicated real pages and contain no prototype task data', async () => {
|
||||
const [operation, home, module, workbench, pages] = await Promise.all([
|
||||
const [operation, home, workbench, pages] = await Promise.all([
|
||||
read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'),
|
||||
read('../src/pages/module/module.vue'), read('../src/data/overseas-workbench.js'), read('../src/pages.json')
|
||||
read('../src/data/overseas-workbench.js'), read('../src/pages.json')
|
||||
])
|
||||
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
|
||||
assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype|模拟成功/)
|
||||
@@ -101,7 +101,7 @@ test('warehouse operations use dedicated real pages and contain no prototype tas
|
||||
}
|
||||
assert.doesNotMatch(workbench, /key: 'stocktake'/)
|
||||
for (const page of ['pages/stocktake/stocktake', 'pages/move/move', 'pages/relabel/relabel', 'pages/query/query']) assert.match(pages, new RegExp(page))
|
||||
assert.match(home + module, /operation\.route \|\|/)
|
||||
assert.match(home, /operation\.route \|\|/)
|
||||
})
|
||||
|
||||
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
|
||||
@@ -130,7 +130,7 @@ test('candidate containers use compact card navigation and a small refresh actio
|
||||
assert.match(operation, /class="candidate-facts"[\s\S]*总订单[\s\S]*入仓码[\s\S]*已生成计划[\s\S]*未操作/)
|
||||
assert.match(operation, /class="candidate-destinations"/)
|
||||
assert.match(operation, /计划到仓:\{\{ formatPlannedArrival\(item\.plannedArrivalAt\) \}\}/)
|
||||
assert.match(operation, /item\.blockReason \|\| '当前柜入库计划尚未齐全'/)
|
||||
assert.match(operation, /selected\?\.blockReason \|\| '当前柜入库计划尚未齐全'/)
|
||||
assert.doesNotMatch(operation, /预计到港/)
|
||||
assert.doesNotMatch(operation, /item\.receivedPackages[\s\S]*item\.totalPackages[\s\S]*progress-fill/)
|
||||
})
|
||||
@@ -164,7 +164,7 @@ test('return receipt is a real PDA flow backed by the dedicated return APIs', as
|
||||
assert.match(page, /startedBy: this\.startedBy/)
|
||||
assert.match(page, /idempotencyKey: key\.value/)
|
||||
assert.match(page, /requestId[\s\S]*traceId/)
|
||||
assert.match(warehouseWork, /returnReceipt:[^\n]*pendingKeys/)
|
||||
assert.doesNotMatch(warehouseWork, /pendingKeys/)
|
||||
assert.doesNotMatch(page, /customerId/)
|
||||
})
|
||||
|
||||
@@ -182,7 +182,7 @@ test('return packing is a separate same-customer inventory operation', async ()
|
||||
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
|
||||
assert.match(page, /idempotencyKey: key\.value/)
|
||||
assert.match(page, /requestId[\s\S]*traceId/)
|
||||
assert.match(warehouseWork, /returnPacking:[^\n]*pendingKeys/)
|
||||
assert.doesNotMatch(warehouseWork, /pendingBatch/)
|
||||
assert.doesNotMatch(page.match(/<template>([\s\S]*?)<\/template>/)?.[1] || '', /customerId|warehouseId/)
|
||||
})
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ test('box merge component compiles both its script and template', () => {
|
||||
compileScript(descriptor, { id: 'box-merge' })
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
|
||||
})
|
||||
function instance() {
|
||||
function instance(runtime = {}) {
|
||||
const boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark) })
|
||||
const context = vm.createContext({ reactive: value => value, ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark), ...runtime })
|
||||
vm.runInContext(readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8').replace(/^import .*$/gm, '').replace(/export /g, ''), context)
|
||||
const component = vm.runInContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), context)
|
||||
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
|
||||
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
|
||||
work.save = () => {}
|
||||
Object.defineProperty(work, 'canComplete', { get: () => component.computed.canComplete.call(work) })
|
||||
return {work,component}
|
||||
}
|
||||
test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
|
||||
@@ -31,6 +34,34 @@ test('new plan requires scanning each source and preserves locked SKU quantities
|
||||
assert.equal(component.computed.canComplete.call(work),true)
|
||||
})
|
||||
|
||||
test('partial merge preserves the scanned snapshot after failure and accepts a fresh scan without locking', async () => {
|
||||
let available = 10
|
||||
const calls = [], errors = []
|
||||
const { work } = instance({
|
||||
scanReturnCarton: async () => ({ cartonMark: 'A', customerId: 'C1', items: [{ skuCode: 'SKU1', quantity: available }] }),
|
||||
mergeReturnBoxes: async request => {
|
||||
calls.push(JSON.parse(JSON.stringify(request)))
|
||||
if (request.expectedSourceQuantities[0].quantity !== available) throw new Error('来源箱库存已变化')
|
||||
available -= request.sourceDeductions[0].quantity
|
||||
if (calls.length === 1) throw new Error('response lost after commit')
|
||||
return { label: { cartonMark: 'NEW' } }
|
||||
}
|
||||
})
|
||||
work.plan = null; work.choose('NEW'); work.$emit = (name, value) => { if (name === 'error') errors.push(value) }
|
||||
await work.scanSource('A'); work.setDeduction('A', 'SKU1', 3)
|
||||
work.dimensions = { length: 10, width: 10, height: 10, weight: 1 }
|
||||
await work.complete(); await work.complete()
|
||||
assert.equal(available, 7)
|
||||
assert.equal(work.busy, false)
|
||||
assert.equal(errors.at(-1).message, '来源箱库存已变化')
|
||||
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
work.remove('A'); await work.scanSource('A'); work.setDeduction('A', 'SKU1', 2)
|
||||
await work.complete()
|
||||
assert.equal(available, 5)
|
||||
assert.deepEqual(calls.map(call => call.expectedSourceQuantities[0].quantity), [10, 10, 7])
|
||||
assert.equal(work.busy, false)
|
||||
})
|
||||
|
||||
test('planned source carton marks are visible before scanning', () => {
|
||||
const {work, component} = instance()
|
||||
work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
|
||||
|
||||
@@ -37,7 +37,7 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
|
||||
}
|
||||
await startReturnReceipt(' RT-001 ')
|
||||
await findReturnSkuCandidates(' SKU-1 ')
|
||||
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
|
||||
const confirm = { returnNo: 'RT-001', expectedReceivedQuantity: 0, trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
|
||||
await confirmReturnReceipt(confirm)
|
||||
assert.deepEqual(calls.map((item) => item.url), [
|
||||
'/api/pda/v1/last-leg/return-receipts/start',
|
||||
|
||||
@@ -122,13 +122,12 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime
|
||||
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
|
||||
vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
|
||||
const instance = Object.assign({}, component.methods, {
|
||||
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
|
||||
receiptResults: [], unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
|
||||
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
|
||||
selectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photoFileIds: [], photoNames: [], photoUrls: {}, trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
|
||||
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
|
||||
quantity: () => 2, boxCount: () => 1
|
||||
})
|
||||
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
|
||||
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
|
||||
return instance
|
||||
}
|
||||
@@ -156,7 +155,7 @@ test('PALLET page submits the required palletCode through the actual confirmatio
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].palletCode, 'PLT-TEST')
|
||||
assert.equal(calls[0].unitCode, undefined)
|
||||
assert.deepEqual(Object.keys(calls[0]).sort(), ['height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
|
||||
assert.deepEqual(Object.keys(calls[0]).sort(), ['expectedReceivedQuantity', 'height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
|
||||
assert.equal(calls[0].unitType, 'PALLET')
|
||||
assert.equal(calls[0].totalBoxCount, 1)
|
||||
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort())
|
||||
@@ -232,7 +231,7 @@ test('one submission signs multiple mixed-SKU parcels sharing one waybill and in
|
||||
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
|
||||
})
|
||||
|
||||
test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
|
||||
test('failed multi-parcel receipt keeps successful results and accepts edits to remaining rows', async () => {
|
||||
const saved = new Map(), calls = []
|
||||
let failSecond = true
|
||||
const confirm = async request => {
|
||||
@@ -243,16 +242,65 @@ test('failed multi-parcel batch resumes immutable requests without re-signing a
|
||||
const page = setup(confirm, saved)
|
||||
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
|
||||
await page.confirmReceipt()
|
||||
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
|
||||
assert.equal(page.receiptResults[0].labels[0].cartonMark, 'RP-1'); assert.equal(page.boxRows.length, 1); assert.equal(page.busy, false)
|
||||
failSecond = false
|
||||
const restored = setup(confirm, saved)
|
||||
const restored = page; page.boxRows[0].items['SKU-TEST'] = 3
|
||||
await restored.confirmReceipt()
|
||||
assert.equal(calls.length, 3)
|
||||
assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
|
||||
assert.deepEqual(calls[1], calls[2])
|
||||
assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey)
|
||||
assert.equal(calls[2].items[0].receivedQuantity, 3)
|
||||
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 1, 1])
|
||||
assert.equal(restored.result.labels.length, 2)
|
||||
})
|
||||
|
||||
test('lost receipt response keeps the old quantity precondition and does not lock editing or refresh', async () => {
|
||||
const calls = []
|
||||
let received = 0
|
||||
const page = setup(async request => {
|
||||
calls.push(JSON.parse(JSON.stringify(request)))
|
||||
if (request.expectedReceivedQuantity !== received) throw new Error('退货单实收数量已变化')
|
||||
received += request.items[0].receivedQuantity
|
||||
if (calls.length === 1) throw new Error('response lost after commit')
|
||||
return { unitType: 'PARCEL', fullyMatched: false }
|
||||
}, new Map(), 'operator:warehouse', {
|
||||
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: received }], photos: [{ fileId: 'PHOTO' }] }),
|
||||
downloadReturnPalletPhoto: async () => 'photo-url'
|
||||
})
|
||||
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
|
||||
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
|
||||
await page.confirmReceipt()
|
||||
await page.confirmReceipt()
|
||||
assert.equal(received, 2)
|
||||
assert.equal(page.busy, false)
|
||||
assert.equal(page.boxRows.length, 1)
|
||||
assert.equal(page.error.message, '退货单实收数量已变化')
|
||||
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
// 用户刷新后,按当前实收量登记下一份实物。
|
||||
await page.reloadCurrent()
|
||||
assert.equal(page.boxRows[0].confirmed, false)
|
||||
assert.deepEqual(Object.keys(page.boxRows[0].items), [])
|
||||
await page.confirmReceipt()
|
||||
assert.equal(received, 2, '刷新后原包裹不能再次提交')
|
||||
assert.equal(calls.length, 2)
|
||||
page.boxRows[0] = physicalRow({ 'SKU-TEST': 3 })
|
||||
await page.confirmReceipt()
|
||||
assert.equal(received, 5)
|
||||
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 0, 2])
|
||||
assert.equal(page.busy, false)
|
||||
})
|
||||
|
||||
test('refresh with unchanged receipt quantities preserves editable input after a rejected request', async () => {
|
||||
const page = setup(async () => { throw new Error('not committed') }, new Map(), 'operator:warehouse', {
|
||||
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photos: [{ fileId: 'PHOTO' }] }),
|
||||
downloadReturnPalletPhoto: async () => 'photo-url'
|
||||
})
|
||||
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
|
||||
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
|
||||
await page.confirmReceipt(); await page.reloadCurrent()
|
||||
assert.equal(page.boxRows[0].items['SKU-TEST'], 2)
|
||||
assert.equal(page.busy, false)
|
||||
})
|
||||
|
||||
test('partial receipt continues the same order with fresh pallet measurements', async () => {
|
||||
const page = setup(async () => ({}))
|
||||
page.finishReceipt([{ fullyMatched: false }])
|
||||
@@ -266,7 +314,7 @@ test('partial receipt continues the same order with fresh pallet measurements',
|
||||
assert.equal(page.palletWeight, '')
|
||||
})
|
||||
|
||||
test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
|
||||
test('PALLET response failure keeps editable input and generates a new key for the next click', async () => {
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
|
||||
await page.confirmReceipt()
|
||||
@@ -275,7 +323,7 @@ test('PALLET response failure keeps the draft and same idempotency key for retry
|
||||
assert.equal(page.error.message, 'Response lost')
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
})
|
||||
|
||||
test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
@@ -58,8 +57,6 @@ test('retry reuses the preparation key; exempt and already consumed commands can
|
||||
assert.deepEqual(calls[0].data, original)
|
||||
assert.equal(calls.length, 2)
|
||||
}
|
||||
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /!cause\.storageRentPreparation/)
|
||||
})
|
||||
|
||||
test('invalid dispatch version or missing command identity cannot start a reservation', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
reportRelabelException, reviewRelabelTask, skipRelabelUnit
|
||||
} from '../src/api/relabel.js'
|
||||
import {
|
||||
releaseWarehouseKey, resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
|
||||
resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
|
||||
warehouseIdempotencyKey, warehouseWork
|
||||
} from '../src/stores/warehouse-work.js'
|
||||
|
||||
@@ -126,7 +126,6 @@ test('relabel review opens a pending task as customer-confirmation read-only det
|
||||
assert.equal(visible.call({ ...context, candidates: [] }), false)
|
||||
assert.equal(visible.call({ ...context, stage: 'review', current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), true)
|
||||
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false)
|
||||
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据'/)
|
||||
assert.match(page, /<view v-else class="task-list">[\s\S]*@tap="openReview\(item\)"/)
|
||||
assert.match(page, /查看并复核/)
|
||||
assert.match(page, /async openReview\(item\)/)
|
||||
@@ -145,8 +144,8 @@ test('relabel review opens a pending task as customer-confirmation read-only det
|
||||
|
||||
test('returned relabel tasks are highlighted, sorted first, and still obey the single active task lock', async () => {
|
||||
const page = await read('../src/pages/relabel/relabel.vue')
|
||||
assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做[\s\S]*优先处理/)
|
||||
assert.match(page, /退回备注:\{\{ item\.reviewRemark \}\}/)
|
||||
assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做/)
|
||||
assert.match(page, /current\.reviewDecision === 'REWORK' && current\.reviewRemark[\s\S]*\{\{ current\.reviewRemark \}\}/)
|
||||
assert.match(page, /\.sort\(\(left, right\) => Number\(right\.reviewDecision === 'REWORK'\) - Number\(left\.reviewDecision === 'REWORK'\)/)
|
||||
assert.match(page, /const active = this\.candidates\.find\(\(candidate\) => candidate\.taskStatus === 'PROCESSING' && candidate\.claimedByCurrentOperator === true\)/)
|
||||
assert.match(page, /if \(active && active\.relabelNo !== item\.relabelNo\) return this\.promptActiveTask\(active\)/)
|
||||
@@ -165,11 +164,10 @@ test('warehouse drafts are isolated by operator and warehouse and retain retry k
|
||||
updateWarehouseWork('move', { sourcePalletCode: 'PLT-1', targetLocationCode: 'A-01', reason: 'capacity' })
|
||||
const first = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
|
||||
const retry = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
|
||||
assert.equal(retry.value, first.value)
|
||||
assert.notEqual(retry.value, first.value)
|
||||
warehouseWork.move.sourcePalletCode = ''
|
||||
assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), true)
|
||||
assert.equal(warehouseWork.move.sourcePalletCode, 'PLT-1')
|
||||
releaseWarehouseKey('move', first.signature)
|
||||
assert.equal(restoreWarehouseWork('operator-b:warehouse-a'), false)
|
||||
assert.equal(warehouseWork.move.sourcePalletCode, '')
|
||||
})
|
||||
@@ -191,7 +189,7 @@ test('new operation pages wire scans, manual inputs, conflict refresh, loading a
|
||||
}
|
||||
for (const page of [stocktake, move, relabel]) {
|
||||
assert.match(page, /warehouseIdempotencyKey/)
|
||||
assert.match(page, /releaseWarehouseKey/)
|
||||
assert.doesNotMatch(page, /releaseWarehouseKey/)
|
||||
assert.match(page, /isConflict[\s\S]*刷新/)
|
||||
}
|
||||
assert.match(stocktake, /expectedVersion: item\.version/)
|
||||
|
||||
Reference in New Issue
Block a user