95 lines
13 KiB
Vue
95 lines
13 KiB
Vue
<template>
|
||
<view v-if="user" class="page-shell portal-page">
|
||
<PortalHeader title="退货箱内清点" :user="user" />
|
||
<view class="work-content counting-content">
|
||
<view class="work-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view class="actions"><button v-if="task || scanResult" class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view>
|
||
<view v-if="error" class="work-card error-card"><text>{{ error.message }}</text><text v-if="error.code" class="hint">错误码:{{ error.code }}</text><text v-if="error.requestId" class="hint">请求号:{{ error.requestId }}</text><text v-if="error.traceId" class="hint">跟踪号:{{ error.traceId }}</text></view>
|
||
<template v-if="!task">
|
||
<view v-if="activeTask" class="work-card"><text class="card-title">有未完成的清点任务</text><text class="hint">{{ activeTask.returnNo }} · {{ activeTask.palletCode }}</text><button class="secondary small" :disabled="busy" @tap="choose(activeTask)">继续未完成清点</button></view>
|
||
<view v-for="row in tasks" :key="row.receiptNo" class="work-card" @tap="choose(row)"><text class="card-title">{{ row.palletCode }}</text><text class="hint">{{ row.returnNo }}</text><text class="hint">{{ row.boxes.length }} 箱 · {{ row.status === 'COUNTING' ? '清点中' : '待清点' }}</text><text class="entry">{{ row.status === 'COUNTING' ? '继续清点' : '开始清点' }} ›</text></view>
|
||
<view v-if="!error && !busy && !tasks.length" class="work-card hint">{{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}</view>
|
||
<view v-if="!scanResult && total > 10" class="actions pager"><button class="secondary small" :disabled="busy || page <= 1" @tap="changePage(-1)">上一页</button><text>{{ page }} / {{ Math.max(1, Math.ceil(total / 10)) }}</text><button class="secondary small" :disabled="busy || page * 10 >= total" @tap="changePage(1)">下一页</button></view>
|
||
</template>
|
||
<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 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>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
<script>
|
||
import CartonCountFields from '@/components/CartonCountFields.vue'
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
|
||
export default {
|
||
components: { PortalHeader, ScanTargetInput, CartonCountFields },
|
||
data() { return { user: session.currentUser, busy: false, error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
|
||
computed: {
|
||
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
|
||
totals() { return (this.task?.expectedItems || []).map(row => ({ ...row, quantity: Number(row.quantity), actual: this.task.boxes.reduce((sum, box) => sum + box.items.filter(line => line.skuCode === row.skuCode).reduce((value, line) => value + (Number(line.quantity) || 0), 0), 0) })) },
|
||
ready() { return this.task?.status === 'COUNTING' && this.totals.length > 0 && this.totals.every(row => row.actual === row.quantity) && this.task.boxes.every(box => box.items.length && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0)) && box.items.every(line => this.skuCodes.includes(line.skuCode) && Number.isSafeInteger(Number(line.quantity)) && Number(line.quantity) > 0) && new Set(box.items.map(line => line.skuCode)).size === box.items.length) },
|
||
draftKey() { return this.task ? `return-count:${session.apiBase}:${this.user?.id}:${this.task.receiptNo}` : '' }
|
||
},
|
||
watch: { task: { deep: true, handler() { if (this.task?.status === 'COUNTING') uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature: this.signature }) } } },
|
||
onLoad(options) { this.scanCode = String(options?.cartonMark || '') },
|
||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.scannerStop = startScanBroadcast({ onScan: value => this.scan(value) }); if (!this.task) { if (this.scanCode) this.scan(this.scanCode); else this.load() } },
|
||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||
methods: {
|
||
stopScanner() { this.scannerStop?.(); this.scannerStop = null },
|
||
async run(work) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.error = { message: error?.message || '清点操作失败', code: error?.code, requestId: error?.requestId, traceId: error?.traceId } } finally { this.busy = false } },
|
||
async fetchTasks() { const [active, value] = await Promise.all([getCurrentReturnCountTask(), listReturnCountTasks({ page: this.page })]); this.activeTask = active; this.tasks = value.records || []; this.total = Number(value.total) || 0 },
|
||
async load() { await this.run(() => this.fetchTasks()) },
|
||
changePage(delta) { this.page += delta; this.load() },
|
||
ask(options) { return new Promise((resolve, reject) => uni.showModal({ ...options, success: resolve, fail: reject })) },
|
||
async choose(row) {
|
||
await this.run(() => this.enterTask(row, true));
|
||
},
|
||
async enterTask(row, confirmStart = false) {
|
||
const active = await getCurrentReturnCountTask(); this.activeTask = active;
|
||
if (active && active.receiptNo !== row.receiptNo) {
|
||
const answer = await this.ask({ title: '有未完成的清点任务', content: `退货单 ${active.returnNo} 尚未清点完成,一次只能清点一单。`, confirmText: '继续当前单', cancelText: '取消' });
|
||
if (answer.confirm) this.open(await beginReturnCountTask(active.receiptNo));
|
||
return;
|
||
}
|
||
if (confirmStart) {
|
||
const answer = await this.ask({ title: active ? '继续清点' : '开始清点', content: `是否${active ? '继续' : '开始'}清点退货单 ${row.returnNo}?`, confirmText: active ? '继续' : '开始', cancelText: '取消' });
|
||
if (!answer.confirm) return;
|
||
}
|
||
this.open(await beginReturnCountTask(row.receiptNo)); this.activeTask = this.task; this.selectedMark = this.scanResult;
|
||
},
|
||
open(row) { this.task = JSON.parse(JSON.stringify(row)); this.requestKey = ''; this.signature = ''; this.selectedMark = ''; const draft = uni.getStorageSync(this.draftKey); if (row.status === 'COUNTING' && draft?.boxes?.length === row.boxes.length && draft.boxes.every(box => row.boxes.some(item => item.cartonMark === box.cartonMark))) { this.task.boxes = draft.boxes; this.requestKey = draft.requestKey || ''; this.signature = draft.signature || '' } },
|
||
async scan(value) { const mark = String(value || this.scanCode).trim(); if (!mark) return; await this.run(async () => { const rows = await scanReturnCountTasks(mark); this.tasks = rows; this.total = rows.length; this.scanResult = mark; this.scanCode = ''; this.selectedMark = ''; if (rows.length === 1) await this.enterTask(rows[0]); else this.task = null }) },
|
||
addSku(box) { box.items.push({ skuCode: '', quantity: '' }) },
|
||
boxVolume(box) { const value = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(value) && value > 0 ? value.toFixed(2) : '—' },
|
||
async confirm() { if (!this.ready) return; await this.run(async () => { const completed = { receiptNo: this.task.receiptNo, palletCode: this.task.palletCode }; const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) })); const signature = JSON.stringify(boxes); if (!this.requestKey || signature !== this.signature) { this.requestKey = `return-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.signature = signature } uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature }); const result = await confirmReturnCount(this.task.receiptNo, { idempotencyKey: this.requestKey, boxes }); if (result?.status !== 'CONFIRMED') throw new Error('清点未完成,请刷新核实任务状态'); uni.removeStorageSync(this.draftKey); this.activeTask = null; this.resetList(); uni.showToast({ title: '箱内清点完成', icon: 'success' }); await this.fetchTasks(); await this.offerNextPalletTask(completed) }) },
|
||
async offerNextPalletTask(completed) {
|
||
if (!completed.palletCode) return;
|
||
let rows;
|
||
try { rows = await scanReturnCountTasks(completed.palletCode) }
|
||
catch (error) { error.message = `当前清点已完成,查询该托盘剩余任务失败:${error.message || '请刷新任务列表'}`; throw error }
|
||
const remaining = rows.filter(row => row.palletCode === completed.palletCode && row.receiptNo !== completed.receiptNo && ['PENDING', 'COUNTING'].includes(row.status));
|
||
if (!remaining.length) return;
|
||
const answer = await this.ask({ title: '该托盘还有待清点项', content: `当前清点已完成,托盘 ${completed.palletCode} 还有 ${remaining.length} 项待清点任务,是否继续?`, confirmText: '继续清点', cancelText: '暂不清点' });
|
||
if (!answer.confirm) return;
|
||
this.tasks = remaining; this.total = remaining.length; this.scanResult = completed.palletCode;
|
||
await this.enterTask(remaining[0]);
|
||
},
|
||
resetList() { this.task = null; this.selectedMark = ''; this.scanCode = ''; this.scanResult = ''; this.page = 1; this.tasks = []; this.total = 0 },
|
||
back() { this.resetList(); this.load() }
|
||
}
|
||
}
|
||
</script>
|
||
<style scoped lang="scss">
|
||
@import '../warehouse/operations.scss';
|
||
.counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0}
|
||
.counting-content .primary{display:flex;align-items:center;justify-content:center;padding:0 12px;line-height:normal;text-align:center}
|
||
.actions{display:flex;align-items:center;gap:8px;margin-top:10px}.small{width:auto;min-height:32px;margin:0;padding:0 12px;font-size:12px;line-height:32px}.search{width:100%;font-size:12px}.pager{justify-content:center;font-size:12px}.entry,.success{color:#135ee8;font-size:12px}.total-row{display:flex;justify-content:space-between;gap:12px;font-size:11px;margin-top:8px;color:#c67716}.total-row.matched{color:#22804a}.box-mark{display:block;color:#65768b;font-size:11px;margin:6px 0;word-break:break-all}.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:11px;word-break:break-all}.quantity{flex:none;width:70px;height:32px;text-align:center;font-size:12px}.box-card.selected{border-color:#135ee8}.row-head .card-title{font-size:13px}
|
||
.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin:10px 0 6px}.measure-grid label text{display:block;margin-bottom:4px;color:#65768b;font-size:10px}.measure-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #cbd9e8;border-radius:8px;color:#203750;font-size:13px;text-align:center;background:#fbfdff}.volume-chip{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin:4px 0 8px;color:#728197;font-size:10px}.volume-chip strong{color:#135ee8;font-size:14px}
|
||
</style>
|