From 86b51760a3b5de414bd09edb3954543271b612c0 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Thu, 17 Sep 2026 13:36:08 +0800
Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 6 +
src/api/overseas.js | 50 +++-
src/api/pallet.js | 3 +
src/api/relabel.js | 1 +
src/api/warehouse-transfers.js | 51 ++++
src/components/RelabelPalletWork.vue | 155 ++++++++++++
src/config/runtime.js | 5 +-
src/data/overseas-workbench.js | 1 +
src/pages.json | 1 +
src/pages/relabel/relabel.vue | 129 +++++++---
.../warehouse-transfer/warehouse-transfer.vue | 238 ++++++++++++++++++
tests/overseas-request.test.mjs | 100 ++++++++
tests/relabel-active-task.test.mjs | 32 +++
tests/relabel-completion-gate.test.mjs | 14 ++
tests/relabel-pallet-work.test.mjs | 93 +++++++
tests/relabel-quick-sku.test.mjs | 37 +++
tests/warehouse-transfer-api.test.mjs | 117 +++++++++
tests/warehouse-workflows.test.mjs | 30 ++-
tests/workbench-availability.test.mjs | 2 +
19 files changed, 1024 insertions(+), 41 deletions(-)
create mode 100644 src/api/warehouse-transfers.js
create mode 100644 src/components/RelabelPalletWork.vue
create mode 100644 src/pages/warehouse-transfer/warehouse-transfer.vue
create mode 100644 tests/relabel-active-task.test.mjs
create mode 100644 tests/relabel-completion-gate.test.mjs
create mode 100644 tests/relabel-pallet-work.test.mjs
create mode 100644 tests/relabel-quick-sku.test.mjs
create mode 100644 tests/warehouse-transfer-api.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 21f8416..eb60a03 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,6 +56,12 @@
## 海外业务接口边界
+- 换标目标确认即提交拆托合托,不再二次点击底部提交。`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` 并清空上一轮输入和照片草稿。
diff --git a/src/api/overseas.js b/src/api/overseas.js
index c792931..c70f9f2 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 b3312a8..619693a 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/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/relabel/relabel.vue b/src/pages/relabel/relabel.vue
index 99088f2..711b9ca 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,25 @@
{{ 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 || '—' }}
-
+
-
- {{ skuDetails(selectedItem)[index]?.skuCode }} → {{ skuDetails(selectedItem)[index]?.newSkuCode }} · {{ sku.quantity }} 件
-
-
-
-
- 箱唛作业:{{ selectedItem.cartonProcessedPackages > 0 ? '已完成' : '待处理' }} · SKU 作业:{{ selectedItem.skuProcessedPackages > 0 ? '已完成' : '待处理' }}
+
完成照片{{ photoFileIds.length }} / 20 张
@@ -53,7 +52,8 @@
-
+ 还不能完成换标{{ completionBlockReason }}
+
@@ -68,21 +68,22 @@
+
+
diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs
index a417d44..8a16a15 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/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-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('