feat: add overseas return operations

This commit is contained in:
2026-09-10 22:43:11 +08:00
parent 03e72c5f6a
commit b242c63f67
13 changed files with 422 additions and 7 deletions
+8 -2
View File
@@ -12,6 +12,8 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
'../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
'../src/pages/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.vue',
'../src/pages/return-receipt/return-receipt.vue',
'../src/pages/return-packing/return-packing.vue',
'../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue',
'../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue',
'../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue'
@@ -40,12 +42,14 @@ test('operator and warehouse cards never fall back to internal IDs', async () =>
})
test('operation templates expose business references only and keep diagnostic evidence', async () => {
const [operation, pallet, summary, stocktake, move, relabel, query, apiFiles] = await Promise.all([
const [operation, pallet, summary, stocktake, move, relabel, query, returnReceipt, returnPacking, apiFiles] = await Promise.all([
read('../src/pages/operation/operation.vue'),
read('../src/pages/pallet/pallet.vue'),
read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/stocktake/stocktake.vue'), read('../src/pages/move/move.vue'),
read('../src/pages/relabel/relabel.vue'), read('../src/pages/query/query.vue'),
read('../src/pages/return-receipt/return-receipt.vue'),
read('../src/pages/return-packing/return-packing.vue'),
Promise.all([
read('../src/api/inbound.js'), read('../src/api/pallet.js'),
read('../src/api/overseas.js'), read('../src/api/receiving-adapter.js'),
@@ -65,13 +69,15 @@ test('operation templates expose business references only and keep diagnostic ev
assert.doesNotMatch(template(pallet), new RegExp(`\\b${field}\\b`))
for (const page of [stocktake, move, relabel, query]) assert.doesNotMatch(template(page), new RegExp(`\\b${field}\\b`))
}
for (const field of [...forbiddenDisplayFields, 'customerId']) assert.doesNotMatch(template(returnReceipt), new RegExp(`\\b${field}\\b`))
for (const field of [...forbiddenDisplayFields, 'customerId']) assert.doesNotMatch(template(returnPacking), new RegExp(`\\b${field}\\b`))
for (const businessField of ['containerNo', 'inboundCode', 'cartonMark', 'palletCode']) {
assert.match(operation + pallet + summary, new RegExp(businessField))
}
assert.match(operation, /requestId[\s\S]*traceId/)
assert.match(pallet, /requestId[\s\S]*traceId/)
for (const page of [stocktake, move, relabel, query]) assert.match(page, /requestId[\s\S]*traceId/)
for (const page of [stocktake, move, relabel, query, returnReceipt, returnPacking]) assert.match(page, /requestId[\s\S]*traceId/)
assert.doesNotMatch(apiFiles, /\/api\/platform\/v1\/|inbound-code-relationships/)
})
+37 -1
View File
@@ -32,7 +32,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
const [data, session, runtime] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
])
for (const label of ['入仓作业', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
assert.match(session, /overseas-pda-auth-session/)
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
@@ -145,6 +145,42 @@ test('Chinese English and Spanish locale selection is persisted from profile wit
assert.match(inbound, /warehouse-profile/)
})
test('return receipt is a real PDA flow backed by the dedicated return APIs', async () => {
const [pages, workbench, page, api, warehouseWork] = await Promise.all([
read('../src/pages.json'), read('../src/data/overseas-workbench.js'),
read('../src/pages/return-receipt/return-receipt.vue'), read('../src/api/return-receipt.js'),
read('../src/stores/warehouse-work.js')
])
assert.match(pages, /pages\/return-receipt\/return-receipt/)
assert.match(workbench, /key: 'returnReceipt'[^\n]*available: true[^\n]*route: '\/pages\/return-receipt\/return-receipt'/)
for (const action of ['startReturnReceipt', 'findReturnSkuCandidates', 'confirmReturnReceipt']) assert.match(page, new RegExp(action))
for (const path of ['/start', '/sku-candidates', '/confirm']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
assert.match(page, /startScanBroadcast/)
assert.match(page, /startedBy: this\.startedBy/)
assert.match(page, /idempotencyKey: key\.value/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(warehouseWork, /returnReceipt:[^\n]*pendingKeys/)
assert.doesNotMatch(page, /customerId/)
})
test('return packing is a separate same-customer inventory operation', async () => {
const [pages, workbench, page, api, warehouseWork] = await Promise.all([
read('../src/pages.json'), read('../src/data/overseas-workbench.js'),
read('../src/pages/return-packing/return-packing.vue'), read('../src/api/return-packing.js'),
read('../src/stores/warehouse-work.js')
])
assert.match(pages, /pages\/return-packing\/return-packing/)
assert.match(workbench, /key: 'returnPacking'[^\n]*available: true[^\n]*route: '\/pages\/return-packing\/return-packing'/)
for (const action of ['findPackingOrders', 'mergeReturnCartons']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/merge', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
assert.match(page, /startScanBroadcast/)
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
assert.match(page, /idempotencyKey: key\.value/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(warehouseWork, /returnPacking:[^\n]*pendingKeys/)
assert.doesNotMatch(page.match(/<template>([\s\S]*?)<\/template>/)?.[1] || '', /customerId|warehouseId/)
})
test('home reuses the persisted warehouse profile instead of requesting it on every show', async () => {
const [home, inbound, operation] = await Promise.all([read('../src/pages/home/home.vue'), read('../src/api/inbound.js'), read('../src/pages/operation/operation.vue')])
assert.match(home, /if \(!this\.user\?\.warehouseCode \|\| !this\.user\?\.warehouseName\) this\.loadWarehouseProfile\(\)/)
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { findPackingOrders, getReturnCartonLabel, mergeReturnCartons, normalizePackingPreview } from '../src/api/return-packing.js'
import { session } from '../src/stores/session.js'
test('packing preview keeps customer scope internal and exposes stable order references', () => {
const value = normalizePackingPreview({ customerId: '100', orders: [{ returnNo: 'RT-1', customerId: '100', customerReferenceNo: 'REF-1', status: 'COMPLETED' }], skus: [{ skuCode: 'SKU-1', productNameZh: '商品', availableQuantity: 8 }] })
assert.equal(value.customerKey, '100')
assert.equal(value.orders[0].returnNo, 'RT-1')
assert.equal('customerId' in value.orders[0], false)
assert.equal(value.skus[0].availableQuantity, 8)
})
test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'packing-token'
globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }); let data = {}; if (options.url.includes('/packing/orders')) data = { customerId: '100', orders: [], skus: [] }; else if (options.url.endsWith('/cartons/merge')) data = { cartonMark: 'RC-1', skuCode: 'SKU-1', totalQuantity: 2, label: { cartonMark: 'RC-1', items: [] } }; else data = { cartonMark: 'RC/1', totalQuantity: 2, items: [] }; options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } }
await findPackingOrders('RT/1')
const request = { returnNos: ['RT/1'], items: [{ skuCode: 'SKU-1', quantity: 2 }], length: 10, width: 20, height: 30, weight: 4, idempotencyKey: 'merge-key' }
await mergeReturnCartons(request)
await getReturnCartonLabel('RC/1')
assert.deepEqual(calls.map((item) => item.url), [
'/overseas-api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1',
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/merge',
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label'
])
assert.deepEqual(calls[1].data, request)
assert.ok(calls.every((item) => item.authorization === 'Bearer packing-token'))
assert.ok(calls.every((item) => !Object.hasOwn(item.data || {}, 'customerId') && !Object.hasOwn(item.data || {}, 'warehouseId')))
})
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { confirmReturnReceipt, findReturnSkuCandidates, normalizeReturnReceiptResult, normalizeReturnReceiptStart, normalizeReturnSkuCandidates, startReturnReceipt } from '../src/api/return-receipt.js'
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 }]
})
})
test('SKU candidates do not expose the backend customer identifier to the page', () => {
const value = normalizeReturnSkuCandidates({ skuCode: 'SKU-1', candidates: [{ returnNo: 'RT-001', customerId: '100', customerReferenceNo: 'REF-1', status: 'PARTIALLY_RECEIVED', skuExpectedQuantity: 5, skuReceivedQuantity: 1, orderExpectedQuantity: 8, orderReceivedQuantity: 2 }] })
assert.equal(value.candidates[0].returnNo, 'RT-001')
assert.equal(value.candidates[0].customerReferenceNo, 'REF-1')
assert.equal('customerId' in value.candidates[0], false)
})
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'
})
})
test('return receipt API calls only the dedicated PDA routes and preserves the receipt payload', async () => {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'return-token'
globalThis.uni = {
request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
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 }
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' }
await confirmReturnReceipt(confirm)
assert.deepEqual(calls.map((item) => item.url), [
'/overseas-api/pda/v1/last-leg/return-receipts/start',
'/overseas-api/pda/v1/last-leg/return-receipts/sku-candidates',
'/overseas-api/pda/v1/last-leg/return-receipts/confirm'
])
assert.deepEqual(calls[0].data, { returnNo: 'RT-001' })
assert.deepEqual(calls[1].data, { skuCode: 'SKU-1' })
assert.deepEqual(calls[2].data, confirm)
assert.ok(calls.every((item) => item.authorization === 'Bearer return-token'))
assert.ok(calls.every((item) => !Object.hasOwn(item.data || {}, 'customerId') && !Object.hasOwn(item.data || {}, 'warehouseId')))
})
+4
View File
@@ -7,6 +7,8 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' },
{ module: 'inbound', name: '退货入库' },
{ module: 'inbound', name: '退货合箱' },
{ module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '库存盘点' },
@@ -22,6 +24,8 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
for (const name of ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
const operation = operations.find((item) => item.name === name)