From b242c63f67521b7ce23f153e0a8bacfeeb29d93b Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 22:43:11 +0800
Subject: [PATCH] feat: add overseas return operations
---
AGENTS.md | 3 +-
src/api/return-packing.js | 47 ++++++++++
src/api/return-receipt.js | 66 ++++++++++++++
src/data/overseas-workbench.js | 6 +-
src/pages.json | 2 +
src/pages/return-packing/return-packing.vue | 70 +++++++++++++++
src/pages/return-receipt/return-receipt.vue | 95 +++++++++++++++++++++
src/stores/warehouse-work.js | 6 +-
tests/business-identifiers-ui.test.mjs | 10 ++-
tests/project-contract.test.mjs | 38 ++++++++-
tests/return-packing-api.test.mjs | 30 +++++++
tests/return-receipt-api.test.mjs | 52 +++++++++++
tests/workbench-availability.test.mjs | 4 +
13 files changed, 422 insertions(+), 7 deletions(-)
create mode 100644 src/api/return-packing.js
create mode 100644 src/api/return-receipt.js
create mode 100644 src/pages/return-packing/return-packing.vue
create mode 100644 src/pages/return-receipt/return-receipt.vue
create mode 100644 tests/return-packing-api.test.mjs
create mode 100644 tests/return-receipt-api.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 97e5561..c076ba2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,7 +28,7 @@
- 工作台定义:`src/data/overseas-workbench.js`。
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
-- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;库内盘点、移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。
+- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;退货入库通过退货单号或 SKU 候选进入并按本包实收生成退货箱与计费批次;库内盘点、移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。
## 页面与能力快速索引
@@ -41,6 +41,7 @@
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
+| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
| 库存盘点真实作业 | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 创建/恢复盘点任务,扫描库位和库存后计数、提交;严格使用服务端版本和幂等键 |
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
diff --git a/src/api/return-packing.js b/src/api/return-packing.js
new file mode 100644
index 0000000..be8afd5
--- /dev/null
+++ b/src/api/return-packing.js
@@ -0,0 +1,47 @@
+import { overseasRequest } from './overseas.js'
+
+const BASE = '/pda/v1/last-leg/return-inventory'
+const encoded = (value) => encodeURIComponent(String(value || '').trim())
+const count = (value) => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0
+
+function normalizeItems(items) {
+ return Array.isArray(items) ? items.map((item) => ({
+ skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''),
+ quantity: count(item.quantity ?? item.availableQuantity)
+ })) : []
+}
+
+export function normalizePackingPreview(value = {}) {
+ return {
+ customerKey: value.customerId == null ? '' : String(value.customerId),
+ orders: Array.isArray(value.orders) ? value.orders.map((item) => ({
+ returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
+ })) : [],
+ skus: Array.isArray(value.skus) ? value.skus.map((item) => ({
+ skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
+ })) : []
+ }
+}
+
+export function normalizeReturnLabel(value = {}) {
+ return {
+ cartonMark: String(value.cartonMark || ''), sourceBatchNo: String(value.sourceBatchNo || ''),
+ sourceReturnNo: String(value.sourceReturnNo || ''), totalQuantity: count(value.totalQuantity),
+ length: count(value.length), width: count(value.width), height: count(value.height), weight: count(value.weight),
+ items: normalizeItems(value.items), generatedAt: value.generatedAt || null, printableHtml: String(value.printableHtml || '')
+ }
+}
+
+export async function findPackingOrders(returnNo = '') {
+ const query = String(returnNo || '').trim()
+ return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
+}
+
+export async function mergeReturnCartons(request) {
+ const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request)
+ return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) }
+}
+
+export async function getReturnCartonLabel(cartonMark) {
+ return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
+}
diff --git a/src/api/return-receipt.js b/src/api/return-receipt.js
new file mode 100644
index 0000000..2cc9de8
--- /dev/null
+++ b/src/api/return-receipt.js
@@ -0,0 +1,66 @@
+import { overseasRequest } from './overseas.js'
+
+const BASE = '/pda/v1/last-leg/return-receipts'
+
+function count(value) {
+ const result = Number(value || 0)
+ return Number.isFinite(result) ? Math.max(0, result) : 0
+}
+
+function normalizeItem(item = {}) {
+ return {
+ skuCode: String(item.skuCode || ''),
+ productNameZh: String(item.productNameZh || ''),
+ expectedQuantity: count(item.expectedQuantity),
+ receivedQuantity: count(item.receivedQuantity)
+ }
+}
+
+export function normalizeReturnReceiptStart(value = {}) {
+ return {
+ returnNo: String(value.returnNo || ''),
+ status: String(value.status || ''),
+ expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : []
+ }
+}
+
+export function normalizeReturnSkuCandidates(value = {}) {
+ return {
+ skuCode: String(value.skuCode || ''),
+ candidates: Array.isArray(value.candidates) ? value.candidates.map((item) => ({
+ returnNo: String(item.returnNo || ''),
+ customerReferenceNo: String(item.customerReferenceNo || ''),
+ status: String(item.status || ''),
+ skuExpectedQuantity: count(item.skuExpectedQuantity),
+ skuReceivedQuantity: count(item.skuReceivedQuantity),
+ orderExpectedQuantity: count(item.orderExpectedQuantity),
+ orderReceivedQuantity: count(item.orderReceivedQuantity)
+ })) : []
+ }
+}
+
+export function normalizeReturnReceiptResult(value = {}) {
+ return {
+ returnNo: String(value.returnNo || ''),
+ receiptNo: String(value.receiptNo || ''),
+ receiptBatchNo: String(value.receiptBatchNo || ''),
+ cartonMark: String(value.cartonMark || ''),
+ orderStatus: String(value.orderStatus || ''),
+ fullyMatched: Boolean(value.fullyMatched),
+ expectedQuantity: count(value.expectedQuantity),
+ receivedQuantity: count(value.receivedQuantity),
+ signedAt: value.signedAt || null
+ }
+}
+
+export async function startReturnReceipt(returnNo) {
+ return normalizeReturnReceiptStart(await overseasRequest('POST', `${BASE}/start`, { returnNo: String(returnNo || '').trim() }))
+}
+
+export async function findReturnSkuCandidates(skuCode) {
+ return normalizeReturnSkuCandidates(await overseasRequest('POST', `${BASE}/sku-candidates`, { skuCode: String(skuCode || '').trim() }))
+}
+
+export async function confirmReturnReceipt(request) {
+ return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request))
+}
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index 0e26b0f..5460583 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -1,6 +1,8 @@
export const MODULES = [
- { key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', descriptionKey: 'modules.inbound.description', operations: [
- { key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }
+ { key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
+ { key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
+ { 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: '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 1f7abe7..cd5d8bf 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -4,6 +4,8 @@
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
+ { "path": "pages/return-receipt/return-receipt", "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" } },
{ "path": "pages/move/move", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商品移库", "backgroundColor": "#F5F7FA" } },
diff --git a/src/pages/return-packing/return-packing.vue b/src/pages/return-packing/return-packing.vue
new file mode 100644
index 0000000..3b3b855
--- /dev/null
+++ b/src/pages/return-packing/return-packing.vue
@@ -0,0 +1,70 @@
+
+
+
+
+ 退货合箱仅可合并当前仓、同一客户、已完成签收的退货单库存
+ 添加退货单
+
+ 没有匹配的已完成退货单或可用库存
+
+
+ 已选退货单不同客户的退货单不能合箱;选择时会自动校验。
+ 新箱 SKU 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}可用 {{ item.availableQuantity }}装入数量
+ 新箱箱规
+
+
+
+ 退货合箱成功{{ result.label.cartonMark || result.cartonMark }}来源退货单:{{ result.label.sourceReturnNo || '—' }}箱规:{{ sizeText(result.label) }}{{ item.skuCode }}{{ item.quantity }} 件{{ item.productNameZh || '商品名称未填写' }}
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+
+
+
+
+
+
+
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
new file mode 100644
index 0000000..f9edb66
--- /dev/null
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -0,0 +1,95 @@
+
+
+
+
+ 退货入库当前仓:{{ warehouseName }};每次提交生成一个签收箱和仓储计费批次
+
+
+ 选择查询方式
+
+
+
+
+
+
+ 匹配退货单{{ candidates.length }} 个候选
+ 当前绑定仓没有包含该 SKU 的待签收退货单
+
+
+
+
+ {{ current.returnNo }}{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}{{ statusLabel(current.status) }}
+
+
+ 本包信息
+ 快递单号和箱号均可不填;箱号留空时由系统自动生成。
+ 快递单号(选填)
+ 箱号(选填)
+
+
+ 本包实收数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}剩余 {{ remaining(item) }}本包实收
+
+ 本包合计{{ receiptQuantity }}
+
+
+ 退货入库成功{{ result.cartonMark }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态
+
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+
+
+
+
+
+
+
diff --git a/src/stores/warehouse-work.js b/src/stores/warehouse-work.js
index 744b7b8..b9faa89 100644
--- a/src/stores/warehouse-work.js
+++ b/src/stores/warehouse-work.js
@@ -6,6 +6,8 @@ const blankState = () => ({
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
+ returnReceipt: { entryMode: 'RETURN_NO', lookupCode: '', currentReturnNo: '', startedBy: 'RETURN_NO', trackingNo: '', cartonMark: '', quantities: {}, pendingKeys: {} },
+ returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
@@ -35,7 +37,9 @@ export function restoreWarehouseWork(scopeKey) {
Object.assign(warehouseWork, fresh, {
scopeKey: expected,
stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
- relabel: domain(saved.relabel, fresh.relabel), query: { ...fresh.query, ...(saved.query || {}) }
+ relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt),
+ returnPacking: domain(saved.returnPacking, fresh.returnPacking),
+ query: { ...fresh.query, ...(saved.query || {}) }
})
return true
}
diff --git a/tests/business-identifiers-ui.test.mjs b/tests/business-identifiers-ui.test.mjs
index 81a8289..e1beb72 100644
--- a/tests/business-identifiers-ui.test.mjs
+++ b/tests/business-identifiers-ui.test.mjs
@@ -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/)
})
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index f88c24a..ad0ef0b 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -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(/([\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\(\)/)
diff --git a/tests/return-packing-api.test.mjs b/tests/return-packing-api.test.mjs
new file mode 100644
index 0000000..1791c69
--- /dev/null
+++ b/tests/return-packing-api.test.mjs
@@ -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')))
+})
diff --git a/tests/return-receipt-api.test.mjs b/tests/return-receipt-api.test.mjs
new file mode 100644
index 0000000..7080344
--- /dev/null
+++ b/tests/return-receipt-api.test.mjs
@@ -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')))
+})
diff --git a/tests/workbench-availability.test.mjs b/tests/workbench-availability.test.mjs
index 7ae0c7e..cbf6766 100644
--- a/tests/workbench-availability.test.mjs
+++ b/tests/workbench-availability.test.mjs
@@ -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)