优化
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
const BASE = '/pda/v1/last-leg/return-inventory/counting'
|
||||
export function getCurrentReturnCountTask() {
|
||||
return overseasRequest('GET', `${BASE}/current`)
|
||||
}
|
||||
export function beginReturnCountTask(receiptNo) {
|
||||
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}/begin`)
|
||||
}
|
||||
export function scanReturnCountTasks(code) {
|
||||
return overseasRequest('GET', `${BASE}/scan/${encodeURIComponent(String(code).trim())}`)
|
||||
}
|
||||
export function listReturnCountTasks({ page = 1, size = 10, keyword = '' } = {}) {
|
||||
return overseasRequest('GET', `${BASE}/tasks?page=${page}&size=${size}&keyword=${encodeURIComponent(keyword.trim())}`)
|
||||
}
|
||||
export function scanReturnCountCarton(mark) {
|
||||
return overseasRequest('GET', `${BASE}/cartons/${encodeURIComponent(String(mark).trim())}`)
|
||||
}
|
||||
export function confirmReturnCount(receiptNo, request) {
|
||||
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}`, request)
|
||||
}
|
||||
@@ -4,7 +4,8 @@ export const MODULES = [
|
||||
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
|
||||
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
|
||||
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
|
||||
{ key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
|
||||
] },
|
||||
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
|
||||
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-counting/return-counting", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货箱内清点", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page">
|
||||
<PortalHeader title="退货箱内清点" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<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>
|
||||
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="busy || task.status !== 'COUNTING'" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="busy || task.status !== 'COUNTING'" placeholder="数量" /><button v-if="task.status === 'COUNTING' && box.items.length > 1" class="link-button" :disabled="busy" @tap="box.items.splice(lineIndex, 1)">删除</button></view>
|
||||
</view>
|
||||
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><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 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 },
|
||||
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 && 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: '' }) },
|
||||
async confirm() { if (!this.ready) return; await this.run(async () => { const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, 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() }) },
|
||||
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}
|
||||
.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}
|
||||
</style>
|
||||
@@ -36,7 +36,7 @@
|
||||
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
|
||||
</template>
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
</view></scroll-view>
|
||||
@@ -74,6 +74,7 @@
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
|
||||
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
|
||||
resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
|
||||
setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { listReturnCountTasks, scanReturnCountCarton, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '../src/api/return-counting.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
test('counting preserves box contents and retry key without accepting client warehouse scope', async () => {
|
||||
session.apiBase = '/api'; session.token = 'counting-test'
|
||||
const calls = []
|
||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: { status: 'PENDING' } }, header: {} }) } }
|
||||
await listReturnCountTasks({ page: 2, keyword: ' RC#2-1 ' })
|
||||
await scanReturnCountCarton('RC#2-1')
|
||||
const request = { idempotencyKey: 'count-retry', boxes: [{ cartonMark: 'RC#2-1', items: [{ skuCode: 'SKU-1', quantity: 3 }] }, { cartonMark: 'RC#2-2', items: [{ skuCode: 'SKU-1', quantity: 7 }] }] }
|
||||
await confirmReturnCount('RR/1', request)
|
||||
assert.deepEqual(calls.map(row => [row.method, row.url]), [
|
||||
['GET', '/api/pda/v1/last-leg/return-inventory/counting/tasks?page=2&size=10&keyword=RC%232-1'],
|
||||
['GET', '/api/pda/v1/last-leg/return-inventory/counting/cartons/RC%232-1'],
|
||||
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1']
|
||||
])
|
||||
assert.deepEqual(calls[2].data, request)
|
||||
assert.ok(calls.every(row => row.header.Authorization === 'Bearer counting-test'))
|
||||
})
|
||||
|
||||
test('unified scan supports box and pallet codes and preserves the no-count empty result', async () => {
|
||||
session.apiBase = '/api'; session.token = 'counting-test'
|
||||
const urls = []
|
||||
globalThis.uni = { request(options) { urls.push(options.url); options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
|
||||
assert.deepEqual(await scanReturnCountTasks(' RC#2-1 '), [])
|
||||
assert.deepEqual(await scanReturnCountTasks('PLT/1'), [])
|
||||
assert.deepEqual(urls, ['/api/pda/v1/last-leg/return-inventory/counting/scan/RC%232-1', '/api/pda/v1/last-leg/return-inventory/counting/scan/PLT%2F1'])
|
||||
})
|
||||
|
||||
test('current task preserves null and begin uses the operator-scoped claim route', async () => {
|
||||
session.apiBase = '/api'; session.token = 'counting-test'
|
||||
const calls = []
|
||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: options.method === 'GET' ? null : { receiptNo: 'RR/1', status: 'COUNTING' } }, header: {} }) } }
|
||||
assert.equal(await getCurrentReturnCountTask(), null)
|
||||
assert.equal((await beginReturnCountTask('RR/1')).status, 'COUNTING')
|
||||
assert.deepEqual(calls.map(row => [row.method, row.url]), [
|
||||
['GET', '/api/pda/v1/last-leg/return-inventory/counting/current'],
|
||||
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1/begin']
|
||||
])
|
||||
assert.equal(calls[1].data, undefined)
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
|
||||
const scope = { PortalHeader: {}, ScanTargetInput: {} }
|
||||
runInNewContext(script, scope)
|
||||
|
||||
function state(a = '2', b = '2', c = '2') {
|
||||
const value = {
|
||||
scanCode: '', selectedMark: '',
|
||||
task: { status: 'COUNTING', expectedItems: [{ skuCode: 'A', quantity: '4' }, { skuCode: 'B', quantity: '2' }], boxes: [
|
||||
{ cartonMark: 'RC#2-1', items: [{ skuCode: 'A', quantity: a }] },
|
||||
{ cartonMark: 'RC#2-2', items: [{ skuCode: 'A', quantity: b }] },
|
||||
{ cartonMark: 'RC#1-1', items: [{ skuCode: 'B', quantity: c }] }
|
||||
] }
|
||||
}
|
||||
value.skuCodes = scope.page.computed.skuCodes.call(value)
|
||||
value.totals = scope.page.computed.totals.call(value)
|
||||
return value
|
||||
}
|
||||
|
||||
test('list entry can confirm without scanning when string wire quantities reconcile', () => {
|
||||
const value = state()
|
||||
assert.equal(value.totals[0].quantity, 4)
|
||||
assert.equal(value.totals[1].quantity, 2)
|
||||
assert.equal(value.totals.every(row => row.actual === row.quantity), true)
|
||||
assert.equal(scope.page.computed.ready.call(value), true)
|
||||
})
|
||||
|
||||
test('counting still rejects mismatched totals and invalid box quantities', () => {
|
||||
for (const value of [state('1', '2', '2'), state('0', '4', '2'), state('1.5', '2.5', '2')]) {
|
||||
assert.equal(scope.page.computed.ready.call(value), false)
|
||||
}
|
||||
const duplicate = state()
|
||||
duplicate.task.boxes[0].items = [{ skuCode: 'A', quantity: 1 }, { skuCode: 'A', quantity: 1 }]
|
||||
assert.equal(scope.page.computed.ready.call(duplicate), false)
|
||||
})
|
||||
|
||||
function scanState() {
|
||||
const value = { task: state().task, tasks: [], error: null, scanCode: '', scanResult: '', selectedMark: '', busy: false,
|
||||
open(row) { this.task = row }, $nextTick(callback) { callback() } }
|
||||
value.run = scope.page.methods.run.bind(value)
|
||||
value.enterTask = scope.page.methods.enterTask.bind(value)
|
||||
return value
|
||||
}
|
||||
|
||||
test('box and single-batch pallet scans claim and open counting directly without start dialog', async () => {
|
||||
for (const mark of ['RC#2-1', 'PLT-1']) {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-SCAN', returnNo: 'RT-SCAN' }]
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
let claimed
|
||||
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; assert.equal(value.busy, true); return { receiptNo, status: 'COUNTING' } }
|
||||
value.ask = async () => { assert.fail('matching scan should enter directly') }
|
||||
await scope.page.methods.scan.call(value, mark)
|
||||
assert.equal(claimed, 'RR-SCAN')
|
||||
assert.equal(value.task.status, 'COUNTING')
|
||||
assert.equal(value.selectedMark, mark)
|
||||
assert.equal(value.activeTask, value.task)
|
||||
}
|
||||
})
|
||||
|
||||
test('direct scan cannot claim a different task while the operator has unfinished work', async () => {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEW', returnNo: 'RT-NEW' }]
|
||||
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
|
||||
scope.beginReturnCountTask = async () => { assert.fail('must not claim a second task') }
|
||||
let dialog
|
||||
value.ask = async options => { dialog = options; return { confirm: false } }
|
||||
await scope.page.methods.scan.call(value, 'PLT-NEW')
|
||||
assert.equal(dialog.title, '有未完成的清点任务')
|
||||
})
|
||||
|
||||
test('a pallet scan shows every pending batch and no pending results clear stale details', async () => {
|
||||
const value = scanState()
|
||||
const rows = [{ receiptNo: 'RR-1', boxes: [] }, { receiptNo: 'RR-2', boxes: [] }]
|
||||
scope.scanReturnCountTasks = async () => rows
|
||||
await scope.page.methods.scan.call(value, 'PLT-1')
|
||||
assert.equal(value.task, null)
|
||||
assert.equal(value.tasks.length, 2)
|
||||
assert.equal(value.scanResult, 'PLT-1')
|
||||
scope.scanReturnCountTasks = async () => []
|
||||
await scope.page.methods.scan.call(value, 'RC-DONE')
|
||||
assert.equal(value.task, null)
|
||||
assert.equal(value.tasks.length, 0)
|
||||
assert.equal(value.scanResult, 'RC-DONE')
|
||||
assert.equal(value.error, null)
|
||||
})
|
||||
|
||||
test('scanner failures remain visible rather than claiming no count is required', async () => {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => { throw Object.assign(new Error('服务暂不可用'), { code: 'B0500' }) }
|
||||
await scope.page.methods.scan.call(value, 'PLT-1')
|
||||
assert.equal(value.error.code, 'B0500')
|
||||
assert.equal(value.scanResult, '')
|
||||
})
|
||||
|
||||
test('declining start never claims a task and another active task blocks a new claim', async () => {
|
||||
const value = scanState(); value.activeTask = null
|
||||
let begins = 0
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
scope.beginReturnCountTask = async () => { begins++; return { status: 'COUNTING' } }
|
||||
value.ask = async () => ({ confirm: false })
|
||||
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
|
||||
assert.equal(begins, 0)
|
||||
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
|
||||
let dialog
|
||||
value.ask = async options => { dialog = options; return { confirm: false } }
|
||||
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
|
||||
assert.equal(dialog.title, '有未完成的清点任务')
|
||||
assert.equal(begins, 0)
|
||||
})
|
||||
|
||||
test('affirming start claims the selected task and switches it to working state', async () => {
|
||||
const value = scanState(); value.scanResult = 'RC-1'
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
let claimed
|
||||
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; return { receiptNo, status: 'COUNTING' } }
|
||||
value.ask = async () => ({ confirm: true })
|
||||
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
|
||||
assert.equal(claimed, 'RR-NEW')
|
||||
assert.equal(value.task.status, 'COUNTING')
|
||||
const unclaimed = state(); unclaimed.task.status = 'PENDING'
|
||||
assert.equal(scope.page.computed.ready.call(unclaimed), false)
|
||||
})
|
||||
|
||||
test('completed counting returns to page one and refreshes remaining tasks while busy', async () => {
|
||||
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'; value.page = 3; value.scanResult = 'PLT-1'
|
||||
value.resetList = scope.page.methods.resetList.bind(value)
|
||||
value.fetchTasks = scope.page.methods.fetchTasks.bind(value)
|
||||
let removed = false, refreshed = false
|
||||
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true }, showToast() {} }
|
||||
scope.confirmReturnCount = async () => ({ status: 'CONFIRMED' })
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
scope.listReturnCountTasks = async ({ page }) => { assert.equal(page, 1); assert.equal(value.busy, true); refreshed = true; return { records: [{ receiptNo: 'RR-NEXT' }], total: '1' } }
|
||||
await scope.page.methods.confirm.call(value)
|
||||
assert.equal(value.task, null)
|
||||
assert.equal(value.scanResult, '')
|
||||
assert.equal(value.activeTask, null)
|
||||
assert.equal(value.tasks[0].receiptNo, 'RR-NEXT')
|
||||
assert.equal(removed && refreshed, true)
|
||||
})
|
||||
|
||||
test('failed confirmation retains the current task and draft for retry', async () => {
|
||||
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'
|
||||
const original = value.task
|
||||
let removed = false
|
||||
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true } }
|
||||
scope.confirmReturnCount = async () => { throw new Error('数量汇总不一致') }
|
||||
await scope.page.methods.confirm.call(value)
|
||||
assert.equal(value.task, original)
|
||||
assert.equal(removed, false)
|
||||
assert.equal(value.error.message, '数量汇总不一致')
|
||||
})
|
||||
@@ -10,6 +10,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
|
||||
{ module: 'inbound', name: '退货入库' },
|
||||
{ module: 'inbound', name: '退货合箱' },
|
||||
{ module: 'inbound', name: '退货箱内清点' },
|
||||
{ module: 'outbound', name: '排车拣货' },
|
||||
{ module: 'outbound', name: '出库作业' },
|
||||
{ module: 'inventory', name: '商品移库' },
|
||||
@@ -26,6 +27,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
|
||||
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
||||
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
|
||||
assert.equal(operations.find((item) => item.name === '退货箱内清点')?.route, '/pages/return-counting/return-counting')
|
||||
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
|
||||
assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user