diff --git a/src/api/return-counting.js b/src/api/return-counting.js
new file mode 100644
index 0000000..93705e4
--- /dev/null
+++ b/src/api/return-counting.js
@@ -0,0 +1,20 @@
+import { overseasRequest } from './overseas.js'
+const BASE = '/pda/v1/last-leg/return-inventory/counting'
+export function getCurrentReturnCountTask() {
+ return overseasRequest('GET', `${BASE}/current`)
+}
+export function beginReturnCountTask(receiptNo) {
+ return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}/begin`)
+}
+export function scanReturnCountTasks(code) {
+ return overseasRequest('GET', `${BASE}/scan/${encodeURIComponent(String(code).trim())}`)
+}
+export function listReturnCountTasks({ page = 1, size = 10, keyword = '' } = {}) {
+ return overseasRequest('GET', `${BASE}/tasks?page=${page}&size=${size}&keyword=${encodeURIComponent(keyword.trim())}`)
+}
+export function scanReturnCountCarton(mark) {
+ return overseasRequest('GET', `${BASE}/cartons/${encodeURIComponent(String(mark).trim())}`)
+}
+export function confirmReturnCount(receiptNo, request) {
+ return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}`, request)
+}
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index d99c613..ec649be 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -4,7 +4,8 @@ export const MODULES = [
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
{ 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' }
+ { 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' },
+ { key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
] },
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
diff --git a/src/pages.json b/src/pages.json
index 31803c6..efd3a9a 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -6,6 +6,7 @@
{ "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-counting/return-counting", "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" } },
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
diff --git a/src/pages/return-counting/return-counting.vue b/src/pages/return-counting/return-counting.vue
new file mode 100644
index 0000000..cc28006
--- /dev/null
+++ b/src/pages/return-counting/return-counting.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+ {{ error.message }}错误码:{{ error.code }}请求号:{{ error.requestId }}跟踪号:{{ error.traceId }}
+
+ 有未完成的清点任务{{ activeTask.returnNo }} · {{ activeTask.palletCode }}
+ {{ row.palletCode }}{{ row.returnNo }}{{ row.boxes.length }} 箱 · {{ row.status === 'COUNTING' ? '清点中' : '待清点' }}{{ row.status === 'COUNTING' ? '继续清点' : '开始清点' }} ›
+ {{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}
+
+
+
+ {{ task.palletCode }}{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}{{ task.returnNo }} · {{ task.boxes.length }} 箱{{ row.skuCode }}签收 {{ row.quantity }} / 已填 {{ row.actual }}
+
+ 第 {{ index + 1 }} 箱{{ box.cartonMark }}
+ {{ line.skuCode || '选择 SKU' }} ▾
+
+ 各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。箱内清点已完成,可以继续换标或出库。
+
+
+
+
+
+
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 2fd2f72..73f81d5 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -36,7 +36,7 @@
本单已完成打托{{ pallet.palletCode }}{{ pallet.skuCode }} · {{ pallet.quantity }} 件签收批次:{{ pallet.receiptBatchNo }}
- 退货签收成功第 {{ index + 1 }} 箱 · {{ label.cartonMark }}{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}托盘:{{ result.palletCode }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态
+ 退货签收成功第 {{ index + 1 }} 箱 · {{ label.cartonMark }}{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}托盘:{{ result.palletCode }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
@@ -74,6 +74,7 @@
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) },
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) }) },
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
diff --git a/tests/return-counting-api.test.mjs b/tests/return-counting-api.test.mjs
new file mode 100644
index 0000000..a3946c0
--- /dev/null
+++ b/tests/return-counting-api.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { listReturnCountTasks, scanReturnCountCarton, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '../src/api/return-counting.js'
+import { session } from '../src/stores/session.js'
+
+test('counting preserves box contents and retry key without accepting client warehouse scope', async () => {
+ session.apiBase = '/api'; session.token = 'counting-test'
+ const calls = []
+ globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: { status: 'PENDING' } }, header: {} }) } }
+ await listReturnCountTasks({ page: 2, keyword: ' RC#2-1 ' })
+ await scanReturnCountCarton('RC#2-1')
+ const request = { idempotencyKey: 'count-retry', boxes: [{ cartonMark: 'RC#2-1', items: [{ skuCode: 'SKU-1', quantity: 3 }] }, { cartonMark: 'RC#2-2', items: [{ skuCode: 'SKU-1', quantity: 7 }] }] }
+ await confirmReturnCount('RR/1', request)
+ assert.deepEqual(calls.map(row => [row.method, row.url]), [
+ ['GET', '/api/pda/v1/last-leg/return-inventory/counting/tasks?page=2&size=10&keyword=RC%232-1'],
+ ['GET', '/api/pda/v1/last-leg/return-inventory/counting/cartons/RC%232-1'],
+ ['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1']
+ ])
+ assert.deepEqual(calls[2].data, request)
+ assert.ok(calls.every(row => row.header.Authorization === 'Bearer counting-test'))
+})
+
+test('unified scan supports box and pallet codes and preserves the no-count empty result', async () => {
+ session.apiBase = '/api'; session.token = 'counting-test'
+ const urls = []
+ globalThis.uni = { request(options) { urls.push(options.url); options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
+ assert.deepEqual(await scanReturnCountTasks(' RC#2-1 '), [])
+ assert.deepEqual(await scanReturnCountTasks('PLT/1'), [])
+ assert.deepEqual(urls, ['/api/pda/v1/last-leg/return-inventory/counting/scan/RC%232-1', '/api/pda/v1/last-leg/return-inventory/counting/scan/PLT%2F1'])
+})
+
+test('current task preserves null and begin uses the operator-scoped claim route', async () => {
+ session.apiBase = '/api'; session.token = 'counting-test'
+ const calls = []
+ globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: options.method === 'GET' ? null : { receiptNo: 'RR/1', status: 'COUNTING' } }, header: {} }) } }
+ assert.equal(await getCurrentReturnCountTask(), null)
+ assert.equal((await beginReturnCountTask('RR/1')).status, 'COUNTING')
+ assert.deepEqual(calls.map(row => [row.method, row.url]), [
+ ['GET', '/api/pda/v1/last-leg/return-inventory/counting/current'],
+ ['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1/begin']
+ ])
+ assert.equal(calls[1].data, undefined)
+})
diff --git a/tests/return-counting-page.test.mjs b/tests/return-counting-page.test.mjs
new file mode 100644
index 0000000..f00a954
--- /dev/null
+++ b/tests/return-counting-page.test.mjs
@@ -0,0 +1,157 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { runInNewContext } from 'node:vm'
+
+const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
+const script = source.match(/