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