Files
pda-overseas-uniapp/src/pages/return-counting/return-counting.vue
T
2026-11-02 17:03:38 +08:00

121 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="user" class="page-shell portal-page" :class="{ 'pda-task-list counting-list-page': !task }">
<PortalHeader title="退货箱内清点" :user="user" />
<view class="work-content counting-content">
<view class="work-card counting-scan-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view v-if="task || scanResult" class="actions"><button 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 class="pda-list-toolbar"><text>清点任务<text v-if="!listLoading && !error"> · {{ total }} 项</text></text><button v-if="!scanResult" :disabled="busy" @tap="load"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ listLoading ? '刷新中…' : '刷新' }}</text></button></view>
<button v-if="activeTask && !activeTaskListed" class="pda-task-card current counting-resume-card" hover-class="pda-task-card-pressed" :disabled="busy" @tap="choose(activeTask)">
<text class="counting-resume-title">未完成的清点任务</text>
<view class="pda-task-head"><text class="pda-task-no">{{ activeTask.palletCode || '未关联托盘' }}</text><text class="pda-task-status counting-status-active">清点中</text></view>
<text class="pda-task-meta">退货单:{{ activeTask.returnNo || '—' }}</text>
<view class="pda-task-footer"><view class="pda-task-action"><text>{{ openingReceiptNo === activeTask.receiptNo ? '打开中…' : '继续清点' }}</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
<view v-if="tasks.length" class="pda-list-cards">
<button v-for="row in tasks" :key="row.receiptNo" class="pda-task-card counting-task-card" :class="{ current: activeTask?.receiptNo && activeTask.receiptNo === row.receiptNo }" hover-class="pda-task-card-pressed" :disabled="busy" @tap="choose(row)">
<view class="pda-task-head"><text class="pda-task-no">{{ row.palletCode || '未关联托盘' }}</text><text class="pda-task-status counting-status" :class="{ 'counting-status-active': row.status === 'COUNTING' }">{{ row.status === 'COUNTING' ? '清点中' : '待清点' }}</text></view>
<text class="pda-task-meta">退货单:{{ row.returnNo || '—' }}</text>
<view class="pda-task-footer"><view class="counting-task-facts"><text>{{ row.boxes.length }} 箱</text><text v-if="activeTask?.receiptNo && activeTask.receiptNo === row.receiptNo" class="counting-current-label">当前任务</text></view><view class="pda-task-action"><text>{{ openingReceiptNo === row.receiptNo ? '打开中…' : row.status === 'COUNTING' ? '继续清点' : '开始清点' }}</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
</view>
<view v-if="listLoading && !tasks.length && !activeTask" class="work-card hint">正在加载清点任务…</view>
<view v-if="!error && !busy && !tasks.length && !activeTask" 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, listLoading: false, openingReceiptNo: '', error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
computed: {
activeTaskListed() { return Boolean(this.activeTask?.receiptNo && this.tasks.some(row => row.receiptNo === this.activeTask.receiptNo)) },
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(async () => { this.listLoading = true; try { await this.fetchTasks() } finally { this.listLoading = false } }) },
changePage(delta) { this.page += delta; this.load() },
ask(options) { return new Promise((resolve, reject) => uni.showModal({ ...options, success: resolve, fail: reject })) },
async choose(row) {
if (!row?.receiptNo || this.busy) return;
await this.run(async () => { this.openingReceiptNo = row.receiptNo; try { await this.enterTask(row, true) } finally { this.openingReceiptNo = '' } });
},
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}
@import '../../styles/task-list.scss';
.counting-list-page{background:#f1f5fd}
.counting-list-page .counting-content{padding:14px 14px calc(42px + env(safe-area-inset-bottom,0px))}
.counting-list-page .work-card{box-shadow:none}
.counting-list-page .pda-task-head{align-items:flex-start}
.counting-list-page .counting-resume-card{margin-bottom:11px}
.counting-resume-title{display:block;margin-bottom:8px;color:#526174;font-size:12px;line-height:18px}
.counting-status{color:#8a570c;background:#fff1dd}
.counting-status-active{color:#135ee8;background:#e4efff}
.counting-task-facts{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0;color:#718095;font-size:12px;line-height:20px;overflow-wrap:anywhere}
.counting-current-label{color:#135ee8;font-size:11px}
</style>