diff --git a/src/api/relabel.js b/src/api/relabel.js index f40d041..a04e836 100644 --- a/src/api/relabel.js +++ b/src/api/relabel.js @@ -3,6 +3,8 @@ 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)) +export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload)) +export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload)) function asList(value) { return Array.isArray(value) ? value : [] } diff --git a/src/api/return-receipt.js b/src/api/return-receipt.js index f18fcb1..3698ddf 100644 --- a/src/api/return-receipt.js +++ b/src/api/return-receipt.js @@ -1,4 +1,4 @@ -import { overseasRequest } from './overseas.js' +import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js' const BASE = '/pda/v1/last-leg/return-receipts' @@ -25,7 +25,9 @@ export function normalizeReturnReceiptStart(value = {}) { return { returnNo: String(value.returnNo || ''), status: String(value.status || ''), - expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : [] + expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : [], + completedPallets: Array.isArray(value.completedPallets) ? value.completedPallets.map((item) => ({ palletCode: String(item.palletCode || ''), skuCode: String(item.skuCode || ''), quantity: count(item.quantity), receiptBatchNo: String(item.receiptBatchNo || '') })) : [] + ,photos: Array.isArray(value.photos) ? value.photos.map((item) => ({ fileId: String(item.fileId || ''), fileName: String(item.fileName || '退货打托照片'), contentType: String(item.contentType || 'image/jpeg'), sizeBytes: count(item.sizeBytes) })) : [] } } @@ -49,7 +51,7 @@ export function normalizeReturnReceiptResult(value = {}) { returnNo: String(value.returnNo || ''), receiptNo: String(value.receiptNo || ''), receiptBatchNo: String(value.receiptBatchNo || ''), - cartonMark: String(value.cartonMark || ''), + palletCode: String(value.palletCode || ''), orderStatus: String(value.orderStatus || ''), fullyMatched: Boolean(value.fullyMatched), expectedQuantity: count(value.expectedQuantity), @@ -80,3 +82,16 @@ export async function findReturnSkuCandidates(skuCode) { export async function confirmReturnReceipt(request) { return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request)) } + +export async function createReturnPallet(returnNo, skuCode) { + const value = await overseasRequest('POST', `${BASE}/pallets`, { returnNo: String(returnNo || '').trim(), skuCode: String(skuCode || '').trim() }) + return { palletCode: String(value?.palletCode || value?.code || ''), printableHtml: String(value?.printableHtml || '') } +} + +export async function checkReturnPallet(returnNo, palletCode) { + const value = await overseasRequest('POST', `${BASE}/pallets/check`, { returnNo: String(returnNo || '').trim(), palletCode: String(palletCode || '').trim() }) + return { palletCode: String(value?.palletCode || ''), empty: Boolean(value?.empty), skuCodes: Array.isArray(value?.skuCodes) ? value.skuCodes.map(String) : [], quantity: count(value?.quantity) } +} +export const manuallyCompleteReturnReceipt = (returnNo, discrepancyReason) => overseasRequest('POST', `${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/manual-complete`, { discrepancyReason: String(discrepancyReason || '').trim() }) +export const uploadReturnPalletPhoto = (returnNo, filePath) => overseasUpload(`${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/photos/files`, filePath) +export const downloadReturnPalletPhoto = (returnNo, fileId) => overseasDownload(`${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/photos/files/${encodeURIComponent(String(fileId || '').trim())}/download`, { expectedContentTypePrefix: 'image/' }) diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue index 3a417eb..8000408 100644 --- a/src/components/RelabelPalletWork.vue +++ b/src/components/RelabelPalletWork.vue @@ -1,35 +1,35 @@ - 退货入库成功{{ result.cartonMark }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态 + 退货签收并入托成功托盘:{{ result.palletCode }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态 {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }} @@ -40,7 +41,7 @@ ')[0].replace(/^import .*$/gm, '').replace('export default', 'return') const component = new Function('ScanTargetInput', script)({}) function context() { @@ -13,7 +14,7 @@ function context() { ] } } 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'] }) + value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] }) return value } test('quantity batch only includes completed boxes from same inbound and destination', () => { @@ -24,22 +25,58 @@ test('quantity batch only includes completed boxes from same inbound and destina 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', () => { +test('submission uses pallet identity instead of scanned presentation text', () => { const value = context(); value.quantity = '1'; value.fillBatch() - value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'source' - value.target = { palletCode: 'TARGET' }; value.targetCode = 'TARGET' + value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = ' source\n' + value.target = { palletId: '30', palletCode: 'TARGET' }; value.targetCode = 'target-alias' 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' + value.toggleMark('A'); value.source = { palletId: '21', palletCode: 'OTHER' }; assert.equal(value.readyToBind, false) + value.source = { palletId: '20', palletCode: 'SOURCE' }; value.target = { palletId: '20', palletCode: 'TARGET-ALIAS' } assert.equal(value.readyToBind, false) }) + +test('pallet comparison tolerates mixed API versions and canonical code formatting', () => { + const value = context() + assert.equal(value.samePallet( + { palletCode: ' plt-us-lax-01-1 ' }, + { palletCode: 'PLT-US-LAX-01-1' } + ), true) + assert.equal(value.samePallet( + { palletId: '20', palletCode: 'SOURCE' }, + { palletId: '21', palletCode: 'OTHER' } + ), false) + assert.equal(value.samePallet( + { palletId: '20', palletCode: 'SOURCE' }, + { palletId: '21', palletCode: ' source ' } + ), true) + assert.equal(value.samePallet( + { palletId: 2100203890682015700, palletCode: 'PLT-US-LAX-01-20260916-2100203890682015746' }, + { palletId: '2100203890682015746', palletCode: ' plt-us-lax-01-20260916-2100203890682015746 ' } + ), true) +}) test('quantity disassembly has no extra carton scanner', () => { assert.doesNotMatch(source, /relabel-move-carton/) assert.match(source, /actualSource:/) assert.match(source, /仍归属/) }) +test('source pallet list is concise and hides rows with no movable packages', () => { + const value = context() + value.sourceRows = [ + { palletCode: 'PALLET-1', inboundCode: 'IN-1', stockPackages: 12, movablePackages: 2 }, + { palletCode: 'PALLET-2', inboundCode: 'IN-2', stockPackages: 10, movablePackages: 0 } + ] + assert.deepEqual(value.operableSourceRows.map(row => row.palletCode), ['PALLET-1']) + assert.match(source, />托盘刷新 { const value = context() value.task.history = ['OLD', 'NEW'].map(pallet => ({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['A'], targetPallets: [pallet] }) })) @@ -61,9 +98,9 @@ test('rescan same bound pallet does not call stock movement', async () => { 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' + value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET' let binds = 0 - value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletCode: 'TARGET' } } + value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletId: '30', palletCode: 'TARGET' } } value.bind = async () => { binds++ } await value.confirmTargetAndBind() assert.equal(binds, 1) @@ -83,7 +120,7 @@ test('missing source scan shows modal and never submits a binding', async () => 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.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET' value.resolve = async () => { value.error = '目标托盘不可用' } let title = '' value.showBindingMessage = heading => { title = heading } diff --git a/tests/relabel-return-bulk.test.mjs b/tests/relabel-return-bulk.test.mjs new file mode 100644 index 0000000..7208e9b --- /dev/null +++ b/tests/relabel-return-bulk.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const api = await readFile(new URL('../src/api/relabel.js', import.meta.url), 'utf8') +const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8') +const palletWork = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8') + +test('PDA exposes the approved return bulk SKU completion endpoint', () => { + assert.match(api, /completeReturnRelabelSkuTotal/) + assert.match(api, /\/return-skus\/complete-total/) +}) + +test('return relabel pallet binding validates pieces while binding physical carton marks', () => { + assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/) + assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/) + assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/) + assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/) +}) + +test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => { + assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/) + assert.match(page, /退货贴标 SKU 明细/) + assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/) + assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/) + assert.match(page, /verifiedReturnSkuKey !== group\.key/) + assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/) + assert.match(page, /returnBulkSkuGroups/) + assert.match(page, /来源托盘拆箱数量/) + assert.match(page, /登记本次贴标.*group\.remaining/) + assert.match(page, /completeReturnRelabelSkuTotal/) + assert.match(page, /taskTab === 'carton' && !isReturnBulk/) +}) + +test('new relabel pallet is immediately copied into the target scan field', () => { + assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/) + assert.match(palletWork, /退货换标无需扫描箱唛/) +}) + +test('return relabel distinguishes a whole pallet from a partial unpack', () => { + assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/) + assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/) + assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/) + assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/) + assert.match(palletWork, /贴标数量等于本托盘全部/) + assert.match(palletWork, /贴标数量少于本托盘库存/) + assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/) + assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/) +}) diff --git a/tests/return-receipt-api.test.mjs b/tests/return-receipt-api.test.mjs index 12ea263..008b52d 100644 --- a/tests/return-receipt-api.test.mjs +++ b/tests/return-receipt-api.test.mjs @@ -5,7 +5,7 @@ import { session } from '../src/stores/session.js' test('return receipt start normalization keeps only stable business facts', () => { assert.deepEqual(normalizeReturnReceiptStart({ returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: '5', receivedQuantity: 2 }] }), { - returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }] + returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }], completedPallets: [], photos: [] }) }) @@ -17,8 +17,8 @@ test('SKU candidates do not expose the backend customer identifier to the page', }) test('confirmed receipt normalization preserves generated carton and batch references', () => { - assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), { - returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' + assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), { + returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }) }) @@ -31,13 +31,13 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r let data if (options.url.endsWith('/start')) data = { returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [] } else if (options.url.endsWith('/sku-candidates')) data = { skuCode: 'SKU-1', candidates: [] } - else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 } + else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 } options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } } await startReturnReceipt(' RT-001 ') await findReturnSkuCandidates(' SKU-1 ') - const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', cartonMark: 'RC-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' } + const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' } await confirmReturnReceipt(confirm) assert.deepEqual(calls.map((item) => item.url), [ '/overseas-api/pda/v1/last-leg/return-receipts/start', diff --git a/tests/warehouse-workflows.test.mjs b/tests/warehouse-workflows.test.mjs index 169ef4f..391a12c 100644 --- a/tests/warehouse-workflows.test.mjs +++ b/tests/warehouse-workflows.test.mjs @@ -91,7 +91,7 @@ test('carton relabel uses one scan input and shows the confirmed FBA mapping aft assert.match(scan, /confirmedItem\?\.fbaCartonMark/) }) -test('mixed relabel keeps SKU work open after carton work and only carton tab mounts pallet work', async () => { +test('mixed relabel keeps SKU work open and return bulk also mounts pallet work', async () => { const page = await read('../src/pages/relabel/relabel.vue') const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1] const remaining = new Function('item', body) @@ -99,7 +99,7 @@ test('mixed relabel keeps SKU work open after carton work and only carton tab mo cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 } assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1) assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0) - assert.match(page, / {