feat: support flexible dropship pallet scanning
This commit is contained in:
@@ -40,10 +40,6 @@ export function bindDropshipPendingCartons(taskId, warehouseId, palletNo) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId: stableId(warehouseId), palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId) } })
|
||||
}
|
||||
|
||||
export function createAndBindDropshipPallet(taskId, warehouseId) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/create-and-bind`, method: 'POST', data: { warehouseId: stableId(warehouseId), operationId: operationId('create-bind', taskId) } })
|
||||
}
|
||||
|
||||
export function sealDropshipPallet(taskId, warehouseId, pallet) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('seal', taskId) } })
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
</view>
|
||||
|
||||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||||
<text class="section-title">1. 扫箱清点</text>
|
||||
<text class="section-desc">扫描箱唛,核对 SKU 与本箱实际件数。</text>
|
||||
<text class="section-title">1. 扫托盘或扫箱</text>
|
||||
<text class="section-desc">顺序不限:先扫托盘后扫箱会直接归托;先扫箱后扫托会把待归托箱绑定到该托盘。</text>
|
||||
<ScanTargetInput v-model="palletCode" name="palletCode" label="托盘码" placeholder="扫描或输入已生成的 DP 托盘码" :active="activeScan === 'palletCode'" @activate="activeScan = 'palletCode'" @submit="selectPallet" />
|
||||
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
|
||||
<view v-if="carton" class="carton-card">
|
||||
<view class="carton-head"><text>{{ carton.boxCode }}</text><text>{{ carton.countStatus === 'UNCOUNTED' ? '待清点' : '已处理' }}</text></view>
|
||||
@@ -28,23 +29,19 @@
|
||||
<text class="carton-line">计划 {{ carton.expectedSkuQuantity || 0 }} 件</text>
|
||||
<view class="field"><text>实际件数</text><input v-model="actualQuantity" type="number" aria-label="本箱实际件数" /></view>
|
||||
<view v-if="quantityDiffers" class="field stacked"><text>差异说明</text><textarea v-model="discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
|
||||
<button class="primary-button" :disabled="!canCount || submitting" @tap="countCarton">确认本箱并进入待归托区</button>
|
||||
<button class="primary-button" :disabled="!canCount || submitting" @tap="countCarton">{{ currentPallet ? `确认并归入 ${currentPallet.palletNo}` : '确认本箱并进入待归托区' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||||
<text class="section-title">2. 完成入库</text>
|
||||
<text class="section-desc">全部箱码清点完成后,系统一次性完成归托、封托并生成正式 DP 托盘码。</text>
|
||||
<button class="primary-button" :disabled="!allCartonsCounted || !task.unboundCartons || submitting" @tap="completeInbound">完成入库并生成托盘码</button>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="section-heading"><view><text class="section-title">3. 托盘码与打印</text><text class="section-desc">完成入库后显示最终托盘码,可立即打印并粘贴。</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
|
||||
<view class="section-heading"><view><text class="section-title">2. 预生成托盘码</text><text class="section-desc">测量审核后已按预计托数生成,可先打印粘贴;装完一个托盘即可封托。</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
|
||||
<view v-if="!task.pallets || !task.pallets.length" class="empty">暂无托盘</view>
|
||||
<view v-for="pallet in task.pallets" :key="pallet.palletId" class="pallet-card">
|
||||
<view class="pallet-head"><text class="pallet-code">{{ pallet.palletNo }}</text><text class="pallet-status" :class="pallet.status === 'SEALED' ? 'sealed' : ''">{{ pallet.status === 'SEALED' ? '已封托' : '待封托' }}</text></view>
|
||||
<text class="pallet-meta">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU · {{ pallet.weightKg || 0 }} kg · {{ pallet.volumeCbm || 0 }} m³</text>
|
||||
<button v-if="pallet.status === 'SEALED'" class="primary-button" :disabled="printing" @tap="printPallet(pallet)">{{ printing ? '正在生成打印文件…' : '打印托盘码' }}</button>
|
||||
<button v-if="pallet.status === 'OPEN'" class="secondary-button" :disabled="submitting" @tap="selectPallet(pallet.palletNo)">{{ currentPallet?.palletId === pallet.palletId ? '当前托盘' : '设为当前托盘' }}</button>
|
||||
<button class="secondary-button" :disabled="printing" @tap="printPallet(pallet)">{{ printing ? '正在生成打印文件…' : '打印托盘码' }}</button>
|
||||
<button v-if="pallet.status === 'OPEN'" class="primary-button" :disabled="!pallet.cartonCount || submitting" @tap="sealPallet(pallet)">封托</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -55,19 +52,19 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { claimDropshipPalletizationTask, countDropshipCarton, createAndBindDropshipPallet, downloadDropshipPalletLabel, getDropshipPalletizationTask, resolveDropshipCarton } from '@/api/pda-dropship.js'
|
||||
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, downloadDropshipPalletLabel, getDropshipPalletizationTask, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { formatApiError } from '@/utils/user-facing.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, boxCode: '', carton: null, actualQuantity: '', discrepancyRemark: '', activeScan: 'boxCode', loading: false, submitting: false, printing: false, error: '', notice: '' } },
|
||||
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', boxCode: '', carton: null, actualQuantity: '', discrepancyRemark: '', activeScan: 'boxCode', loading: false, submitting: false, printing: false, error: '', notice: '' } },
|
||||
computed: {
|
||||
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
|
||||
quantityDiffers() { return this.carton && Number(this.actualQuantity) !== Number(this.carton.expectedSkuQuantity) },
|
||||
canCount() { return Boolean(this.carton?.canCount && Number(this.actualQuantity) > 0 && (!this.quantityDiffers || this.discrepancyRemark.trim())) },
|
||||
allCartonsCounted() { return Number(this.task?.totalCartons || 0) > 0 && Number(this.task?.countedCartons || 0) === Number(this.task?.totalCartons || 0) }
|
||||
currentPallet() { return (this.task?.pallets || []).find((item) => String(item.palletId) === String(this.selectedPalletId) && item.status === 'OPEN') || null }
|
||||
},
|
||||
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTask() },
|
||||
@@ -75,7 +72,7 @@
|
||||
methods: {
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { this.boxCode = String(value).trim().toUpperCase(); this.resolveCarton() },
|
||||
handleScan(value) { const code = String(value).trim().toUpperCase(); if (code.startsWith('DP-')) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
|
||||
resetMessages() { this.error = ''; this.notice = '' },
|
||||
async loadTask() {
|
||||
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
|
||||
@@ -93,12 +90,20 @@
|
||||
finally { this.loading = false }
|
||||
},
|
||||
async countCarton() {
|
||||
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, actualSkuQuantity: Number(this.actualQuantity), discrepancyAction: this.quantityDiffers ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.quantityDiffers ? this.discrepancyRemark.trim() : null }), '本箱已完成入库清点')
|
||||
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.currentPallet?.palletNo || null, actualSkuQuantity: Number(this.actualQuantity), discrepancyAction: this.quantityDiffers ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.quantityDiffers ? this.discrepancyRemark.trim() : null }), this.currentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
|
||||
if (result) { this.boxCode = ''; this.carton = null; this.actualQuantity = ''; this.discrepancyRemark = '' }
|
||||
},
|
||||
async completeInbound() { await this.runMutation(() => createAndBindDropshipPallet(this.taskId, this.warehouseId), '入库已完成,正式托盘码已生成') },
|
||||
async selectPallet(value) {
|
||||
const code = String(value || this.palletCode || '').trim().toUpperCase(); const pallet = (this.task?.pallets || []).find((item) => item.palletNo === code)
|
||||
if (!pallet) { this.error = '托盘码不属于当前一件代发任务'; return }
|
||||
if (pallet.status !== 'OPEN') { this.error = '托盘已封托,不能继续归托'; return }
|
||||
this.palletCode = code; this.selectedPalletId = String(pallet.palletId); this.activeScan = 'boxCode'
|
||||
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code), `待归托箱已绑定至 ${code}`)
|
||||
else this.notice = `当前托盘:${code}`
|
||||
},
|
||||
async sealPallet(pallet) { const result = await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`); if (result && String(this.selectedPalletId) === String(pallet.palletId)) { this.selectedPalletId = ''; this.palletCode = '' } },
|
||||
async printPallet(pallet) {
|
||||
if (this.printing || pallet.status !== 'SEALED') return
|
||||
if (this.printing) return
|
||||
this.printing = true; this.resetMessages()
|
||||
try {
|
||||
const filePath = await downloadDropshipPalletLabel(this.taskId, this.warehouseId, pallet.palletId)
|
||||
|
||||
@@ -19,7 +19,7 @@ test('dropship palletization adapter uses only the dedicated production PDA cont
|
||||
const api = await read('../api/pda-dropship.js')
|
||||
for (const route of [
|
||||
'/tasks/pending', '/cartons/resolve', '/cartons/count',
|
||||
'/pallets/create-and-bind', '/print'
|
||||
'/pallets/bind-pending', '/seal', '/print'
|
||||
]) assert.match(api, new RegExp(route.replaceAll('/', '\\/')))
|
||||
assert.match(api, /apiDownload/)
|
||||
assert.match(api, /application\\\/pdf/)
|
||||
@@ -27,14 +27,14 @@ test('dropship palletization adapter uses only the dedicated production PDA cont
|
||||
assert.match(api, /tasks\/pending\?\$\{params\.join\('&'\)\}/)
|
||||
assert.match(api, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
|
||||
assert.match(api, /operationId\('count'/)
|
||||
assert.match(api, /operationId\('create-bind'/)
|
||||
assert.match(api, /operationId\('bind'/)
|
||||
assert.match(api, /function stableId\(value\)[\s\S]*?String\(value \?\? ''\)\.trim\(\)/)
|
||||
assert.match(api, /warehouseId: stableId\(warehouseId\)/)
|
||||
assert.match(api, /warehouseId: stableId\(payload\.warehouseId\)/)
|
||||
assert.doesNotMatch(api, /customerId|tenantId|inboundId/)
|
||||
})
|
||||
|
||||
test('palletization UI generates and prints the final pallet number only after inbound work is complete', async () => {
|
||||
test('palletization UI accepts carton and pre-generated pallet scans in either order', async () => {
|
||||
const [tasks, work] = await Promise.all([
|
||||
read('../pages/dropship-tasks/dropship-tasks.vue'),
|
||||
read('../pages/dropship-palletization/dropship-palletization.vue')
|
||||
@@ -43,15 +43,15 @@ test('palletization UI generates and prints the final pallet number only after i
|
||||
assert.match(tasks, /\.search-card\s*\{[^}]*display:\s*flex;[^}]*box-shadow:\s*none;/)
|
||||
assert.match(tasks, /\.task-list\s*\{[^}]*overflow:\s*hidden;[^}]*border-radius:\s*7px;/)
|
||||
assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
|
||||
for (const text of ['一件代发入库', '领取并开始入库', '扫箱清点', '差异说明', '完成入库并生成托盘码', '托盘码与打印', '打印托盘码']) assert.match(work, new RegExp(text))
|
||||
for (const text of ['一件代发入库', '领取并开始入库', '扫托盘或扫箱', '差异说明', '预生成托盘码', '打印托盘码', '封托']) assert.match(work, new RegExp(text))
|
||||
for (const text of ['绑定到扫描托盘', '生成 DP 托盘并绑定', '确认封托']) assert.doesNotMatch(work, new RegExp(text))
|
||||
assert.match(work, /class="task-summary-card"/)
|
||||
assert.match(work, /\.task-summary-card\s*\{\s*display:\s*block;/)
|
||||
assert.doesNotMatch(work, /class="summary-card"/)
|
||||
assert.match(work, /startScanBroadcast/)
|
||||
assert.match(work, /quantityDiffers[\s\S]*?ACCEPT_VARIANCE/)
|
||||
assert.match(work, /allCartonsCounted\(\)[\s\S]*?countedCartons[\s\S]*?totalCartons/)
|
||||
assert.match(work, /pallet\.status !== 'SEALED'/)
|
||||
assert.match(work, /bindDropshipPendingCartons/)
|
||||
assert.match(work, /palletNo: this\.currentPallet/)
|
||||
assert.match(work, /downloadDropshipPalletLabel/)
|
||||
assert.match(work, /formatApiError/)
|
||||
assert.doesNotMatch(work, /Number\(this\.warehouseId\)/)
|
||||
|
||||
Reference in New Issue
Block a user