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 01/39] =?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 @@
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index 65cdc72..26c728c 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -5,7 +5,7 @@
出库任务整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”
当前任务出库作业详情
-
+
任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
diff --git a/src/pages/return-counting/return-counting.vue b/src/pages/return-counting/return-counting.vue
index 335375f..4b6eb5c 100644
--- a/src/pages/return-counting/return-counting.vue
+++ b/src/pages/return-counting/return-counting.vue
@@ -13,8 +13,7 @@
{{ task.palletCode }}{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}{{ task.returnNo }} · {{ task.boxes.length }} 箱{{ row.skuCode }}签收 {{ row.quantity }} / 已填 {{ row.actual }}
- 第 {{ index + 1 }} 箱{{ box.cartonMark }}体积{{ boxVolume(box) }} cm³
- {{ line.skuCode || '选择 SKU' }} ▾
+ 第 {{ index + 1 }} 箱{{ box.cartonMark }}
各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。箱内清点已完成,可以继续换标或出库。
@@ -22,13 +21,14 @@
diff --git a/tests/return-counting-page.test.mjs b/tests/return-counting-page.test.mjs
index 577c226..f50cc7d 100644
--- a/tests/return-counting-page.test.mjs
+++ b/tests/return-counting-page.test.mjs
@@ -5,7 +5,7 @@ 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(/
diff --git a/tests/warehouse-transfer-api.test.mjs b/tests/warehouse-transfer-api.test.mjs
index bb9704d..1b062a9 100644
--- a/tests/warehouse-transfer-api.test.mjs
+++ b/tests/warehouse-transfer-api.test.mjs
@@ -36,7 +36,7 @@ test('warehouse transfer page loads detail without exposing internal identifiers
assert.match(page, /entryMode === 'work' && taskNo/)
assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/)
assert.match(page, /v-if="!selected && entryMode === 'list'"/)
- assert.match(page, /作业详情/)
+ assert.match(page, /返回列表/)
assert.match(page, /backToCandidates\(\)/)
assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/)
assert.match(page, /存在未完结的测量单/)
diff --git a/tests/warehouse-transfer-mixed-cartons.test.mjs b/tests/warehouse-transfer-mixed-cartons.test.mjs
new file mode 100644
index 0000000..e625e00
--- /dev/null
+++ b/tests/warehouse-transfer-mixed-cartons.test.mjs
@@ -0,0 +1,117 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const source = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
+const script = source.match(/')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = []
- const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
+ const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'bindRelabelPallet', script)(
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
- () => ({ value: 'stable-key', signature: 'signature' }), () => {})
+ () => ({ value: 'stable-key', signature: 'signature' }), () => {}, async payload => calls.push(payload))
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
return { component, value, calls }
@@ -33,3 +35,74 @@ test('only whole physical pallet quantities offer original pallet reuse', () =>
ctx.plannedPackages = 10;ctx.isReturnBulk = true
assert.equal(canRetain.call(ctx), false)
})
+
+async function renderMeasurements(measurements) {
+ const { component } = setup()
+ const template = source.slice(source.indexOf('') + 10, source.indexOf('
+.compact-measure .count-line{padding-top:8px;border-top:1px solid #edf1f6}.compact-measure .sku-picker{line-height:18px}.compact-measure .quantity{width:76px;font-weight:600;background:#fff}.compact-measure .measure-grid{gap:10px}.compact-measure .measure-input{height:38px;background:#f8fafd}.compact-measure .volume-chip{margin:8px 0;padding:6px 10px;border-radius:7px;background:#f4f8ff}.compact-measure .volume-chip strong{font-size:13px}
diff --git a/src/pages/warehouse-transfer/warehouse-transfer.vue b/src/pages/warehouse-transfer/warehouse-transfer.vue
index ead8f28..64deff4 100644
--- a/src/pages/warehouse-transfer/warehouse-transfer.vue
+++ b/src/pages/warehouse-transfer/warehouse-transfer.vue
@@ -3,7 +3,7 @@
-
+
@@ -29,21 +29,23 @@
测量已完成该转运单已完成 PDA 测量,不再重复进入测量或托盘作业。
库存转运 · 无需测量该单来源于仓库现有库存,不需要重复测量或上传现场照片。请进入托盘作业,按来源托盘执行整托转运、拆托重打,或与相同目的仓合托。
-
+
当前测量任务{{ selected.transferNo }}
{{ cartonsCounted ? actualReceivedCartons : receivedCartonBoxes.length }}{{ cartonsCounted ? '实收箱' : '待确认箱' }}
+ {{ summaryCollapsed ? '展开 ⌄' : '收起 ⌃' }}
- {{ selected.packageCount || 0 }}客户预报{{ cartonsCounted ? actualReceivedCartons : '—' }}{{ cartonsCounted ? '当前实收' : '清点后确认' }}{{ !cartonsCounted ? '—' : cartonDifference > 0 ? `+${cartonDifference}` : cartonDifference }}{{ !cartonsCounted ? '差异待确认' : cartonDifference === 0 ? '无差异' : cartonDifference > 0 ? '超收差异' : '短收差异' }}
- {{ pallets.length }}托盘{{ totalVolume }}箱子总体积 m³{{ totalWeight }}箱子总重量 kg
+ {{ forecastSkuQuantity }}客户预报(件){{ cartonsCounted ? cartonQuantity : '—' }}SKU实收(件){{ !cartonsCounted ? '—' : hasSkuReceiptDifference ? skuReceiptDifferences.length + ' 种' : '无差异' }}{{ !cartonsCounted ? '差异待确认' : 'SKU签收差异' }}
+ {{ pallets.length }}托盘{{ totalVolume }}箱子总体积 m³{{ totalWeight }}箱子总重量 kg
{{ String(index + 1).padStart(2, '0') }}
- 托盘 {{ index + 1 }} · {{ pallet.bound ? '已绑定' : pallet.generated ? '待绑定' : '待创建' }}{{ pallet.collapsed && pallet.palletNo ? pallet.palletNo : '先创建托盘号,再录入并分配签收箱子' }}
-
+ 托盘 {{ index + 1 }}{{ pallet.bound ? '已绑定' : pallet.generated ? '待绑定' : '待创建' }}
+
- {{ palletSummary(pallet).cartonCount }} 箱{{ palletVolume(pallet) }} m³{{ palletSummary(pallet).weight.toFixed(2) }} kg
+ {{ palletSummary(pallet).cartonCount }} 箱实收箱数{{ palletVolume(pallet) }} m³体积{{ palletSummary(pallet).weight.toFixed(2) }} kg重量
+ {{ pallet.palletNo }}当前签收
本托箱子汇总由分配到本托的实收箱自动汇总,无需填写托盘尺寸和重量
{{ palletSummary(pallet).cartonCount }}实收箱数{{ palletVolume(pallet) }}箱子体积 m³{{ palletSummary(pallet).weight.toFixed(2) }}箱子重量 kg
@@ -57,7 +59,7 @@
请先生成托盘号,再填写签收箱子。
- 签收箱子
+ 签收箱子
@@ -67,27 +69,28 @@
批量所属托盘:{{ generatedPallets.find(pallet => String(pallet.draftId) === String(batchPalletId))?.palletNo || '请选择' }} ▾
-
+
已选 {{ batchTargetBoxes.length }} 箱 · 每箱 {{ Number(batchBox.items[0].quantity) || 0 }} 件 · 合计 {{ batchTotalQuantity }} 件
{{ batchCountMessage }}
-
- 第 {{ index + 1 }} 箱 · {{ box.cartonMark || '新增实收箱(确认后生成箱号)' }}
+
+ {{ box.cartonMark || '新增实收箱' }}
+
+ 第 {{ index + 1 }} 箱
+ {{ box.cartonMark || '新增实收箱' }}
- 客户箱唛:{{ box.customerCartonMark }}
- 可添加本单预报 SKU,数量按箱内实际件数填写
所属托盘:{{ cartonPalletLabel(box) }} ▾
-
+
+
+
-
-
-
+
测量现场照片完成测量并打托前至少拍摄 1 张{{ photoFileIds.length }} / 20 张
@@ -119,7 +122,7 @@ import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, resetWarehouseDomain, updateWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
-import { getWarehouseTransferCartonLabel, countWarehouseTransferCartons, bindWarehouseTransferPallet, deleteWarehouseTransferPallet, downloadWarehouseTransferMeasurementPhoto, downloadWarehouseTransferPalletLabel, generateWarehouseTransferPallet, getWarehouseTransferDetail, listWarehouseTransferCandidates, listWarehouseTransferMeasurementPhotos, palletizeWarehouseTransfer, saveWarehouseTransferPalletDraft, uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
+import { getWarehouseTransferCartonLabel, countWarehouseTransferCartons, reopenWarehouseTransferMeasurement, bindWarehouseTransferPallet, deleteWarehouseTransferPallet, downloadWarehouseTransferMeasurementPhoto, downloadWarehouseTransferPalletLabel, generateWarehouseTransferPallet, getWarehouseTransferDetail, listWarehouseTransferCandidates, listWarehouseTransferMeasurementPhotos, palletizeWarehouseTransfer, saveWarehouseTransferPalletDraft, uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
const newPallet = () => ({ key: `${Date.now()}-${Math.random()}`, cartonCount: 0, lengthCm: null, widthCm: null, heightCm: null, grossWeightKg: null, generated: false, bound: false, collapsed: false, palletNo: '', fbaWarehouseCode: '', labelDownloadPath: '', bindCode: '', busy: false })
const MAX_MEASUREMENT_PHOTOS = 20
@@ -135,7 +138,7 @@ const restoredPallet = (value, index) => ({
export default {
components: { PortalHeader, ScanTargetInput, CartonCountFields },
- data() { return { receivingPalletId: '', cartonPalletFilter: '', batchPalletId: '', batchCountOpen: false, batchSku: '', batchBox: newBatchBox(), batchSelectedIndexes: [], batchCountMessage: '', cartonScrollTarget: '', cartonBoxes: [], countDiscrepancyReason: '', countingCartons: false, cartonRequestKey: '', cartonSignature: '', user: session.currentUser, entryMode: 'work', pendingTaskNo: '', candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoNames: [], photoUrls: {}, completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
+ data() { return { summaryCollapsed: false, receivingPalletId: '', cartonPalletFilter: '', batchPalletId: '', batchCountOpen: false, batchSku: '', batchBox: newBatchBox(), batchSelectedIndexes: [], batchCountMessage: '', cartonScrollTarget: '', cartonBoxes: [], countDiscrepancyReason: '', countingCartons: false, cartonRequestKey: '', cartonSignature: '', user: session.currentUser, entryMode: 'work', pendingTaskNo: '', candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoNames: [], photoUrls: {}, completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
computed: {
generatedPallets() { return this.pallets.filter(pallet => pallet.generated && pallet.draftId) },
declaredSkuCodes() { return [...new Set((this.selected?.items || []).map(item => item.skuCode))] },
@@ -143,19 +146,30 @@ export default {
batchTargetBoxes() { return this.cartonBoxes.filter((box, index) => this.batchSelectedIndexes.includes(index) && box.received !== false && box.items.length === 1 && box.items[0]?.skuCode === this.batchSku) },
batchTotalQuantity() { const quantity = Number(this.batchBox.items[0].quantity); return Number.isSafeInteger(quantity) && quantity > 0 ? this.batchTargetBoxes.length * quantity : 0 },
batchCountReady() { return this.batchTargetBoxes.length > 0 && this.validCartonValues(this.batchBox) && this.generatedPallets.some(pallet => String(pallet.draftId) === String(this.batchPalletId)) },
+ // 确认快照与当前内容一致,才允许保存整单清点结果。
+ // 按 SKU 分别核对件数,不能让不同 SKU 的短收与超收相互抵消。
+ forecastSkuQuantity() { return (this.selected?.items || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0) },
+ skuReceiptDifferences() {
+ const expected = new Map(), received = new Map()
+ for (const item of this.selected?.items || []) expected.set(item.skuCode, (expected.get(item.skuCode) || 0) + Number(item.quantity || 0))
+ for (const box of this.receivedCartonBoxes) for (const line of box.items) received.set(line.skuCode, (received.get(line.skuCode) || 0) + Number(line.quantity || 0))
+ return [...new Set([...expected.keys(), ...received.keys()])].filter(sku => (received.get(sku) || 0) !== (expected.get(sku) || 0))
+ },
+ hasSkuReceiptDifference() { return this.skuReceiptDifferences.length > 0 },
+ allCartonsConfirmed() { return this.receivedCartonBoxes.length > 0 && this.receivedCartonBoxes.every(box => this.cartonConfirmed(box)) },
actualReceivedCartons() { return this.cartonsCounted ? this.receivedCartonBoxes.length : 0 },
cartonsCounted() { return Boolean(this.selected?.cartons?.length) && this.selected.cartons.every(box => box.countedAt) },
- cartonVolume() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.length) || 0) * (Number(box.width) || 0) * (Number(box.height) || 0) / 1000000, 0).toFixed(6) },
+ cartonVolume() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.length) || 0) * (Number(box.width) || 0) * (Number(box.height) || 0) / 1000000, 0).toFixed(3) },
cartonWeight() { return this.receivedCartonBoxes.reduce((sum, box) => sum + (Number(box.weight) || 0), 0).toFixed(2) },
cartonQuantity() { return this.receivedCartonBoxes.reduce((sum, box) => sum + box.items.reduce((total, line) => total + (Number(line.quantity) || 0), 0), 0) },
receivedCartonBoxes() { return this.cartonBoxes.filter(box => box.received !== false) },
- cartonCountReady() { return this.receivedCartonBoxes.length > 0 && this.pallets.every(pallet => pallet.generated && this.palletSummary(pallet).cartonCount > 0) && (this.receivedCartonBoxes.length === Number(this.selected?.packageCount) || Boolean(this.countDiscrepancyReason.trim())) && this.receivedCartonBoxes.every(box => this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId)) && [box.length, box.width, box.height, box.weight].every(value => Number(value) >= 0.01) && this.validCartonSkuLines(box)) },
+ cartonCountReady() { return this.receivedCartonBoxes.length > 0 && this.pallets.every(pallet => pallet.generated && this.palletSummary(pallet).cartonCount > 0) && this.receivedCartonBoxes.every(box => this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId)) && [box.length, box.width, box.height, box.weight].every(value => Number(value) >= 0.01) && this.validCartonSkuLines(box)) },
completedDestinationLabel() { return this.destinationLabel({ fbaWarehouseCode: this.completedDestination, destinationType: this.completedDestinationType }) },
totalCartons() { return this.pallets.reduce((sum, pallet) => sum + this.palletSummary(pallet).cartonCount, 0) },
cartonDifference() { return this.actualReceivedCartons - Number(this.selected?.packageCount || 0) },
isInventoryTransfer() { return Boolean(this.selected && this.selected.status === 'WAIT_PDA_RECEIVING' && this.selected.inboundCode && this.selected.inboundCode !== this.selected.transferNo) },
isMeasurementComplete() { return Boolean(this.selected && !['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'].includes(String(this.selected.status))) },
- totalVolume() { return this.pallets.reduce((sum, pallet) => sum + this.rawPalletVolume(pallet), 0).toFixed(6) },
+ totalVolume() { return this.pallets.reduce((sum, pallet) => sum + this.rawPalletVolume(pallet), 0).toFixed(3) },
totalWeight() { return this.pallets.reduce((sum, pallet) => sum + this.palletSummary(pallet).weight, 0).toFixed(2) },
validationMessage() {
if (this.isInventoryTransfer) return ''
@@ -172,7 +186,7 @@ export default {
},
watch: {
countDiscrepancyReason() { this.saveCartonDraft() },
- cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
+ cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
},
onShow() { if (requireSession()) { this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.loadCandidates().then(() => { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); const taskNo = this.pendingTaskNo || activeNo; if (this.entryMode === 'work' && taskNo) { const active = this.candidates.find((item) => String(item.transferNo) === taskNo); if (active) { const fromExplicitTask = Boolean(this.pendingTaskNo); this.pendingTaskNo = ''; void this.select(active, fromExplicitTask) } } }) } },
@@ -180,6 +194,31 @@ export default {
onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() },
methods: {
+ // 快照只包含业务数据;复制、批量修改或改托后会自动失效并重新展开。
+ cartonConfirmationSignature(box) {
+ return JSON.stringify({ pallet: String(box.transferPalletId || ''), dimensions: [box.length, box.width, box.height, box.weight].map(Number), items: box.items.map(line => [line.skuCode, Number(line.quantity)]) })
+ },
+ cartonConfirmed(box) { return Boolean(box.confirmedSignature && box.confirmedSignature === this.cartonConfirmationSignature(box)) },
+ // 单箱先保存设备草稿,最后一箱确认后统一提交后端已有的整单清点事务。
+ async confirmSingleCarton(box) {
+ if (this.countingCartons || this.submitting || this.cartonsCounted || !this.validCartonValues(box) || !this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId))) return
+ box.confirmedSignature = this.cartonConfirmationSignature(box)
+ this.error = null; this.saveCartonDraft()
+ if (this.allCartonsConfirmed && this.cartonCountReady) await this.confirmCartons()
+ },
+ // 已保存清点时先请求服务端重开;成功后只展开当前箱,其他箱继续保留确认状态。
+ async editCarton(box) {
+ if (this.countingCartons || this.submitting || this.pallets.some(pallet => pallet.busy)) return
+ if (!this.cartonsCounted) { box.confirmedSignature = ''; this.saveCartonDraft(); return }
+ const mark = box.cartonMark
+ this.countingCartons = true; this.error = null
+ try {
+ const detail = await reopenWarehouseTransferMeasurement(this.selected.transferNo)
+ uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.restoreCartons()
+ this.cartonBoxes.forEach(current => { current.confirmedSignature = current.cartonMark === mark ? '' : this.cartonConfirmationSignature(current) })
+ this.pendingKey = ''; this.saveCartonDraft()
+ } catch (error) { this.error = error } finally { this.countingCartons = false }
+ },
useReceivingPallet(pallet) {
if (this.cartonsCounted || this.countingCartons || !pallet.generated) return;
this.receivingPalletId = pallet.draftId; this.batchPalletId = pallet.draftId; this.cartonPalletFilter = pallet.draftId;
@@ -228,15 +267,17 @@ export default {
if (!target || target.received === false || !this.validCartonValues(source)) return;
this.copyCartonValues(source, target); this.saveCartonDraft();
},
- saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
+ saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
restoreCartons() {
const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || '', quantity: box.quantity ?? '' }] }));
const draft = uni.getStorageSync(this.cartonDraftKey());
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || rows.some(row => row.cartonMark === box.cartonMark))) {
- this.cartonBoxes = draft.boxes; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = draft.key || ''; this.cartonSignature = draft.signature || '';
+ const knownMarks = new Set(draft.forecastMarks || draft.boxes.map(box => box.cartonMark));
+ this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = draft.key || ''; this.cartonSignature = draft.signature || '';
} else this.cartonBoxes = rows;
+ this.cartonBoxes.forEach(box => { if (this.cartonsCounted || (box.status === 'COUNTED' && this.validCartonValues(box))) box.confirmedSignature = this.cartonConfirmationSignature(box) });
this.receivingPalletId = this.generatedPallets[0]?.draftId || ''; this.cartonPalletFilter = ''; this.batchPalletId = this.receivingPalletId; this.batchCountOpen = false; this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.batchSku = this.batchSkuCodes[0] || ''; this.batchBox = newBatchBox(); this.batchBox.items[0].skuCode = this.batchSku;
},
async printCartonLabels() {
@@ -273,10 +314,10 @@ export default {
this.countingCartons = true; this.error = null;
try {
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
- const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason });
+ const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
if (!this.cartonRequestKey || signature !== this.cartonSignature) { this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
- uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
- const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason });
+ uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
+ const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
} catch (error) { this.error = error } finally { this.countingCartons = false }
@@ -291,8 +332,9 @@ export default {
const boxes = this.receivedCartonBoxes.filter(box => pallet.draftId && String(box.transferPalletId) === String(pallet.draftId));
return { cartonCount: boxes.length, volume: boxes.reduce((sum,box) => sum + (Number(box.length)||0)*(Number(box.width)||0)*(Number(box.height)||0)/1000000,0), weight: boxes.reduce((sum,box) => sum + (Number(box.weight)||0),0) };
},
- rawPalletVolume(pallet) { return this.palletSummary(pallet).volume },
- palletVolume(pallet) { return this.rawPalletVolume(pallet).toFixed(6) },
+ // 逐托汇总后取三位,与服务端落库精度一致。
+ rawPalletVolume(pallet) { return Number(this.palletSummary(pallet).volume.toFixed(3)) },
+ palletVolume(pallet) { return this.rawPalletVolume(pallet).toFixed(3) },
cartonPalletLabel(box) { return this.generatedPallets.find(pallet => String(pallet.draftId) === String(box.transferPalletId))?.palletNo || '请选择托盘' },
assignCartonPallet(box, index) { if (!this.cartonsCounted && !this.countingCartons && box.received !== false) box.transferPalletId = this.generatedPallets[index]?.draftId || '' },
applyPallet(target, value) { Object.assign(target, restoredPallet(value, this.pallets.indexOf(target)), { key: target.key, busy: false, collapsed: false }) },
@@ -318,6 +360,16 @@ export default {
catch (error) { this.error = error; pallet.busy = false }
},
async printDraftPallet(pallet) { if (!pallet.labelDownloadPath) { this.error = new Error('服务端未返回托盘标签下载地址'); return }; await this.openLabelPdf(() => downloadWarehouseTransferPalletLabel(pallet.labelDownloadPath)) },
+ // 刷新本单时合并预报新增箱号,保留本机已填写的测量草稿。
+ async refreshCurrentTransfer() {
+ if (!this.selected || this.loading || this.submitting || this.countingCartons || this.pallets.some(pallet => pallet.busy)) return
+ this.saveCartonDraft(); this.loading = true; this.error = null
+ try {
+ const detail = await getWarehouseTransferDetail(this.selected.transferNo)
+ this.selected = detail; this.pallets = detail.pallets?.length ? detail.pallets.map(restoredPallet) : [newPallet()]
+ this.restoreCartons(); this.pendingKey = ''; await this.restorePhotos(detail.transferNo, detail.measurementPhotos)
+ } catch (error) { this.error = error } finally { this.loading = false }
+ },
async loadCandidates() {
this.loading = true; this.error = null
try {
@@ -352,13 +404,36 @@ export default {
this.candidates = this.candidates.map((candidate) => String(candidate.transferNo) === String(item.transferNo) ? { ...candidate, status: 'PARTIALLY_RECEIVED' } : candidate)
if (this.entryMode === 'list') { uni.navigateTo({ url: `/pages/warehouse-transfer/warehouse-transfer?mode=work&task=${encodeURIComponent(String(item.transferNo))}` }); return }
this.detailLoading = true; this.error = null; this.success = false; this.completedPallets = []; this.completedTransferNo = ''; this.completedDestination = ''; this.completedDestinationType = ''
- try { this.selected = await getWarehouseTransferDetail(item.transferNo); this.pendingKey = ''; this.pallets = this.selected.pallets?.length ? this.selected.pallets.map(restoredPallet) : [newPallet()]; this.restoreCartons(); await this.restorePhotos(item.transferNo, this.selected.measurementPhotos) }
+ try { this.selected = await getWarehouseTransferDetail(item.transferNo); this.summaryCollapsed = false; this.pendingKey = ''; this.pallets = this.selected.pallets?.length ? this.selected.pallets.map(restoredPallet) : [newPallet()]; this.restoreCartons(); await this.restorePhotos(item.transferNo, this.selected.measurementPhotos) }
catch (e) { this.selected = null; this.error = e }
finally { this.detailLoading = false }
},
backToCandidates() { if (this.submitting || this.detailLoading || this.countingCartons || this.pallets.some(pallet => pallet.busy)) return; this.entryMode = 'list'; this.selected = null; this.error = null; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = [] },
addPallet() { if (this.cartonsCounted || this.countingCartons) return; this.pendingKey = ''; this.pallets.push(newPallet()) },
- async removePallet(index) { const pallet = this.pallets[index]; if (!pallet || pallet.generated || pallet.busy) return; pallet.busy = true; try { if (pallet.draftId) await deleteWarehouseTransferPallet(this.selected.transferNo, pallet.draftId); this.pallets.splice(index, 1); this.pendingKey = '' } catch (error) { pallet.busy = false; this.error = error } },
+ async remeasure() {
+ if (!this.selected || this.countingCartons || this.submitting || this.pallets.some(pallet => pallet.busy)) return;
+ this.countingCartons = true; this.error = null;
+ try {
+ const detail = await reopenWarehouseTransferMeasurement(this.selected.transferNo);
+ uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.restoreCartons(); this.pendingKey = '';
+ this.cartonScrollTarget = 'transfer-boxes';
+ } catch (error) { this.error = error } finally { this.countingCartons = false }
+ },
+ async removePallet(index) {
+ const pallet = this.pallets[index];
+ if (!pallet || this.countingCartons || this.submitting || this.pallets.some(item => item.busy)) return;
+ pallet.busy = true;
+ try {
+ const answer = await new Promise((resolve, reject) => uni.showModal({ title: '删除托盘', content: '删除后保留箱子和测量数据,本托箱子需重新选择托盘并确认清点。是否继续?', success: resolve, fail: reject }));
+ if (!answer.confirm) return;
+ this.error = null;
+ if (pallet.draftId) this.selected = await deleteWarehouseTransferPallet(this.selected.transferNo, pallet.draftId);
+ this.cartonBoxes.forEach(box => { if (pallet.draftId && String(box.transferPalletId) === String(pallet.draftId)) box.transferPalletId = '' });
+ this.pallets.splice(index, 1); if (!this.pallets.length) this.pallets = [newPallet()];
+ this.receivingPalletId = this.generatedPallets[0]?.draftId || ''; this.batchPalletId = this.receivingPalletId; this.cartonPalletFilter = ''; this.activePalletKey = '';
+ this.cartonRequestKey = ''; this.cartonSignature = ''; this.pendingKey = ''; this.batchSelectedIndexes = []; this.saveCartonDraft();
+ } catch (error) { this.error = error } finally { pallet.busy = false }
+ },
async restorePhotos(transferNo, knownPhotos) {
let photos
try {
@@ -416,8 +491,23 @@ export default {
if (this.validationMessage) { this.error = new Error(this.validationMessage); return }
this.submitting = true; this.error = null
try {
- if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
- const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds] }
+ // 清点、绑托和拍照完成后,仅 SKU 件数差异要求填写原因。
+ if (this.hasSkuReceiptDifference) {
+ const answer = await new Promise((resolve, reject) => uni.showModal({
+ title: 'SKU签收数量存在差异', content: this.countDiscrepancyReason || '', editable: true,
+ placeholderText: '请填写SKU签收差异原因(必填)', confirmText: '确认完成',
+ success: resolve, fail: reject
+ }))
+ if (!answer.confirm) return
+ const reason = String(answer.content || '').trim()
+ if (!reason) { this.error = new Error('请填写差异原因后再完成测量'); return }
+ if (reason.length > 1000) { this.error = new Error('差异原因不能超过1000字'); return }
+ if (reason !== this.countDiscrepancyReason) this.pendingKey = ''
+ this.countDiscrepancyReason = reason
+ this.saveCartonDraft()
+ }
+ if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
+ const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds], discrepancyReason: this.hasSkuReceiptDifference ? this.countDiscrepancyReason : null }
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
} catch (e) { this.error = e } finally { this.submitting = false }
},
@@ -444,7 +534,11 @@ export default {
diff --git a/tests/warehouse-transfer-api.test.mjs b/tests/warehouse-transfer-api.test.mjs
index 1b062a9..a4bf222 100644
--- a/tests/warehouse-transfer-api.test.mjs
+++ b/tests/warehouse-transfer-api.test.mjs
@@ -56,8 +56,8 @@ test('warehouse transfer removes completed work from the pending list', async ()
test('warehouse transfer shows forecast and actual variance and requires counted box reconciliation', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
- for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
- assert.match(page, /cartonDifference/)
+ for (const label of ['客户预报', 'SKU实收', 'SKU签收差异']) assert.match(page, new RegExp(label))
+ assert.match(page, /hasSkuReceiptDifference/)
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
})
@@ -137,7 +137,7 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
assert.match(page, /handlePalletScan/)
assert.match(page, /扫描或手输新托盘标签号/)
assert.match(page, /已绑定实物托盘/)
- assert.match(page, /!pallet\.generated.*removePallet/)
+ assert.match(page, /@tap\.stop="removePallet\(index\)">×/)
assert.match(page, /请先生成托盘号,再填写签收箱子/)
assert.match(page, /collapsed: false/)
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
@@ -149,5 +149,5 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
assert.match(page, /bindWarehouseTransferPallet/)
assert.match(page, /deleteWarehouseTransferPallet/)
assert.match(page, /请先生成并扫描确认绑定全部托盘/)
- assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
+ assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\], discrepancyReason:/)
})
diff --git a/tests/warehouse-transfer-counting.test.mjs b/tests/warehouse-transfer-counting.test.mjs
index 1bda1bf..21fb8f3 100644
--- a/tests/warehouse-transfer-counting.test.mjs
+++ b/tests/warehouse-transfer-counting.test.mjs
@@ -11,18 +11,63 @@ function setup() {
const value = scope.page.data()
for(const [key,method] of Object.entries(scope.page.methods)) value[key]=method.bind(value)
for(const [key,get] of Object.entries(scope.page.computed)) Object.defineProperty(value,key,{get:()=>get.call(value)})
- value.selected={transferNo:'WT1',packageCount:1,status:'WAIT_PDA_RECEIVING',items:[{id:'I1',skuCode:'A'}],cartons:[{cartonMark:'BOX1',transferItemId:'I1',status:'EXPECTED'}]}
+ value.selected={transferNo:'WT1',packageCount:1,status:'WAIT_PDA_RECEIVING',items:[{id:'I1',skuCode:'A',quantity:4}],cartons:[{cartonMark:'BOX1',transferItemId:'I1',status:'EXPECTED'}]}
value.pallets=[{key:'P1',draftId:'P1',palletNo:'PALLET1',generated:true,bound:false}];value.restoreCartons();Object.assign(value.cartonBoxes[0],{length:10,width:20,height:30,weight:2});value.cartonBoxes[0].items[0].quantity=4
return {scope,value,storage}
}
+test('summary toggle preserves measurements and volume uses three decimals',()=>{
+ const {value}=setup()
+ assert.equal(value.summaryCollapsed,false)
+ value.summaryCollapsed=true
+ assert.equal(value.cartonBoxes[0].weight,2)
+ Object.assign(value.cartonBoxes[0],{length:10,width:10,height:6})
+ value.addExtraCarton()
+ Object.assign(value.cartonBoxes[1],{length:10,width:10,height:6,weight:2})
+ assert.equal(value.palletVolume(value.pallets[0]),'0.001')
+ assert.equal(value.totalVolume,'0.001')
+ assert.match(source,/aria-label="添加 SKU"/)
+ assert.match(source,/aria-label="删除箱子"/)
+})
+
+test('remeasure preserves saved measurements and unlocks carton editing',async()=>{
+ const {value,scope}=setup();
+ const box={...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'};
+ value.selected.cartons=[box]; value.cartonRequestKey='old-key';
+ scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:[{...box,countedAt:null}]});
+ await value.remeasure();
+ assert.equal(value.cartonsCounted,false); assert.equal(value.cartonBoxes[0].weight,2);
+ assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.cartonRequestKey,'');
+ assert.equal(value.cartonCountReady,true);
+})
+
+test('bound pallet deletion retains box values and requires reassignment',async()=>{
+ const {value,scope}=setup();
+ scope.uni.showModal=options=>options.success({confirm:true});
+ value.pallets[0].bound=true;
+ scope.deleteWarehouseTransferPallet=async()=>({...value.selected,pallets:[]});
+ await value.removePallet(0);
+ assert.equal(value.cartonBoxes[0].transferPalletId,''); assert.equal(value.cartonBoxes[0].weight,2);
+ assert.equal(value.generatedPallets.length,0); assert.equal(value.pallets.length,1);
+ assert.equal(value.cartonCountReady,false); assert.equal(value.cartonPalletFilter,'');
+})
+
+test('failed pallet deletion retains binding and measurements',async()=>{
+ const {value,scope}=setup();
+ scope.uni.showModal=options=>options.success({confirm:true});
+ scope.deleteWarehouseTransferPallet=async()=>{throw new Error('offline')};
+ await value.removePallet(0);
+ assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.pallets[0].busy,false);
+ assert.equal(value.error.message,'offline');
+})
+
test('transfer counts require valid pieces and measurements and record variance reason',()=>{
const {value}=setup();assert.equal(value.cartonCountReady,true)
value.cartonBoxes[0].items[0].quantity=0;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].items[0].quantity=1.5;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].items[0].quantity=4;value.cartonBoxes[0].height=0;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].height=30;value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
- assert.equal(value.cartonCountReady,false);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
- assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012000')
+ assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
+ assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
})
test('network retry preserves request key and changed content uses a new request',async()=>{
const {value,scope}=setup();const requests=[]
@@ -32,11 +77,9 @@ test('network retry preserves request key and changed content uses a new request
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
})
-test('draft restores additional cartons and discrepancy reason and scanning rejects foreign box',()=>{
+test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
const {value}=setup();value.addExtraCarton();value.countDiscrepancyReason='超收';value.saveCartonDraft();value.restoreCartons()
assert.equal(value.cartonBoxes.length,2);assert.equal(value.countDiscrepancyReason,'超收')
- value.locateCarton('FOREIGN');assert.match(value.error.message,/不属于/)
- value.locateCarton('BOX1');assert.equal(value.selectedCartonMark,'BOX1');assert.equal(value.cartonScrollTarget,'transfer-box-0')
value.selected={...value.selected,transferNo:'WT2'};value.restoreCartons();assert.equal(value.cartonBoxes.length,1)
})
test('received count gates pallet completion and saving cannot switch active order',()=>{
@@ -47,6 +90,7 @@ test('received count gates pallet completion and saving cannot switch active ord
test('batch applies only selected received boxes of one SKU and preserves independent drafts',()=>{
const {value}=setup()
+ value.selected.items.push({id:'I2',skuCode:'B'})
value.addExtraCarton();value.addExtraCarton();value.addExtraCarton()
value.cartonBoxes[2].items[0].skuCode='B'
value.cartonBoxes[3].received=false
@@ -127,3 +171,83 @@ test('pallet number can be created before measurements and new boxes receive tha
assert.equal(value.cartonBoxes[0].transferPalletId,'CREATED')
assert.equal(value.pallets[0].palletNo,'NEWPALLET')
})
+
+// 最终完成入口需要区分无差异、取消、空白原因和网络重试。
+for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:true,content:'少到一箱'}]) {
+ test('completion handles discrepancy answer '+JSON.stringify(answer),async()=>{
+ const {value,scope}=setup();let requests=[];let prompts=0
+ value.selected.items[0].quantity=5;value.selected.cartons[0].countedAt='2026-10-16'
+ value.pallets[0].bound=true;value.photoFileIds=[91]
+ scope.uni.showModal=options=>{prompts++;options.success(answer)}
+ scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
+ await value.submit()
+ assert.equal(prompts,1);assert.equal(value.submitting,false)
+ assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
+ if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)}
+ })
+}
+test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{
+ const {value,scope}=setup();let prompts=0;let requests=[]
+ value.selected.packageCount=9;value.selected.cartons[0].countedAt='2026-10-16';value.pallets[0].bound=true
+ scope.uni.showModal=()=>{prompts++}
+ scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
+ await value.submit();assert.equal(requests.length,0);assert.equal(prompts,0)
+ value.photoFileIds=[91];await value.submit();assert.equal(prompts,0);assert.equal(requests.length,1);assert.equal(requests[0].discrepancyReason,null)
+})
+
+// 逐箱确认收起、再次编辑及草稿恢复必须保留其他箱的确认状态。
+test('individual confirmation collapses only that box and resumes from draft',async()=>{
+ const {value,scope}=setup();value.addExtraCarton()
+ Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
+ let saves=0;scope.countWarehouseTransferCartons=async()=>{saves++;throw new Error('offline')}
+ await value.confirmSingleCarton(value.cartonBoxes[0])
+ assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true);assert.equal(value.allCartonsConfirmed,false);assert.equal(saves,0)
+ value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
+ await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
+ await value.confirmSingleCarton(value.cartonBoxes[0]);await value.confirmSingleCarton(value.cartonBoxes[1])
+ assert.equal(value.allCartonsConfirmed,true);assert.equal(saves,1);assert.equal(value.error.message,'offline')
+ value.cartonBoxes[0].weight=3;assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.allCartonsConfirmed,false)
+})
+test('saved boxes restore collapsed and editing reopens only selected box',async()=>{
+ const {value,scope}=setup()
+ const first={...value.cartonBoxes[0],quantity:4,countedAt:'2026-10-16',status:'COUNTED'}
+ const second={...first,cartonMark:'BOX2'}
+ value.selected.cartons=[first,second];value.restoreCartons()
+ assert.equal(value.cartonBoxes.every(box=>value.cartonConfirmed(box)),true)
+ scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))})
+ await value.editCarton(value.cartonBoxes[0])
+ assert.equal(value.cartonsCounted,false);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
+ value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
+})
+test('failed reopening preserves confirmed data and invalid box cannot confirm',async()=>{
+ const {value,scope}=setup();value.cartonBoxes[0].weight=0
+ await value.confirmSingleCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
+ value.cartonBoxes[0].weight=2;value.selected.cartons=[{...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'}];value.restoreCartons()
+ scope.reopenWarehouseTransferMeasurement=async()=>{throw new Error('offline')}
+ await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonsCounted,true);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
+})
+
+// 箱数变化不影响 SKU 门禁,异 SKU 正负差异不能按总件数抵消。
+test('SKU differences compare each SKU and ignore carton count',()=>{
+ const {value}=setup();value.selected.packageCount=9
+ assert.equal(value.hasSkuReceiptDifference,false)
+ value.selected.items.push({id:'I2',skuCode:'B',quantity:3})
+ value.cartonBoxes[0].items=[{skuCode:'A',quantity:3},{skuCode:'B',quantity:4}]
+ assert.equal(value.cartonQuantity,value.forecastSkuQuantity)
+ assert.equal(value.skuReceiptDifferences.length,2)
+ value.cartonBoxes[0].items=[{skuCode:'A',quantity:4},{skuCode:'B',quantity:3}]
+ assert.equal(value.hasSkuReceiptDifference,false)
+})
+
+// 客户补箱后,新增预报箱进入PDA,原本机草稿和删除标记不丢失。
+test('forecast additions merge into existing draft without losing edited boxes',()=>{
+ const {value}=setup();value.cartonBoxes[0].weight=7;value.saveCartonDraft()
+ value.selected.packageCount=2;value.selected.cartons.push({cartonMark:'BOX2',transferItemId:'I1',status:'EXPECTED'})
+ value.restoreCartons();assert.equal(value.cartonBoxes.length,2);assert.equal(value.cartonBoxes[0].weight,7);assert.equal(value.cartonBoxes[1].cartonMark,'BOX2')
+ value.removeCarton(0);value.restoreCartons();assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].cartonMark,'BOX2')
+})
+test('count payload carries forecast version for stale snapshot rejection',async()=>{
+ const {value,scope}=setup();let payload
+ scope.countWarehouseTransferCartons=async(no,request)=>{payload=request;throw new Error('offline')}
+ await value.confirmCartons();assert.equal(payload.forecastPackageCount,1);assert.equal(payload.boxes[0].cartonMark,'BOX1')
+})
diff --git a/tests/warehouse-transfer-mixed-cartons.test.mjs b/tests/warehouse-transfer-mixed-cartons.test.mjs
index e625e00..bdf13d4 100644
--- a/tests/warehouse-transfer-mixed-cartons.test.mjs
+++ b/tests/warehouse-transfer-mixed-cartons.test.mjs
@@ -58,14 +58,14 @@ test('delete only changes actual draft and preserves forecast; additions keep th
test('draft reload preserves deletions, including an empty actual list', () => {
const ctx = context()
ctx.selected.cartons = [{ cartonMark: 'BOX1', transferItemId: '1', status: 'EXPECTED' }, { cartonMark: 'BOX2', transferItemId: '2', status: 'EXPECTED' }]
- let saved = { boxes: [{ ...box('B'), cartonMark: 'BOX2', transferItemId: '2' }], discrepancyReason: '少到一箱' }
+ let saved = { forecastMarks: ['BOX1','BOX2'], boxes: [{ ...box('B'), cartonMark: 'BOX2', transferItemId: '2' }], discrepancyReason: '少到一箱' }
globalThis.uni = { getStorageSync: () => saved }
try {
ctx.restoreCartons()
assert.equal(ctx.cartonBoxes.length, 1)
assert.equal(ctx.cartonBoxes[0].cartonMark, 'BOX2')
assert.equal(ctx.countDiscrepancyReason, '少到一箱')
- saved = { boxes: [], discrepancyReason: '重新录入' }
+ saved = { forecastMarks: ['BOX1','BOX2'], boxes: [], discrepancyReason: '重新录入' }
ctx.restoreCartons()
assert.equal(ctx.cartonBoxes.length, 0)
assert.equal(ctx.selected.cartons.length, 2)
@@ -93,7 +93,7 @@ test('100 same SKU cartons accept two distinct specification groups and independ
assert.equal(ctx.palletSummary(ctx.pallets[1]).cartonCount, 40)
assert.equal(ctx.cartonQuantity, 1040)
assert.equal(ctx.totalWeight, '580.00')
- assert.equal(ctx.totalVolume, '3.840000')
+ assert.equal(ctx.totalVolume, '3.840')
assert.equal(ctx.cartonCountReady, true)
ctx.cartonBoxes[0].items[0].quantity = 11
ctx.cartonBoxes[0].weight = 6
--
2.54.0
From d4225ff5a1c98bb5394143be2fa934099268e1fd Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Fri, 16 Oct 2026 16:53:09 +0800
Subject: [PATCH 15/39] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
src/pages/warehouse-transfer/warehouse-transfer.vue | 6 +++---
tests/warehouse-transfer-counting.test.mjs | 10 ++++++++++
tests/warehouse-transfer-mixed-cartons.test.mjs | 10 ++++++++++
4 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f3207ef..57a8218 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,7 +61,7 @@
## 海外业务接口边界
-- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;SKU件数逐种核对差异,不以预报箱数强制核平。箱唛沿用客户预报编号,PDA不得另造新箱号;测量完成前客户可增加预报箱数,PDA刷新时合并新箱并保留本机草稿,清点提交携带forecastPackageCount拦截旧快照。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求提交稳定幂等键、照片文件 ID,SKU实收件数有差异时在完成入口弹窗必填差异原因,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
+- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;SKU件数逐种核对差异,不以预报箱数强制核平。箱唛沿用客户预报编号,PDA不得另造新箱号;测量完成前客户与平台可增加整单预报箱数,不选SKU,箱内SKU以PDA实点为准,PDA刷新时合并新箱并保留本机草稿,清点提交携带forecastPackageCount拦截旧快照。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求提交稳定幂等键、照片文件 ID,SKU实收件数有差异时在完成入口弹窗必填差异原因,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。
diff --git a/src/pages/warehouse-transfer/warehouse-transfer.vue b/src/pages/warehouse-transfer/warehouse-transfer.vue
index 64deff4..522f225 100644
--- a/src/pages/warehouse-transfer/warehouse-transfer.vue
+++ b/src/pages/warehouse-transfer/warehouse-transfer.vue
@@ -85,7 +85,7 @@
所属托盘:{{ cartonPalletLabel(box) }} ▾
-
+
@@ -270,7 +270,7 @@ export default {
saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
restoreCartons() {
- const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || '', quantity: box.quantity ?? '' }] }));
+ const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: box.quantity ?? '' }] }));
const draft = uni.getStorageSync(this.cartonDraftKey());
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || rows.some(row => row.cartonMark === box.cartonMark))) {
@@ -306,7 +306,7 @@ export default {
if (skuCode) box.items.push({ skuCode, quantity: '' });
},
validCartonSkuLines(box) {
- return box.items.length > 0 && box.items.length <= 200 && (!box.cartonMark || box.items.some(line => line.skuCode === this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode)) && new Set(box.items.map(line => line.skuCode)).size === box.items.length
+ return box.items.length > 0 && box.items.length <= 200 && new Set(box.items.map(line => line.skuCode)).size === box.items.length
&& box.items.every(line => this.declaredSkuCodes.includes(line.skuCode) && Number.isSafeInteger(Number(line.quantity)) && Number(line.quantity) > 0 && Number(line.quantity) <= 2147483647);
},
async confirmCartons() {
diff --git a/tests/warehouse-transfer-counting.test.mjs b/tests/warehouse-transfer-counting.test.mjs
index 21fb8f3..d69202d 100644
--- a/tests/warehouse-transfer-counting.test.mjs
+++ b/tests/warehouse-transfer-counting.test.mjs
@@ -251,3 +251,13 @@ test('count payload carries forecast version for stale snapshot rejection',async
scope.countWarehouseTransferCartons=async(no,request)=>{payload=request;throw new Error('offline')}
await value.confirmCartons();assert.equal(payload.forecastPackageCount,1);assert.equal(payload.boxes[0].cartonMark,'BOX1')
})
+
+test('新增预报箱无SKU绑定时恢复为可编辑箱,保留已有草稿', () => {
+ const { value } = setup()
+ value.selected.cartons.push({ cartonMark: 'NEW-UNBOUND', cartonIndex: 2, status: 'EXPECTED', transferItemId: null })
+ value.selected.packageCount = 2
+ value.restoreCartons()
+ const added = value.cartonBoxes.find(box => box.cartonMark === 'NEW-UNBOUND')
+ assert.ok(added)
+ assert.ok(value.declaredSkuCodes.includes(added.items[0].skuCode))
+})
diff --git a/tests/warehouse-transfer-mixed-cartons.test.mjs b/tests/warehouse-transfer-mixed-cartons.test.mjs
index bdf13d4..ccae108 100644
--- a/tests/warehouse-transfer-mixed-cartons.test.mjs
+++ b/tests/warehouse-transfer-mixed-cartons.test.mjs
@@ -115,3 +115,13 @@ test('copy previous matching SKU skips other SKUs and preserves the target palle
ctx.cartonBoxes[2].items[0].quantity = 9
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 12)
})
+
+test('预报箱可调整原SKU,新增无绑定箱也可清点', () => {
+ const ctx = context(); const current = { ...box('B', 4), cartonMark: 'FORECAST-1', transferItemId: '1' }
+ assert.equal(ctx.validCartonSkuLines(current), true)
+ current.transferItemId = null
+ assert.equal(ctx.validCartonSkuLines(current), true)
+ current.items[0].skuCode = 'OTHER'
+ assert.equal(ctx.validCartonSkuLines(current), false)
+ assert.doesNotMatch(source, /:lock-first-sku="Boolean\(box.cartonMark\)"/)
+})
--
2.54.0
From f8533772b754e882dbf9148aeb122ac24e245eca Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Fri, 16 Oct 2026 17:43:48 +0800
Subject: [PATCH 16/39] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../warehouse-transfer/warehouse-transfer.vue | 60 +++++++++++++++----
tests/warehouse-transfer-counting.test.mjs | 44 +++++++++++++-
.../warehouse-transfer-mixed-cartons.test.mjs | 6 +-
3 files changed, 93 insertions(+), 17 deletions(-)
diff --git a/src/pages/warehouse-transfer/warehouse-transfer.vue b/src/pages/warehouse-transfer/warehouse-transfer.vue
index 522f225..f76f5bd 100644
--- a/src/pages/warehouse-transfer/warehouse-transfer.vue
+++ b/src/pages/warehouse-transfer/warehouse-transfer.vue
@@ -90,6 +90,7 @@
+
@@ -138,8 +139,17 @@ const restoredPallet = (value, index) => ({
export default {
components: { PortalHeader, ScanTargetInput, CartonCountFields },
- data() { return { summaryCollapsed: false, receivingPalletId: '', cartonPalletFilter: '', batchPalletId: '', batchCountOpen: false, batchSku: '', batchBox: newBatchBox(), batchSelectedIndexes: [], batchCountMessage: '', cartonScrollTarget: '', cartonBoxes: [], countDiscrepancyReason: '', countingCartons: false, cartonRequestKey: '', cartonSignature: '', user: session.currentUser, entryMode: 'work', pendingTaskNo: '', candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoNames: [], photoUrls: {}, completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
+ data() { return { deletedCartonBoxes: [], deletingCarton: false, summaryCollapsed: false, receivingPalletId: '', cartonPalletFilter: '', batchPalletId: '', batchCountOpen: false, batchSku: '', batchBox: newBatchBox(), batchSelectedIndexes: [], batchCountMessage: '', cartonScrollTarget: '', cartonBoxes: [], countDiscrepancyReason: '', countingCartons: false, cartonRequestKey: '', cartonSignature: '', user: session.currentUser, entryMode: 'work', pendingTaskNo: '', candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoNames: [], photoUrls: {}, completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
computed: {
+ restorableCartons() {
+ const current = new Set(this.cartonBoxes.map(box => box.cartonMark || box.localKey));
+ const removed = new Map();
+ for (const box of [...(this.selected?.cartons || []), ...this.deletedCartonBoxes]) {
+ const key = box.cartonMark || box.localKey;
+ if (key && !current.has(key)) removed.set(key, box);
+ }
+ return [...removed.values()];
+ },
generatedPallets() { return this.pallets.filter(pallet => pallet.generated && pallet.draftId) },
declaredSkuCodes() { return [...new Set((this.selected?.items || []).map(item => item.skuCode))] },
batchSkuCodes() { return [...new Set(this.receivedCartonBoxes.map(box => box.items[0]?.skuCode).filter(Boolean))] },
@@ -186,7 +196,7 @@ export default {
},
watch: {
countDiscrepancyReason() { this.saveCartonDraft() },
- cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
+ cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
},
onShow() { if (requireSession()) { this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.loadCandidates().then(() => { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); const taskNo = this.pendingTaskNo || activeNo; if (this.entryMode === 'work' && taskNo) { const active = this.candidates.find((item) => String(item.transferNo) === taskNo); if (active) { const fromExplicitTask = Boolean(this.pendingTaskNo); this.pendingTaskNo = ''; void this.select(active, fromExplicitTask) } } }) } },
@@ -214,7 +224,7 @@ export default {
this.countingCartons = true; this.error = null
try {
const detail = await reopenWarehouseTransferMeasurement(this.selected.transferNo)
- uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.restoreCartons()
+ uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.restoreCartons()
this.cartonBoxes.forEach(current => { current.confirmedSignature = current.cartonMark === mark ? '' : this.cartonConfirmationSignature(current) })
this.pendingKey = ''; this.saveCartonDraft()
} catch (error) { this.error = error } finally { this.countingCartons = false }
@@ -267,13 +277,14 @@ export default {
if (!target || target.received === false || !this.validCartonValues(source)) return;
this.copyCartonValues(source, target); this.saveCartonDraft();
},
- saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
+ saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
restoreCartons() {
const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: box.quantity ?? '' }] }));
const draft = uni.getStorageSync(this.cartonDraftKey());
+ this.deletedCartonBoxes = Array.isArray(draft?.deletedBoxes) ? draft.deletedBoxes.filter(box => !box.cartonMark || (this.selected?.cartons || []).some(row => row.cartonMark === box.cartonMark)) : [];
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
- if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || rows.some(row => row.cartonMark === box.cartonMark))) {
+ if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || (this.selected?.cartons || []).some(row => row.cartonMark === box.cartonMark))) {
const knownMarks = new Set(draft.forecastMarks || draft.boxes.map(box => box.cartonMark));
this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = draft.key || ''; this.cartonSignature = draft.signature || '';
} else this.cartonBoxes = rows;
@@ -291,9 +302,36 @@ export default {
this.error = new Error('当前设备未配置箱标签打印服务');
// #endif
},
- removeCarton(index) {
- if (this.cartonsCounted || this.countingCartons) return;
- this.cartonBoxes.splice(index, 1); this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.saveCartonDraft();
+ async removeCarton(index) {
+ if (this.cartonsCounted || this.countingCartons || this.submitting || this.deletingCarton) return;
+ const box = this.cartonBoxes[index], transferNo = this.selected?.transferNo;
+ if (!box) return;
+ this.deletingCarton = true;
+ try {
+ const answer = await new Promise((resolve, reject) => uni.showModal({ title: '确认删除箱子', content: `确定删除${box.cartonMark || `第 ${index + 1} 箱`}吗?删除后本箱不计入本次实收,保存清点时会记为未收到。误删可通过“恢复已删除箱子”找回。`, confirmText: '确认删除', confirmColor: '#b74545', cancelText: '取消', success: resolve, fail: reject }));
+ if (!answer.confirm || transferNo !== this.selected?.transferNo || this.cartonsCounted || this.countingCartons || this.submitting) return;
+ const position = this.cartonBoxes.indexOf(box);
+ if (position < 0) return;
+ if (!box.cartonMark && !box.localKey) box.localKey = `removed-${Date.now()}-${Math.random()}`;
+ this.deletedCartonBoxes.push(box); this.cartonBoxes.splice(position, 1);
+ this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.saveCartonDraft();
+ } catch (error) { this.error = error } finally { this.deletingCarton = false }
+ },
+ async restoreDeletedCarton() {
+ if (this.countingCartons || this.submitting || this.deletingCarton || !this.restorableCartons.length) return;
+ const candidates = this.restorableCartons, transferNo = this.selected?.transferNo;
+ try {
+ const choice = candidates.length === 1 ? 0 : await new Promise((resolve, reject) => uni.showActionSheet({ itemList: candidates.map((box, index) => box.cartonMark || `已删除实收箱 ${index + 1}`), success: result => resolve(result.tapIndex), fail: () => resolve(-1) }));
+ if (choice < 0 || transferNo !== this.selected?.transferNo) return;
+ const removed = candidates[choice], key = removed.cartonMark || removed.localKey;
+ if (this.cartonsCounted) { await this.remeasure(); if (this.cartonsCounted || transferNo !== this.selected?.transferNo) return }
+ if (this.cartonBoxes.some(box => (box.cartonMark || box.localKey) === key)) return;
+ const box = { ...removed, received: true, status: 'EXPECTED', countedAt: null, confirmedSignature: '',
+ items: removed.items?.length ? removed.items.map(line => ({ ...line })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(removed.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: removed.quantity ?? '' }] };
+ if (!this.generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId))) box.transferPalletId = this.receivingPalletId || this.generatedPallets[0]?.draftId || '';
+ this.deletedCartonBoxes = this.deletedCartonBoxes.filter(item => (item.cartonMark || item.localKey) !== key);
+ this.cartonBoxes.push(box); this.cartonPalletFilter = ''; this.cartonRequestKey = ''; this.cartonSignature = ''; this.saveCartonDraft();
+ } catch (error) { this.error = error }
},
addExtraCarton() {
if (this.cartonsCounted || this.countingCartons || !this.selected?.items?.length) return;
@@ -316,10 +354,10 @@ export default {
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
if (!this.cartonRequestKey || signature !== this.cartonSignature) { this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
- uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
+ uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
- uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
+ uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
} catch (error) { this.error = error } finally { this.countingCartons = false }
},
openPalletOperations() { uni.navigateTo({ url: '/pages/pallet/pallet' }) },
@@ -415,7 +453,7 @@ export default {
this.countingCartons = true; this.error = null;
try {
const detail = await reopenWarehouseTransferMeasurement(this.selected.transferNo);
- uni.removeStorageSync(this.cartonDraftKey()); this.selected = detail; this.restoreCartons(); this.pendingKey = '';
+ uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.restoreCartons(); this.pendingKey = '';
this.cartonScrollTarget = 'transfer-boxes';
} catch (error) { this.error = error } finally { this.countingCartons = false }
},
diff --git a/tests/warehouse-transfer-counting.test.mjs b/tests/warehouse-transfer-counting.test.mjs
index d69202d..8046f3d 100644
--- a/tests/warehouse-transfer-counting.test.mjs
+++ b/tests/warehouse-transfer-counting.test.mjs
@@ -6,7 +6,7 @@ const source = readFileSync(new URL('../src/pages/warehouse-transfer/warehouse-t
const script = source.match(/
-
diff --git a/src/stores/fulfillment.js b/src/stores/fulfillment.js
index 43208c9..2a4939e 100644
--- a/src/stores/fulfillment.js
+++ b/src/stores/fulfillment.js
@@ -1,10 +1,10 @@
import { reactive } from 'vue'
-const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
+const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v2'
const blankState = () => ({
scopeKey: '',
- dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
+ dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingCommand: null },
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
})
@@ -18,7 +18,7 @@ function safeDomain(value, fallback) {
return {
...fallback,
...(value || {}),
- pendingKeys: { ...(value?.pendingKeys || {}) }
+ ...(Object.hasOwn(fallback, 'pendingKeys') ? { pendingKeys: { ...(value?.pendingKeys || {}) } } : {})
}
}
@@ -57,6 +57,7 @@ function randomKey(domain, action) {
export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
+ if (domain === 'dispatch') throw new Error('排车必须保存完整原命令,不能只生成请求键')
const signature = `${action}:${JSON.stringify(payload)}`
const pendingKeys = fulfillment[domain].pendingKeys
if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action)
@@ -66,10 +67,39 @@ export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
export function releaseFulfillmentKey(domain, signature) {
if (!fulfillment[domain]) return
+ if (domain === 'dispatch') throw new Error('排车原命令只能在服务端事实核对后清除')
delete fulfillment[domain].pendingKeys[signature]
persistFulfillmentDraft()
}
+export function beginDispatchCommand(dispatchNo, action, body) {
+ if (!fulfillment.scopeKey) throw new Error('当前作业身份未恢复')
+ if (fulfillment.dispatch.pendingCommand) throw new Error('请先核对原排车操作结果')
+ const idempotencyKey = randomKey('dispatch', action)
+ fulfillment.dispatch.pendingCommand = { dispatchNo, action, body: JSON.parse(JSON.stringify({ ...body, idempotencyKey })), attempted: false, rejected: false, result: null }
+ persistFulfillmentDraft()
+ return fulfillment.dispatch.pendingCommand
+}
+
+export function patchDispatchCommand(patch) {
+ if (!fulfillment.dispatch.pendingCommand) throw new Error('原排车请求不存在')
+ Object.assign(fulfillment.dispatch.pendingCommand, patch)
+ persistFulfillmentDraft()
+}
+
+export function finishDispatchCommand(key) {
+ if (fulfillment.dispatch.pendingCommand?.body.idempotencyKey !== key) throw new Error('核对结果与原请求键不一致')
+ fulfillment.dispatch.pendingCommand = null
+ persistFulfillmentDraft()
+}
+
+export function discardRejectedDispatchCommand() {
+ if (fulfillment.dispatch.pendingCommand?.rejected === true) {
+ fulfillment.dispatch.pendingCommand = null
+ persistFulfillmentDraft()
+ }
+}
+
export function resetFulfillmentDomain(domain) {
if (!fulfillment[domain]) return
fulfillment[domain] = blankState()[domain]
diff --git a/tests/dispatch-workflow.test.mjs b/tests/dispatch-workflow.test.mjs
new file mode 100644
index 0000000..355e08e
--- /dev/null
+++ b/tests/dispatch-workflow.test.mjs
@@ -0,0 +1,103 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
+import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
+const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index 0116afd..316e1b5 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -9,7 +9,7 @@
正在加载排车事实…
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}网络中断,原请求和作业草稿已保留。
排车任务暂无排车任务,可扫描排车单号读取。
-
+
{{ current.dispatchNo }}{{ stageLabel(current) }}任务号 {{ current.taskNo || '—' }}{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}{{ plannedPackages }}计划箱数{{ pickedPackages }}真实已拣{{ remainingPackages }}未拣箱数
理货员审核
@@ -30,7 +30,7 @@
本车暂无货物,空单不能核平、装车或发车。
请选择本次真实拣货来源
本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}拣货箱数最多{{ selectedRemaining }}箱
- 确认装车完成{{ current.pickingReconciled ? 'PDA拣货已核平,确认装车完成后直接进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}
+ 确认装车完成{{ current.pickingReconciled ? 'PDA拣货已核平,确认装车完成后进入装车后,补齐5张装车照片再进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}
{{ successMessage }}
核对真实装载 · {{ loadingPreview.dispatchNo }}{{ row.palletNo || '—' }}实际托位实测重量(kg)实测体积(m³)已匹配卸货顺序:{{ row.deliveryIndex ?? '尚未匹配,请后台核对' }}{{ evidence.actual ? '真实测量依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }}:{{ evidence.value ?? '—' }}
@@ -40,6 +40,7 @@
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
+import DispatchLoadingDocuments from '@/components/DispatchLoadingDocuments.vue'
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, getDispatchCommand, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js'
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession, session } from '@/stores/session.js'
@@ -47,7 +48,7 @@ import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
- components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
+ components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, packages() { return fulfillment.dispatch.packages },
@@ -123,12 +124,12 @@ export default {
await this.recoverCommand()
} catch (cause) { this.setError(cause, '理货审核未提交') }
},
- async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && result.stage !== 'DELIVERING' || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? '装车完成已确认,本车已进入派送中' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
+ async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING' || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() },
async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } },
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
- stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
+ stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
}
--
2.54.0
From a181239cbf9bcc2fd4a30cdb20a161f6beca7aac Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 21 Oct 2026 17:27:13 +0800
Subject: [PATCH 26/39] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
docs/business-index.md | 6 +-
src/api/dispatch-loading-documents.js | 2 +-
src/components/DispatchLoadingDocuments.vue | 1 +
src/pages/dispatch/dispatch.vue | 10 ++--
tests/dispatch-loading-documents.test.mjs | 65 +++++++++++++++++++++
tests/dispatch-workflow.test.mjs | 39 +++++++++----
tests/fulfillment-workflows.test.mjs | 6 +-
8 files changed, 109 insertions(+), 22 deletions(-)
create mode 100644 tests/dispatch-loading-documents.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 5ae07ce..e6be3ad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,7 +82,7 @@
- 签收照片三角色各一当前文件;上传后以服务端 `downloadPath` 会话内鉴权读。换图失败留原 `fileId`/可提交状态,无原图失败槽不计 `photosReady`;临时路径仅本次预览。
- 柜号签收入仓用Lastleg `/api/pda/v1/last-leg/receiving-containers/**`,退货签收/合箱分别用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,入口见上表。
- 理货审核沿既有排车详情;规则/回归见业务索引排车条目。
-- 排车/出库网络失败留原命令/幂等键,恢复看服务端任务。排车拣货/装车完成核 `actions.PICK/LOADING_COMPLETE`;装车完成一次提交直接进入 `DELIVERING`,服务端自动生成派送并发车,无独立安排派送/发车入口。完成成功禁重提。
+- 排车/出库网络失败留原命令和键;资格、阶段、恢复及装车资料API见业务索引排车条目。`src/components/DispatchLoadingDocuments.vue` 支持装车中同步上传,装车与资料均完成后自动派送,无独立发车入口。
## 质量门禁
diff --git a/docs/business-index.md b/docs/business-index.md
index 87dbf1a..06b1184 100644
--- a/docs/business-index.md
+++ b/docs/business-index.md
@@ -25,8 +25,8 @@
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
-| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、调度审核状态/意见和货物。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;实载核对后一次提交装车完成,服务端同一事务自动生成派送并发车,直接进入 `DELIVERING`,不再额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
-| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一张排车单代表一趟车,使用 `dispatchNo` 与公开 `recordVersion`(0 合法),保留完整原命令和 `idempotencyKey`。审核和现场作业均恢复校验原单号、动作、请求键、结果及版本;理货审核通过结果须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;已确认结果重读失败不再 POST,首个明确拒绝可手动重读、网络未知保留完整原命令且不以新版本覆盖。`LOADING_COMPLETE` 结果须为 `stage=DELIVERING`(result 仍为 Loaded,输入仍为原版本、manifestHash、measurements),重读任务且版本不早于已确认结果后再清除原命令。司机证照、资源及各客户派送资格由服务端校验。正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不再使用独立车次发车请求或以车次状态代替排车公开状态 |
+| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、调度审核状态/意见和货物。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;实载核对后一次提交装车完成,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;装车中即可通过 `src/components/DispatchLoadingDocuments.vue` 上传五张整车照片,照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
+| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一张排车单代表一趟车,使用 `dispatchNo` 与公开 `recordVersion`(0 合法),保留完整原命令和 `idempotencyKey`。审核和现场作业均恢复校验原单号、动作、请求键、结果及版本;理货审核通过结果须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;已确认结果重读失败不再 POST,首个明确拒绝可手动重读、网络未知保留完整原命令且不以新版本覆盖。`LOADING_COMPLETE` 结果须为 `stage=AFTER_LOADING|DELIVERING`(result 仍为 Loaded,输入仍为原版本、manifestHash、measurements),重读任务且版本不早于已确认结果后再清除原命令。司机证照、资源及各客户派送资格由服务端校验。正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不再使用独立车次发车请求或以车次状态代替排车公开状态 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
@@ -61,4 +61,4 @@
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
-- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,排车装车完成一次提交即生成派送并发车,前端不补派送费用或额外请求。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
+- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
diff --git a/src/api/dispatch-loading-documents.js b/src/api/dispatch-loading-documents.js
index 265f3ec..f355e9d 100644
--- a/src/api/dispatch-loading-documents.js
+++ b/src/api/dispatch-loading-documents.js
@@ -11,7 +11,7 @@ function slotPath(no, slot) {
}
export function normalizeLoadingDocuments(value, no) {
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !Array.isArray(value.photos)
- || !['AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
+ || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
const slots = new Set()
for (const photo of value.photos) {
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || slots.has(photo.slot)) throw new Error('装车照片引用不完整或重复')
diff --git a/src/components/DispatchLoadingDocuments.vue b/src/components/DispatchLoadingDocuments.vue
index 9989690..4986e99 100644
--- a/src/components/DispatchLoadingDocuments.vue
+++ b/src/components/DispatchLoadingDocuments.vue
@@ -1,6 +1,7 @@
装车资料
+ 可边装车边上传 5 张装车照片;确认装车完成且照片齐全后自动进入派送中。
上传并保存 5 张装车照片后,本车自动进入派送中。
{{ error.message }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
有装车照片尚未确认保存。
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index 316e1b5..a181083 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -9,7 +9,7 @@
正在加载排车事实…
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}网络中断,原请求和作业草稿已保留。
排车任务暂无排车任务,可扫描排车单号读取。
-
+
{{ current.dispatchNo }}{{ stageLabel(current) }}任务号 {{ current.taskNo || '—' }}{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}{{ plannedPackages }}计划箱数{{ pickedPackages }}真实已拣{{ remainingPackages }}未拣箱数
理货员审核
@@ -28,9 +28,10 @@
{{ item.palletNo || '未关联托盘码' }}{{ item.pickedPackages }}/{{ item.scheduledPackages }}箱{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }} {{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · {{ routeLabel(item) }}{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}
本车暂无货物,空单不能核平、装车或发车。
+ 该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。
请选择本次真实拣货来源
本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}拣货箱数最多{{ selectedRemaining }}箱
- 确认装车完成{{ current.pickingReconciled ? 'PDA拣货已核平,确认装车完成后进入装车后,补齐5张装车照片再进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}
+ 确认装车完成{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}
{{ successMessage }}
核对真实装载 · {{ loadingPreview.dispatchNo }}{{ row.palletNo || '—' }}实际托位实测重量(kg)实测体积(m³)已匹配卸货顺序:{{ row.deliveryIndex ?? '尚未匹配,请后台核对' }}{{ evidence.actual ? '真实测量依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }}:{{ evidence.value ?? '—' }}
@@ -104,7 +105,8 @@ export default {
selectPallet(item) {
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细')
- updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: 1 })
+ const actualPackages = item.returnCartons.length ? 1 : Math.min(item.scheduledPackages - item.pickedPackages, item.positions.find(row => row.availablePackages > 0)?.availablePackages || 0)
+ updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: actualPackages })
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
},
async submitPick() { if (!this.canPick || this.pendingCommand) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; beginDispatchCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }); await this.recoverCommand() } catch (cause) { this.setError(cause, '拣货未提交') } },
@@ -124,7 +126,7 @@ export default {
await this.recoverCommand()
} catch (cause) { this.setError(cause, '理货审核未提交') }
},
- async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING' || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
+ async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? latest.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() },
async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } },
diff --git a/tests/dispatch-loading-documents.test.mjs b/tests/dispatch-loading-documents.test.mjs
new file mode 100644
index 0000000..cadf494
--- /dev/null
+++ b/tests/dispatch-loading-documents.test.mjs
@@ -0,0 +1,65 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
+import { session } from '../src/stores/session.js'
+const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/
diff --git a/tests/dispatch-loading-documents.test.mjs b/tests/dispatch-loading-documents.test.mjs
index cadf494..81a6dfc 100644
--- a/tests/dispatch-loading-documents.test.mjs
+++ b/tests/dispatch-loading-documents.test.mjs
@@ -55,6 +55,18 @@ test('a failed upload does not replace a saved photo or advance the stage', asyn
assert.ok(state.pending.key); assert.equal(state.pending.photo, undefined)
})
+test('definitive file rejection allows selecting a new image while retaining the saved one', async () => {
+ let uploads = 0
+ const { state } = component({ getLoadingDocuments: async () => data({ photos: [photo] }), uploadLoadingPhoto: async () => {
+ if (++uploads === 1) throw { code: 'A0400', message: '图片内容无效' }
+ return photo
+ } })
+ await state.load(); await state.choose('LOADING_SCENE')
+ assert.equal(state.pending, null); assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
+ assert.equal(state.error.message, '图片内容无效')
+ await state.choose('LOADING_SCENE'); assert.equal(uploads, 2); assert.equal(state.pending, null)
+})
+
test('loading documents can be saved during LOADING without claiming departure', async () => {
assert.equal(normalizeLoadingDocuments(data({ stage: 'LOADING' }), 'PC/1').stage, 'LOADING')
assert.throws(() => normalizeLoadingDocuments(data({ stage: 'BEFORE_LOADING' }), 'PC/1'), /不一致/)
diff --git a/tests/dispatch-workflow.test.mjs b/tests/dispatch-workflow.test.mjs
index 6d631fb..4006cb8 100644
--- a/tests/dispatch-workflow.test.mjs
+++ b/tests/dispatch-workflow.test.mjs
@@ -66,7 +66,7 @@ test('unknown POST queries the original key and retries the unchanged payload; a
})
test('proven loading completion already delivered to the driver survives a failed GET without another POST', async () => {
storage(); let posts = 0, reads = 0
- beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
+ beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'AFTER_LOADING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'AFTER_LOADING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
@@ -83,7 +83,7 @@ test('actual loading confirmation leaves reference measurements blank and blocks
storage(); const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: 'pallet-1', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: null, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: false, sourceType: 'ORDER_SNAPSHOT_ALLOCATION' }] }] }
let posts = 0; const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async () => { posts++ } })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading()
- assert.equal(state.loadingRows[0].weightKg, null); assert.equal(state.loadingRows[0].volumeCbm, null)
+ assert.equal(state.loadingRows[0].deliveryIndex, null)
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
})
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
@@ -186,10 +186,20 @@ test('two picked logical sources use the single authoritative physical loading r
let sent
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
- await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.deepEqual(sent[1].measurements, [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }])
+ await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.equal('measurements' in sent[1], false)
})
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
+test('missing appointment blocks approval while preserving rejection to customer service', async () => {
+ storage(); const sent = []
+ const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
+ state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
+ assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
+ assert.equal(state.reviewTime(null), '未填写')
+ state.reviewReason = '请客服补齐预约时间'
+ await state.submitReview('APPROVED'); assert.equal(sent.length, 0); assert.match(state.error.message, /预约收货时间尚未确认/)
+ await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
+})
test('review task is reachable in the existing list and blocks actual source selection until approval', async () => {
storage(); const state = page({ listDispatchTasks: async () => [reviewTask()], getDispatchTask: async () => reviewTask() })
await state.loadTasks(); assert.equal(state.tasks.length, 1); assert.equal(state.stageLabel(state.tasks[0]), '待理货审核')
@@ -253,22 +263,22 @@ test('review UI exposes authoritative resources and reason without reviewer data
assert.doesNotMatch(template, /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/)
})
-test('loading completion keeps original manifest and measurements through a network replay and immediately enters delivery', async () => {
+test('loading completion keeps original manifest through a network replay and immediately enters delivery', async () => {
storage(); let fail = true; const sent = []
- beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest', measurements: [{ warehousePalletId: '21', weightKg: 100, volumeCbm: 2, palletSpaces: 1, deliveryIndex: 1 }] })
+ beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest' })
const state = page({ completeDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: '404' } }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
await state.recoverCommand(); restoreFulfillmentDraft('operator:warehouse'); state.current.recordVersion = 9; await state.recoverCommand()
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.canComplete, false); assert.equal(fulfillment.dispatch.pendingCommand, null)
await state.completeLoading(); await state.recoverCommand(); assert.equal(sent.length, 2)
})
test('loading completion requires an after-loading result and does not accept an intermediate stage as success', async () => {
- storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
+ storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
})
test('documents already complete allow loading confirmation to return DELIVERING and recover without reposting', async () => {
- storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash', measurements: [] })
+ storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash' })
let posts = 0, reads = 0
const state = page({ completeDispatchLoading: async () => { posts++; return { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'DELIVERING' } }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'DELIVERING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'DELIVERING')
@@ -285,3 +295,33 @@ test('scanning received pallets defaults to real nine and eleven boxes without m
state.handleScan('PALLET-1'); assert.equal(state.packages, 11); assert.equal(state.canPick, true); await state.submitPick()
assert.deepEqual(sent, [9, 11]); assert.equal(state.pickedPackages, 20); assert.equal(state.remainingPackages, 0)
})
+
+test('photo tab preserves the selected pallet and quantity and ignores picking broadcasts', () => {
+ storage(); const state = page(); state.handleScan('PALLET-1'); state.updatePackages({ detail: { value: '1' } })
+ const before = JSON.parse(JSON.stringify(fulfillment.dispatch))
+ state.selectTab('photos'); state.handleScan('UNKNOWN-PALLET')
+ assert.equal(state.activeTab, 'photos'); assert.equal(state.error, null)
+ assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch)), before)
+ state.selectTab('picking'); assert.equal(state.selectedItem.palletNo, 'PALLET-1'); assert.equal(state.packages, 1)
+})
+
+test('same-task photo refresh stays on photos while a new task starts on picking', async () => {
+ storage(); const state = page({ getDispatchTask: async no => task({ dispatchNo: no, recordVersion: 2 }) })
+ state.selectTab('photos'); await state.openTask('PC/1'); assert.equal(state.activeTab, 'photos')
+ await state.openTask('PC/2'); assert.equal(state.activeTab, 'picking')
+})
+
+test('tab changes cannot interrupt an upload or pending command, or open photos before loading', () => {
+ storage(); const state = page(); state.busy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
+ state.busy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
+ state.current.stage = 'LOADING'; beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0 })
+ state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
+})
+
+test('thirty scanned pallets confirm once without weight volume or pallet entry', async () => {
+ storage(); let sent
+ const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'thirty', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: Array.from({ length: 30 }, (_, i) => ({ warehousePalletId: String(i), palletNo: `PALLET-${i}`, deliveryIndex: 1, pickedPackages: 10, weightKg: null, volumeCbm: null, evidence: [] })) }
+ const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
+ state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading(); await state.completeLoading()
+ assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
+})
diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs
index 1787f4c..6e0cf4e 100644
--- a/tests/fulfillment-workflows.test.mjs
+++ b/tests/fulfillment-workflows.test.mjs
@@ -118,7 +118,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
await listDispatchTasks(); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { recordVersion: 0, positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
- await getDispatchLoading('DSP/1'); await getDispatchCommand('DSP/1', 'pick/key'); await completeDispatchLoading('DSP/1', { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 300, volumeCbm: 1, deliveryIndex: 1 }] })
+ await getDispatchLoading('DSP/1'); await getDispatchCommand('DSP/1', 'pick/key'); await completeDispatchLoading('DSP/1', { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash' })
await reviewDispatchLoading('DSP/1', { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1')
@@ -140,7 +140,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
}
const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/loading-complete'))
- assert.deepEqual(dispatchComplete.data, { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 300, volumeCbm: 1, deliveryIndex: 1 }] }); assert.ok(calls.some(call => call.url.endsWith('/commands/pick%2Fkey')))
+ assert.deepEqual(dispatchComplete.data, { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash' }); assert.ok(calls.some(call => call.url.endsWith('/commands/pick%2Fkey')))
})
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
@@ -241,7 +241,7 @@ async function dispatchScanHarness(items) {
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},DispatchLoadingDocuments:{},fulfillment:{dispatch:draft},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}}
const definition=new Function(...Object.keys(deps),source.replace(/^\s*import .*$/gm,'').replace('export default','return'))(...Object.values(deps))
- const state={current:{dispatchNo:'DS-TEST',stage:'LOADING',actions:{PICK:{allowed:true}},items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
+ const state={activeTab:'picking',current:{dispatchNo:'DS-TEST',stage:'LOADING',actions:{PICK:{allowed:true}},items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
state.gate=definition.methods.gate.bind(state);state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds}
}
--
2.54.0
From 9c2f0a54ec1e9147db7cd434455e963e8c4cb22a Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 21 Oct 2026 23:20:12 +0800
Subject: [PATCH 28/39] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8E=92=E8=BD=A6?=
=?UTF-8?q?=E8=A3=85=E8=BD=A6=E7=85=A7=E7=89=87=E4=B8=8A=E4=BC=A0=E5=B8=83?=
=?UTF-8?q?=E5=B1=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/DispatchLoadingDocuments.vue | 75 +++++++++++++++------
1 file changed, 56 insertions(+), 19 deletions(-)
diff --git a/src/components/DispatchLoadingDocuments.vue b/src/components/DispatchLoadingDocuments.vue
index 2fff691..b77428f 100644
--- a/src/components/DispatchLoadingDocuments.vue
+++ b/src/components/DispatchLoadingDocuments.vue
@@ -1,18 +1,31 @@
- 装车资料
- 可边装车边上传 5 张装车照片;确认装车完成且照片齐全后自动进入派送中。
- 上传并保存 5 张装车照片后,本车自动进入派送中。
+
+ 装车照片每类 1 张,上传后自动保存,可更换
+ {{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}
+
+
+ 可边装车边上传;确认装车完成且照片齐全后自动进入派送中。
+ 上传并保存 5 张装车照片后,本车自动进入派送中。
{{ error.message }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
- 有装车照片尚未确认保存。
-
- 装车照片 {{ index + 1 }}
-
-
-
-
- 未上传
-
+ 有装车照片尚未确认保存。
+
+
+
+
+
+
+
+ 查看大图
+
+
+
@@ -99,11 +112,35 @@ export default {
}
--
2.54.0
From b1e8de46e2b0366d258c962d88525a9805787b2c Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 22 Oct 2026 11:56:59 +0800
Subject: [PATCH 29/39] =?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/pages/operation/operation.vue | 6 ++----
src/pages/query/query.vue | 2 +-
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index 1fb7979..fda012f 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -1,6 +1,6 @@
-
+
{{ pageTitle }}{{ pageSubtitle }}
@@ -8,7 +8,6 @@
正在恢复当前柜作业…
- 当前 PDA 绑定仓{{ boundWarehouseLabel }}仅显示已分配到当前仓的集装箱
共 {{ candidates.length }} 柜
{{ candidateError.message || '待卸货集装箱加载失败' }}code={{ candidateError.code }}requestId={{ candidateError.requestId }}traceId={{ candidateError.traceId }}
正在刷新待卸货集装箱…
@@ -162,7 +161,6 @@
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 } return Boolean(this.activeInbound.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && !this.current?.hasDifference && values.every((item) => Number(item.pendingPackages) === 0) },
canFinishOnsite() { return Boolean(this.current && ['SIGNED', 'RECEIVING'].includes(this.current.status)) },
- boundWarehouseLabel() { const code = String(this.user?.warehouseCode || '').trim(); const name = String(this.user?.warehouseName || '').trim(); return code && name ? `${code} · ${name}` : '海外仓未关联' },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
@@ -250,5 +248,5 @@
.sticky-action button{display:flex;align-items:center;justify-content:center;line-height:normal;text-align:center}
.list-toolbar{height:28px;margin:0 2px 7px;align-items:center}.list-toolbar button{box-sizing:border-box;width:auto;min-width:48px;height:28px;min-height:28px;margin:0;padding:0 8px;border:1px solid #bdd1eb;border-radius:7px;color:#1668dc;font-size:11px;line-height:26px;background:#f7faff}.container-card-compact{padding:11px 13px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}.container-card-pressed{border-color:#93baf0;background:#f4f8ff}.container-card-actions{display:flex;flex-shrink:0;align-items:flex-end;flex-direction:column;gap:5px}.container-detail-link{box-sizing:border-box;min-height:24px;padding:0 4px;color:#1668dc;font-size:11px;font-weight:700;line-height:24px}.candidate-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:9px;padding:8px 0;border-top:1px solid #edf1f5;border-bottom:1px solid #edf1f5}.candidate-facts view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #edf1f5}.candidate-facts view:last-child{border-right:0}.candidate-facts strong{color:#17324d;font-size:17px;line-height:21px}.candidate-facts text{margin-top:2px;color:#6f8194;font-size:10px}.candidate-destinations{display:block;margin-top:7px;overflow:hidden;color:#6f8194;font-size:11px;line-height:17px;text-overflow:ellipsis;white-space:nowrap}
.candidate-arrival{display:block;margin-top:3px;color:#718095;font-size:10px;line-height:15px}.candidate-progress-copy{display:block;margin-top:7px;color:#435a72;font-size:10px;line-height:16px}.candidate-block-reason{display:block;margin-top:6px;padding:6px 8px;border-radius:7px;color:#73551b;font-size:10px;line-height:16px;background:#f0e9dc}.container-card-disabled{border-color:#d9dee5;background:#f1f3f5;box-shadow:none}.container-card-disabled .container-no,.container-card-disabled .candidate-facts strong{color:#778391}.container-card-disabled .container-detail-link{color:#788797}.container-card-disabled .candidate-progress-copy,.container-card-disabled .candidate-arrival,.container-card-disabled .candidate-destinations{color:#87919d}.status-disabled{color:#788491;background:#e1e5e9}.readonly-candidate-card{padding:13px;border:1px solid #d9dee5;border-radius:13px;background:#f1f3f5}.readonly-candidate-card .candidate-block-reason{margin-top:9px}
- .warehouse-scope-card{display:flex;flex-direction:column;gap:3px;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:10px;color:#617389;font-size:10px;line-height:15px;background:#f2f7ff}.warehouse-scope-card strong{color:#17324d;font-size:13px}.candidate-receiving-warehouse{display:block;margin-top:7px;color:#315777;font-size:11px;font-weight:700;line-height:17px}
+ .candidate-receiving-warehouse{display:block;margin-top:7px;color:#315777;font-size:11px;font-weight:700;line-height:17px}
diff --git a/src/pages/query/query.vue b/src/pages/query/query.vue
index 459db89..ec5d2b4 100644
--- a/src/pages/query/query.vue
+++ b/src/pages/query/query.vue
@@ -2,7 +2,7 @@
- {{ title }}{{ subtitle }};查询范围固定为 Token 绑定的海外仓
+ {{ title }}{{ subtitle }};查询范围固定为 Token 绑定的海外仓
正在读取最新库存事实…
--
2.54.0
From 161128ff9e4068c41b3bcb2bea210ce474b80608 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 22 Oct 2026 14:38:15 +0800
Subject: [PATCH 30/39] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
docs/business-index.md | 2 +-
src/components/ReceivingSessionSummary.vue | 4 +-
src/pages.json | 1 -
src/pages/module/module.vue | 30 ------------
src/pages/move/move.vue | 2 +-
src/pages/operation/operation.vue | 33 +++++++------
src/pages/outbound/outbound.vue | 3 +-
src/pages/query/query.vue | 12 ++---
src/pages/relabel/relabel.vue | 29 +++++++----
src/pages/return-counting/return-counting.vue | 5 +-
src/pages/return-packing/return-packing.vue | 2 +-
src/pages/return-receipt/return-receipt.vue | 2 +-
src/pages/stocktake/stocktake.vue | 2 +-
.../warehouse-transfer/warehouse-transfer.vue | 2 +-
src/styles/pda.scss | 43 +----------------
tests/business-identifiers-ui.test.mjs | 3 +-
tests/multi-plan-receiving.test.mjs | 3 +-
tests/navigation.test.mjs | 48 +++++++++++++++++++
tests/pallet-operations.test.mjs | 5 +-
tests/project-contract.test.mjs | 8 ++--
tests/warehouse-workflows.test.mjs | 5 +-
22 files changed, 114 insertions(+), 132 deletions(-)
delete mode 100644 src/pages/module/module.vue
diff --git a/AGENTS.md b/AGENTS.md
index e6be3ad..d93b921 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,7 @@
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` |
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example` |
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` |
-| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` |
+| 海外仓工作台与作业入口 | `src/pages/home/home.vue`;首页直接进入各作业,页面刷新后返回首页 |
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js` |
diff --git a/docs/business-index.md b/docs/business-index.md
index 8a89718..419844c 100644
--- a/docs/business-index.md
+++ b/docs/business-index.md
@@ -14,7 +14,7 @@
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | Android/iOS 尺寸映射由 `tests/app-icons.test.mjs` 锁定;独立 AppID、签名和发布通道不得复用国内 PDA |
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example`、`src/manifest.json` | `VITE_PDA_UPDATE_URL` 使用海外独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 |
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
-| 海外仓工作台与模块入口 | `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/pages/home/home.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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `A0` 共享溢出位提示,不在设备端重新分配。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/**` |
diff --git a/src/components/ReceivingSessionSummary.vue b/src/components/ReceivingSessionSummary.vue
index 9e80c34..242d696 100644
--- a/src/components/ReceivingSessionSummary.vue
+++ b/src/components/ReceivingSessionSummary.vue
@@ -5,7 +5,6 @@
{{ statusLabel }}
- 海外接柜仓:{{ receivingWarehouseLabel }}
目的仓:{{ value }}
@@ -33,7 +32,6 @@
props: { session: { type: Object, default: null } },
computed: {
planFlags() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds : [] },
- receivingWarehouseLabel() { const code = String(this.session?.overseasWarehouseCode || '').trim(); const name = String(this.session?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' },
inboundCodeCount() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds.length : 0 },
plannedPalletCount() { return (this.session?.inbounds || []).reduce((sum, item) => sum + Math.max(0, Number(item?.plannedPalletCount || 0)), 0) },
destinationValues() { const values = (this.session?.inbounds || []).map((item) => item?.fbaWarehouseCode || item?.destinationWarehouseName || item?.destinationWarehouseCode).filter(Boolean); const unique = [...new Set(values)]; return unique.length ? unique : ['—'] },
@@ -51,7 +49,7 @@
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
- .receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-warehouse{display:block}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
+ .receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
.receiving-summary-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:7px;padding:7px 2px;border-radius:9px;background:rgba(255,255,255,.8)}.receiving-summary-metrics>view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #e3eaf2}.receiving-summary-metrics>view:last-child{border-right:0}
.receiving-plan-flags{margin-top:7px;padding:3px 8px;border-radius:9px;background:rgba(255,255,255,.82)}.receiving-plan-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 0;border-bottom:1px solid #e9eef5}.receiving-plan-row:last-child{border-bottom:0}.receiving-plan-code{min-width:0;color:#17324d;font-size:10px;font-weight:750;overflow-wrap:anywhere}.receiving-plan-tags{display:flex;flex-shrink:0;gap:3px}.receiving-plan-tags text{padding:2px 5px;border-radius:99px;font-size:8px;line-height:12px}.flag-positive{color:#08705f;background:#e2f6f0}.flag-option{color:#1668dc;background:#e7f0ff}.flag-urgent{color:#b54708;background:#fff0dc}.flag-muted{color:#718095;background:#edf1f5}
.metric-value{color:#17324d;font-size:17px;font-weight:850;line-height:21px}.metric-pending{color:#b66a08}.metric-label{margin-top:1px;color:#718095;font-size:8px;line-height:12px;text-align:center}
diff --git a/src/pages.json b/src/pages.json
index efd3a9a..8a04281 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -2,7 +2,6 @@
"pages": [
{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柏悦物流海外版", "backgroundColor": "#F5F7FA" } },
{ "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/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
diff --git a/src/pages/module/module.vue b/src/pages/module/module.vue
deleted file mode 100644
index 22911b3..0000000
--- a/src/pages/module/module.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
- {{ currentModule.icon }}{{ currentModule.name }}{{ currentModule.description }}{{ availableCount }}/{{ currentModule.operations.length }} 可用
- 业务功能
-
- 灰色功能尚未开放,仅可进入已接入真实接口的作业。
-
-
-
-
-
diff --git a/src/pages/move/move.vue b/src/pages/move/move.vue
index b7fbaaa..4b400a4 100644
--- a/src/pages/move/move.vue
+++ b/src/pages/move/move.vue
@@ -1,6 +1,6 @@
-
+
整托移库
正在恢复未完成的移库草稿…
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index fda012f..af25943 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -3,7 +3,7 @@
- {{ pageTitle }}{{ pageSubtitle }}
+ {{ pageTitle }}{{ pageSubtitle }}
正在恢复当前柜作业…
@@ -13,19 +13,18 @@
正在刷新待卸货集装箱…
暂无待卸货集装箱
- {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›
- {{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作
- 已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作,其中 {{ item.missingInboundCodeCount }} 个缺入仓码
- {{ item.blockReason || '当前柜入库计划尚未齐全' }}
- 海外接柜仓:{{ receivingWarehouseLabel(item) }}
- 业务目的仓:{{ item.destinationCodes.join(' / ') }}
+ {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›
+ {{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作
+ 已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作,其中 {{ item.missingInboundCodeCount }} 个缺入仓码
+ {{ item.pendingPlanCount }} 单待处理
+ 海外接柜仓:{{ receivingWarehouseLabel(item) }}
+ 业务目的仓:{{ item.destinationCodes.join(' / ') }}
- 接柜仓由平台预先分配;确认后只建立当前账号的接柜会话,完成前不能切柜。
@@ -36,7 +35,7 @@
{{ selected?.blockReason || '当前柜入库计划尚未齐全' }}
海外接柜仓:{{ receivingWarehouseLabel(selected) }}
-
+
@@ -75,7 +74,7 @@
- 国内装柜照片已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照{{ domesticPhotosExpanded ? '收起' : '查看' }} ›
+ 国内装柜照片({{ domesticPhotoCount }} / 5){{ domesticPhotosExpanded ? '收起' : '查看' }} ›
@@ -85,7 +84,7 @@
- 1. 扫描 DP、箱码或 FBA 任一箱
+ 1. 货物码
@@ -93,17 +92,17 @@
{{ activeInbound.destinationType === 'FBA' ? 'FBA 按箱数收货' : '海外仓逐箱收货' }}{{ destinationLabel(activeInbound) }}
{{ (activeInbound.inboundCodes || [activeInbound.inboundCode]).join(' / ') }}
- {{ activeInbound.operationMode === 'FBA_GROUP' ? '按 FBA 仓成组收货' : '逐箱收货' }}{{ activeInbound.assigned ? '已分配' : '未分配' }}{{ activeInbound.mergeAllowed ? '可合托(现场自选)' : '不合托' }}{{ activeInbound.urgent ? '加急' : '普通' }}
+ {{ activeInbound.assigned ? '已分配' : '未分配' }}{{ activeInbound.mergeAllowed ? '可合托(现场自选)' : '不合托' }}{{ activeInbound.urgent ? '加急' : '普通' }}
{{ activeInbound.mergeHint }}
总计 {{ activeInbound.totalPackages }} 箱已收 {{ activeInbound.receivedPackages }} 箱待收 {{ activeInbound.pendingPackages }} 箱
- 2. 扫描预留实物托盘码
+ 2. 托盘码
{{ resolvedPallet.palletCode }}{{ resolvedPallet.compatible ? '预留托盘校验通过,可收货' : '托盘不匹配' }}库位:{{ resolvedPallet.locationCode }}{{ resolvedPallet.locationType === 'SHARED_OVERFLOW' ? '(A0共享溢出位)' : '' }}库位:待现场确认
- 3. 输入该托累计箱数
+ 3. 该托累计箱数
@@ -162,7 +161,7 @@
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && !this.current?.hasDifference && values.every((item) => Number(item.pendingPackages) === 0) },
canFinishOnsite() { return Boolean(this.current && ['SIGNED', 'RECEIVING'].includes(this.current.status)) },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
- pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
+ pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
@@ -215,7 +214,7 @@
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index 439cf9d..82b535f 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -3,12 +3,11 @@
- 出库任务整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”
+ 出库任务
当前任务出库作业详情
- 任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
正在加载出库任务…
diff --git a/src/pages/query/query.vue b/src/pages/query/query.vue
index ec5d2b4..77bbd5c 100644
--- a/src/pages/query/query.vue
+++ b/src/pages/query/query.vue
@@ -1,8 +1,8 @@
-
+
- {{ title }}{{ subtitle }};查询范围固定为 Token 绑定的海外仓
+ {{ title }}
正在读取最新库存事实…
@@ -26,13 +26,13 @@
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const MODES = {
- inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '查看可用、冻结和收货中库存' },
- locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码', subtitle: '查看库位状态、容量和占用' },
- palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码', subtitle: '查看托盘状态和绑定库存' }
+ inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码' },
+ locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码' },
+ palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码' }
}
export default {
components: { PortalHeader, ScanTargetInput },
- data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '', loading: false, searched: false, result: null, error: null } },
+ data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', loading: false, searched: false, result: null, error: null } },
computed: {
storeField() { return `${this.mode}Code` },
code: { get() { return warehouseWork.query[this.storeField] || '' }, set(value) { updateWarehouseWork('query', { [this.storeField]: value }) } },
diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue
index a012b34..a4c2a19 100644
--- a/src/pages/relabel/relabel.vue
+++ b/src/pages/relabel/relabel.vue
@@ -1,21 +1,32 @@
-
+
- {{ title }}{{ stageSubtitle }}
- {{ candidateHeading }}
+ {{ candidateHeading }}
正在加载换标任务…
{{ emptyText }}
- 退回重做优先处理{{ item.relabelNo }}所属公司:{{ item.customerName || '未提供' }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›
- {{ item.relabelNo }}所属公司:{{ item.customerName || '未提供' }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常查看并复核 ›
+
+
+ {{ item.relabelNo }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}
+ {{ relabelTypeName(item.operationType) }}退回重做
+ 已换标 {{ item.relabelledPackages }} / {{ item.expectedPackages }}跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}进入 ›
+
+
+
+
+ {{ item.relabelNo }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}
+ {{ relabelTypeName(item.operationType) }}
+ 已换标 {{ item.relabelledPackages }} / {{ item.expectedPackages }}跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}查看并复核 ›
+
+
换标类型:{{ relabelTypeName(current.operationType) }}
- {{ current.relabelNo }}客户订单:{{ current.customerOrderNo || '—' }}标签截止:{{ formatTime(current.labelDeadline) }}{{ statusLabel(current.taskStatus || current.applicationStatus) }}
+ {{ current.relabelNo }}所属公司:{{ current.customerName || candidates.find(item => item.relabelNo === current.relabelNo)?.customerName || '未提供' }}客户订单:{{ current.customerOrderNo || '—' }}标签截止:{{ formatTime(current.labelDeadline) }}{{ statusLabel(current.taskStatus || current.applicationStatus) }}
FBA 目的仓:{{ destination }}待维护应处理 {{ current.expectedPackages }}已换标 {{ current.relabelledPackages }}跳过 {{ current.skippedPackages }}异常 {{ current.exceptionPackages }}
标签文件暂无标签文件{{ file.scope === 'CARTON_TOTAL' ? '总箱唛' : file.scope === 'SKU_TOTAL' ? '总 SKU 标签' : '明细标签' }}:{{ file.fileName }}
@@ -125,7 +136,6 @@
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
},
showCurrent() { if (!this.current) return false; if (this.stage === 'review') return this.current.taskStatus === 'WAIT_REVIEW'; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
- stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '退回重做单置顶优先处理;同一账号一次只能执行一票' },
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
skuVerified() { if (!this.selectedItem || this.selectedItem.operationType === 'CARTON_LABEL') return true; const expected = this.skuDetails(this.selectedItem); return expected.length > 0 && expected.every((sku, index) => { const actual = this.unitDraft.skuVerifications[index]; return actual && actual.skuCode.trim() === sku.skuCode && actual.newSkuCode.trim() === sku.newSkuCode && Number(actual.quantity) > 0 && Number(actual.quantity) === Number(sku.quantity) }) },
@@ -376,9 +386,10 @@
}
-
+.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
+.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}
diff --git a/src/pages/return-counting/return-counting.vue b/src/pages/return-counting/return-counting.vue
index 4b6eb5c..1775a6a 100644
--- a/src/pages/return-counting/return-counting.vue
+++ b/src/pages/return-counting/return-counting.vue
@@ -1,6 +1,6 @@
-
+
{{ error.message }}错误码:{{ error.code }}请求号:{{ error.requestId }}跟踪号:{{ error.traceId }}
@@ -15,7 +15,7 @@
第 {{ index + 1 }} 箱{{ box.cartonMark }}
- 各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。箱内清点已完成,可以继续换标或出库。
+ 箱内清点已完成,可以继续换标或出库。
@@ -88,6 +88,7 @@ export default {
diff --git a/src/pages/return-packing/return-packing.vue b/src/pages/return-packing/return-packing.vue
index 4fdc37d..a59aac9 100644
--- a/src/pages/return-packing/return-packing.vue
+++ b/src/pages/return-packing/return-packing.vue
@@ -1,6 +1,6 @@
-
+
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index dc5eef3..fd2ce88 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -1,6 +1,6 @@
-
+
扫描 SKU 69 码
diff --git a/src/pages/stocktake/stocktake.vue b/src/pages/stocktake/stocktake.vue
index 39753fe..0f1e271 100644
--- a/src/pages/stocktake/stocktake.vue
+++ b/src/pages/stocktake/stocktake.vue
@@ -1,6 +1,6 @@
-
+
库存盘点
diff --git a/src/pages/warehouse-transfer/warehouse-transfer.vue b/src/pages/warehouse-transfer/warehouse-transfer.vue
index 3380952..249e1d1 100644
--- a/src/pages/warehouse-transfer/warehouse-transfer.vue
+++ b/src/pages/warehouse-transfer/warehouse-transfer.vue
@@ -1,6 +1,6 @@
-
+
diff --git a/src/styles/pda.scss b/src/styles/pda.scss
index 91ed560..b269ab7 100644
--- a/src/styles/pda.scss
+++ b/src/styles/pda.scss
@@ -699,10 +699,6 @@
}
.portal-title,
- .module-hero-title,
- .module-hero-desc,
- .operation-name,
- .operation-desc,
.summary-number,
.summary-label,
.profile-name,
@@ -780,50 +776,15 @@
.group-title { font-size: 16px; font-weight: 700; }
- .module-hero-icon, .operation-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: 700;
- }
- .operation-info { display: flex; min-width: 0; flex: 1; flex-direction: column; margin-left: 11px; }
-
.inline-back { height: 48px; padding: 0 4px; color: #42617f; font-size: 13px; line-height: 48px; background: transparent; }
- .module-hero { position: relative; display: flex; align-items: center; margin-top: 5px; padding: 17px; overflow: hidden; border: 1px solid rgba(23, 105, 232, 0.09); border-radius: 16px; background: #e7f0ff; box-shadow: 0 5px 16px rgba(32, 67, 111, 0.07); }
- .module-hero-inbound { background: #e1f7f0; }
- .module-hero-inventory { background: #fff3da; }
- .module-hero-relabel { background: #f1eafe; }
- .module-hero-query { background: #f0e9ff; }
- .module-hero-icon { width: 52px; height: 52px; flex-shrink: 0; margin-right: 13px; border-radius: 14px; color: #ffffff; font-size: 18px; background: $primary; }
- .module-hero-inbound .module-hero-icon { background: #0d9488; }
- .module-hero-inventory .module-hero-icon { background: #d68c0c; }
- .module-hero-relabel .module-hero-icon { background: #7c3aed; }
- .module-hero-query .module-hero-icon { background: #6d55c7; }
- .module-hero-copy { min-width: 0; flex: 1; }
- .module-hero-title { font-size: 18px; font-weight: 700; }
- .module-hero-desc { margin-top: 4px; color: #617187; font-size: 11px; line-height: 17px; }
- .module-hero-count { flex-shrink: 0; margin-left: 8px; padding: 4px 7px; border-radius: 99px; color: #49627d; font-size: 9px; line-height: 15px; background: rgba(255, 255, 255, 0.72); }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; margin: 22px 2px 11px; }
.section-heading .group-title { margin: 0; }
.section-heading-hint { color: #8190a3; font-size: 10px; line-height: 16px; }
.group-title { display: block; margin: 22px 2px 11px; }
- .operation-list { overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 4px 15px rgba(32, 67, 111, 0.06); }
- .operation-item, .history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
- .operation-item:active { background: #f2f7ff; }
- .operation-item-disabled, .operation-item-disabled[disabled] { color: #8d98a6; opacity: 1; background: #f2f4f6; }
- .operation-item-disabled .operation-icon { color: #8b97a5; background: #e3e7eb; }
- .operation-item-disabled .operation-desc { color: #929daa; }
- .operation-item:last-child, .history-item:last-child { border-bottom: 0; }
- .operation-icon { width: 39px; height: 39px; flex-shrink: 0; border-radius: 10px; color: #366cae; font-size: 12px; background: #edf4fd; }
- .operation-name { font-size: 13px; font-weight: 700; }
- .operation-desc { margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; }
- .operation-ready, .operation-locked { flex-shrink: 0; padding: 3px 6px; border-radius: 99px; font-size: 9px; line-height: 15px; }
- .operation-ready { color: #0b6f65; background: #dff7f0; }
- .operation-locked { color: #7c8795; background: #e5e9ee; }
+ .history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
+ .history-item:last-child { border-bottom: 0; }
.operation-count { min-width: 22px; height: 22px; border-radius: 11px; color: #ffffff; font-size: 11px; line-height: 22px; text-align: center; background: $danger; }
.operation-prototype { flex-shrink: 0; padding: 5px 7px; border-radius: 5px; color: #737f8d; font-size: 11px; background: #e1e5e9; }
- .operation-arrow { margin-left: 8px; color: #9aa5b1; font-size: 21px; }
- .module-empty-note { margin-top: 14px; padding: 12px; border-radius: 9px; color: #758194; font-size: 11px; line-height: 18px; text-align: center; background: #e9eef5; }
.operation-page { padding-top: 12px; }
.operation-page-title-row, .operation-scan-row, .task-toolbar, .prototype-task-head, .prototype-task-meta, .prototype-task-foot { display: flex; align-items: center; }
diff --git a/tests/business-identifiers-ui.test.mjs b/tests/business-identifiers-ui.test.mjs
index e1beb72..f2ddd2c 100644
--- a/tests/business-identifiers-ui.test.mjs
+++ b/tests/business-identifiers-ui.test.mjs
@@ -8,7 +8,7 @@ const interpolations = (source) => [...template(source).matchAll(/\{\{([\s\S]*?)
test('all registered user-facing pages avoid rendering internal identifiers', async () => {
const paths = [
- '../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
+ '../src/pages/login/login.vue', '../src/pages/home/home.vue',
'../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',
@@ -86,7 +86,6 @@ test('missing user-visible relationships use explicit empty labels instead of ID
read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/pallet/pallet.vue')
])
- assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
assert.match(summary, /unique\.length \? unique : \['—'\]/)
assert.match(pallet, /inboundCode \|\| '无入仓码'/)
assert.match(pallet, /locationCode \|\| '未绑定库位'/)
diff --git a/tests/multi-plan-receiving.test.mjs b/tests/multi-plan-receiving.test.mjs
index 4171a31..64783e6 100644
--- a/tests/multi-plan-receiving.test.mjs
+++ b/tests/multi-plan-receiving.test.mjs
@@ -108,7 +108,7 @@ test('container UI locks and signs once, then renders the resolved destination g
for (const fact of ['已分配', '未分配', '可合托·非强制', '不合托', '加急', '普通']) assert.match(summary, new RegExp(fact))
assert.doesNotMatch(operation, /v-for="[^"]*inbound[^"]*"/)
assert.match(operation, /v-if="activeInbound"[\s\S]*activeInbound\.inboundCodes[\s\S]*activeInbound\.pendingPackages/)
- assert.match(operation, /activeInbound\.operationMode === 'FBA_GROUP'[\s\S]*按 FBA 仓成组收货/)
+ assert.match(operation, /class="mode-banner"[^\n]*activeInbound\.destinationType === 'FBA'[^\n]*FBA 按箱数收货[^\n]*海外仓逐箱收货/)
assert.doesNotMatch(operation, /activeInbound\.customer|客户/)
assert.match(operation, /resolveCurrentCarton\(cartonMark\)/)
assert.match(operation, /operationMode !== 'DROPSHIP_PALLET'[\s\S]*receiveCurrentWholePallet/)
@@ -121,7 +121,6 @@ test('container UI locks and signs once, then renders the resolved destination g
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
assert.match(operation, /prepareReceivingContainer[\s\S]*validateReceivingWarehouse/)
- assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) {
assert.doesNotMatch(inboundApi, new RegExp(forbidden))
}
diff --git a/tests/navigation.test.mjs b/tests/navigation.test.mjs
index 9a53bca..0aa40c1 100644
--- a/tests/navigation.test.mjs
+++ b/tests/navigation.test.mjs
@@ -1,7 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { MODULES } from '../src/data/overseas-workbench.js'
import { navigateBackOr, navigateToUnique } from '../src/utils/navigation.js'
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+const component = (source, dependencies) => new Function(...Object.keys(dependencies), source.match(/