diff --git a/AGENTS.md b/AGENTS.md
index 6771a53..c08d50d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,6 +42,7 @@
| 中英西语言设置 | `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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。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/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/**` |
| 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 |
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
@@ -56,6 +57,14 @@
## 海外业务接口边界
+- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
+
+- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。
+
+- SKU 正常作业固定显示一个旧SKU扫描框,通过 `relabel-tasks/{relabelNo}/skus/scan` 提交 itemId、skuIndex 和旧SKU,不要求扫描新标签;多箱同SKU先选择具体明细。服务端每条保存 `SKU_SCAN` 历史,页面按当前返工轮次隐藏已完成子项;全部SKU完成才完成箱的SKU阶段,混合单仍需箱唛阶段。箱唛待处理列表同样隐藏已完成项。回归 `tests/relabel-quick-sku.test.mjs`。
+
+- 换标执行页内嵌 `src/components/RelabelPalletWork.vue`:`relabel-sources` 展示原入仓单各托盘箱数;选择来源库存行并扫描实托、输入拆出箱数、核对本批已完成换标箱,再扫描目标托盘合托。每次一个来源库存行,可分批处理多个托盘,无需为拆托额外逐箱扫码。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源并移动库存,目标仍归原入仓单,不新建入库。仅同新目的仓合托;新增托盘不等待客户确认,409后刷新重扫。H5 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。
+
- 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
- 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
@@ -79,6 +88,7 @@
## 质量门禁
- 安装:`npm install`;检查:`npm test`。
+- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
- 当前 uni-app H5 编译链要求 `vue-i18n` 精确固定为 `9.1.9`;不得改回范围版本或单独升级。依赖变更必须同步 `package.json`、`package-lock.json` 与现有 `pnpm-lock.yaml`,并至少执行 H5 编译门禁。
- 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
diff --git a/src/api/inbound.js b/src/api/inbound.js
index 1d22e16..1a62731 100644
--- a/src/api/inbound.js
+++ b/src/api/inbound.js
@@ -14,8 +14,9 @@ export const getCurrentWarehouseProfile = () => {
}
return warehouseProfileRequest
}
-export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`))
-export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request))
+const receivingIdentityQuery = (sourceContainerBookingId) => `sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`
+export const getReceivingContainer = async (containerNo, sourceContainerBookingId) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}?${receivingIdentityQuery(sourceContainerBookingId)}`))
+export const prepareReceivingContainer = async (containerNo, sourceContainerBookingId, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare?${receivingIdentityQuery(sourceContainerBookingId)}`, request))
export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath)
export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
diff --git a/src/api/overseas.js b/src/api/overseas.js
index 44d70df..f1c7d97 100644
--- a/src/api/overseas.js
+++ b/src/api/overseas.js
@@ -42,6 +42,13 @@ async function tokenAfterUnauthorized(failedToken) {
function overseasUrl(path) {
const base = String(session.apiBase || '').replace(/\/$/, '')
+ const nativeRuntime = typeof plus !== 'undefined'
+ || (typeof location !== 'undefined' && location.protocol === 'file:')
+ if (nativeRuntime && !/^https?:\/\//i.test(base)) {
+ const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_OVERSEAS_API_BASE_URL')
+ error.code = 'PDA_OVERSEAS_API_BASE_REQUIRED'
+ throw error
+ }
const value = `/${String(path || '').replace(/^\/+/, '')}`
const normalized = /\/(?:api|overseas-api)$/.test(base)
? value.replace(/^\/api(?=\/)/, '')
@@ -79,14 +86,53 @@ export async function overseasRequest(method, path, data, retry = true) {
}
function rawUpload(path, filePath, name, token) {
+ const browserFile = typeof Blob !== 'undefined' && filePath instanceof Blob
+ if (!browserFile && !String(filePath || '').trim()) {
+ const error = new Error('未获取到可上传的照片路径,请重新拍摄')
+ error.code = 'PDA_UPLOAD_FILE_PATH_REQUIRED'
+ return Promise.reject(error)
+ }
+ let url
+ try { url = overseasUrl(path) } catch (error) { return Promise.reject(error) }
+ const h5Runtime = typeof window !== 'undefined' && typeof plus === 'undefined'
+ if (h5Runtime && browserFile) return new Promise((resolve, reject) => {
+ uni.uploadFile({
+ url, files: [{ name, file: filePath }],
+ header: { Authorization: `Bearer ${token}` },
+ success: resolve,
+ fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
+ })
+ })
+ if (h5Runtime && /^blob:/i.test(filePath)) {
+ const selectedFile = fetch(filePath).then((source) => {
+ if (!source.ok) throw new Error('无法读取已选择的照片')
+ return source.blob()
+ })
+ return selectedFile.then(async (blob) => {
+ const form = new FormData()
+ const suffix = String(blob.type || '').split('/')[1] || 'jpg'
+ form.append(name, blob, blob.name || `pda-photo.${suffix}`)
+ if (typeof XMLHttpRequest !== 'undefined') return new Promise((resolve, reject) => {
+ const request = new XMLHttpRequest()
+ request.open('POST', url)
+ request.setRequestHeader('Authorization', `Bearer ${token}`)
+ request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
+ request.onerror = () => 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()) }
+ })
+ .catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) })
+ }
return new Promise((resolve, reject) => {
uni.uploadFile({
- url: overseasUrl(path),
+ url,
filePath,
name,
header: { Authorization: `Bearer ${token}` },
success: resolve,
- fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true }))
+ fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
})
})
}
diff --git a/src/api/pallet.js b/src/api/pallet.js
index 8393493..d90081f 100644
--- a/src/api/pallet.js
+++ b/src/api/pallet.js
@@ -1,6 +1,9 @@
import { overseasRequest } from './overseas.js'
const BASE = '/pda/v1/last-leg/pallet-operations'
+export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
+export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
+export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
diff --git a/src/api/relabel.js b/src/api/relabel.js
index d0d24f0..f40d041 100644
--- a/src/api/relabel.js
+++ b/src/api/relabel.js
@@ -2,6 +2,7 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js
const BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
+export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
function asList(value) { return Array.isArray(value) ? value : [] }
diff --git a/src/api/warehouse-transfers.js b/src/api/warehouse-transfers.js
new file mode 100644
index 0000000..90e7479
--- /dev/null
+++ b/src/api/warehouse-transfers.js
@@ -0,0 +1,51 @@
+import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js'
+
+const BASE = '/pda/v1/last-leg/warehouse-transfers'
+const encoded = (value) => encodeURIComponent(String(value || '').trim())
+
+export async function listWarehouseTransferCandidates() {
+ const value = await overseasRequest('GET', `${BASE}/candidates`)
+ return Array.isArray(value) ? value : (Array.isArray(value?.records) ? value.records : [])
+}
+
+export function getWarehouseTransferDetail(transferNo) {
+ return overseasRequest('GET', `${BASE}/${encoded(transferNo)}`)
+}
+
+export function palletizeWarehouseTransfer(transferNo, payload) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/palletize`, payload)
+}
+
+export function saveWarehouseTransferPalletDraft(transferNo, payload) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/drafts`, payload)
+}
+
+export function generateWarehouseTransferPallet(transferNo, palletId, payload) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/generate`, payload)
+}
+
+export function bindWarehouseTransferPallet(transferNo, palletId, payload) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/bind`, payload)
+}
+
+export function deleteWarehouseTransferPallet(transferNo, palletId) {
+ return overseasRequest('DELETE', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}`)
+}
+
+export function uploadWarehouseTransferMeasurementPhoto(transferNo, filePath) {
+ return overseasUpload(`${BASE}/${encoded(transferNo)}/measurement-photos/files`, filePath)
+}
+
+export function listWarehouseTransferMeasurementPhotos(transferNo) {
+ return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/measurement-photos/files`)
+}
+
+export function downloadWarehouseTransferMeasurementPhoto(transferNo, fileId) {
+ return overseasDownload(`${BASE}/${encoded(transferNo)}/measurement-photos/files/${encoded(fileId)}/download`)
+}
+
+export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
+ const path = String(labelDownloadPath || '').trim()
+ if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
+ return overseasDownload(path)
+}
diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue
new file mode 100644
index 0000000..3a417eb
--- /dev/null
+++ b/src/components/RelabelPalletWork.vue
@@ -0,0 +1,155 @@
+
+
+ 换标后组托
+
+ 已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。
+
+
+ 正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}
+ 已记录组托:{{ entry.cartonMarks.join('、') }} → {{ entry.targetPallets.join('、') }}(含历史轮次)
+ 按原入仓单拆托,再合入同新目的仓的托盘。下方为入仓单在各托盘的箱数,并非历史箱唛定位;合托后仍归属原入仓单。
+
+
+ {{ row.palletCode }} · {{ row.inboundCode }}
+ {{ row.locationCode || '未维护库位' }} · 在库 {{ row.stockPackages }} 箱 · 可拆 {{ row.movablePackages }} 箱
+
+ 当前没有可操作的来源托盘
+
+
+ 已确认:{{ source.palletCode }}
+
+ 本次拆出箱数(可分多个来源托盘,逐批提交)
+
+ 选择本批新目的仓
+
+
+ 请核对下面勾选的箱与现场搬动一致;数量不符时不能提交。此处确认的是新目标托盘绑定,不推断旧箱唛位置。
+ {{ marks.includes(item.cartonMark) ? '☑' : '☐' }} {{ item.cartonMark }} → {{ item.fbaCartonMark || item.destinationFbaWarehouseCode }}
+ 本批确认 {{ marks.length }} 箱 / 拆出 {{ quantity || 0 }} 箱
+
+
+
+ 确认后直接提交本批拆托合托,只有提示“绑定成功”才表示已保存。
+ 已识别目标:{{ target.palletCode }},尚未绑定成功
+ 新托盘号:{{ newPallet.palletCode }},请贴好托盘标签后扫描确认。
+ {{ message }}{{ error }}
+
+
+
+
diff --git a/src/config/runtime.js b/src/config/runtime.js
index 26cdef6..6ab17d4 100644
--- a/src/config/runtime.js
+++ b/src/config/runtime.js
@@ -18,7 +18,10 @@ selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/
// root there, even if a stale H5 bundle or compiler flag was reused.
if (nativeRuntime) {
selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot
- selectedOverseasApiRoot = configuredOverseasApiRoot || selectedOverseasApiRoot
+ // Native file:// builds cannot use the H5-only /overseas-api Vite proxy.
+ // Keep the missing value explicit so the request layer can report a useful
+ // configuration error instead of surfacing an opaque uploadFile:fail.
+ selectedOverseasApiRoot = configuredOverseasApiRoot || ''
}
export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index 6ea3219..77d37f4 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -1,6 +1,7 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
+ { key: 'warehouseTransfer', name: '仓储转运测量', nameKey: 'modules.warehouseTransfer.name', icon: '转', iconType: 'compose', description: '回填箱规、箱数并完成托盘打托', descriptionKey: 'modules.warehouseTransfer.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer' },
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
] },
diff --git a/src/manifest.json b/src/manifest.json
index 1c7949d..6153bb4 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端",
- "versionName" : "0.0.2",
- "versionCode" : 2,
+ "versionName" : "0.0.3",
+ "versionCode" : 3,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
@@ -83,5 +83,7 @@
"devServer" : {
"port" : 5176
}
- }
+ },
+ "locale" : "zh-Hans",
+ "fallbackLocale" : "zh-Hans"
}
diff --git a/src/pages.json b/src/pages.json
index cd5d8bf..31803c6 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -4,6 +4,7 @@
{ "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" } },
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index 7d76efc..52de268 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -13,7 +13,7 @@
{{ candidateError.message || '待卸货集装箱加载失败' }}code={{ candidateError.code }}requestId={{ candidateError.requestId }}traceId={{ candidateError.traceId }}
正在刷新待卸货集装箱…
暂无待卸货集装箱
-
+
{{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›
{{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作
已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作,其中 {{ item.missingInboundCodeCount }} 个缺入仓码
@@ -144,6 +144,8 @@
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024
+ const MAX_PACKAGES_PER_PALLET = 66
+ const PALLET_CAPACITY_MESSAGE = '一个托盘最多只能收66箱,请拆分到其他托盘'
export default {
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
@@ -168,6 +170,7 @@
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
methods: {
+ candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` },
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status },
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' },
formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' },
@@ -190,9 +193,9 @@
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
- async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
+ async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); const sourceContainerBookingId = String(candidate?.sourceContainerBookingId || '').trim(); if (!containerNo || !sourceContainerBookingId || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo, sourceContainerBookingId), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, sourceContainerBookingId, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
- async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
+ async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) },
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
@@ -200,7 +203,7 @@
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
- async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
+ async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue
index 99088f2..ae78c6a 100644
--- a/src/pages/relabel/relabel.vue
+++ b/src/pages/relabel/relabel.vue
@@ -5,12 +5,14 @@
{{ title }}{{ stageSubtitle }}
- {{ candidateHeading }}
+
+ {{ candidateHeading }}
正在加载换标任务…
{{ emptyText }}
{{ item.relabelNo }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常执行 ›
{{ item.relabelNo }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常
+
换标类型:{{ relabelTypeName(current.operationType) }}
{{ current.relabelNo }}客户订单:{{ current.customerOrderNo || '—' }}标签截止:{{ formatTime(current.labelDeadline) }}{{ statusLabel(current.taskStatus || current.applicationStatus) }}
@@ -22,28 +24,26 @@
{{ taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}
- 仓库唛头:{{ lastCarton.cartonMark }}请贴 FBA 箱唛:{{ lastCarton.fbaCartonMark }}
- {{ taskTab === 'sku' ? '本单没有 SKU 换标明细' : '本单没有箱唛换标明细' }}
-
- 仓库唛头:{{ item.cartonMark || '—' }}待处理 {{ remaining(item) }} 箱
- 旧 SKU:{{ sku.skuCode || '—' }}新 SKU:{{ sku.newSkuCode || '—' }}数量:{{ sku.quantity == null ? '—' : sku.quantity }} 件
- 新 FBA 箱唛:{{ item.fbaCartonMark || '—' }}箱数:{{ item.expectedPackages }}
- {{ item.productNameZh }}{{ item.labelRequirement }}
+
+
+
+
+ 同一旧 SKU 有多条明细,请核对实际箱选择本次作业:
+
+ {{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}{{ row.item.cartonMark }} · {{ row.sku.quantity }} 件
+
+
+ {{ item.cartonMark || '—' }} → {{ item.fbaCartonMark || '—' }}
+ FBA 目的仓:{{ item.destinationFbaWarehouseCode || '待维护' }}
-
+
-
- {{ skuDetails(selectedItem)[index]?.skuCode }} → {{ skuDetails(selectedItem)[index]?.newSkuCode }} · {{ sku.quantity }} 件
-
-
-
-
- 箱唛作业:{{ selectedItem.cartonProcessedPackages > 0 ? '已完成' : '待处理' }} · SKU 作业:{{ selectedItem.skuProcessedPackages > 0 ? '已完成' : '待处理' }}
+
完成照片{{ photoFileIds.length }} / 20 张
@@ -53,7 +53,8 @@
-
+ 还不能完成换标{{ completionBlockReason }}
+
@@ -68,21 +69,22 @@
+
+
diff --git a/tests/multi-plan-receiving.test.mjs b/tests/multi-plan-receiving.test.mjs
index e562a7e..4171a31 100644
--- a/tests/multi-plan-receiving.test.mjs
+++ b/tests/multi-plan-receiving.test.mjs
@@ -116,7 +116,7 @@ test('container UI locks and signs once, then renders the resolved destination g
assert.match(inboundApi, /\$\{RECEIVING_BASE\}\/\$\{encodeURIComponent\(containerNo\)\}\/prepare/)
assert.match(inboundApi, /current\('\/sign'\)/)
- assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, \{ idempotencyKey: key\.value \}\)/)
+ assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, this\.selected\.sourceContainerBookingId, \{ idempotencyKey: key\.value \}\)/)
assert.match(operation, /getCurrentReceiving\(\)[\s\S]*applyCurrentReceiving\(current\)/)
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
@@ -139,7 +139,7 @@ test('PDA does not infer container eligibility from partial plan workflow states
assert.match(adapter, /allPlansReady: value\.allPlansReady === true/)
assert.match(adapter, /operable: value\.operable === true/)
assert.match(operation, /if \(!candidate\.operable\) \{ receiving\.selected = candidate; this\.detailOpen = true; return \}/)
- assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
+ assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.sourceContainerBookingId \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
assert.match(operation, /recoverCurrentAfterConflict\(error, true\)/)
assert.match(operation, /preserveEvidence \? error : null[\s\S]*this\.candidateError = preserveEvidence \? error : null/)
assert.doesNotMatch(inbound, /sort\(/)
diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs
index 0a2a2ee..90b1b7d 100644
--- a/tests/overseas-request.test.mjs
+++ b/tests/overseas-request.test.mjs
@@ -162,6 +162,106 @@ test('photo upload refreshes and replays once while retaining the file payload',
])
})
+test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile', async () => {
+ const value = prepareSession()
+ const originalWindow = globalThis.window
+ const originalLocation = globalThis.location
+ const originalFetch = globalThis.fetch
+ let uploadFileCalls = 0
+ let uploaded
+ globalThis.window = {}
+ globalThis.location = { protocol: 'http:' }
+ globalThis.uni.uploadFile = () => { uploadFileCalls += 1 }
+ globalThis.fetch = async (url, options) => {
+ if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
+ uploaded = { url, options }
+ return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 })
+ }
+ try {
+ session.apiBase = '/overseas-api'
+ assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' })
+ assert.equal(uploadFileCalls, 0)
+ assert.equal(uploaded.url, '/overseas-api/pda/v1/last-leg/photos/files')
+ assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.overseasAccessToken}`)
+ assert.ok(uploaded.options.body instanceof FormData)
+ } finally {
+ globalThis.fetch = originalFetch
+ if (originalWindow === undefined) delete globalThis.window
+ else globalThis.window = originalWindow
+ if (originalLocation === undefined) delete globalThis.location
+ else globalThis.location = originalLocation
+ }
+})
+
+test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
+ const value = prepareSession()
+ const originalWindow = globalThis.window
+ const originalLocation = globalThis.location
+ globalThis.window = {}
+ globalThis.location = { protocol: 'http:' }
+ const selected = new Blob(['photo'], { type: 'image/png' })
+ let calls = 0
+ globalThis.uni.uploadFile = (options) => {
+ calls += 1
+ assert.equal(options.url, '/overseas-api/pda/photo')
+ assert.equal(options.header.Authorization, `Bearer ${value.overseasAccessToken}`)
+ assert.equal(options.files[0].file, selected)
+ assert.equal(options.files[0].name, 'file')
+ options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} })
+ }
+ try {
+ session.apiBase = '/overseas-api'
+ assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' })
+ assert.equal(calls, 1)
+ } finally {
+ if (originalWindow === undefined) delete globalThis.window
+ else globalThis.window = originalWindow
+ if (originalLocation === undefined) delete globalThis.location
+ else globalThis.location = originalLocation
+ }
+})
+
+test('native file runtime rejects an H5-relative overseas root before upload with a clear configuration error', async () => {
+ prepareSession()
+ const originalLocation = globalThis.location
+ globalThis.location = { protocol: 'file:' }
+ session.apiBase = '/overseas-api'
+ let uploadCalls = 0
+ globalThis.uni.uploadFile = () => { uploadCalls += 1 }
+ try {
+ await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => {
+ assert.equal(error.code, 'PDA_OVERSEAS_API_BASE_REQUIRED')
+ assert.match(error.message, /VITE_OVERSEAS_API_BASE_URL/)
+ assert.match(error.message, /HTTP\(S\)/)
+ return true
+ })
+ assert.equal(uploadCalls, 0)
+ } finally {
+ if (originalLocation === undefined) delete globalThis.location
+ else globalThis.location = originalLocation
+ }
+})
+
+test('native file runtime uploads to an absolute overseas root', async () => {
+ prepareSession()
+ const originalLocation = globalThis.location
+ globalThis.location = { protocol: 'file:' }
+ session.apiBase = 'https://overseas.example.com/api/'
+ let call
+ globalThis.uni.uploadFile = (options) => {
+ call = options
+ options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'native-1' } }), header: {} })
+ }
+ try {
+ assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', '_doc/photo.jpg'), { fileId: 'native-1' })
+ assert.equal(call.url, 'https://overseas.example.com/api/pda/v1/last-leg/photos/files')
+ assert.equal(call.filePath, '_doc/photo.jpg')
+ } finally {
+ if (originalLocation === undefined) delete globalThis.location
+ else globalThis.location = originalLocation
+ }
+})
+
test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => {
const value = prepareSession(); const calls = []
globalThis.uni.downloadFile = (options) => {
diff --git a/tests/receiving-adapter.test.mjs b/tests/receiving-adapter.test.mjs
index 51bdfae..339b3cf 100644
--- a/tests/receiving-adapter.test.mjs
+++ b/tests/receiving-adapter.test.mjs
@@ -49,6 +49,10 @@ test('first scan and receipt requests confirm server reservations without resour
assert.ok(gate)
assert.doesNotMatch(gate, /mergeAllowed|assigned|urgent/)
assert.match(gate, /resolvedPallet\?\.compatible/)
+ assert.match(page, /const MAX_PACKAGES_PER_PALLET = 66/)
+ assert.match(page, /Number\(this\.packageCount\) > MAX_PACKAGES_PER_PALLET/)
+ assert.match(page, /一个托盘最多只能收66箱,请拆分到其他托盘/)
+ assert.match(page, /this\.showToast\(PALLET_CAPACITY_MESSAGE, 'error'\)/)
})
test('one-click dropship inbound is marked only by an explicit server boolean', () => {
diff --git a/tests/relabel-active-task.test.mjs b/tests/relabel-active-task.test.mjs
new file mode 100644
index 0000000..e2080f8
--- /dev/null
+++ b/tests/relabel-active-task.test.mjs
@@ -0,0 +1,32 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+
+const source = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
+const body = source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+function setup(confirm) {
+ const calls = []
+ const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
+ const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
+ context.promptActiveTask = component.methods.promptActiveTask.bind(context)
+ return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
+}
+test('conflicting task opens modal and confirm resumes owned task without claiming another', async () => {
+ const { calls, context, open } = setup(true)
+ await open()
+ assert.equal(calls[0].confirmText, '前往任务')
+ assert.equal(calls[0].cancelText, '取消')
+ assert.match(calls[0].content, /RL-ACTIVE/)
+ assert.equal(context.taskCode, 'RL-ACTIVE')
+ assert.equal(calls[1].url, '/pages/relabel/relabel?op=relabelExecute')
+ assert.equal(context.error, null)
+ assert.equal(context.busy, false)
+})
+test('cancel stays on list and preserves existing task selection', async () => {
+ const { calls, context, open } = setup(false)
+ await open()
+ assert.equal(calls.length, 1)
+ assert.equal(context.taskCode, 'PREVIOUS')
+ assert.equal(context.busy, false)
+})
diff --git a/tests/relabel-completion-gate.test.mjs b/tests/relabel-completion-gate.test.mjs
new file mode 100644
index 0000000..33ff060
--- /dev/null
+++ b/tests/relabel-completion-gate.test.mjs
@@ -0,0 +1,14 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
+
+test('PDA relabel completion explains pallet and photo blockers instead of silently disabling submit', () => {
+ assert.match(page, /还不能完成换标/)
+ assert.match(page, /请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定/)
+ assert.match(page, /请至少上传 1 张换标完成照片/)
+ assert.match(page, /:disabled="busy \|\| Boolean\(completionBlockReason\)"/)
+ assert.match(page, /entry\.action === 'PALLET_BIND'/)
+ assert.match(page, /detail\.round === round/)
+})
diff --git a/tests/relabel-destination.test.mjs b/tests/relabel-destination.test.mjs
new file mode 100644
index 0000000..2884823
--- /dev/null
+++ b/tests/relabel-destination.test.mjs
@@ -0,0 +1,12 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
+
+test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
+ assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
+ assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
+ assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
+ assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
+})
diff --git a/tests/relabel-pallet-work.test.mjs b/tests/relabel-pallet-work.test.mjs
new file mode 100644
index 0000000..147dd2d
--- /dev/null
+++ b/tests/relabel-pallet-work.test.mjs
@@ -0,0 +1,93 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
+const script = source.split('')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
+const component = new Function('ScanTargetInput', script)({})
+function context() {
+ const value = { ...component.data(), $emit() {}, task: { items: [
+ { cartonMark: 'A', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' },
+ { cartonMark: 'B', relabelledPackages: 1, destinationFbaWarehouseCode: 'JFK8' },
+ { cartonMark: 'C', relabelledPackages: 0, destinationFbaWarehouseCode: 'ONT8' },
+ { cartonMark: 'OTHER', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }
+ ] } }
+ for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
+ for (const [key, method] of Object.entries(component.computed)) Object.defineProperty(value, key, { get: method.bind(value) })
+ value.selectSource({ positionId: '1', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
+ return value
+}
+test('quantity batch only includes completed boxes from same inbound and destination', () => {
+ const value = context()
+ value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['A'])
+ value.quantity = '2'; value.fillBatch(); assert.match(value.error, /不超过/)
+ value.quantity = '1.5'; value.fillBatch(); assert.match(value.error, /正整数/)
+ value.selectedDestination = 'JFK8'; value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['B'])
+})
+
+test('submission requires exact count and scanned distinct source and target pallets', () => {
+ const value = context(); value.quantity = '1'; value.fillBatch()
+ value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'source'
+ value.target = { palletCode: 'TARGET' }; value.targetCode = 'TARGET'
+ assert.equal(value.readyToBind, true)
+ value.toggleMark('A'); assert.equal(value.readyToBind, false)
+ value.toggleMark('A'); value.sourceCode = 'OTHER'; assert.equal(value.readyToBind, false)
+ value.sourceCode = 'SOURCE'; value.target = { palletCode: 'SOURCE' }; value.targetCode = 'SOURCE'
+ assert.equal(value.readyToBind, false)
+})
+test('quantity disassembly has no extra carton scanner', () => {
+ assert.doesNotMatch(source, /relabel-move-carton/)
+ assert.match(source, /actualSource:/)
+ assert.match(source, /仍归属/)
+})
+
+test('latest current-round binding is used for rebind source', () => {
+ const value = context()
+ value.task.history = ['OLD', 'NEW'].map(pallet => ({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['A'], targetPallets: [pallet] }) }))
+ assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
+ value.task.reviewedAt = 'NEW-ROUND'
+ assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
+ value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
+ assert.deepEqual(value.currentBindings, [])
+})
+
+test('rescan same bound pallet does not call stock movement', async () => {
+ const value = context(); value.rebindSelection = { mark: 'A', pallet: 'TARGET' }; value.targetCode = 'target'
+ let message = ''
+ value.showBindingMessage = (title, content) => { message = content }
+ value.run = async () => assert.fail('same pallet must not move stock')
+ await value.submitRebind()
+ assert.match(message, /不会重复扣库存/)
+})
+
+test('target confirmation submits binding directly, with no second submit button', async () => {
+ const value = context(); value.quantity = '1'; value.fillBatch()
+ value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
+ let binds = 0
+ value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletCode: 'TARGET' } }
+ value.bind = async () => { binds++ }
+ await value.confirmTargetAndBind()
+ assert.equal(binds, 1)
+ assert.match(source, /@tap="confirmTargetAndBind"/)
+ assert.doesNotMatch(source, /@tap="bind"/)
+})
+
+test('missing source scan shows modal and never submits a binding', async () => {
+ const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
+ let message = ''
+ value.showBindingMessage = (title, content) => { message = content }
+ value.bind = async () => assert.fail('must not bind without source confirmation')
+ value.resolve = async () => assert.fail('must validate source first')
+ await value.confirmTargetAndBind()
+ assert.match(message, /扫描并确认实际来源托盘/)
+})
+
+test('target lookup failure cannot submit binding or report success', async () => {
+ const value = context(); value.quantity = '1'; value.fillBatch()
+ value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.error = '目标托盘不可用' }
+ let title = ''
+ value.showBindingMessage = heading => { title = heading }
+ value.bind = async () => assert.fail('must not bind after lookup failure')
+ await value.confirmTargetAndBind()
+ assert.equal(title, '绑定失败')
+})
diff --git a/tests/relabel-quick-sku.test.mjs b/tests/relabel-quick-sku.test.mjs
new file mode 100644
index 0000000..89df0a0
--- /dev/null
+++ b/tests/relabel-quick-sku.test.mjs
@@ -0,0 +1,37 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
+const body = source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
+function context() {
+ const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
+ for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
+ for (const name of ['visibleItems', 'pendingSkuRows']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
+ return value
+}
+test('each persisted SKU hides independently; mixed carton progress is separate', () => {
+ const value = context(); assert.equal(value.pendingSkuRows.length, 2)
+ value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: 0 }) })
+ assert.equal(value.pendingSkuRows.length, 1); assert.equal(value.pendingSkuRows[0].sku.skuCode, 'OTHER')
+ value.taskTab = 'carton'; assert.equal(value.visibleItems.length, 0)
+ value.taskTab = 'sku'; value.current.reviewedAt = '2026-09-15T18:00:00'; assert.equal(value.pendingSkuRows.length, 2)
+ value.current.items[0].skuProcessedPackages = 1; assert.equal(value.visibleItems.length, 0)
+})
+test('normal SKU input is always visible in SKU tab and never requests the new label', () => {
+ assert.match(source, /name="quickSku"/)
+ assert.doesNotMatch(source, /placeholder="扫描或输入新 SKU 标签号"/)
+ assert.match(source, /matches.length > 1/)
+ assert.match(source, /scanRelabelSku/)
+})
+test('pending list removes one confirmed row at a time without persistent empty or last-scan banners', () => {
+ const value = context()
+ assert.equal(value.pendingSkuRows.length, 2)
+ for (const index of [0, 1]) {
+ value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: index }) })
+ assert.equal(value.pendingSkuRows.length, 1 - index)
+ }
+ const template = source.split('