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 @@ + + + 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(/