From c20d5c53e1076318ab877ddc01470fd0be9f2582 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Thu, 8 Oct 2026 15:34:47 +0800
Subject: [PATCH 01/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/fulfillment.js | 1 +
src/api/return-packing.js | 59 +++++++-
src/components/ReturnBoxMergeWork.vue | 145 ++++++++++++++++++++
src/pages/dispatch/dispatch.vue | 33 +++--
src/pages/return-packing/return-packing.vue | 121 +++++++++++++---
src/stores/fulfillment.js | 2 +-
src/stores/warehouse-work.js | 2 +-
src/utils/return-packing-allocation.js | 26 ++++
tests/project-contract.test.mjs | 4 +-
tests/return-box-merge-work.test.mjs | 70 ++++++++++
tests/return-packing-allocation.test.mjs | 24 ++++
tests/return-packing-api.test.mjs | 5 +
tests/return-packing-completion.test.mjs | 62 +++++++++
13 files changed, 515 insertions(+), 39 deletions(-)
create mode 100644 src/components/ReturnBoxMergeWork.vue
create mode 100644 src/utils/return-packing-allocation.js
create mode 100644 tests/return-box-merge-work.test.mjs
create mode 100644 tests/return-packing-allocation.test.mjs
create mode 100644 tests/return-packing-completion.test.mjs
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index 41971bf..6f04379 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -37,6 +37,7 @@ export function normalizeDispatchTask(value) {
warehouseRouteLabel: String(item?.warehouseRouteLabel || ''),
scheduledPackages: count(item?.scheduledPackages),
pickedPackages: count(item?.pickedPackages),
+ returnCartons: asList(item?.returnCartons).map((carton) => ({ ...carton, id: String(carton.id || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: count(carton.productQuantity) })),
positions: asList(item?.positions).map((position) => ({
...position,
id: String(position?.id || ''),
diff --git a/src/api/return-packing.js b/src/api/return-packing.js
index 9b43e69..c5f42b3 100644
--- a/src/api/return-packing.js
+++ b/src/api/return-packing.js
@@ -17,7 +17,17 @@ function normalizeItems(items) {
}
export function normalizePackingPreview(value = {}) {
- return {
+ value = value && typeof value === 'object' ? value : {}
+ const normalizeCustomer = (customer = {}) => ({
+ customerId: customer.customerId == null ? '' : String(customer.customerId),
+ orders: Array.isArray(customer.orders) ? customer.orders.map((item) => ({
+ returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
+ })) : [],
+ skus: Array.isArray(customer.skus) ? customer.skus.map((item) => ({
+ skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
+ })) : []
+ })
+ const result = {
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 || '')
@@ -26,6 +36,13 @@ export function normalizePackingPreview(value = {}) {
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
}
+ if (Array.isArray(value.customers)) result.customers = value.customers.map(normalizeCustomer).filter((item) => item.customerId)
+ else if (result.customerKey && result.orders.length) {
+ // Older service versions returned the single-customer shape. Keep the
+ // PDA behavior consistent by exposing it through the grouped contract.
+ result.customers = [{ customerId: result.customerKey, orders: result.orders, skus: result.skus }]
+ }
+ return result
}
export function normalizeReturnLabel(value = {}) {
@@ -37,16 +54,52 @@ export function normalizeReturnLabel(value = {}) {
}
}
-export async function findPackingOrders(returnNo = '') {
+export async function findPackingOrders(returnNo = '', mode = 'RETURN_NO') {
const query = String(returnNo || '').trim()
try {
- return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
+ return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?${mode === 'RETURN_NO' ? 'returnNo' : 'skuCode'}=${encoded(query)}` : ''}`))
} catch (error) {
if (isNotFound(error)) return normalizePackingPreview()
throw error
}
}
+export async function listPackingPlans() {
+ const value = await overseasRequest('GET', `${BASE}/packing/plans`)
+ return Array.isArray(value) ? value.map(normalizePackingPlan) : []
+}
+
+export async function completePackingPlan(planNo, request) {
+ const value = await overseasRequest('POST', `${BASE}/packing/plans/${encoded(planNo)}/complete`, request)
+ return { planNo: String(value?.planNo || planNo), labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
+}
+
+export function normalizePackingPlan(value = {}) {
+ return { planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
+}
+
+export async function getPackingPlan(planNo) {
+ return normalizePackingPlan(await overseasRequest('GET', `${BASE}/packing/plans/${encoded(planNo)}`))
+}
+
+export async function scanReturnCarton(mark) {
+ return overseasRequest('GET', `${BASE}/cartons/${encoded(mark)}`)
+}
+
+export async function mergeReturnBoxes(request) {
+ const value = await overseasRequest('POST', `${BASE}/cartons/merge-boxes`, request)
+ return { labels: [normalizeReturnLabel(value?.label)] }
+}
+
+export async function packReturnSkus(request) {
+ const value = await overseasRequest('POST', `${BASE}/cartons/pack`, request)
+ return { labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
+}
+
+export async function putawayReturnCarton(mark, request) {
+ return overseasRequest('POST', `${BASE}/cartons/${encoded(mark)}/putaway`, request)
+}
+
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) }
diff --git a/src/components/ReturnBoxMergeWork.vue b/src/components/ReturnBoxMergeWork.vue
new file mode 100644
index 0000000..9afb349
--- /dev/null
+++ b/src/components/ReturnBoxMergeWork.vue
@@ -0,0 +1,145 @@
+
+
+
+ 箱与箱合并
+ {{ plan.planNo }}
+
+
+ {{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}
+
+
+
+
+
+ {{ box.cartonMark }}
+ {{ item.skuCode }} · {{ item.quantity }} 件
+ 来源箱已确认
+
+
+
+
+
+ {{ mode === 'NEW' ? '新增目标箱' : '已有目标箱' }}
+
+
+
+ {{ target.cartonMark }}{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)
+
+ 体积:{{ volume }} m³
+
+ 计划箱子明细
+
+ {{ box.cartonMark }}
+ {{ item.skuCode }} · {{ item.quantity }} 件
+ {{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '来源箱已确认 · 下方填写扣除数量' : '待扫描确认' }}
+
+
+
+ {{ box.cartonMark }}
+ {{ item.skuCode }} · 可扣 {{ item.quantity }} 件
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index e57881b..9459b59 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -10,7 +10,7 @@
当前任务排车拣货详情
-
+
网络失败不会清空草稿,再次提交会沿用同一幂等键。
@@ -45,9 +45,10 @@
-
+
{{ item.palletNo || '未关联托盘码' }}{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱
{{ routeLabel(item) }}
+ {{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}
@@ -55,7 +56,8 @@
本次拣货:{{ selectedItem.palletNo }}
- 拣货箱数最多 {{ selectedRemaining }} 箱
+ {{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}
+ 拣货箱数最多 {{ selectedRemaining }} 箱
@@ -100,16 +102,17 @@
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages },
- selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
+ selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
+ selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
- selectedRemaining() { return Math.max(0, Math.min(this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0, this.selectedPosition?.availablePackages || 0)) },
+ selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
departed() { return this.current?.status === 'DEPARTED' },
- canPick() { return this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
+ canPick() { return (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && this.current?.canPick === true && !this.pickingCompleted },
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
@@ -139,26 +142,32 @@
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
async openTask(dispatchNo) {
const code = String(dispatchNo || '').trim(); if (!code) return
- await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: previousNo === this.current.dispatchNo ? fulfillment.dispatch.palletCode : '' }); this.scanCode = '' }, true)
+ await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
},
- closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', packages: 1 }); this.loadTasks() },
+ closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
this.scanCode = code
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
+ const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
+ if (cartonItem) {
+ if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
+ this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
+ }
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
},
- selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
+ selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
async submitPick() {
if (!this.canPick) return
- const payload = { dispatchNo: this.current.dispatchNo, palletNo: this.selectedItem.palletNo, positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
+ const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
+ const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
await this.run('提交排车拣货', async () => {
- await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0, idempotencyKey: key.value })
- this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
+ await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
+ this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
})
},
async completePicking() {
diff --git a/src/pages/return-packing/return-packing.vue b/src/pages/return-packing/return-packing.vue
index 835c67b..9c03496 100644
--- a/src/pages/return-packing/return-packing.vue
+++ b/src/pages/return-packing/return-packing.vue
@@ -2,18 +2,36 @@
- 添加退货单
+
+
+ 扫描 SKU 或箱号
+ 待作业合箱计划审核通过后推送
- 没有匹配的已完成退货单或可用库存
+ 选择客户该 SKU 在多个客户有可用退货库存,请先选择客户
+ 没有匹配的已完成退货单或可用库存
-
- 已选退货单不同客户的退货单不能合箱;选择时会自动校验。
- 新箱 SKU 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}可用 {{ item.availableQuantity }}装入数量
- 新箱箱规
+
+ {{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}
+ 新箱 SKU 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}可用 {{ item.availableQuantity }}装入数量
+
+
+ 目标箱 {{ index + 1 }}
+ {{ item.skuCode }}
+
+ 体积 {{ boxVolume(box) }} m³
+
+
- 退货合箱成功{{ result.label.cartonMark || result.cartonMark }}来源退货单:{{ result.label.sourceReturnNo || '—' }}箱规:{{ sizeText(result.label) }}{{ item.skuCode }}{{ item.quantity }} 件{{ item.productNameZh || '商品名称未填写' }}
+ 合箱完成 · {{ result.labels.length }} 箱
+
+ {{ label.cartonMark }}来源退货单:{{ label.sourceReturnNo }}
+ {{ item.skuCode }} · {{ item.quantity }} 件
+
+
+
+
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
@@ -22,40 +40,103 @@
+
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 2fd2f72..73f81d5 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/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(/
-
+.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
+.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
+.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
+.candidate-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px}.candidate-customer{color:#52647c;font-size:10px;line-height:16px;overflow-wrap:anywhere}
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 282835e..5ec3f47 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -21,7 +21,7 @@
本次实物单元
- 包裹码/快递单号(选填)
+ 快递单号(选填)
托盘码({{ unitType === 'BOX' ? '选填' : '必填' }}){{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}
@@ -103,7 +103,7 @@
await this.run(async () => {
const rows = this.unitType === 'BOX' ? this.boxRows : [null]
const entries = rows.map((box, index) => {
- const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
+ const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null }
})
diff --git a/src/utils/relabel-error.js b/src/utils/relabel-error.js
new file mode 100644
index 0000000..302c7f3
--- /dev/null
+++ b/src/utils/relabel-error.js
@@ -0,0 +1,8 @@
+export function isRelabelFundsBlocked(error) {
+ return /Relabel funds must be frozen before processing|换标资金尚未冻结/.test(String(error?.message || ''))
+}
+
+export function relabelErrorMessage(error, fallback = '换标作业失败') {
+ if (isRelabelFundsBlocked(error)) return '换标资金尚未冻结,暂时不能认领。请后台检查客户余额或授信及资金冻结结果,处理后再认领。'
+ return error?.message || fallback
+}
diff --git a/tests/relabel-destination.test.mjs b/tests/relabel-destination.test.mjs
index 2884823..0a73723 100644
--- a/tests/relabel-destination.test.mjs
+++ b/tests/relabel-destination.test.mjs
@@ -6,7 +6,8 @@ const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.met
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
- assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
+ assert.match(page, /class="destination-summary"[^\n]+FBA 目的仓:[^\n]+v-for="destination in fbaDestinations"/)
+ assert.match(page, /v-if="fbaDestinations.length > 1"[^\n]+item\.destinationFbaWarehouseCode/)
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
})
diff --git a/tests/relabel-error.test.mjs b/tests/relabel-error.test.mjs
new file mode 100644
index 0000000..2abf2af
--- /dev/null
+++ b/tests/relabel-error.test.mjs
@@ -0,0 +1,12 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { isRelabelFundsBlocked, relabelErrorMessage } from '../src/utils/relabel-error.js'
+
+test('wallet block receives actionable Chinese guidance without version-conflict recovery', () => {
+ const error = { code: 'A0409', message: 'Relabel funds must be frozen before processing' }
+ assert.equal(isRelabelFundsBlocked(error), true)
+ assert.match(relabelErrorMessage(error), /后台检查客户余额或授信/)
+ assert.doesNotMatch(relabelErrorMessage(error), /刷新任务版本/)
+ assert.equal(isRelabelFundsBlocked({ code: 'A0409', message: 'Task version changed' }), false)
+ assert.equal(relabelErrorMessage({ message: 'Task version changed' }), 'Task version changed')
+})
diff --git a/tests/relabel-pallet-work.test.mjs b/tests/relabel-pallet-work.test.mjs
index ca1f068..192b7d3 100644
--- a/tests/relabel-pallet-work.test.mjs
+++ b/tests/relabel-pallet-work.test.mjs
@@ -14,6 +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.confirmBindingChoice = async () => true
value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
return value
}
@@ -58,7 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
- assert.match(source, /仍归属/)
+ assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
test('source pallet list is concise and hides rows with no movable packages', () => {
@@ -70,7 +71,8 @@ test('source pallet list is concise and hides rows with no movable packages', ()
assert.deepEqual(value.operableSourceRows.map(row => row.palletCode), ['PALLET-1'])
assert.match(source, />托盘)
assert.match(source, />刷新)
- assert.match(source, /托盘号:\{\{ row\.palletCode \}\} · 入仓码:\{\{ row\.inboundCode \}\}/)
+ assert.match(source, /class="source-caption">托盘号<\/text>\{\{ row\.palletCode \}\}/)
+ assert.doesNotMatch(template, /入仓码|row\.inboundCode/)
assert.match(source, /可拆\{\{ isReturnBulk \? '件数' : '箱数' \}\}:\{\{ row\.movablePackages \}\}/)
assert.doesNotMatch(template, /刷新来源托盘/)
assert.doesNotMatch(template, /按原入仓单拆托/)
@@ -104,10 +106,55 @@ test('target confirmation submits binding directly, with no second submit button
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
- assert.match(source, /@tap="confirmTargetAndBind"/)
+ assert.match(source, /@submit="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
+test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
+ const value = context()
+ value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
+ value.sourceCode = 'SOURCE'
+ let prompts = 0
+ value.resolve = async () => { value.source = { palletCode: 'SOURCE' } }
+ value.confirmBindingChoice = async () => { prompts++; return false }
+ await value.confirmSourceScan()
+ assert.equal(value.source, null)
+ value.confirmBindingChoice = async () => { prompts++; return true }
+ await value.confirmSourceScan()
+ assert.equal(value.source.palletCode, 'SOURCE')
+ value.sourceCode = 'OTHER'
+ value.showBindingMessage = () => {}
+ value.resolve = async () => assert.fail('unknown pallet must not resolve')
+ await value.confirmSourceScan()
+ assert.equal(value.source, null)
+ assert.equal(prompts, 2)
+})
+
+test('declining target confirmation does not move stock', async () => {
+ const value = context()
+ value.quantity = '1'; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
+ value.confirmBindingChoice = async () => false
+ value.bind = async () => assert.fail('cancelled target must not bind')
+ await value.confirmTargetAndBind()
+ assert.equal(value.target, null)
+})
+
+test('simplified split automatically fills a batch and counts saved bindings without repeats', async () => {
+ const value = context()
+ value.task.items = ['A', 'B', 'C'].map(cartonMark => ({ cartonMark, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }))
+ value.task.history = [{ action: 'PALLET_BIND', detail: JSON.stringify({ cartonMarks: ['A'], targetPallets: ['OLD-TARGET'] }) }]
+ assert.equal(value.requiredSplitPackages, 3)
+ assert.equal(value.splitPackages, 1)
+ value.quantity = '1'; value.marks = []; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
+ value.bind = async () => { assert.deepEqual(value.marks, ['B']); assert.equal(value.readyToBind, true) }
+ await value.confirmTargetAndBind()
+ assert.doesNotMatch(template, /按箱数填入本批换标箱|选择本批新目的仓|本批确认|@tap="toggleMark/)
+ value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
+ assert.equal(value.splitPackages, 0)
+})
+
test('missing source scan shows modal and never submits a binding', async () => {
const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
let message = ''
diff --git a/tests/return-receipt-page.test.mjs b/tests/return-receipt-page.test.mjs
index 224b108..3bf41e6 100644
--- a/tests/return-receipt-page.test.mjs
+++ b/tests/return-receipt-page.test.mjs
@@ -70,7 +70,17 @@ test('parcel signing allows an empty code but still rejects quantities above the
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].trackingNo, undefined)
- assert.equal(calls[0].unitCode, '')
+ assert.equal(calls[0].unitCode, undefined)
+ assert.equal(calls[0].palletCode, undefined)
+})
+
+test('parcel waybill is submitted as trackingNo and leaves unitCode for the server', async () => {
+ const calls = []
+ const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
+ page.unitType = 'PARCEL'; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'
+ await page.confirmReceipt()
+ assert.equal(calls[0].trackingNo, 'sf561626')
+ assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})
From 4f56d97f5e4142ff87e1b0d30c95be8363740b89 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 11 Oct 2026 21:48:44 +0800
Subject: [PATCH 10/19] fix(pda): keep pallet label actions readable on
handheld screens
---
src/pages/warehouse-transfer/warehouse-transfer.vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/pages/warehouse-transfer/warehouse-transfer.vue b/src/pages/warehouse-transfer/warehouse-transfer.vue
index 259c77b..188d5aa 100644
--- a/src/pages/warehouse-transfer/warehouse-transfer.vue
+++ b/src/pages/warehouse-transfer/warehouse-transfer.vue
@@ -305,6 +305,7 @@ export default {
.take-photo-button[disabled]{color:#9aa7b5;background:#e7ecf2;opacity:1}
.print-card{box-sizing:border-box;margin-bottom:11px;padding:14px;border:1px solid #b9d3f4;border-radius:13px;background:#f8fbff}.print-row button{width:auto;margin:0;padding:0 10px;color:#135ee8;border:1px solid #9fc0ef;border-radius:8px;background:#fff}.print-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid #dce8f5;color:#728196;font-size:9px}.print-code{display:block;color:#17314f;font-size:13px;font-weight:850}
.generate-button{width:100%;margin-top:12px;color:#fff;background:#135ee8}.generated-card{display:flex;align-items:center;justify-content:space-between;margin-top:12px;padding:10px;border-radius:9px;background:#edf5ff}.generated-card button,.bind-panel>button{width:auto;margin:0;color:#135ee8;background:#fff}.bind-panel{margin-top:10px}.bound-state{margin-top:10px;padding:9px;border-radius:8px;color:#08705f;font-weight:800;background:#dff5ed}
+.generated-card,.print-row{gap:10px}.generated-card>view,.print-row>view{flex:1;min-width:0;overflow-wrap:anywhere}.generated-card button,.print-row button{flex-shrink:0;white-space:nowrap;padding:0 10px}
.operation-detail-heading{display:flex;align-items:flex-start;gap:10px;margin:0 2px 12px;padding:2px 0}.back-to-list{width:auto;height:30px;margin:0;padding:0 9px;border:1px solid #c8d8ec;border-radius:8px;color:#135ee8;font-size:10px;line-height:30px;background:#f8fbff}.operation-detail-heading>view{min-width:0}.operation-detail-title{display:block;color:#243951;font-size:15px;font-weight:850}.operation-detail-subtitle{display:block;margin-top:3px;color:#8794a4;font-size:9px;line-height:14px}
.collapse-button{width:30px;height:28px;margin:0;padding:0;color:#135ee8;font-size:16px;line-height:28px;background:#edf5ff}
.receipt-comparison{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-bottom:9px}.receipt-comparison view{padding:9px;border-radius:9px;text-align:center;background:#fff}.receipt-comparison strong,.receipt-comparison text{display:block}.receipt-comparison strong{color:#17314f;font-size:15px}.receipt-comparison text{margin-top:2px;color:#8390a1;font-size:8px}.receipt-comparison .difference{border:1px solid #e3a044;background:#fff4df}.receipt-comparison .difference strong,.receipt-comparison .difference text{color:#a35b0c;font-weight:800}
From aebe4f9528f8f37c9694e9026a2689b1b50d20c8 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Mon, 12 Oct 2026 13:43:37 +0800
Subject: [PATCH 11/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/relabel.js | 2 +
src/api/return-packing.js | 4 +
src/components/ReturnBoxMergeWork.vue | 35 ++++--
src/components/ReturnLabelConfirmation.vue | 31 +++++
src/components/ReturnRelabelPackWork.vue | 41 +++++++
src/pages/relabel/relabel.vue | 107 +++++++++++------
src/pages/return-counting/return-counting.vue | 14 ++-
src/pages/return-packing/return-packing.vue | 17 ++-
src/pages/return-receipt/return-receipt.vue | 30 ++---
src/utils/return-relabel.js | 25 ++++
tests/relabel-active-task.test.mjs | 4 +-
tests/relabel-quick-sku.test.mjs | 2 +-
tests/relabel-return-bulk.test.mjs | 112 +++++++++++-------
tests/relabel-return-carton-mixed.test.mjs | 14 +++
tests/relabel-return-pack.test.mjs | 31 +++++
tests/return-box-merge-work.test.mjs | 12 +-
tests/return-counting-page.test.mjs | 30 +++++
tests/return-label-confirmation.test.mjs | 20 ++++
tests/return-packing-completion.test.mjs | 14 ++-
tests/return-receipt-page.test.mjs | 74 ++++++++++--
tests/warehouse-workflows.test.mjs | 4 +-
21 files changed, 482 insertions(+), 141 deletions(-)
create mode 100644 src/components/ReturnLabelConfirmation.vue
create mode 100644 src/components/ReturnRelabelPackWork.vue
create mode 100644 src/utils/return-relabel.js
create mode 100644 tests/relabel-return-carton-mixed.test.mjs
create mode 100644 tests/relabel-return-pack.test.mjs
create mode 100644 tests/return-label-confirmation.test.mjs
diff --git a/src/api/relabel.js b/src/api/relabel.js
index a04e836..117342b 100644
--- a/src/api/relabel.js
+++ b/src/api/relabel.js
@@ -4,6 +4,7 @@ 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 packReturnRelabelCartons = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-cartons/pack`, 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 : [] }
@@ -11,6 +12,7 @@ function asList(value) { return Array.isArray(value) ? value : [] }
export function normalizeRelabel(value = {}) {
const items = asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
+ returnCartonId: item.returnCartonId == null ? null : String(item.returnCartonId), returnQuantity: Number(item.returnQuantity || 0),
newSkuCode: String(item.newSkuCode || ''), operationType: String(item.operationType || 'CARTON_LABEL'), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
diff --git a/src/api/return-packing.js b/src/api/return-packing.js
index c5f42b3..8b3340d 100644
--- a/src/api/return-packing.js
+++ b/src/api/return-packing.js
@@ -108,3 +108,7 @@ export async function mergeReturnCartons(request) {
export async function getReturnCartonLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
}
+
+export async function confirmReturnLabel(cartonMark) {
+ return normalizeReturnLabel(await overseasRequest('POST', `${BASE}/cartons/${encoded(cartonMark)}/confirm-label`))
+}
diff --git a/src/components/ReturnBoxMergeWork.vue b/src/components/ReturnBoxMergeWork.vue
index 9afb349..b6f519f 100644
--- a/src/components/ReturnBoxMergeWork.vue
+++ b/src/components/ReturnBoxMergeWork.vue
@@ -1,12 +1,12 @@
- 箱与箱合并
+ 包裹/箱合箱
{{ plan.planNo }}
-
+
{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}
-
+
@@ -36,7 +36,8 @@
{{ box.cartonMark }}
- {{ item.skuCode }} · 可扣 {{ item.quantity }} 件
+
+ {{ item.skuCode }} · 可扣 {{ item.quantity }} 件
@@ -45,16 +46,18 @@
diff --git a/src/components/ReturnRelabelPackWork.vue b/src/components/ReturnRelabelPackWork.vue
new file mode 100644
index 0000000..1ecb4f2
--- /dev/null
+++ b/src/components/ReturnRelabelPackWork.vue
@@ -0,0 +1,41 @@
+
+
+ 换标商品独立合箱部分换标须装入独立新箱;来源余量留在原包裹/箱。所有来源登记成功后才可生成新箱。
+ {{ row.cartonMark }} · {{ row.skuCode }} · {{ row.quantity }} 件
+
+ 新箱:{{ fact.cartonMark }} · {{ fact.quantity }} 件此标签用于识别仓库包裹/箱;商品目标 SKU 标签请按上方换标明细核对。
+
+
+
+
diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue
index 63837c6..d91e125 100644
--- a/src/pages/relabel/relabel.vue
+++ b/src/pages/relabel/relabel.vue
@@ -24,17 +24,14 @@
{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}
- 先扫描实际贴标的 SKU,核对数量后登记;无需扫描箱唛。SKU 已识别,请确认实际贴标数量后登记
-
- {{ group.skuCode }}FBA 目的仓:{{ group.destinationFbaWarehouseCode || '—' }}剩余 {{ group.remaining }} 件
- {{ group.expectedPackages }}贴标预报{{ group.relabelledPackages }}已贴
- 来源托盘换标数量来源托盘拆箱数量{{ source.palletCode || '未记录托盘' }}{{ isReturnBulk ? '换标' : '拆箱' }} {{ source.expectedPackages }} 件合计{{ isReturnBulk ? '换标' : '拆箱' }}{{ group.expectedPackages }} 件
-
-
+ 先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。已确认:{{ verifiedReturnCarton }}
+ {{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)
+ {{ item.cartonMark }} · {{ item.skuCode }}目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件
+
- 同一旧 SKU 有多条明细,请核对实际箱选择本次作业:
+ 请核对来源箱、目标商品标签和实际件数,选择明细登记:
{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}{{ row.item.cartonMark }} · {{ row.sku.quantity }} 件
@@ -49,9 +46,8 @@
-
-
+
+
完成照片{{ photoFileIds.length }} / 20 张
@@ -79,22 +75,37 @@
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 049fc13..49d2336 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -29,9 +29,26 @@
- {{ unitType === 'PARCEL' ? '逐包裹实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}本托箱数合计{{ palletBoxCount }}{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}{{ item.skuCode }} · 数量剩余 {{ remaining(item) }}{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}剩余 {{ remaining(item) }}数量SKU 箱数
- {{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}{{ receiptQuantity }}
- 退货打托照片签收过程中可随时拍照{{ photoFileIds.length }} / 20 张{{ photoNames[index] || '退货打托照片' }}
+
+ {{ unitType === 'PARCEL' ? '逐包裹实收数量' : unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量' }}{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}
+ 本托箱数合计{{ palletBoxCount }}
+
+
+ 第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}实测信息
+ {{ box.length }} × {{ box.width }} × {{ box.height }} cm · {{ box.weight }} kg · {{ box.originalPackaging ? '原包装' : '新包装' }}{{ item.skuCode }} · {{ box.items[item.skuCode] }} 件
+
+
+ 体积{{ rowVolume(box) }}cm³
+ 是否使用原{{ unitType === 'PARCEL' ? '包装' : '箱' }}
+ {{ item.skuCode }} · 数量
+
+
+
+
+ {{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}剩余 {{ remaining(item) }}数量SKU 箱数
+ {{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}{{ receiptQuantity }}
+ 退货打托照片{{ unitType !== 'PALLET' ? '支持多张照片,上传后点击确认签收入库' : '签收过程中可随时拍照' }}{{ photoFileIds.length }} / 20 张{{ photoNames[index] || '退货打托照片' }}
+ 本次合计{{ receiptQuantity }}
本单已完成打托{{ pallet.palletCode }}{{ pallet.skuCode }} · {{ pallet.quantity }} 件签收批次:{{ pallet.receiptBatchNo }}
@@ -54,7 +71,7 @@
export default {
components: { PortalHeader, ScanTargetInput },
- data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {} }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
+ data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
@@ -63,10 +80,11 @@
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
palletCode: { get() { return warehouseWork.returnReceipt.palletCode }, set(value) { updateWarehouseWork('returnReceipt', { palletCode: value }) } },
selectedItems() { return this.current?.expectedItems || [] },
+ allBoxRowsConfirmed() { return this.unitType !== 'PALLET' && this.boxRows.length > 0 && this.boxRows.every((box, index) => box.confirmed && this.boxRowValid(index)) },
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
activeSkuCode() { return this.selectedItems.find(item => item.skuCode === this.selectedSku)?.skuCode || this.selectedItems[0]?.skuCode || '' },
receiptQuantity() { return this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
- canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' || (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0))) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
+ canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0) && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0 && typeof box.originalPackaging === 'boolean'))) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && this.palletBoxCount > 0) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
@@ -75,9 +93,24 @@
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 } },
+ selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
+ resizeBoxRows(raw) { if (this.busy) return; const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }) },
+ setOriginalPackaging(index, value) { if (this.busy) return; this.boxRows[index].originalPackaging = value; this.boxRows[index].confirmed = false },
+ rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? v.toFixed(2) : '—' },
+ setBoxQuantity(index, skuCode, raw) { if (this.busy) return; this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: raw }; this.boxRows[index].confirmed = false },
+ boxRemaining(index, item) { const allocated = this.boxRows.reduce((sum, box, rowIndex) => sum + (rowIndex === index ? 0 : Math.max(0, Number(box.items[item.skuCode]) || 0)), 0); return Math.max(0, this.remaining(item) - allocated) },
+ boxRowValid(index) {
+ const box = this.boxRows[index]
+ if (!box || !['length', 'width', 'height', 'weight'].every(field => Number.isFinite(Number(box[field])) && Number(box[field]) > 0) || typeof box.originalPackaging !== 'boolean') return false
+ return this.selectedItems.some(item => Number(box.items[item.skuCode]) > 0) && this.selectedItems.every(item => { const quantity = Number(box.items[item.skuCode] || 0); return Number.isSafeInteger(quantity) && quantity >= 0 && quantity <= this.boxRemaining(index, item) })
+ },
+ editBoxRow(index) { if (this.busy) return; this.boxRows[index].confirmed = false; this.error = null },
+ confirmBoxRow(index) {
+ if (this.busy) return
+ if (!this.boxRowValid(index)) return this.setError(new Error('请填写有效箱规、重量和正整数实收数量,数量不能超过扣除其他箱后的剩余量'), '本箱确认失败')
+ this.boxRows[index].confirmed = true; this.error = null
+ if (this.allBoxRowsConfirmed) this.choosePhoto(true)
+ },
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
@@ -99,11 +132,11 @@
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
- async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
+ async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
await this.run(async () => {
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
const entries = rows.map((box, index) => {
- const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
+ const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' || this.unitType === 'BOX' ? undefined : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : Number(box.length), width: this.unitType === 'PALLET' ? Number(this.palletWidth) : Number(box.width), height: this.unitType === 'PALLET' ? Number(this.palletHeight) : Number(box.height), weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : Number(box.weight), originalPackaging: this.unitType === 'PALLET' ? undefined : box.originalPackaging, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null }
})
@@ -142,7 +175,22 @@
playSuccessSound()
},
manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) },
- choosePhoto() { if (!this.current || this.busy || this.photoFileIds.length >= 20) return; const no = this.current.returnNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { await this.run(async () => { for (const [index,path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId); this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path } playSuccessSound() }, '退货打托照片上传失败') } }) },
+ choosePhoto(cameraOnly = false) {
+ if (!this.current || this.busy) return
+ if (this.photoFileIds.length >= 20) return
+ const no = this.current.returnNo; const draft = JSON.stringify(this.boxRows); const type = this.unitType
+ uni.chooseImage({ count: cameraOnly ? 1 : Math.min(9, 20 - this.photoFileIds.length), sourceType: cameraOnly ? ['camera'] : ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
+ if (this.current?.returnNo !== no || this.unitType !== type || JSON.stringify(this.boxRows) !== draft) return
+ await this.run(async () => {
+ for (const [index,path] of (tempFilePaths || []).entries()) {
+ if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
+ const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId)
+ this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path
+ }
+ playSuccessSound()
+ }, '退货打托照片上传失败')
+ } })
+ },
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
@@ -154,7 +202,7 @@
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
this.palletCheck = null; this.newPalletHtml = ''
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
- this.unitCode = ''; this.boxRows = [{ items: {} }]
+ this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
this.photoFileIds = []; this.photoNames = []; this.photoUrls = {}
},
async nextReceipt() {
@@ -164,7 +212,7 @@
await this.run(async () => {
await this.openOrderValue(returnNo, 'SKU')
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
- this.unitCode = ''; this.boxRows = [{ items: {} }]; this.newPalletHtml = ''
+ this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.newPalletHtml = ''
this.result = null
}, '读取剩余待签收货物失败')
},
@@ -185,4 +233,6 @@
.unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin-top:10px}.dimension-grid label>text{display:block;margin-bottom:3px;color:#65768b;font-size:10px}.dimension-grid .number-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #d7e1ef;border-radius:7px;color:#24364b;font-size:13px;text-align:center;background:#fff}.dimension-grid .number-input:focus{border-color:#2167e8;box-shadow:0 0 0 2px rgba(33,103,232,.12)}.volume-box{display:flex;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 6px;border:1px solid #b9d3f4;border-radius:7px;color:#65768b;font-size:10px;background:#f4f8ff}.volume-box strong{color:#135ee8;font-size:13px}
.work-card>.row-head .business-code{font-size:16px;line-height:20px}.work-card>.row-head .meta-line{font-size:10px;line-height:15px}.work-card>.row-head .status-pill{padding:4px 7px;font-size:10px}
+ .physical-unit-card{padding:12px!important;border:1px solid #d8e5f5!important;background:linear-gradient(145deg,#fff 0%,#f7faff 100%);box-shadow:0 4px 12px rgba(30,78,130,.08)}.unit-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.unit-card-tag{padding:3px 7px;border-radius:10px;background:#eaf2ff;color:#135ee8;font-size:10px;font-weight:700}.unit-dimension-grid{margin-top:0}.unit-volume{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin-top:7px;color:#728197;font-size:10px}.unit-volume strong{color:#135ee8;font-size:15px}.packaging-choice{display:flex;align-items:center;gap:7px;margin-top:10px;padding-top:9px;border-top:1px solid #e6edf6;color:#52667e;font-size:11px}.packaging-choice>text{flex:1}.choice-button{width:42px!important;min-width:42px!important;height:28px!important;min-height:28px!important;margin:0!important;padding:0!important;border:1px solid #d1ddeb!important;border-radius:7px!important;background:#fff!important;color:#65768b!important;font-size:11px!important;line-height:28px!important}.choice-button.selected{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
+ .receipt-quantity-heading{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;margin-bottom:10px}.receipt-quantity-heading .card-title{margin-bottom:0;white-space:nowrap}.unit-count-row{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0;white-space:nowrap;color:#65768b;font-size:10px}.unit-count-row .number-input{box-sizing:border-box;width:48px;min-width:48px;height:30px;min-height:30px;margin:0;padding:0 4px;border-radius:6px;text-align:center;font-size:13px}.quantity-row .unit-quantity-input{width:88px;flex-shrink:0;font-size:12px}.physical-unit-card .quantity-row>text{min-width:0;overflow-wrap:anywhere}.unit-card-head .link-button{margin:0;padding:0;font-size:10px}
diff --git a/tests/relabel-quick-sku.test.mjs b/tests/relabel-quick-sku.test.mjs
index 535c67d..35de5cb 100644
--- a/tests/relabel-quick-sku.test.mjs
+++ b/tests/relabel-quick-sku.test.mjs
@@ -24,6 +24,35 @@ test('normal SKU input is always visible in SKU tab and never requests the new l
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
+
+test('return SKU follows ordinary single scan and requires explicit choice when the same SKU spans boxes', async () => {
+ const calls = []
+ let value
+ const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
+ {}, {}, {}, {}, async (no, payload) => { calls.push(payload); return value.current },
+ () => ({ value: 'KEY', signature: 'SIG' }), () => {}, { showModal: options => options.complete() }
+ )
+ value = context()
+ Object.assign(value, { busy: false, quickSku: '', skuChoices: [], verifiedReturnCarton: '', returnCartonCode: '',
+ async run(action, work) { await work() }, applyCurrent(task) { this.current = task },
+ setError(error) { throw error }
+ })
+ for (const name of ['submitSku', 'commitSku']) value[name] = subject.methods[name].bind(value)
+ value.current.relabelNo = 'RL'; value.current.version = 3
+ value.current.items = [{ itemId: '1', returnCartonId: 'R1', cartonMark: 'BOX-A', operationType: 'SKU_LABEL', expectedPackages: 1, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 20 }] }]
+ await value.submitSku('OLD')
+ assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', newSkuCode: 'NEW', quantity: 20, skuIndex: 0, oldSku: 'OLD', expectedVersion: 3, idempotencyKey: 'KEY' }])
+ calls.length = 0
+ value.current.items.push({ ...value.current.items[0], itemId: '2', returnCartonId: 'R2', cartonMark: 'BOX-B' })
+ await value.submitSku('OLD')
+ assert.equal(calls.length, 0)
+ assert.equal(value.skuChoices.length, 2)
+ await value.commitSku(value.skuChoices[1])
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].itemId, '2')
+ assert.equal(calls[0].cartonMark, 'BOX-B')
+ assert.doesNotMatch(source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
-test('return carton label identifies source with one carton scan and retains source check for SKU tab', () => {
+test('return carton and normal SKU use single scans; explicit source scan is reserved for bulk quantities', () => {
assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
- assert.match(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
+ assert.doesNotMatch(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
+ assert.match(page, /v-if="isReturnBulk" class="section-gap">先扫描来源/)
assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
})
diff --git a/tests/return-receipt-page.test.mjs b/tests/return-receipt-page.test.mjs
index 6f73470..2989e74 100644
--- a/tests/return-receipt-page.test.mjs
+++ b/tests/return-receipt-page.test.mjs
@@ -3,10 +3,118 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
-function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
+const physicalRow = items => ({ items, length: 10, width: 10, height: 10, weight: 1, originalPackaging: true, confirmed: true })
+
+test('box and parcel confirmation opens the camera only after every valid row is confirmed', () => {
+ for (const type of ['BOX', 'PARCEL']) {
+ const cameras = []
+ const page = setup(async () => ({}), new Map(), 'operator:warehouse', { uni: { chooseImage: options => cameras.push(options) } })
+ page.unitType = type
+ page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
+ page.boxRows.forEach(row => { row.confirmed = false })
+ page.confirmBoxRow(0)
+ assert.equal(page.boxRows[0].confirmed, true)
+ assert.equal(cameras.length, 0)
+ page.confirmBoxRow(1)
+ assert.equal(cameras.length, 1)
+ assert.deepEqual(Array.from(cameras[0].sourceType), ['camera'])
+ page.editBoxRow(0)
+ assert.equal(page.allBoxRowsConfirmed, false)
+ assert.equal(page.boxRows[0].confirmed, false)
+ }
+})
+
+test('remaining quantity accounts for other drafts and rejects fractional negative or excessive input', () => {
+ const page = setup(async () => ({}))
+ page.unitType = 'BOX'
+ page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
+ assert.equal(page.boxRemaining(0, page.selectedItems[0]), 7)
+ for (const quantity of ['7.5', '-1', '8']) {
+ page.setBoxQuantity(0, 'SKU-TEST', quantity)
+ assert.equal(page.boxRows[0].items['SKU-TEST'], quantity)
+ assert.equal(page.boxRowValid(0), false)
+ page.confirmBoxRow(0)
+ assert.equal(page.boxRows[0].confirmed, false)
+ }
+ page.setBoxQuantity(0, 'SKU-TEST', '7')
+ assert.equal(page.boxRowValid(0), true)
+ page.resizeBoxRows(3)
+ assert.equal(page.boxRows[2].originalPackaging, true)
+ assert.equal(page.boxRows[2].confirmed, false)
+})
+
+test('final photo upload waits for explicit confirmation and failed uploads cannot submit', async () => {
+ for (const failUpload of [false, true]) {
+ let camera, requests = []
+ const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
+ uni: { chooseImage: options => { camera = options } },
+ uploadReturnPalletPhoto: async () => { if (failUpload) throw new Error('upload failed'); return { fileId: 'PHOTO-1' } }
+ })
+ page.unitType = 'BOX'; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
+ page.boxRows[0].confirmed = false
+ page.confirmBoxRow(0)
+ assert.equal(requests.length, 0)
+ await camera.success({ tempFilePaths: ['photo.jpg'], tempFiles: [{ size: 100 }] })
+ assert.equal(requests.length, 0)
+ await page.confirmReceipt()
+ assert.equal(requests.length, failUpload ? 0 : 1)
+ if (failUpload) assert.match(page.error.message, /照片/)
+ }
+})
+
+test('parcel photos accumulate without submitting until explicit receipt confirmation', async () => {
+ let camera, uploads = 0
+ const requests = []
+ const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
+ uni: { chooseImage: options => { camera = options } },
+ uploadReturnPalletPhoto: async () => ({ fileId: `PHOTO-${++uploads}` })
+ })
+ page.unitType = 'PARCEL'; page.unitCode = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
+ page.boxRows[0].confirmed = false
+ await page.confirmReceipt()
+ assert.equal(requests.length, 0)
+ page.confirmBoxRow(0)
+ await camera.success({ tempFilePaths: ['first.jpg'] })
+ assert.equal(requests.length, 0)
+ assert.ok(page.current)
+ page.choosePhoto()
+ assert.equal(camera.count, 9)
+ await camera.success({ tempFilePaths: ['second.jpg', 'third.jpg'] })
+ assert.equal(page.photoFileIds.length, 3)
+ assert.equal(requests.length, 0)
+ await page.confirmReceipt()
+ assert.equal(requests.length, 1)
+ assert.equal(page.current, null)
+})
+
+test('parcel signing requires a photo even for a partial receipt', async () => {
+ let requests = 0
+ const page = setup(async () => { requests++; return {} })
+ page.unitType = 'PARCEL'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
+ await page.confirmReceipt()
+ assert.equal(requests, 0)
+ assert.match(page.error.message, /照片/)
+})
+
+test('editing the draft before the camera returns prevents late photo submission', async () => {
+ let camera, uploads = 0, requests = 0
+ const page = setup(async () => { requests++; return {} }, new Map(), 'operator:warehouse', {
+ uni: { chooseImage: options => { camera = options } },
+ uploadReturnPalletPhoto: async () => { uploads++; return { fileId: 'PHOTO-1' } }
+ })
+ page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
+ page.choosePhoto(true)
+ page.editBoxRow(0)
+ await camera.success({ tempFilePaths: ['late.jpg'] })
+ assert.equal(uploads, 0)
+ assert.equal(requests, 0)
+})
+
+function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime = {}) {
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
- uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } })
+ ...runtime,
+ uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key), ...runtime.uni } })
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
vm.runInContext(store.replace(/^import .*$/mg, '').replace(/export /g, ''), context)
const page = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
@@ -16,11 +124,12 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
const instance = Object.assign({}, component.methods, {
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
- selectedItems: [{ skuCode: 'SKU-TEST' }], photoFileIds: [], trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
+ selectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photoFileIds: [], photoNames: [], photoUrls: {}, trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
+ Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
return instance
}
@@ -61,13 +170,13 @@ test('PALLET page submits the required palletCode through the actual confirmatio
test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/
+
diff --git a/src/components/ReturnBoxMergeWork.vue b/src/components/ReturnBoxMergeWork.vue
index b6f519f..2013627 100644
--- a/src/components/ReturnBoxMergeWork.vue
+++ b/src/components/ReturnBoxMergeWork.vue
@@ -8,7 +8,17 @@
{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}
-
+
+
+ 计划需扫描箱子
+
+ {{ box.cartonMark }} · {{ box.participation === 'FULL' ? '整箱' : box.participation === 'PARTIAL' ? '部分' : '计划来源' }}
+ 来源退货单:{{ box.sourceReturnNo || '—' }}
+ {{ item.skuCode }} · {{ item.quantity }} 件
+ {{ item.skuCode }} · 原有 {{ item.quantity }} 件 · 计划后剩余 {{ target?.cartonMark === box.cartonMark ? item.quantity : Math.max(0, item.quantity - (box.items || []).filter(row => row.skuCode === item.skuCode).reduce((sum, row) => sum + row.quantity, 0)) }} 件
+ {{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '已扫描确认' : '待扫描' }}
+
+
{{ box.cartonMark }}
@@ -26,18 +36,10 @@
{{ target.cartonMark }}{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)
体积:{{ volume }} m³
-
- 计划箱子明细
-
- {{ box.cartonMark }}
- {{ item.skuCode }} · {{ item.quantity }} 件
- {{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '来源箱已确认 · 下方填写扣除数量' : '待扫描确认' }}
-
-
{{ box.cartonMark }}
-
- {{ item.skuCode }} · 可扣 {{ item.quantity }} 件
+
+ {{ item.skuCode }} · {{ plan ? '计划取出' : '可扣' }} {{ item.quantity }} 件
@@ -46,15 +48,14 @@
-
diff --git a/src/components/ReturnBoxMergeWork.vue b/src/components/ReturnBoxMergeWork.vue
index 2013627..80227cb 100644
--- a/src/components/ReturnBoxMergeWork.vue
+++ b/src/components/ReturnBoxMergeWork.vue
@@ -15,7 +15,7 @@
{{ box.cartonMark }} · {{ box.participation === 'FULL' ? '整箱' : box.participation === 'PARTIAL' ? '部分' : '计划来源' }}
来源退货单:{{ box.sourceReturnNo || '—' }}
{{ item.skuCode }} · {{ item.quantity }} 件
- {{ item.skuCode }} · 原有 {{ item.quantity }} 件 · 计划后剩余 {{ target?.cartonMark === box.cartonMark ? item.quantity : Math.max(0, item.quantity - (box.items || []).filter(row => row.skuCode === item.skuCode).reduce((sum, row) => sum + row.quantity, 0)) }} 件
+ {{ item.skuCode }} · 原有 {{ item.quantity }} 件 · 计划后剩余 {{ target?.cartonMark === box.cartonMark ? item.quantity : Math.max(0, item.quantity - (box.items || []).filter(row => row.skuCode === item.skuCode).reduce((sum, row) => sum + Number(row.quantity), 0)) }} 件
{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '已扫描确认' : '待扫描' }}
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 378e19b..49d2336 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -2,7 +2,6 @@
-
扫描 SKU 69 码
@@ -63,7 +62,6 @@