Compare commits
8
Commits
92a86d8566
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96b9a7e02 | ||
|
|
627dfd32da | ||
|
|
fec7295a04 | ||
|
|
016ccd5dd3 | ||
|
|
5c0000472d | ||
|
|
dee7ee5eef | ||
|
|
5bebd23e32 | ||
|
|
f0c0002f99 |
@@ -1,4 +1,4 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
|
||||
|
||||
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
|
||||
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
|
||||
@@ -175,6 +175,11 @@ export async function resolveCargoDiscardPallet(palletNo) {
|
||||
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
|
||||
}
|
||||
|
||||
export async function confirmCargoDiscard(discardNo, palletNo, lines) {
|
||||
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines }))
|
||||
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) {
|
||||
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds }))
|
||||
}
|
||||
|
||||
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
|
||||
export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
|
||||
export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
|
||||
export const downloadCargoDiscardPhoto = (discardNo, palletNo, fileId) => overseasDownload(`${discardPhotoPath(discardNo, palletNo)}/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
|
||||
|
||||
@@ -4,6 +4,8 @@ const BASE = '/pda/v1/last-leg/pallet-operations'
|
||||
export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
|
||||
export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
|
||||
export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
|
||||
export const listRelabelPalletMeasurements = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-pallet-measurements`, { relabelNo })
|
||||
export const measureRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-measure`, payload)
|
||||
|
||||
export function resolvePallet(code) {
|
||||
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
|
||||
|
||||
@@ -75,7 +75,8 @@ export async function completePackingPlan(planNo, request) {
|
||||
}
|
||||
|
||||
export function normalizePackingPlan(value = {}) {
|
||||
return { planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
|
||||
value = { ...value, completionCartons: Array.isArray(value.completionCartons) ? value.completionCartons.map(normalizeReturnLabel) : [], transfers: Array.isArray(value.transfers) ? value.transfers : [], completedAt: value.completedAt || null }
|
||||
return { completionCartons: value.completionCartons, transfers: value.transfers, completedAt: value.completedAt, planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
|
||||
}
|
||||
|
||||
export async function getPackingPlan(planNo) {
|
||||
|
||||
@@ -9,14 +9,24 @@
|
||||
<button v-for="binding in currentBindings" :key="binding.mark" class="binding-row" :disabled="busy" @tap="selectRebind(binding)"><text class="binding-mark">{{ binding.mark }}</text><text class="binding-arrow">→</text><text class="binding-pallet">{{ binding.pallet }}</text><text class="binding-action">重新绑定</text></button>
|
||||
</view>
|
||||
<view v-for="pallet in boundPalletTotals" :key="pallet.palletCode" class="hint"><text>{{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}</text></view>
|
||||
<view v-for="pallet in measurements" :key="pallet.palletCode" class="measurement-card">
|
||||
<text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text>
|
||||
<text class="hint">{{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}</text>
|
||||
<text v-if="pallet.volumeCbm != null" class="hint">已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
|
||||
<view class="measurement-inputs">
|
||||
<label v-for="field in measurementFields" :key="field.key"><text>{{ field.label }}</text><input v-model="pallet[field.key]" type="digit" :disabled="busy" :placeholder="field.label" /></label>
|
||||
</view>
|
||||
<button :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
|
||||
</view>
|
||||
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</button></view>
|
||||
<template v-if="!allBindingsComplete && !rebindSelection">
|
||||
<view v-for="row in operableSourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
|
||||
<view class="source-identifiers"><view class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view></view>
|
||||
<view class="source-identifiers"><view v-if="!unboundSourceMode" class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view><view v-else class="source-identifier"><text class="source-caption">来源箱</text><text class="source-code">{{ (row.cartonMarks || []).join('、') }}</text></view></view>
|
||||
<text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
|
||||
</view>
|
||||
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘</text>
|
||||
<ScanTargetInput v-if="!isReturnBulk" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
|
||||
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘或来源箱</text>
|
||||
<ScanTargetInput v-if="!isReturnBulk && !unboundSourceMode" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
|
||||
<ScanTargetInput v-if="unboundSourceMode" v-model="sourceCode" name="relabel-source-carton" label="来源箱唛" placeholder="扫描来源箱唛确认" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
|
||||
<text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
|
||||
<view v-if="selectedSource">
|
||||
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
|
||||
@@ -33,6 +43,7 @@
|
||||
</template>
|
||||
<template v-if="!allBindingsComplete || rebindSelection">
|
||||
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
|
||||
<button v-if="canRetainOriginal" :disabled="busy" @tap="quantity = String(source.totalPackages); targetCode = source.palletCode; confirmTargetAndBind()">整托全部贴标,保留原托盘</button>
|
||||
<button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">{{ rebindSelection ? '确认改绑目标托盘' : '确认绑定目标托盘' }}</button>
|
||||
<text v-if="labelBlockReason" class="hint">{{ labelBlockReason }}</text>
|
||||
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
|
||||
@@ -43,16 +54,17 @@
|
||||
</template>
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources } from '@/api/pallet.js'
|
||||
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
|
||||
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
components: { ScanTargetInput },
|
||||
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
|
||||
emits: ['activate', 'busy', 'saved'],
|
||||
data() { return { palletTotals: {}, rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
|
||||
data() { return { measurements: [], measurementFields: [{ key: 'lengthCm', label: '长 cm' }, { key: 'widthCm', label: '宽 cm' }, { key: 'heightCm', label: '高 cm' }, { key: 'grossWeightKg', label: '毛重 kg' }], palletTotals: {}, rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
|
||||
mounted() { this.loadSources() },
|
||||
computed: {
|
||||
boundPalletTotals() { const codes = new Set(this.currentBindings.map(binding => binding.pallet)); return Object.values(this.palletTotals).filter(pallet => codes.has(pallet.palletCode)) },
|
||||
canRetainOriginal() { return !this.isReturnBulk && !this.unboundSourceMode && this.source && this.selectedSource && this.plannedPackages === Number(this.source.totalPackages) },
|
||||
boundPalletTotals() { const codes = new Set(this.currentBindings.map(binding => binding.pallet)); return Object.values(this.palletTotals).filter(pallet => codes.has(pallet.palletCode) && !this.measurements.some(measured => measured.palletCode === pallet.palletCode)) },
|
||||
allBindingsComplete() {
|
||||
const required = (this.task.items || []).filter(item => Number(item.expectedPackages || 0) > 0 && (item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)))
|
||||
const bound = new Set(this.currentBindings.map(binding => binding.mark))
|
||||
@@ -76,6 +88,7 @@ export default {
|
||||
destinations() { return [...new Set((this.isReturnBulk ? this.sourceItems() : this.completedItems()).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
|
||||
eligibleItems() { return (this.isReturnBulk ? this.sourceItems() : this.completedItems()).filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
|
||||
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
|
||||
returnCartonItems() { return (this.task.items || []).filter(item => item.returnCartonId && !this.currentBindings.some(binding => binding.mark === item.cartonMark)) },
|
||||
operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) },
|
||||
plannedPackages() { return this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
|
||||
labelBlockReason() {
|
||||
@@ -86,9 +99,22 @@ export default {
|
||||
return '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。'
|
||||
},
|
||||
wholeSource() { return this.isReturnBulk && this.selectedSource && (!this.allowDisassemble || this.plannedPackages === Number(this.selectedSource.stockPackages || 0)) },
|
||||
readyToBind() { return this.source && this.target && this.selectedSource && this.samePallet(this.source, this.selectedSource) && !this.samePallet(this.source, this.target) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
|
||||
unboundSourceMode() { return !this.isReturnBulk && this.returnCartonItems.length > 0 && (this.selectedSource ? (!this.selectedSource.palletCode || this.selectedSource.palletCode === 'UNBOUND') : !this.operableSourceRows.some(row => row.palletCode && row.palletCode !== 'UNBOUND')) },
|
||||
readyToBind() { return this.target && this.selectedSource && (this.unboundSourceMode || (this.source && this.samePallet(this.source, this.selectedSource) && (!this.samePallet(this.source, this.target) || this.canRetainOriginal))) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
|
||||
},
|
||||
methods: {
|
||||
async saveMeasurement(pallet) {
|
||||
const dimensions = Object.fromEntries(this.measurementFields.map(field => [field.key, Number(pallet[field.key])]))
|
||||
if (!Object.values(dimensions).every(value => Number.isFinite(value) && value > 0)) { this.error = '请填写有效的长、宽、高和毛重'; return }
|
||||
const payload = { relabelNo: this.task.relabelNo, palletCode: pallet.palletCode, stockVersion: pallet.stockVersion, ...dimensions }
|
||||
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
|
||||
await this.run(async () => {
|
||||
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
this.message = `托盘 ${pallet.palletCode} 实测已保存`
|
||||
this.$emit('saved')
|
||||
})
|
||||
},
|
||||
savePalletTotals(result) { for (const pallet of result?.pallets || []) this.palletTotals = { ...this.palletTotals, [pallet.palletCode]: pallet } },
|
||||
samePallet(left, right) {
|
||||
if (!left || !right) return false
|
||||
@@ -137,11 +163,17 @@ export default {
|
||||
},
|
||||
sourceItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark)) },
|
||||
completedItems() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : Number(item.relabelledPackages) >= 1 && !bound.has(item.cartonMark)) },
|
||||
async loadSources() { await this.run(async () => { this.sourceRows = await findRelabelSources(this.task.relabelNo); for (const code of new Set(this.currentBindings.map(binding => binding.pallet))) { const pallet = await resolvePallet(code); this.savePalletTotals({ pallets: [pallet] }) } this.selectedSource = null; this.source = null; this.marks = []; this.quantity = '' }) },
|
||||
async loadSources() { await this.run(async () => { try { this.sourceRows = await findRelabelSources(this.task.relabelNo) } catch (error) { if (!this.isReturnBulk && this.returnCartonItems.length) this.sourceRows = []; else throw error } if (!this.isReturnBulk && this.returnCartonItems.length && !this.sourceRows.some(row => Number(row.movablePackages) > 0)) this.sourceRows = [{ positionId: this.returnCartonItems[0].returnCartonId, positionVersion: 0, palletCode: 'UNBOUND', stockPackages: this.returnCartonItems.length, movablePackages: this.returnCartonItems.length, cartonMarks: this.returnCartonItems.map(item => item.cartonMark).filter(Boolean) }]; for (const code of new Set(this.currentBindings.map(binding => binding.pallet))) { const pallet = await resolvePallet(code); this.savePalletTotals({ pallets: [pallet] }) } this.measurements = await listRelabelPalletMeasurements(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = ''; if (this.unboundSourceMode && this.operableSourceRows.length === 1) { this.selectedSource = this.operableSourceRows[0]; this.selectedDestination = this.returnCartonItems[0]?.destinationFbaWarehouseCode || ''; this.marks = this.completedItems().map(item => item.cartonMark); this.quantity = String(this.marks.length); this.activate('target') } }) },
|
||||
async selectSource(row) {
|
||||
if (this.busy) return
|
||||
this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []
|
||||
this.selectedDestination = (this.isReturnBulk ? this.sourceItems() : this.completedItems())[0]?.destinationFbaWarehouseCode || ''
|
||||
if (this.unboundSourceMode) {
|
||||
this.marks = this.completedItems().map(item => item.cartonMark)
|
||||
this.quantity = String(this.marks.length)
|
||||
this.activate('target')
|
||||
return
|
||||
}
|
||||
if (this.isReturnBulk) {
|
||||
const items = this.eligibleItems
|
||||
this.marks = items.map(item => item.cartonMark)
|
||||
@@ -172,9 +204,19 @@ export default {
|
||||
},
|
||||
async confirmSourceScan() {
|
||||
if (this.busy || !this.sourceCode.trim()) return
|
||||
const matched = this.operableSourceRows.find(row => this.samePallet(row, { palletCode: this.sourceCode }))
|
||||
const matched = this.unboundSourceMode
|
||||
? this.operableSourceRows.find(row => (row.cartonMarks || []).some(mark => String(mark).trim().toUpperCase() === this.sourceCode.trim().toUpperCase()))
|
||||
: this.operableSourceRows.find(row => this.samePallet(row, { palletCode: this.sourceCode }))
|
||||
this.source = null
|
||||
if (!matched) { this.error = '该托盘不在本单可操作的来源托盘列表中'; this.showBindingMessage('来源托盘不匹配', this.error); return }
|
||||
if (this.unboundSourceMode) {
|
||||
this.selectedSource = matched
|
||||
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
|
||||
this.marks = this.completedItems().map(item => item.cartonMark)
|
||||
this.quantity = String(this.marks.length)
|
||||
this.activate('target')
|
||||
return
|
||||
}
|
||||
await this.resolve('source')
|
||||
if (this.error || !this.source) return
|
||||
const source = this.source; this.source = null
|
||||
@@ -193,8 +235,9 @@ export default {
|
||||
}
|
||||
this.message = ''
|
||||
let reason = ''
|
||||
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘。'
|
||||
else if (!this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
|
||||
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。'
|
||||
else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。'
|
||||
else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
|
||||
else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成本托盘的 SKU 贴标登记,再绑定新托盘;整托无需填写拆箱数量。'
|
||||
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = this.isReturnBulk ? '本托盘可操作件数不足,请刷新来源托盘。' : '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
|
||||
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
|
||||
@@ -202,8 +245,8 @@ export default {
|
||||
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
|
||||
await this.resolve('target')
|
||||
if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
|
||||
if (!this.readyToBind) { this.error = '来源和目标托盘不能相同,请核对后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
|
||||
if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?`)) { this.target = null; return }
|
||||
if (!this.readyToBind) { this.error = '请核对来源箱、目标托盘和换标数量后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
|
||||
if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?${this.samePallet(this.source, this.target) ? '整托保留原托盘且货物和包装未变可沿用原测量;包装变化请重新补测。' : ''}`)) { this.target = null; return }
|
||||
await this.bind()
|
||||
},
|
||||
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
|
||||
@@ -238,7 +281,7 @@ export default {
|
||||
async bind() {
|
||||
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
|
||||
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
|
||||
const payload = { relabelNo: this.task.relabelNo, source: expectation(this.source), target: expectation(this.target), cartonMarks: [...this.marks], actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) }, reason: 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
|
||||
const payload = { relabelNo: this.task.relabelNo, ...(this.unboundSourceMode ? {} : { source: expectation(this.source), actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) } }), target: expectation(this.target), cartonMarks: [...this.marks], reason: this.unboundSourceMode ? 'PDA 扫描无来源托盘退货箱并绑定目的托盘' : 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
|
||||
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
|
||||
let saved = false
|
||||
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
|
||||
@@ -258,6 +301,9 @@ export default {
|
||||
</script>
|
||||
<style scoped>
|
||||
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
|
||||
.measurement-card{margin:12px 0;padding:12px;border:1px solid #d7e1ef;border-radius:8px}
|
||||
.measurement-inputs{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:10px 0}
|
||||
.measurement-inputs input{padding:8px;border:1px solid #d7e1ef;border-radius:6px}
|
||||
.pallet-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.heading{display:block;font-size:18px;font-weight:700}
|
||||
.refresh-button{flex:none;margin:0;padding:6px 14px;font-size:13px;line-height:1.5}
|
||||
|
||||
@@ -8,7 +8,17 @@
|
||||
<text class="hint">{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}</text>
|
||||
<template v-if="mode">
|
||||
<ScanTargetInput name="source" v-model="sourceCode" label="来源箱唛" placeholder="扫描来源箱唛" :active="active === 'source'" @activate="active = 'source'" @submit="scanSource" />
|
||||
<button class="secondary" :disabled="busy || !mode || !sourceCode.trim()" @tap="scanSource(sourceCode)">{{ mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
|
||||
<button class="secondary" :disabled="busy || !mode || !sourceCode.trim()" @tap="scanSource(sourceCode)">{{ !plan && mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
|
||||
<view v-if="plan" class="plan-boxes section-gap">
|
||||
<text class="card-title">计划需扫描箱子</text>
|
||||
<view v-for="box in plannedSourceRows" :key="box.cartonMark" class="plan-box">
|
||||
<text class="business-code">{{ box.cartonMark }} · {{ box.participation === 'FULL' ? '整箱' : box.participation === 'PARTIAL' ? '部分' : '计划来源' }}</text>
|
||||
<text class="meta-line">来源退货单:{{ box.sourceReturnNo || '—' }}</text>
|
||||
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
|
||||
<text v-for="item in box.originalItems || []" :key="'original-' + item.skuCode" class="meta-line">{{ 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)) }} 件</text>
|
||||
<text class="hint">{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '已扫描确认' : '待扫描' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="mode === 'EXISTING'" class="confirmed-sources">
|
||||
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
|
||||
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button class="link-button" :disabled="busy" @tap="active = 'source'">更换来源箱</button></view>
|
||||
@@ -26,18 +36,10 @@
|
||||
<view v-if="target" class="item-card section-gap"><text class="business-code">{{ target.cartonMark }}</text><text v-for="item in target.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)</text></view>
|
||||
</template>
|
||||
<template v-else><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input class="small-input" type="digit" v-model="dimensions[field.key]" placeholder="请输入" /></label></view><text class="hint">体积:{{ volume }} m³</text></template>
|
||||
<view v-if="mode === 'EXISTING' && plan" class="plan-boxes section-gap">
|
||||
<text class="card-title">计划箱子明细</text>
|
||||
<view v-for="box in (plan.sources || []).filter(box => !sources.some(source => source.cartonMark === box.cartonMark))" :key="box.cartonMark" class="plan-box">
|
||||
<text class="business-code">{{ box.cartonMark }}</text>
|
||||
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
|
||||
<text class="hint">{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '来源箱已确认 · 下方填写扣除数量' : '待扫描确认' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
|
||||
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button v-if="!plan" class="link-button" @tap="remove(box.cartonMark)">移除</button></view>
|
||||
<template v-if="!plan"><button class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button><ReturnLabelConfirmation ref="sourceLabels" :carton-mark="box.cartonMark" /></template>
|
||||
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · 可扣 {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="plan?.mode === 'SKU'" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
|
||||
<button v-if="!plan" class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button>
|
||||
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · {{ plan ? '计划取出' : '可扣' }} {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="!!plan" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
|
||||
</view>
|
||||
<button class="primary" :disabled="!canComplete" @tap="complete">{{ busy ? '处理中…' : '确认合箱' }}</button>
|
||||
</view>
|
||||
@@ -46,15 +48,14 @@
|
||||
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
|
||||
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
|
||||
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
components: { ScanTargetInput, ReturnLabelConfirmation },
|
||||
components: { ScanTargetInput },
|
||||
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
|
||||
emits: ['completed', 'error', 'busy-change'],
|
||||
data() { return { mode:'', dimensions:{length:'',width:'',height:'',weight:''}, fields:[{key:'length',label:'长 cm'},{key:'width',label:'宽 cm'},{key:'height',label:'高 cm'},{key:'weight',label:'重量 kg'}], sourceCode: '', targetCode: '', sources: [], target: null, deductions: {}, active: 'source', busy: false } },
|
||||
computed: { volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && (!this.plan || this.plan.mode !== 'SKU' || this.plan.sourceCartonMarks.every(mark => this.sources.some(box => box.cartonMark === mark))) && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && this.sources[0]?.cartonMark === this.sourceCode.trim()) } },
|
||||
computed: { plannedSourceRows() { const listed = Array.isArray(this.plan?.sources) ? this.plan.sources : []; const marks = this.plan?.sourceCartonMarks?.length ? this.plan.sourceCartonMarks : listed.map(box => box.cartonMark).filter(Boolean); return marks.map(mark => listed.find(box => box.cartonMark === mark) || { cartonMark: mark, items: [] }) }, volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && (!this.plan || (this.plan.sourceCartonMarks || this.plan.sources.map(box => box.cartonMark)).every(mark => this.sources.some(box => box.cartonMark === mark) || this.target?.cartonMark === mark)) && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && (!!this.plan || this.sources[0]?.cartonMark === this.sourceCode.trim())) } },
|
||||
async mounted() {
|
||||
if (!this.plan && this.initialSource) { this.choose('NEW'); this.sources = [this.initialSource]; this.sourceCode = ''; this.active = 'source'; return }
|
||||
this.choose('NEW')
|
||||
@@ -79,16 +80,16 @@ export default {
|
||||
if (this.mode === 'EXISTING') { this.sourceCode = mark; this.active = 'target'; return }
|
||||
throw new Error('该来源箱已添加')
|
||||
}
|
||||
if (this.mode !== 'EXISTING' && this.target?.cartonMark === mark) throw new Error('来源箱不能与目标箱相同')
|
||||
if ((this.plan || this.mode !== 'EXISTING') && this.target?.cartonMark === mark) throw new Error('来源箱不能与目标箱相同')
|
||||
const planned = this.plan?.sources?.find(box => box.cartonMark === mark)
|
||||
if(this.plan && !planned) throw new Error('该来源箱不在合箱计划中')
|
||||
const box = planned ? {...planned,customerId:this.plan.customerId} : await scanReturnCarton(mark)
|
||||
const customer = this.sources[0]?.customerId || this.target?.customerId
|
||||
if (customer && customer !== box.customerId) throw new Error('不同客户的箱不能合并')
|
||||
if (this.mode === 'EXISTING') {
|
||||
if (this.mode === 'EXISTING' && !this.plan) {
|
||||
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
|
||||
} else { this.sources.push(box); this.sourceCode = '' }
|
||||
if (this.plan?.mode === 'SKU') this.deductions = { ...this.deductions, [mark]: Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)])) }
|
||||
if (this.plan) this.deductions = { ...this.deductions, [mark]: Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)])) }
|
||||
this.save(); this.active = this.mode === 'NEW' ? 'source' : 'target'
|
||||
})
|
||||
},
|
||||
@@ -104,7 +105,7 @@ export default {
|
||||
// target. The remaining planned boxes are the source boxes.
|
||||
this.target = {...plannedSource,customerId:this.plan.customerId}
|
||||
this.targetCode = mark
|
||||
this.deductions = Object.fromEntries(this.sources.map(box => [box.cartonMark, Object.fromEntries((box.items || []).map(item => [item.skuCode, '']))]))
|
||||
this.deductions = Object.fromEntries(this.sources.map(box => [box.cartonMark, Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)]))]))
|
||||
this.save()
|
||||
return
|
||||
}
|
||||
@@ -115,7 +116,7 @@ export default {
|
||||
this.target = box; this.save()
|
||||
})
|
||||
},
|
||||
acceptScan(value) { if (!this.plan && this.sources.some(box => box.cartonMark === String(value).trim())) { for (const label of [].concat(this.$refs?.sourceLabels || [])) label.acceptScan(value); return } if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
|
||||
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
|
||||
async printSource(mark) {
|
||||
// #ifdef H5
|
||||
const tab = window.open('', '_blank')
|
||||
@@ -129,6 +130,7 @@ export default {
|
||||
},
|
||||
deduction(mark, sku) { return this.deductions?.[mark]?.[sku] || '' },
|
||||
setDeduction(mark, sku, raw) {
|
||||
if (this.plan) return
|
||||
const box = this.sources.find(item => item.cartonMark === mark)
|
||||
const max = Number(box?.items?.find(item => item.skuCode === sku)?.quantity || 0)
|
||||
const value = Math.min(max, Math.max(0, Math.floor(Number(raw) || 0)))
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<template v-else-if="discardCurrent">
|
||||
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><button class="secondary-button" :disabled="!discardPalletCode || discardLoading || discardBusy" @tap="resolveDiscardPallet">{{ discardLoading ? '读取中…' : '打开托盘废弃作业' }}</button><text class="scan-help">扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。</text></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">托盘 {{ discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>实际废弃数量</text><input type="number" :value="discardActuals[sku.itemId]" :disabled="discardTask.status === 'COMPLETED'" :placeholder="`请输入 0-${sku.plannedPackages} 箱`" @input="updateDiscardActual(sku.itemId, $event)" /><text>最多 {{ sku.plannedPackages }} 箱</text></view></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">托盘 {{ discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>实际废弃数量</text><input type="number" :value="discardActuals[sku.itemId]" :disabled="discardTask.status === 'COMPLETED'" :placeholder="`请输入 0-${sku.plannedPackages} 箱`" @input="updateDiscardActual(sku.itemId, $event)" /><text>最多 {{ sku.plannedPackages }} 箱</text></view></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前托盘,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
||||
</template>
|
||||
|
||||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||
@@ -58,7 +58,7 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { listCargoDiscardPhotos, uploadCargoDiscardPhoto, downloadCargoDiscardPhoto, completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
||||
computed: {
|
||||
displayItems() {
|
||||
const items = this.current?.items || []
|
||||
@@ -101,9 +101,9 @@
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
|
||||
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
|
||||
discardOrderCount() { return this.discardOrders.length },
|
||||
canConfirmDiscard() { return Boolean(!this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
|
||||
canConfirmDiscard() { return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner() },
|
||||
beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
@@ -129,25 +129,59 @@
|
||||
}, true)
|
||||
},
|
||||
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
|
||||
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
|
||||
closeDiscard() { if (this.discardBusy) return; this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
||||
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
|
||||
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
||||
updateDiscardPalletCode(value) { this.discardPalletCode = value },
|
||||
async resolveDiscardPallet(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
|
||||
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading) return
|
||||
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
|
||||
const order = this.discardCurrent
|
||||
this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
|
||||
this.resetDiscardPhotos(); this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
|
||||
try {
|
||||
const task = await resolveCargoDiscardPallet(code)
|
||||
if (this.discardCurrent !== order) return
|
||||
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
|
||||
this.discardTask = normalizeCargoDiscardTask(task)
|
||||
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages]))
|
||||
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])); await this.restoreDiscardPhotos()
|
||||
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
|
||||
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
||||
},
|
||||
resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false },
|
||||
async restoreDiscardPhotos() {
|
||||
if (!this.discardTask || !this.discardCurrent) return
|
||||
const order = this.discardCurrent; const task = this.discardTask
|
||||
this.discardPhotoReady = false
|
||||
try {
|
||||
const photos = await listCargoDiscardPhotos(order.discardNo, task.palletNo)
|
||||
if (this.discardCurrent !== order || this.discardTask !== task) return
|
||||
this.discardPhotos = photos; this.discardPhotoReady = true
|
||||
await Promise.all(photos.map(photo => this.loadDiscardPhoto(photo.fileId)))
|
||||
} catch (error) { if (this.discardCurrent === order && this.discardTask === task) this.setError(error, '废弃照片读取失败') }
|
||||
},
|
||||
async loadDiscardPhoto(fileId) {
|
||||
if (!this.discardCurrent || !this.discardTask) return
|
||||
const order = this.discardCurrent; const task = this.discardTask
|
||||
try {
|
||||
const url = await downloadCargoDiscardPhoto(order.discardNo, task.palletNo, fileId)
|
||||
if (this.discardCurrent === order && this.discardTask === task) { this.discardPhotoUrls = { ...this.discardPhotoUrls, [fileId]: url }; this.discardPhotoErrors = { ...this.discardPhotoErrors, [fileId]: false } }
|
||||
} catch (_) { if (this.discardCurrent === order && this.discardTask === task) this.discardPhotoErrors = { ...this.discardPhotoErrors, [fileId]: true } }
|
||||
},
|
||||
previewDiscardPhoto(fileId) { const urls = this.discardPhotos.map(photo => this.discardPhotoUrls[photo.fileId]).filter(Boolean); if (this.discardPhotoUrls[fileId]) uni.previewImage({ current: this.discardPhotoUrls[fileId], urls }) },
|
||||
takeDiscardPhoto() {
|
||||
if (!this.discardCurrent || !this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardPhotoBusy || this.discardBusy || this.discardLoading || !this.discardPhotoReady || this.discardPhotos.length >= 20) return
|
||||
const order = this.discardCurrent; const task = this.discardTask; this.discardPhotoBusy = true
|
||||
uni.chooseImage({ count: 1, sourceType: ['camera'], success: async ({ tempFilePaths }) => {
|
||||
try {
|
||||
if (this.discardCurrent !== order || this.discardTask !== task || !tempFilePaths?.[0]) return
|
||||
const photo = await uploadCargoDiscardPhoto(order.discardNo, task.palletNo, tempFilePaths[0])
|
||||
if (this.discardCurrent !== order || this.discardTask !== task) return
|
||||
this.discardPhotos.push(photo); this.discardPhotoUrls = { ...this.discardPhotoUrls, [photo.fileId]: tempFilePaths[0] }; this.error = null
|
||||
} catch (error) { this.setError(error, '废弃照片上传失败'); await this.restoreDiscardPhotos() }
|
||||
finally { this.discardPhotoBusy = false }
|
||||
}, fail: () => { this.discardPhotoBusy = false } })
|
||||
},
|
||||
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId))); this.successMessage = '废弃出库作业及照片已提交,等待仓库确认'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
|
||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||
@@ -189,6 +223,7 @@
|
||||
|
||||
<style scoped>
|
||||
@import '../dispatch/fulfillment.scss';
|
||||
.discard-photo-section{margin:14px 0;padding-top:12px;border-top:1px solid #d9e3ee}.discard-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0}.discard-photo-tile image{display:block;width:100%;height:120px;border-radius:8px}
|
||||
.outbound-line { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
|
||||
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
|
||||
.line-code, .line-meta { display: block; }
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<button class="secondary" :disabled="busy" @tap="printLabel(label)">打印新箱唛</button>
|
||||
<ReturnLabelConfirmation ref="labelConfirmations" :carton-mark="label.cartonMark" />
|
||||
</view>
|
||||
<button class="primary" :disabled="busy" @tap="nextPacking">返回合箱计划列表</button>
|
||||
<button class="primary" @tap="nextPacking">返回合箱计划列表</button>
|
||||
</view>
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="selectedOrders.length" class="secondary" :disabled="busy" @tap="refreshSelected">重新读取可用库存</button></view>
|
||||
</view></scroll-view>
|
||||
@@ -66,7 +66,7 @@
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.mode = this.packing.mode || 'SKU'; this.activePlan = this.packing.activePlan || null; this.result = this.packing.result || null; if (this.activePlan) { const quantities = {}; for (const box of this.activePlan.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity); this.selectedCustomer = { skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }; } this.startScanner(); this.loadPlans(); if (!this.activePlan && !this.result && this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.result) { for (const label of [].concat(this.$refs.labelConfirmations || [])) label.acceptScan(value); return } if (!this.busy && !this.result) { if (this.mode === 'BOX') this.$refs.boxWork?.acceptScan(value); else { this.searchCode = value; this.search(value) } } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.result) { for (const confirmation of this.$refs.labelConfirmations || []) confirmation.acceptScan(value); return } if (!this.busy && !this.result) { if (this.mode === 'BOX') this.$refs.boxWork?.acceptScan(value); else { this.searchCode = value; this.search(value) } } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
async loadPlans() { try { this.plans = await listPackingPlans() } catch (error) { this.setError(error, '加载合箱计划失败') } },
|
||||
async search(raw) {
|
||||
@@ -120,7 +120,7 @@
|
||||
|
||||
changeMode(mode) { if (this.activePlan || this.busy) return; this.mode = mode; resetWarehouseDomain('returnPacking'); updateWarehouseWork('returnPacking', { mode }); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null },
|
||||
leavePlan() { if (this.busy) return; this.returnToPlans() },
|
||||
packingCompleted(value) { playSuccessSound(); if (value.targetMode === 'EXISTING') { this.returnToPlans(); return } this.result = { labels: value.labels || (value.label ? [value.label] : []) }; updateWarehouseWork('returnPacking', { result: this.result }) },
|
||||
packingCompleted(value) { this.busy = false; playSuccessSound(); if (value.targetMode === 'EXISTING') { this.returnToPlans(); return } this.result = { labels: value.labels || (value.label ? [value.label] : []) }; updateWarehouseWork('returnPacking', { result: this.result }) },
|
||||
async printLabel(label) {
|
||||
// #ifdef H5
|
||||
const tab = window.open('', '_blank')
|
||||
@@ -132,7 +132,7 @@
|
||||
// #endif
|
||||
},
|
||||
copyCartonMark() { const value = this.result?.label?.cartonMark || this.result?.cartonMark; if (value) uni.setClipboardData({ data: value }) },
|
||||
nextPacking() { if (this.busy) return; this.returnToPlans() },
|
||||
nextPacking() { this.returnToPlans() },
|
||||
returnToPlans() { resetWarehouseDomain('returnPacking'); this.initialSource = null; this.activePlan = null; this.result = null; this.mode = 'SKU'; this.error = null; this.customerCandidates = []; this.selectedCustomer = null; this.candidates = null; this.previews = {}; this.loadPlans() },
|
||||
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
|
||||
@@ -9,15 +9,37 @@ function page(overrides = {}) {
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
|
||||
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
|
||||
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
|
||||
getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
|
||||
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
|
||||
...overrides }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
const vm = definition.data()
|
||||
for (const [name, fn] of Object.entries(definition.methods)) vm[name] = fn.bind(vm)
|
||||
for (const [name, fn] of Object.entries(definition.computed)) Object.defineProperty(vm, name, { get: () => fn.call(vm) })
|
||||
return { vm, calls }
|
||||
return { vm, calls, definition }
|
||||
}
|
||||
|
||||
test('discard requires restored photos and retains task while native camera returns', async () => {
|
||||
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
|
||||
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 1 }
|
||||
assert.equal(vm.canConfirmDiscard, false)
|
||||
vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false)
|
||||
vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true)
|
||||
vm.discardPhotoBusy = true; assert.equal(vm.canConfirmDiscard, false)
|
||||
let loads = 0; vm.startScanner = () => {}; vm.loadTasks = () => { loads++ }
|
||||
definition.onShow.call(vm)
|
||||
assert.equal(loads, 0); assert.equal(vm.discardTask.palletNo, 'PALLET')
|
||||
})
|
||||
|
||||
test('discard list failure blocks confirmation and stale downloads cannot replace a new pallet', async () => {
|
||||
const pending = deferred()
|
||||
const { vm } = page({ listCargoDiscardPhotos: async () => { throw new Error('offline photos') }, downloadCargoDiscardPhoto: () => pending.promise })
|
||||
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'A', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
||||
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||
await vm.restoreDiscardPhotos(); assert.equal(vm.canConfirmDiscard, false)
|
||||
const first = vm.loadDiscardPhoto('101'); vm.discardTask = { palletNo: 'B' }; vm.resetDiscardPhotos()
|
||||
pending.resolve('old-photo-url'); await first; assert.deepEqual(vm.discardPhotoUrls, {})
|
||||
})
|
||||
|
||||
test('return pallet rows sum carton counts without changing raw pick lines', () => {
|
||||
const { vm } = page()
|
||||
const cartons = Array.from({ length: 5 }, (_, index) => ({ outboundPalletItemId: index + 1, palletNo: 'PALLET', plannedPackages: 1, pickedPackages: index < 3 ? 1 : 0, positions: [] }))
|
||||
@@ -87,7 +109,7 @@ test('discard confirmation is guarded against double submission', async () => {
|
||||
const pending = deferred(); let submits = 0
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { submits++; await pending.promise }, listCargoDiscardTasks: async () => [] })
|
||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
||||
const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
|
||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]; const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
|
||||
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
|
||||
const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
function setup() {
|
||||
const calls = []
|
||||
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
|
||||
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
|
||||
() => ({ value: 'stable-key', signature: 'signature' }), () => {})
|
||||
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 }
|
||||
}
|
||||
test('pallet measurement submits actual dimensions, weight and current stock version and refreshes results', async () => {
|
||||
const { value, calls } = setup()
|
||||
await value.saveMeasurement({ palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: '100', widthCm: '50', heightCm: '50', grossWeightKg: '40' })
|
||||
assert.deepEqual(calls, [{ relabelNo: 'RL-1', palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: 100, widthCm: 50, heightCm: 50, grossWeightKg: 40, idempotencyKey: 'stable-key' }])
|
||||
assert.equal(value.measurements[0].volumeCbm, 0.25)
|
||||
assert.equal(value.busy, false)
|
||||
await value.saveMeasurement({ palletCode: 'NEW', lengthCm: 0, widthCm: 50, heightCm: 50, grossWeightKg: 40 })
|
||||
assert.equal(calls.length, 1)
|
||||
assert.match(value.error, /有效/)
|
||||
})
|
||||
test('only whole physical pallet quantities offer original pallet reuse', () => {
|
||||
const { component } = setup()
|
||||
const canRetain = component.computed.canRetainOriginal
|
||||
const ctx = { source: { totalPackages: 10 }, selectedSource: {}, plannedPackages: 10, isReturnBulk: false, unboundSourceMode: false }
|
||||
assert.equal(canRetain.call(ctx), true)
|
||||
ctx.plannedPackages = 4
|
||||
assert.equal(canRetain.call(ctx), false)
|
||||
ctx.plannedPackages = 10;ctx.isReturnBulk = true
|
||||
assert.equal(canRetain.call(ctx), false)
|
||||
})
|
||||
@@ -12,13 +12,14 @@ test('box merge component compiles both its script and template', () => {
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
|
||||
})
|
||||
function instance() {
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null })
|
||||
const boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
|
||||
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark) })
|
||||
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
|
||||
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
|
||||
work.save = () => {}
|
||||
return {work,component}
|
||||
}
|
||||
test('new plan requires scanning each source and keeps independent SKU deductions', async () => {
|
||||
test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
|
||||
const {work,component}=instance(); work.choose('NEW')
|
||||
assert.equal(work.sources.length,0)
|
||||
assert.equal(work.active,'source')
|
||||
@@ -26,14 +27,20 @@ test('new plan requires scanning each source and keeps independent SKU deduction
|
||||
work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
|
||||
work.dimensions={length:10,width:20,height:30,weight:1}
|
||||
assert.equal(work.sources.length,2)
|
||||
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),5)
|
||||
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),7)
|
||||
assert.equal(component.computed.canComplete.call(work),true)
|
||||
})
|
||||
|
||||
test('planned source carton marks are visible before scanning', () => {
|
||||
const {work, component} = instance()
|
||||
work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
|
||||
assert.deepEqual(component.computed.plannedSourceRows.call(work).map(box => box.cartonMark), ['A', 'B'])
|
||||
})
|
||||
test('existing box uses another box as target and never removes target inventory', async () => {
|
||||
const {work,component}=instance(); work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
|
||||
assert.equal(work.target.cartonMark,'B'); assert.equal(work.sources.length,1)
|
||||
work.setDeduction('A','SKU1',1)
|
||||
assert.equal(work.plannedItems().length,1)
|
||||
assert.equal(work.plannedItems().length,2)
|
||||
assert.equal(work.target.items[0].quantity,2)
|
||||
assert.equal(component.computed.canComplete.call(work),true)
|
||||
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
|
||||
@@ -42,6 +49,7 @@ test('existing box uses another box as target and never removes target inventory
|
||||
|
||||
test('existing source can be replaced before merging, echoing the mark and clearing old deductions and target', async () => {
|
||||
const {work,component}=instance()
|
||||
work.plan=null
|
||||
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
|
||||
work.setDeduction('A','SKU1',1)
|
||||
assert.equal(work.sourceCode,'A')
|
||||
@@ -58,6 +66,18 @@ test('existing source can be replaced before merging, echoing the mark and clear
|
||||
assert.equal(component.computed.canComplete.call(work),true)
|
||||
})
|
||||
|
||||
test('BOX plan requires every source and accumulates multiple scanned sources', async () => {
|
||||
const {work,component}=instance()
|
||||
work.plan.mode='BOX'
|
||||
work.plan.sources.push({cartonMark:'C',items:[{skuCode:'SKU1',quantity:4}]})
|
||||
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
|
||||
assert.equal(component.computed.canComplete.call(work),false)
|
||||
await work.scanSource('C')
|
||||
assert.equal(work.sources.length,2)
|
||||
assert.equal(component.computed.canComplete.call(work),true)
|
||||
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),9)
|
||||
})
|
||||
|
||||
test('a directly scanned box survives switching between existing and new targets', async () => {
|
||||
const {work}=instance()
|
||||
const box=work.plan.sources[0];work.plan=null;work.initialSource=box
|
||||
|
||||
@@ -56,9 +56,20 @@ test('existing target immediately clears work and returns to refreshed plans eve
|
||||
test('new target retains labels for printing and can return without putaway', () => {
|
||||
const {page,saved,reset}=instance()
|
||||
page.packingCompleted({targetMode:'NEW',labels:[{cartonMark:'NEW#1-1'}]})
|
||||
assert.equal(page.busy,false)
|
||||
assert.equal(saved().result.labels[0].cartonMark,'NEW#1-1')
|
||||
assert.equal(reset(),false)
|
||||
page.busy=false; page.nextPacking()
|
||||
assert.equal(page.result,null)
|
||||
assert.equal(reset(),true)
|
||||
})
|
||||
|
||||
test('completed packing can return to plans even when the page busy flag is stale', () => {
|
||||
const {page,reset}=instance()
|
||||
page.result={labels:[{cartonMark:'NEW#1-1'}]}
|
||||
page.nextPacking()
|
||||
assert.equal(page.result,null)
|
||||
assert.equal(reset(),true)
|
||||
assert.equal(page.plans[0].planNo,'P2')
|
||||
assert.doesNotMatch(descriptor.template.content, /<button[^>]*:disabled[^>]*@tap="nextPacking"/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user