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/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index ee1cf5c..7b120ea 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -6,7 +6,7 @@
出库任务整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”
当前任务出库作业详情
-
+
任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
@@ -27,12 +27,12 @@
{{ current.taskNo || '—' }}{{ statusLabel(current.status) }}出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
-
+
{{ item.palletNo || '散件出库' }}{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱
{{ lineBusinessCode(position) }}{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱
- 整托出库请扫描托盘码 {{ item.palletNo }}
+ 整托出库请扫描托盘号 {{ item.palletNo }}
当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。
@@ -68,6 +68,28 @@
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
computed: {
+ displayItems() {
+ const items = this.current?.items || []
+ if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
+ const pallets = new Map()
+ const rows = []
+ for (const item of items) {
+ if (!item.palletNo) { rows.push(item); continue }
+ let pallet = pallets.get(item.palletNo)
+ if (!pallet) {
+ pallet = { ...item, displayKey: `pallet:${item.palletNo}`, plannedPackages: 0, pickedPackages: 0, positions: [] }
+ pallets.set(item.palletNo, pallet)
+ rows.push(pallet)
+ }
+ pallet.plannedPackages += Number(item.plannedPackages || 0)
+ pallet.pickedPackages += Number(item.pickedPackages || 0)
+ pallet.positions.push(...(item.positions || []))
+ }
+ return rows
+ },
+ hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) },
+ scanLabel() { return this.discardCurrent ? '废弃订单托盘码' : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' },
+ scanPlaceholder() { return this.discardCurrent ? '扫描废弃订单对应托盘码' : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
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 4470d52..2ba4ebf 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/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs
index a498fdd..2c65cc7 100644
--- a/tests/fulfillment-workflows.test.mjs
+++ b/tests/fulfillment-workflows.test.mjs
@@ -76,7 +76,7 @@ test('outbound partial pick submits the selected carton and distinguishes shared
assert.deepEqual(outboundPickPayload({ outboundPalletItemId: '13' }, { inventoryPositionId: '23' }, 2).cartonMarks, [])
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /listOutboundTasks\('PENDING'\)/)
- assert.match(page, /:key="item.outboundPalletItemId"/)
+ assert.match(page, /:key="item.displayKey \|\| item.outboundPalletItemId"/)
assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode\)/)
assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/)
assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/)
@@ -88,7 +88,7 @@ test('return outbound preserves its task type and only permits pallet scans for
assert.equal(task.outboundType, 'RETURN_OUTBOUND')
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /this\.current\?\.outboundType === 'RETURN_OUTBOUND' && item\.palletNo/)
- assert.match(page, /整托出库请扫描托盘码/)
+ assert.match(page, /整托出库请扫描托盘号/)
const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs
index a77f491..8f86a2e 100644
--- a/tests/outbound-lifecycle.test.mjs
+++ b/tests/outbound-lifecycle.test.mjs
@@ -18,6 +18,32 @@ function page(overrides = {}) {
return { vm, calls }
}
+test('return pallet rows sum carton counts without changing raw pick lines', () => {
+ const { vm } = page()
+ const cartons = Array.from({ length: 5 }, (_, index) => ({ outboundPalletItemId: index + 1, palletNo: 'PALLET', plannedPackages: 1, pickedPackages: index < 3 ? 1 : 0, positions: [] }))
+ const loose = { outboundPalletItemId: 6, palletNo: '', plannedPackages: 2, pickedPackages: 0, positions: [] }
+ vm.current = { outboundType: 'RETURN_OUTBOUND', items: [...cartons, loose, { outboundPalletItemId: 7, palletNo: 'OTHER', plannedPackages: 2, pickedPackages: 1, positions: [] }] }
+ const before = JSON.stringify(vm.current.items)
+ assert.equal(vm.displayItems.length, 3)
+ assert.equal(vm.displayItems[0].plannedPackages, 5)
+ assert.equal(vm.displayItems[0].pickedPackages, 3)
+ assert.equal(vm.displayItems[1], loose)
+ assert.equal(vm.displayItems[2].palletNo, 'OTHER')
+ assert.equal(JSON.stringify(vm.current.items), before)
+ assert.match(vm.scanLabel, /扫描托盘号/)
+ assert.match(vm.scanPlaceholder, /扫描托盘号/)
+ cartons.forEach(item => { item.pickedPackages = 1 })
+ assert.equal(vm.displayItems[0].pickedPackages, 5)
+})
+
+test('other outbound types preserve individual selectable lines', () => {
+ const { vm } = page()
+ const items = [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }, { palletNo: 'PALLET', plannedPackages: 2, pickedPackages: 0, positions: [] }]
+ vm.current = { outboundType: 'NORMAL', items }
+ assert.equal(vm.displayItems, items)
+ assert.equal(vm.hasReturnPallet, false)
+})
+
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
for (const status of ['COMPLETED', 'CANCELLED']) {
const { vm, calls } = page()
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(/