Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test

This commit is contained in:
2026-11-02 17:10:11 +08:00
4 changed files with 95 additions and 10 deletions
+7
View File
@@ -0,0 +1,7 @@
import { overseasRequest, overseasUpload } from './overseas.js'
const base = '/pda/v1/last-leg/return-receipts/exceptions'
const path = (number) => `${base}/${encodeURIComponent(number)}`
export const listExceptionReturns = () => overseasRequest('GET', base)
export const startExceptionReturn = (number) => overseasRequest('POST', `${path(number)}/start`, {})
export const uploadExceptionReturnEvidence = (number, filePath) => overseasUpload(`${path(number)}/evidence`, filePath)
export const receiveExceptionReturn = (number, body) => overseasRequest('POST', `${path(number)}/receive`, body)
+74
View File
@@ -0,0 +1,74 @@
<template>
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="error" class="work-card"><text role="alert">{{ error }}</text><button :disabled="busy" @tap="refresh">刷新任务</button></view>
<template v-if="!current">
<view class="row-head"><text class="card-title">异常退货入库</text><button :disabled="busy" @tap="refresh">刷新</button></view>
<text v-if="!busy && !tasks.length" class="empty-copy">当前仓没有待验收异常退货任务</text>
<view v-for="task in tasks" :key="task.exceptionNo" class="work-card">
<text class="business-code">{{ task.exceptionNo }}</text>
<text class="meta-line">车牌号:{{ task.vehiclePlate || '未登记' }}</text>
<text class="meta-line">客户:{{ task.customerName || '—' }} · 待收 {{ task.packageCount - task.receivedPackages }} 箱</text>
<button class="primary-button" :disabled="busy" @tap="start(task)">开始作业</button>
</view>
</template>
<template v-else>
<view class="work-card"><text class="business-code">{{ current.order.exceptionNo }}</text><text class="meta-line">车牌号:{{ selectedTask.vehiclePlate || '未登记' }}</text><button :disabled="busy" @tap="leave">返回任务列表</button></view>
<view v-for="(item,index) in current.returnSources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`" class="work-card">
<text class="card-title">{{ item.skuCode || '货物' }}</text><text class="meta-line">原托盘 / 箱唛:{{ item.cartonMark || item.palletNo || '—' }}</text>
<text class="meta-line">剩余可收 {{ item.maxReturnPackages }} 箱</text>
<input v-model="quantities[index]" type="number" placeholder="本批实收箱数" :disabled="busy" />
</view>
<view class="work-card"><text class="card-title">上架位置</text><input v-model="palletCode" placeholder="目标托盘号" :disabled="busy" /><input v-model="locationCode" placeholder="目标库位号" :disabled="busy" /></view>
<view class="work-card"><text class="card-title">实收照片</text><text v-for="photo in photos" :key="photo.fileId" class="meta-line">{{ photo.fileName }}</text><button :disabled="busy || photos.length >= 20" @tap="photo">拍照 / 选择照片</button></view>
<button class="primary-button" :disabled="busy || !ready" @tap="receive">{{ busy ? '处理中…' : '确认本批入库' }}</button>
</template>
<text v-if="message" class="meta-line" role="status">{{ message }}</text>
</view></scroll-view>
</template>
<script>
import { listExceptionReturns, startExceptionReturn, uploadExceptionReturnEvidence, receiveExceptionReturn } from '@/api/exception-returns.js'
export default {
data() { return { tasks: [], current: null, selectedTask: null, quantities: [], palletCode: '', locationCode: '', photos: [], busy: false, error: '', message: '', revision: 0 } },
computed: {
ready() { return this.current && this.palletCode.trim() && this.locationCode.trim() && this.photos.length && this.quantities.some(value => Number(value) > 0) },
},
mounted() { this.refresh() },
beforeUnmount() { this.revision++ },
methods: {
// 页面切换后不再用迟到响应覆盖另一张作业单。
async run(action) { if (this.busy) return; const revision = this.revision; this.busy = true; this.error = ''; try { await action(() => revision === this.revision) } catch (error) { if (revision === this.revision) this.error = error.message || '操作失败,请刷新核对实际进度' } finally { if (revision === this.revision) this.busy = false } },
async refresh() { await this.run(async valid => { const tasks = await listExceptionReturns(); if (valid()) this.tasks = tasks }) },
async start(task) { await this.run(async valid => { const value = await startExceptionReturn(task.exceptionNo); if (valid()) { this.current = value; this.selectedTask = task; this.quantities = []; this.photos = []; this.palletCode = ''; this.locationCode = ''; this.message = '' } }) },
async leave() { if (this.busy) return; this.current = null; this.photos = []; await this.refresh() },
async photo() { await this.run(async valid => { const result = await new Promise((resolve,reject) => uni.chooseImage({ count: 1, sourceType: ['camera','album'], success: resolve, fail: reject })); const uploaded = await uploadExceptionReturnEvidence(this.current.order.exceptionNo,result.tempFilePaths[0]); if (valid()) this.photos.push(uploaded) }) },
async receive() {
if (!this.ready) return
await this.run(async valid => {
const items = this.current.returnSources.flatMap((item,index) => {
const packages = Number(this.quantities[index] || 0)
if (!Number.isSafeInteger(packages) || packages < 0 || packages > item.maxReturnPackages || item.returnCartonId && packages > 1) throw new Error('实收箱数必须为整数且不超过剩余可收数量')
return packages ? [{ outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packages }] : []
})
const number = this.current.order.exceptionNo
const result = await receiveExceptionReturn(number,{ expectedVersion: this.current.order.version, idempotencyKey: `exception-return-${Date.now()}-${Math.random().toString(36).slice(2)}`, palletCode: this.palletCode.trim(), locationCode: this.locationCode.trim(), items, evidenceFileIds: this.photos.map(photo => photo.fileId) })
if (!valid()) return
this.quantities = []; this.photos = []; this.message = `本批已入库 ${result.restoredPackages} 箱${result.status === 'COMPLETED' ? ',异常处理完成' : ',还有货物待收'}`
if (result.status === 'COMPLETED') { this.current = null; this.tasks = await listExceptionReturns() }
else this.current = await startExceptionReturn(number)
})
// 结果不明时重新读取权威版本,禁止使用旧快照重复累计数量。
if (this.error && this.current) {
try { this.current = await startExceptionReturn(this.current.order.exceptionNo); this.quantities = [] }
catch (_) { this.current = null; this.tasks = []; }
}
},
},
}
</script>
<style scoped>
.work-card { margin: 12px 0; padding: 14px; border: 1px solid #dfe7ef; border-radius: 12px; background: #fff; }
.business-code,.meta-line,.card-title { display: block; margin-bottom: 10px; }
.business-code,.card-title { font-weight: 700; }.meta-line { color: #61758b; font-size: 13px; }
input { height: 44px; margin: 8px 0; padding: 0 10px; border: 1px solid #cbd7e4; border-radius: 8px; }
button { margin-top: 10px; font-size: 14px; }.primary-button { color: #fff; background: #1668dc; }.primary-button[disabled] { background: #a2b2c8; }
</style>
+10 -6
View File
@@ -1,7 +1,9 @@
<template>
<view v-if="user" class="page-shell portal-page work-page" :class="{ 'pda-task-list receipt-lookup-page': !current && !result }">
<view v-if="user" class="page-shell portal-page work-page" :class="{ 'pda-task-list receipt-lookup-page': receiptDomain === 'customer' && !current && !result }">
<PortalHeader title="退货入库" :user="user" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="row-head" style="padding: 10px"><button :disabled="busy" @tap="switchDomain('exception')">异常退货入库</button><button :disabled="busy" @tap="switchDomain('customer')">客户退货入库</button></view>
<ExceptionReturnWork v-if="receiptDomain === 'exception'" />
<scroll-view v-if="receiptDomain === 'customer'" class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card receipt-lookup-card">
<text class="card-title">扫描 SKU 69 码</text>
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描或输入69码" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
@@ -66,7 +68,8 @@
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ExceptionReturnWork from '@/components/ExceptionReturnWork.vue'
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.js'
import { session, requireSession } from '@/stores/session.js'
@@ -75,8 +78,8 @@
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
components: { PortalHeader, ScanTargetInput, ExceptionReturnWork },
data() { return { receiptDomain: 'exception', user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
@@ -93,9 +96,10 @@
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; if (this.receiptDomain === 'customer') this.startScanner(); if (this.receiptDomain === 'customer' && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
switchDomain(value) { if (this.busy) return; this.stopScanner(); this.receiptDomain = value; if (value === 'customer') this.startScanner() },
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: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { if (this.busy) return; const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }) },
+4 -4
View File
@@ -111,7 +111,7 @@ test('editing the draft before the camera returns prevents late photo submission
})
function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime = {}) {
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {},
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
...runtime,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key), ...runtime.uni } })
@@ -135,7 +135,7 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime
test('SKU lookup displays all pending order lines while retaining scanned SKU for pallet generation', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {} })
const lines = [{ skuCode: 'SKU-A', expectedQuantity: 6 }, { skuCode: 'SKU-B', expectedQuantity: 4 }]
const instance = { current: { expectedItems: lines }, selectedSku: 'SKU-A' }
instance.selectedItems = component.computed.selectedItems.call(instance)
@@ -168,7 +168,7 @@ test('PALLET page submits the required palletCode through the actual confirmatio
test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), true)
state.boxRows[0].items.A = 4
@@ -205,7 +205,7 @@ test('batch waybill scans stay on the single parcel waybill input', () => {
test('parcel rows must be nonempty and summed quantities cannot exceed the order remainder', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 4, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 }), physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), false)
state.remaining = () => 5