From 5c43a820aa78f640cb5a802dd466b1b0f78b62b5 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Fri, 16 Oct 2026 16:19:41 +0800
Subject: [PATCH] =?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/api/warehouse-transfers.js | 4 +
src/components/CartonCountFields.vue | 15 +-
.../warehouse-transfer/warehouse-transfer.vue | 197 ++++++++++++++----
tests/warehouse-transfer-api.test.mjs | 8 +-
tests/warehouse-transfer-counting.test.mjs | 136 +++++++++++-
.../warehouse-transfer-mixed-cartons.test.mjs | 6 +-
7 files changed, 312 insertions(+), 56 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ea0a386..f3207ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,7 +61,7 @@
## 海外业务接口边界
-- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
+- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;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/api/warehouse-transfers.js b/src/api/warehouse-transfers.js
index 764ccc9..156b48d 100644
--- a/src/api/warehouse-transfers.js
+++ b/src/api/warehouse-transfers.js
@@ -54,6 +54,10 @@ export function countWarehouseTransferCartons(transferNo, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/count`, payload)
}
+export function reopenWarehouseTransferMeasurement(transferNo) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/reopen`)
+}
+
export function getWarehouseTransferCartonLabel(transferNo) {
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/carton-label`)
}
diff --git a/src/components/CartonCountFields.vue b/src/components/CartonCountFields.vue
index f3796da..481b29b 100644
--- a/src/components/CartonCountFields.vue
+++ b/src/components/CartonCountFields.vue
@@ -1,12 +1,17 @@
-体积{{ boxVolume(box) }} cm³
- {{ line.skuCode || '选择 SKU' }} ▾
+体积{{ boxVolume(box) }} {{ volumeUnit === 'm3' ? 'm³' : 'cm³' }}
+ {{ line.skuCode || '选择 SKU' }} ▾
+.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