优化
This commit is contained in:
@@ -188,9 +188,36 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) {
|
||||
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
|
||||
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
|
||||
requireStockCommandIdentity(request)
|
||||
// 出库前按同一份货物、照片和幂等键校验仓租,资金就绪后才提交库存扣减。
|
||||
await prepareDiscardRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body })
|
||||
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
|
||||
}
|
||||
|
||||
/** 本次点击内只查询资金回执,不缓存或自动重放失败的出库命令。 */
|
||||
async function prepareDiscardRent(body) {
|
||||
const base = '/pda/v1/last-leg/storage-rent/preparations'
|
||||
let preparationId
|
||||
try {
|
||||
let preparation = await overseasRequest('POST', base, body)
|
||||
preparationId = preparation?.preparationId
|
||||
for (let attempt = 0; preparation?.ready !== true && preparation?.status === 'FREEZE_PENDING' && preparationId && attempt < 20; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
preparation = await overseasRequest('GET', `${base}/${encoded(preparationId)}`)
|
||||
}
|
||||
if (preparation?.ready !== true) {
|
||||
const message = preparation?.message ? `仓租校验未通过:${preparation.message}` : '仓租资金预校验尚未通过,请稍后重新提交'
|
||||
throw Object.assign(new Error(message), { code: 'A0409' })
|
||||
}
|
||||
} catch (error) {
|
||||
// 尚未发送出库命令时释放已知的未使用预占,释放失败保留原错误诊断。
|
||||
if (preparationId) {
|
||||
try { await overseasRequest('DELETE', `${base}/${encoded(preparationId)}`) }
|
||||
catch (releaseError) { console.error('[Overseas PDA discard] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-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><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-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><text v-if="error.network" class="offline-note">网络连接失败,请刷新核对任务状态后重试。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
|
||||
|
||||
<template v-if="!loading && !current && !discardCurrent">
|
||||
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
@@ -44,12 +44,25 @@
|
||||
|
||||
<template v-else-if="discardCurrent">
|
||||
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card"><text class="card-title">本单相关托盘及废弃范围</text><view v-for="task in discardCurrentTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text v-for="mark in task.cartonMarks" :key="mark" class="line-code">{{ mark }}</text><text v-if="!task.cartonMarks.length" class="scan-help">缺少箱唛与托盘对应关系,请核对相关托盘,以仓库实际废弃箱数为准。</text></view></view>
|
||||
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text class="task-meta">本次废弃 {{ discardQuantitySummary }}</text><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 }} {{ sku.quantityUnit || '箱' }}</text><view v-if="sku.actualQuantityEditable" class="quantity-row"><text>实际废弃箱数</text><input type="number" placeholder="请输入实际箱数" :value="discardActuals[sku.itemId] ?? ''" :disabled="discardTask.status === 'COMPLETED' || discardBusy || discardLoading || discardPhotoBusy" @input="updateDiscardActual(sku.itemId, $event)" /><text>箱(0–{{ sku.plannedPackages }})</text></view><text v-else-if="sku.actualPackages != null" class="line-meta">实际废弃 {{ sku.actualPackages }} {{ sku.quantityUnit || '箱' }}</text><text v-if="sku.actualQuantityEditable" class="scan-help">按现场实点填写,未废弃的预留库存将释放。</text></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></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>
|
||||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><text class="card-title">本单待废弃托盘</text><view v-for="task in discardPendingTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>待废弃</text></view></view></view>
|
||||
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="discardTask?.scanMode !== 'CARTON'" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
|
||||
<view v-if="discardTask" class="fulfillment-card"><ScanTargetInput v-if="discardNeedsRefresh" name="cargo-discard-retry" :model-value="discardPalletCode" label="重新扫描废弃托盘" placeholder="重新扫描当前托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><view v-else class="task-head"><text class="business-code">{{ discardTask.palletNo }}</text><text>{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card">
|
||||
<view v-for="sku in discardQuantityLines" :key="sku.itemId" class="outbound-line">
|
||||
<text class="line-code">{{ sku.skuCode || '—' }}</text>
|
||||
<view class="quantity-row"><text>实际废弃箱数</text><input type="number" placeholder="请输入实际箱数" :value="discardActuals[sku.itemId] ?? ''" :disabled="discardTask.status === 'COMPLETED' || discardBusy || discardLoading || discardPhotoBusy" @input="updateDiscardActual(sku.itemId, $event)" /><text>箱(0–{{ sku.plannedPackages }})</text></view>
|
||||
<text class="scan-help">按现场实点填写,未废弃的预留库存将释放。</text>
|
||||
</view>
|
||||
<view v-if="discardTask.scanMode === 'CARTON'"><view class="discard-carton-heading" role="button" :aria-expanded="discardCartonsExpanded" :aria-label="discardCartonsExpanded ? '收起箱号核对' : '展开箱号核对'" @tap="discardCartonsExpanded = !discardCartonsExpanded"><text class="business-code">箱号核对</text><view class="discard-carton-heading-end"><text class="scan-help">待扫描 {{ discardPendingMarks.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardCartonsExpanded }" aria-hidden="true"></view></view></view>
|
||||
<text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text>
|
||||
<view v-if="discardCartonsExpanded">
|
||||
<view v-for="mark in discardPendingMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text></view>
|
||||
<text v-if="!discardPendingMarks.length" class="scan-help">箱号已全部核对</text>
|
||||
</view><view v-if="discardTask.status !== 'COMPLETED'"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-carton" :model-value="discardCartonCode" label="废弃箱号" placeholder="扫描本次废弃的系统箱号" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /></view></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</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>
|
||||
<view v-if="successMessage && !discardCurrent" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -66,8 +79,11 @@
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardScannedMarks: [], discardAllCartons: false, 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 } },
|
||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardScannedMarks: [], discardCartonsExpanded: true, discardCartonCode: '', discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, discardNeedsRefresh: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
||||
computed: {
|
||||
// 已扫描箱号保留提交凭据,列表只显示尚未核对的箱子。
|
||||
discardPendingMarks() { return (this.discardTask?.cartonMarks || []).filter(mark => !this.discardScannedMarks.includes(mark)) },
|
||||
discardQuantityLines() { return (this.discardTask?.skus || []).filter(sku => sku.actualQuantityEditable) },
|
||||
displayItems() {
|
||||
const items = this.current?.items || []
|
||||
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
|
||||
@@ -101,6 +117,7 @@
|
||||
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, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
|
||||
discardOrderCount() { return this.discardOrders.length },
|
||||
discardPendingTasks() { return this.discardCurrentTasks.filter(task => task.status !== 'COMPLETED') },
|
||||
discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
|
||||
discardQuantitySummary() {
|
||||
const task = this.discardTask
|
||||
@@ -109,7 +126,7 @@
|
||||
const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
|
||||
return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
|
||||
},
|
||||
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && (this.discardAllCartons || task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))))) }
|
||||
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark))))) }
|
||||
},
|
||||
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() },
|
||||
@@ -137,17 +154,32 @@
|
||||
}, 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) { 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, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardScannedMarks = []; this.discardAllCartons = false; this.error = null; const loose = this.discardTasks.find(task => task.discardNo === code && task.scanMode === 'CARTON' && task.status !== 'COMPLETED'); if (loose) { this.discardTask = normalizeCargoDiscardTask(loose); this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => [sku.itemId, sku.plannedPackages])); this.restoreDiscardPhotos() } },
|
||||
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, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardNeedsRefresh = false; this.error = null; this.successMessage = ''; },
|
||||
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 scanDiscardCarton(raw) {
|
||||
if (!this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
|
||||
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardCartonCode).trim()
|
||||
if (!code) return
|
||||
if (this.discardNeedsRefresh && !await this.refreshDiscardTask()) return
|
||||
if (this.discardTask.status === 'COMPLETED') return
|
||||
if (!this.discardTask.cartonMarks.includes(code)) { this.setError(new Error('箱号不属于当前废弃任务'), '箱号核对失败'); return }
|
||||
if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]
|
||||
this.discardCartonCode = ''; this.error = null; playSuccessSound()
|
||||
},
|
||||
async resolveDiscardPallet(raw) {
|
||||
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardPalletCode).trim()
|
||||
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
|
||||
if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]; this.error = null; playSuccessSound(); return }
|
||||
if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { await this.scanDiscardCarton(code); return }
|
||||
if (this.discardTask?.palletNo === code) { await this.refreshDiscardTask(); return }
|
||||
// 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
|
||||
if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
|
||||
// 有托盘的任务须先扫描本单托盘,箱号仅用于展开后的逐箱核对。
|
||||
const selectedTasks = this.discardTasks.filter(task => task.discardNo === this.discardCurrent.discardNo)
|
||||
if (selectedTasks.length && !selectedTasks.some(task => task.palletNo !== '散箱' ? task.palletNo === code : (task.cartonMarks || []).includes(code))) { this.setError(new Error('请先扫描当前废弃单对应的托盘码'), '托盘核对失败'); return }
|
||||
const order = this.discardCurrent
|
||||
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
|
||||
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardPalletCode = code; this.discardLoading = true; this.discardNeedsRefresh = false; this.discardTask = null; this.discardActuals = {}; this.error = null; this.successMessage = ''
|
||||
try {
|
||||
const task = await resolveCargoDiscardPallet(code, order.discardNo)
|
||||
if (this.discardCurrent !== order) return
|
||||
@@ -158,6 +190,29 @@
|
||||
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
|
||||
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
||||
},
|
||||
/** 重扫与重试先读取当前权威任务,只保留仍匹配的箱号和实点数;照片仍归当前托盘。 */
|
||||
async refreshDiscardTask() {
|
||||
if (!this.discardCurrent || !this.discardTask || this.discardLoading || this.discardPhotoBusy) return false
|
||||
const order = this.discardCurrent; const previous = this.discardTask
|
||||
this.discardLoading = true
|
||||
try {
|
||||
const latest = await resolveCargoDiscardPallet(previous.palletNo, order.discardNo)
|
||||
if (this.discardCurrent !== order || this.discardTask !== previous) return false
|
||||
if (latest.discardNo !== order.discardNo || latest.palletNo !== previous.palletNo) throw new Error('刷新结果不属于当前废弃托盘')
|
||||
this.discardTask = normalizeCargoDiscardTask(latest)
|
||||
this.discardScannedMarks = this.discardScannedMarks.filter(mark => (this.discardTask.cartonMarks || []).includes(mark))
|
||||
this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => {
|
||||
const old = previous.skus.find(line => line.itemId === sku.itemId)
|
||||
const unchanged = old && old.plannedPackages === sku.plannedPackages && old.actualQuantityEditable === sku.actualQuantityEditable
|
||||
return [sku.itemId, sku.actualPackages ?? (unchanged ? this.discardActuals[sku.itemId] : sku.actualQuantityEditable ? '' : sku.plannedPackages)]
|
||||
}))
|
||||
this.discardPalletCode = latest.palletNo; this.discardNeedsRefresh = false; this.error = null
|
||||
if (!this.discardPhotoReady) await this.restoreDiscardPhotos()
|
||||
if (latest.status === 'COMPLETED') this.successMessage = '当前托盘已完成废弃,请返回列表核对本单剩余任务'
|
||||
return true
|
||||
} catch (error) { if (this.discardCurrent === order) { this.discardNeedsRefresh = true; this.setError(error, '废弃任务刷新失败,请重新扫描') }; return false }
|
||||
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
|
||||
@@ -195,7 +250,29 @@
|
||||
// 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。
|
||||
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
|
||||
updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.error = null; this.successMessage = ''; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId)); const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo: this.discardCurrent.discardNo, palletNo: this.discardTask.palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: this.discardAllCartons }); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, photoFileIds, this.discardScannedMarks, this.discardAllCartons, { idempotencyKey: key.value }); this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); this.successMessage = '废弃出库作业及照片已提交,已完成'; 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.error = null; this.successMessage = ''; this.discardBusy = true
|
||||
const palletNo = this.discardTask.palletNo; const discardNo = this.discardCurrent.discardNo
|
||||
try {
|
||||
if (this.discardNeedsRefresh) {
|
||||
if (!await this.refreshDiscardTask() || this.discardTask.status === 'COMPLETED' || !this.discardPhotoReady || !this.discardTask.skus.every(sku => this.validDiscardActual(sku)) || (this.discardTask.scanMode === 'CARTON' && !this.discardTask.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))) return
|
||||
}
|
||||
const lines = this.discardTask.skus.map(sku => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) }))
|
||||
const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId))
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo, palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: false })
|
||||
await confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, this.discardScannedMarks, false, { idempotencyKey: key.value })
|
||||
this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardScannedMarks = []; this.discardCartonCode = ''
|
||||
// 确认已成功时,刷新失败不能被显示为出库失败或宣告整单完成。
|
||||
try { this.discardTasks = await listCargoDiscardTasks() }
|
||||
catch (error) { this.discardTasks = this.discardTasks.filter(task => task.discardNo !== discardNo || task.palletNo !== palletNo); this.successMessage = '托盘 ' + palletNo + ' 废弃已完成,其他托盘待办刷新失败,请返回列表重新加载'; this.setError(error, '待办刷新失败'); return }
|
||||
const remaining = this.discardPendingTasks.map(task => task.palletNo)
|
||||
this.successMessage = remaining.length ? '托盘 ' + palletNo + ' 废弃已完成,本单还有 ' + remaining.length + ' 个待废弃任务:' + remaining.join('、') + ',请继续扫描对应托盘' : '本单全部废弃出库作业及照片已提交,已完成'
|
||||
playSuccessSound()
|
||||
} catch (error) { this.discardNeedsRefresh = true; 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 })
|
||||
@@ -226,7 +303,7 @@
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
},
|
||||
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async retry() { if (this.discardCurrent) { if (this.discardTask) await this.refreshDiscardTask(); else if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
|
||||
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
|
||||
@@ -239,6 +316,12 @@
|
||||
|
||||
<style scoped>
|
||||
@import '../dispatch/fulfillment.scss';
|
||||
.discard-carton-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; cursor: pointer; }
|
||||
.discard-carton-heading > .business-code { flex-shrink: 0; white-space: nowrap; }
|
||||
.discard-carton-heading-end { display: flex; align-items: center; gap: 12px; }
|
||||
.discard-carton-heading-end .scan-help { margin: 0; }
|
||||
.discard-carton-chevron { flex-shrink: 0; width: 8px; height: 8px; margin-right: 3px; border-right: 2px solid #718095; border-bottom: 2px solid #718095; transform: rotate(45deg); }
|
||||
.discard-carton-chevron.expanded { transform: rotate(225deg); }
|
||||
.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; }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<template v-else>
|
||||
<view class="work-card"><text class="card-title">{{ task.palletCode }}</text><text class="hint">{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}</text><text class="hint">{{ task.returnNo }} · {{ task.boxes.length }} 箱</text><view v-for="row in totals" :key="row.skuCode" class="total-row" :class="{ matched: row.actual === row.quantity }"><text>{{ row.skuCode }}</text><text>签收 {{ row.quantity }} / 已填 {{ row.actual }}</text></view></view>
|
||||
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields volume-unit="m3" :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
||||
</view>
|
||||
<view class="work-card"><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<view class="measure-section-head"><view><text class="measure-title">本托箱子汇总</text></view></view>
|
||||
<view class="summary-metrics"><view><strong>{{ palletSummary(pallet).cartonCount }}</strong><text>实收箱数</text></view><view><strong>{{ palletVolume(pallet) }}</strong><text>箱子体积 m³</text></view><view><strong>{{ palletSummary(pallet).weight.toFixed(2) }}</strong><text>箱子重量 kg</text></view></view>
|
||||
<button v-if="!pallet.generated" class="generate-button" :disabled="pallet.busy || cartonsCounted || countingCartons" @tap="generatePallet(pallet)">{{ pallet.busy ? '生成中…' : '生成托盘号' }}</button>
|
||||
<view v-else class="generated-card"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ destinationLabel(pallet, selected) }}</text></view><button :disabled="pallet.busy" @tap="printDraftPallet(pallet)">打印标签</button></view>
|
||||
<view v-else class="generated-card"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ destinationLabel(pallet, selected) }}</text></view><button :disabled="pallet.busy" @tap="printDraftPallet(pallet)">打印托盘</button></view>
|
||||
<view v-if="pallet.generated && !pallet.bound" class="bind-panel"><ScanTargetInput :name="`transfer-pallet-${index}`" v-model="pallet.bindCode" label="确认实物托盘码" placeholder="扫描或手输新托盘标签号" :active="activePalletKey === pallet.key" @activate="activePalletKey = pallet.key" @submit="bindPallet(pallet)" /><button :disabled="pallet.busy || !String(pallet.bindCode || '').trim()" @tap="bindPallet(pallet)">{{ pallet.busy ? '确认中…' : '确认绑定' }}</button></view>
|
||||
<view v-if="pallet.bound" class="bound-state">已绑定实物托盘 {{ pallet.palletNo }}</view>
|
||||
</view>
|
||||
@@ -89,7 +89,7 @@
|
||||
<button class="confirm-carton-button" :disabled="countingCartons || submitting || !validCartonValues(box) || !generatedPallets.some(pallet => String(pallet.draftId) === String(box.transferPalletId))" @tap="confirmSingleCarton(box)">确认本箱</button>
|
||||
</template>
|
||||
</view>
|
||||
<button v-if="cartonsCounted" class="add-pallet-button" @tap="printCartonLabels">打开箱标签打印</button>
|
||||
<button v-if="cartonsCounted" class="add-pallet-button" @tap="printCartonLabels">打印唛头</button>
|
||||
<button v-if="restorableCartons.length" class="add-pallet-button" :disabled="countingCartons || submitting || deletingCarton" @tap="restoreDeletedCarton">恢复已删除箱子({{ restorableCartons.length }})</button>
|
||||
<button class="transfer-primary" :disabled="!allCartonsConfirmed || !cartonCountReady || countingCartons || cartonsCounted" @tap="confirmCartons">{{ cartonsCounted ? '全部箱子已清点' : countingCartons ? '保存中…' : '保存全部清点结果' }}</button>
|
||||
</view>
|
||||
@@ -110,7 +110,7 @@
|
||||
<view v-if="success" class="transfer-success" role="status"><text class="feedback-title">提交成功</text><text>测量与托盘打托已完成</text></view>
|
||||
<view v-if="completedPallets.length" class="print-card">
|
||||
<view class="photo-head"><view><text class="measure-title">新托盘标签</text><text class="measure-hint">{{ completedDestinationLabel }} · 逐托打开 PDF 后打印并粘贴</text></view></view>
|
||||
<view v-for="pallet in completedPallets" :key="pallet.palletNo" class="print-row"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ pallet.cartonCount }} 箱 · {{ destinationLabel(pallet, { destinationType: completedDestinationType }) }}</text></view><button @tap="printPallet(pallet)">打印标签</button></view>
|
||||
<view v-for="pallet in completedPallets" :key="pallet.palletNo" class="print-row"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ pallet.cartonCount }} 箱 · {{ destinationLabel(pallet, { destinationType: completedDestinationType }) }}</text></view><button @tap="printPallet(pallet)">打印托盘</button></view>
|
||||
</view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { confirmCargoDiscard } from '../src/api/fulfillment.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
const rentPath = '/api/pda/v1/last-leg/storage-rent/preparations'
|
||||
const stockPath = '/api/pda/v1/last-leg/cargo-discards/CD%2F1/pallets/P%2F1/confirm'
|
||||
const body = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1', 'BOX-2'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
|
||||
const ready = { data: { preparationId: '9007199254740995', ready: true, status: 'READY' } }
|
||||
const confirmed = { data: { discardNo: 'CD/1', palletNo: 'P/1', status: 'COMPLETED' } }
|
||||
const pending = { data: { preparationId: '9007199254740995', ready: false, status: 'FREEZE_PENDING' } }
|
||||
const submit = () => confirmCargoDiscard('CD/1', 'P/1', body.lines, body.photoFileIds, body.scannedCartonMarks, false, { idempotencyKey: body.idempotencyKey })
|
||||
|
||||
function transport(responses) {
|
||||
const calls = []; session.apiBase = '/api'; session.token = 'overseas-token'
|
||||
globalThis.uni = { request(options) {
|
||||
calls.push({ method: options.method, url: options.url, data: options.data })
|
||||
const response = responses.shift(); assert.ok(response, 'unexpected additional request')
|
||||
if (response.network) return options.fail({ errMsg: 'network down' })
|
||||
options.success({ statusCode: 200, data: { code: '200', ...response }, header: {} })
|
||||
} }
|
||||
return calls
|
||||
}
|
||||
|
||||
test('出库先预校验仓租,再用相同明细、照片、箱号和幂等键确认一次', async () => {
|
||||
const calls = transport([ready, confirmed]); await submit()
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'POST', url: rentPath, data: { mode: 'DISCARD', businessNo: 'CD/1', palletNo: 'P/1', ...body } },
|
||||
{ method: 'POST', url: stockPath, data: body }
|
||||
])
|
||||
})
|
||||
|
||||
test('免费或不计仓租的服务端资格可以直接继续出库', async () => {
|
||||
const calls = transport([{ data: { status: 'NOT_REQUIRED', ready: true, amount: 0 } }, confirmed])
|
||||
await submit(); assert.equal(calls.length, 2); assert.equal(calls[1].url, stockPath)
|
||||
})
|
||||
|
||||
test('等待财务回执期间只读状态,就绪后只提交一次出库', async t => {
|
||||
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
|
||||
const calls = transport([pending, ready, confirmed]); await submit()
|
||||
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'POST'])
|
||||
assert.equal(calls[1].url, rentPath + '/9007199254740995')
|
||||
})
|
||||
|
||||
test('余额授信不足时显示财务原因,释放未使用预占且不发出库请求', async () => {
|
||||
const calls = transport([{ data: { ...pending.data, status: 'FAILED', message: '可用余额和授信额度不足' } }, { data: {} }])
|
||||
await assert.rejects(submit(), error => error.code === 'A0409' && /仓租校验未通过.*余额和授信额度不足/.test(error.message))
|
||||
assert.deepEqual(calls.map(call => call.method), ['POST', 'DELETE'])
|
||||
})
|
||||
|
||||
test('缺失、失败或断网的预校验不降级绕过,保留诊断信息', async () => {
|
||||
for (const response of [{ code: 'A0404', message: 'missing', requestId: 'req', traceId: 'trace' }, { code: 'A0409', message: 'conflict' }, { network: true }]) {
|
||||
const calls = transport([response])
|
||||
await assert.rejects(submit(), error => response.network ? error.network : error.code === response.code && error.requestId === response.requestId && error.traceId === response.traceId)
|
||||
assert.equal(calls.length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('回执超时释放预占,阻止出库并提示稍后重新提交', async t => {
|
||||
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
|
||||
const calls = transport([pending, ...Array(20).fill(pending), { data: {} }])
|
||||
await assert.rejects(submit(), /仓租资金预校验尚未通过/)
|
||||
assert.equal(calls.filter(call => call.method === 'GET').length, 20)
|
||||
assert.equal(calls.at(-1).method, 'DELETE')
|
||||
assert.ok(calls.every(call => call.url.includes('/storage-rent/')))
|
||||
})
|
||||
|
||||
test('状态查询失败仍释放原预占;释放失败不覆盖原始诊断', async t => {
|
||||
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
|
||||
const calls = transport([pending, { code: 'S0503', message: 'read failed', requestId: 'req', traceId: 'trace' }, { code: 'S0503', message: 'release failed' }])
|
||||
await assert.rejects(submit(), error => error.message === 'read failed' && error.requestId === 'req' && error.traceId === 'trace')
|
||||
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'DELETE'])
|
||||
})
|
||||
|
||||
test('就绪标记必须为布尔真,异常响应不能放行出库', async () => {
|
||||
const calls = transport([{ data: { ...ready.data, ready: 'true' } }, { data: {} }])
|
||||
await assert.rejects(submit(), /预校验/); assert.equal(calls.at(-1).method, 'DELETE')
|
||||
})
|
||||
|
||||
test('出库请求已发送后,业务错误或结果不明不重发、不取消原预占', async () => {
|
||||
for (const failure of [{ code: 'A0409', message: 'stock changed' }, { network: true }]) {
|
||||
const calls = transport([ready, failure])
|
||||
await assert.rejects(submit(), error => failure.network ? error.network : error.code === failure.code)
|
||||
assert.equal(calls.length, 2); assert.ok(calls.every(call => call.method === 'POST'))
|
||||
}
|
||||
})
|
||||
@@ -24,7 +24,6 @@ const modes = [
|
||||
{ name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) },
|
||||
{ name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) },
|
||||
{ name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) },
|
||||
{ name: 'discard', path: '/cargo-discards/CD%2F1/pallets/P%2F1/confirm', body: discard, submit: () => confirmCargoDiscard('CD/1', 'P/1', discard.lines, discard.photoFileIds, discard.scannedCartonMarks, false, { idempotencyKey: discard.idempotencyKey }) }
|
||||
]
|
||||
|
||||
for (const mode of modes) {
|
||||
|
||||
@@ -9,7 +9,7 @@ function page(overrides = {}) {
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
|
||||
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }),
|
||||
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
|
||||
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
|
||||
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value, resolveCargoDiscardPallet: async code => vm.discardTasks.find(task => task.palletNo === code || task.cartonMarks?.includes(code)),
|
||||
...overrides }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
const vm = definition.data()
|
||||
@@ -79,10 +79,13 @@ test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,
|
||||
await vm.resolveDiscardPallet('P-1')
|
||||
assert.equal(vm.error, null)
|
||||
await vm.resolveDiscardPallet('BOX-2')
|
||||
assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||
await vm.resolveDiscardPallet('P-2')
|
||||
await vm.resolveDiscardPallet('BOX-2')
|
||||
assert.equal(vm.discardTask.palletNo, 'P-2')
|
||||
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
||||
await vm.resolveDiscardPallet('BOX-3')
|
||||
assert.deepEqual(requests, ['P-1', 'BOX-2'])
|
||||
assert.deepEqual(requests, ['P-1', 'P-2'])
|
||||
assert.equal(vm.discardTask.palletNo, 'P-2')
|
||||
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
||||
})
|
||||
@@ -206,6 +209,19 @@ test('无箱唛对应托盘扫码后实际数保持未填,必须由仓库实
|
||||
assert.equal(vm.canConfirmDiscard, false)
|
||||
})
|
||||
|
||||
test('无箱号任务先扫托盘,实际箱数保持未填供现场实点', async () => {
|
||||
const { vm } = page()
|
||||
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true }] }]
|
||||
vm.openDiscardOrder('CD-NO-BOX'); await Promise.resolve()
|
||||
assert.equal(vm.discardTask, null)
|
||||
await vm.resolveDiscardPallet('P-1')
|
||||
assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||
assert.equal(vm.discardActuals.sku, '')
|
||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
assert.equal(vm.canConfirmDiscard, false)
|
||||
vm.discardActuals.sku = 2; assert.equal(vm.canConfirmDiscard, true)
|
||||
})
|
||||
|
||||
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
|
||||
const { vm } = page(); const calls = []
|
||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
|
||||
@@ -224,11 +240,12 @@ test('closing or switching discard order ignores the previous resolve response',
|
||||
})
|
||||
|
||||
|
||||
test('loose disposal opens all marks, blocks partial scans and accepts explicit bulk confirmation', async () => {
|
||||
test('loose disposal opens all marks and requires every carton scan', async () => {
|
||||
const { vm } = page()
|
||||
vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
|
||||
vm.openDiscardOrder('CD-L')
|
||||
await Promise.resolve()
|
||||
assert.equal(vm.discardTask, null)
|
||||
await vm.resolveDiscardPallet('BOX-1')
|
||||
assert.equal(vm.discardTask.scanMode, 'CARTON')
|
||||
assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
|
||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
@@ -240,7 +257,7 @@ test('loose disposal opens all marks, blocks partial scans and accepts explicit
|
||||
assert.equal(vm.canConfirmDiscard, true)
|
||||
vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
|
||||
vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
|
||||
assert.equal(vm.canConfirmDiscard, true)
|
||||
assert.equal(vm.canConfirmDiscard, false)
|
||||
vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
|
||||
})
|
||||
|
||||
@@ -250,6 +267,8 @@ test('partial bound pallet confirmation sends only selected marks and preserves
|
||||
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
||||
vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
|
||||
vm.openDiscardOrder('CD-P'); await Promise.resolve()
|
||||
assert.equal(vm.discardTask, null)
|
||||
await vm.resolveDiscardPallet('P-1')
|
||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
|
||||
await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||
@@ -267,6 +286,7 @@ test('discard confirmation preserves inputs after stock command failure and star
|
||||
})
|
||||
vm.discardCurrent = { discardNo: 'CD-RETRY' }
|
||||
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
vm.discardTasks = [{ ...vm.discardTask, discardNo: 'CD-RETRY' }]
|
||||
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
await vm.confirmDiscard()
|
||||
assert.equal(vm.discardBusy, false)
|
||||
@@ -283,3 +303,129 @@ test('discard confirmation preserves inputs after stock command failure and star
|
||||
assert.equal(vm.error, null)
|
||||
assert.match(vm.successMessage, /已完成/)
|
||||
})
|
||||
|
||||
test('dedicated scanner validates carton scope and preserves photos', () => {
|
||||
const { vm } = page()
|
||||
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BX-1', 'BX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }
|
||||
vm.discardActuals = { sku: 2 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
assert.deepEqual(vm.discardPendingMarks, ['BX-1', 'BX-2'])
|
||||
assert.deepEqual(vm.discardQuantityLines, [])
|
||||
vm.discardCartonCode = ' BX-1 '; vm.scanDiscardCarton()
|
||||
assert.equal(vm.discardCartonCode, '')
|
||||
vm.scanDiscardCarton('BX-1'); vm.scanDiscardCarton('OTHER')
|
||||
assert.deepEqual(vm.discardScannedMarks, ['BX-1']); assert.equal(vm.canConfirmDiscard, false)
|
||||
assert.deepEqual(vm.discardPendingMarks, ['BX-2'])
|
||||
assert.match(vm.error.message, /箱号不属于/)
|
||||
vm.discardCartonsExpanded = false
|
||||
vm.scanDiscardCarton('BX-2'); assert.equal(vm.canConfirmDiscard, true)
|
||||
assert.deepEqual(vm.discardPendingMarks, [])
|
||||
assert.equal(vm.discardCartonsExpanded, false)
|
||||
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }]); assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||
vm.discardTask.status = 'COMPLETED'; vm.scanDiscardCarton('OTHER'); assert.equal(vm.error, null)
|
||||
})
|
||||
|
||||
test('bound discard opens only after its pallet scan and summary hides carton numbers', async () => {
|
||||
const { vm } = page()
|
||||
vm.discardTasks = [{ discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
|
||||
vm.openDiscardOrder('CD-1'); assert.equal(vm.discardTask, null)
|
||||
await vm.resolveDiscardPallet('BX-1'); assert.equal(vm.discardTask, null)
|
||||
await vm.resolveDiscardPallet('OTHER'); assert.equal(vm.discardTask, null)
|
||||
await vm.resolveDiscardPallet('P-1'); assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||
assert.deepEqual(vm.discardScannedMarks, [])
|
||||
assert.deepEqual(vm.discardTask.cartonMarks, ['BX-1'])
|
||||
const template = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8')
|
||||
const summary = template.slice(template.indexOf('本单待废弃托盘'), template.indexOf('<ScanTargetInput name="cargo-discard"'))
|
||||
assert.doesNotMatch(summary, /task.cartonMarks/)
|
||||
})
|
||||
|
||||
test('multi-pallet discard prompts remaining pallet numbers and requires a new pallet scan', async () => {
|
||||
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku1', plannedPackages: 1 }] }
|
||||
const second = { ...first, palletNo: 'P-2', cartonMarks: ['BX-2'], skus: [{ itemId: 'sku2', plannedPackages: 1 }] }
|
||||
const other = { ...first, discardNo: 'CD-OTHER', palletNo: 'P-OTHER' }
|
||||
let completed = 0
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { completed++ }, listCargoDiscardTasks: async () => [{ ...first, status: 'COMPLETED' }, { ...second, status: completed === 2 ? 'COMPLETED' : 'PENDING' }, other] })
|
||||
vm.discardTasks = [first, second, other]; vm.openDiscardOrder('CD-M')
|
||||
await vm.resolveDiscardPallet('P-1'); vm.scanDiscardCarton('BX-1')
|
||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
await vm.confirmDiscard()
|
||||
assert.equal(vm.discardTask, null); assert.equal(vm.discardPalletCode, '')
|
||||
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
|
||||
assert.match(vm.successMessage, /P-1.*已完成.*P-2.*继续扫描/)
|
||||
assert.doesNotMatch(vm.successMessage, /P-OTHER|全部/)
|
||||
assert.equal(vm.canConfirmDiscard, false)
|
||||
await vm.resolveDiscardPallet('P-2'); assert.equal(vm.successMessage, '')
|
||||
assert.deepEqual(vm.discardPendingMarks, ['BX-2']); assert.deepEqual(vm.discardPhotos, [])
|
||||
vm.scanDiscardCarton('BX-2'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '102' }]
|
||||
await vm.confirmDiscard()
|
||||
assert.deepEqual(vm.discardPendingTasks, []); assert.match(vm.successMessage, /本单全部.*已完成/)
|
||||
})
|
||||
|
||||
test('successful discard followed by refresh failure does not claim the whole order completed', async () => {
|
||||
let confirms = 0
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { confirms++ }, listCargoDiscardTasks: async () => { throw new Error('offline') } })
|
||||
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
vm.discardTasks = [first, { ...first, palletNo: 'P-2' }]; vm.openDiscardOrder('CD-M')
|
||||
await vm.resolveDiscardPallet('P-1'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
await vm.confirmDiscard(); await vm.confirmDiscard()
|
||||
assert.equal(confirms, 1); assert.equal(vm.discardTask, null)
|
||||
assert.match(vm.successMessage, /P-1.*已完成.*刷新失败/); assert.doesNotMatch(vm.successMessage, /全部/)
|
||||
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
|
||||
})
|
||||
|
||||
test('仓租余额不足显示原因并保留当前托盘、已扫箱号、实际数与现场照片', async () => {
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { throw Object.assign(new Error('仓租校验未通过:可用余额和授信额度不足'), { code: 'A0409' }) } })
|
||||
vm.discardCurrent = { discardNo: 'CD-1' }
|
||||
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', status: 'PENDING', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
|
||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||
await vm.confirmDiscard()
|
||||
assert.match(vm.error.message, /余额和授信额度不足/); assert.equal(vm.error.code, 'A0409')
|
||||
assert.equal(vm.discardTask.palletNo, 'P-1'); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
|
||||
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
|
||||
assert.equal(vm.successMessage, ''); assert.equal(vm.discardBusy, false)
|
||||
})
|
||||
|
||||
for (const action of ['pallet', 'carton', 'retry']) {
|
||||
test(`出库资金不足后通过 ${action} 重新核对并再次手动提交`, async () => {
|
||||
let submits = 0; let reads = 0; let key = 0; const keys = []
|
||||
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
const { vm } = page({
|
||||
resolveCargoDiscardPallet: async () => { reads++; return { ...task } },
|
||||
confirmCargoDiscard: async (...args) => { keys.push(args.at(-1).idempotencyKey); if (++submits === 1) throw new Error('可用余额和授信额度不足') },
|
||||
fulfillmentIdempotencyKey: () => ({ value: `key-${++key}` }), listCargoDiscardTasks: async () => []
|
||||
})
|
||||
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task; vm.discardTasks = [task]
|
||||
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
|
||||
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
|
||||
if (action === 'pallet') await vm.resolveDiscardPallet('P-1')
|
||||
else if (action === 'carton') await vm.scanDiscardCarton('BX-1')
|
||||
else await vm.retry()
|
||||
assert.equal(reads, 1); assert.equal(submits, 1); assert.equal(vm.error, null)
|
||||
assert.equal(vm.discardNeedsRefresh, false); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
|
||||
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
|
||||
await vm.confirmDiscard(); assert.equal(submits, 2); assert.notEqual(keys[0], keys[1])
|
||||
assert.match(vm.successMessage, /全部.*已完成/)
|
||||
})
|
||||
}
|
||||
|
||||
test('结果不明后刷新发现已完成,不再重复出库', async () => {
|
||||
let submits = 0
|
||||
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw Object.assign(new Error('网络中断'), { network: true }) }, resolveCargoDiscardPallet: async () => ({ ...task, status: 'COMPLETED' }) })
|
||||
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task
|
||||
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||
await vm.confirmDiscard(); await vm.confirmDiscard()
|
||||
assert.equal(submits, 1); assert.equal(vm.discardTask.status, 'COMPLETED'); assert.equal(vm.canConfirmDiscard, false)
|
||||
})
|
||||
|
||||
test('失败后刷新仍报错,保留草稿且不再发送出库', async () => {
|
||||
let submits = 0
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw new Error('出库失败') }, resolveCargoDiscardPallet: async () => { throw new Error('任务读取失败') } })
|
||||
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||
await vm.confirmDiscard(); await vm.confirmDiscard()
|
||||
assert.equal(submits, 1); assert.match(vm.error.message, /任务读取失败/)
|
||||
assert.equal(vm.discardNeedsRefresh, true); assert.equal(vm.discardBusy, false); assert.equal(vm.discardLoading, false)
|
||||
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user