feat(pda): add locked container receiving flow

This commit is contained in:
2026-08-24 17:04:40 +08:00
parent 8438b2de6c
commit c9bc6bf616
8 changed files with 125 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
import { overseasRequest, overseasUpload } from '@/api/overseas.js'
const root = '/pda/v1/last-leg/receiving-containers'
const container = (containerNo) => `${root}/${encodeURIComponent(containerNo)}`
export const listReceivingContainers = (page = 1, size = 50) => overseasRequest('GET', `${root}?status=WAIT_SIGN&page=${page}&size=${size}`)
export const getActiveReceivingContainer = () => overseasRequest('GET', `${root}/active`)
export const getReceivingContainer = (containerNo) => overseasRequest('GET', container(containerNo))
export const uploadSignPhoto = (containerNo, filePath) => overseasUpload(`${container(containerNo)}/sign-photos/files`, filePath)
export const signReceivingContainer = (containerNo, request) => overseasRequest('POST', `${container(containerNo)}/sign`, request)
export const resolveReceivingCarton = (containerNo, request) => overseasRequest('POST', `${container(containerNo)}/cartons/resolve`, request)
export const resolveReceivingPallet = (containerNo, request) => overseasRequest('POST', `${container(containerNo)}/pallets/resolve`, request)
export const receiveContainerCargo = (containerNo, request) => overseasRequest('POST', `${container(containerNo)}/receive-batches`, request)
export const completeReceivingContainer = (containerNo, request) => overseasRequest('POST', `${container(containerNo)}/complete`, request)
+3 -3
View File
@@ -1,8 +1,8 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', icon: '入', description: '到仓、卸柜收货与上架', operations: [
{ name: '到仓登记', icon: '到', iconType: 'download', description: '扫描入仓计划并登记车辆到仓', available: true, badge: '已接入' },
{ name: '卸柜收货', icon: '卸', iconType: 'paperplane', description: '按箱或托盘确认卸柜实收', available: true, badge: '已接入' },
{ name: '入仓上架', icon: '上', iconType: 'upload', description: '扫描容器与目标库位完成上架', available: true, badge: '已接入' }
{ name: '入库签收', icon: '签', iconType: 'camera', description: '选择待处理柜号、拍照并确认签收', path: '/pages/inbound-sign/inbound-sign', available: true, badge: '真实接口' },
{ name: '收货', icon: '收', iconType: 'paperplane', description: '自动读取当前柜并按目的仓规则收货', path: '/pages/inbound-receiving/inbound-receiving', available: true, badge: '真实接口' },
{ name: '入仓上架', icon: '上', iconType: 'upload', description: '扫描容器与目标库位完成上架', available: false, badge: '待接入' }
] },
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: true, badge: '原型' },
+2
View File
@@ -4,6 +4,8 @@
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "现场作业", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/inbound-sign/inbound-sign", "style": { "navigationStyle": "custom", "navigationBarTitleText": "入库签收", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/inbound-receiving/inbound-receiving", "style": { "navigationStyle": "custom", "navigationBarTitleText": "收货", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } }
+1 -1
View File
@@ -41,7 +41,7 @@
openOperation(operation) {
if (!operation.available) return
const module = this.groups.find((group) => group.operations.some((item) => item.name === operation.name))
uni.navigateTo({ url: `/pages/operation/operation?module=${module?.key || 'query'}&name=${encodeURIComponent(operation.name)}` })
uni.navigateTo({ url: operation.path || `/pages/operation/operation?module=${module?.key || 'query'}&name=${encodeURIComponent(operation.name)}` })
}
}
}
@@ -0,0 +1,46 @@
<template>
<view v-if="user" class="page-shell portal-page">
<PortalHeader title="收货" :user="user" fallback-url="/pages/home/home" />
<view class="portal-content">
<view v-if="active" class="summary"><text class="title">当前柜 {{ active.containerNo }}</text><text>{{ active.inboundCodeCount }} 个入仓码 · {{ active.totalPallets }} 托 · {{ active.totalPackages }} 箱</text><text>已收 {{ active.receivedPackages }} · 待收 {{ active.remainingPackages }}</text><text class="locked">当前柜完成前不能处理其他柜号</text></view>
<view v-if="!active && !busy" class="empty"><text>当前没有已签收柜号</text><button class="primary" @tap="goSign">去入库签收</button></view>
<template v-if="active">
<view class="scan-card"><text class="title">{{ scanTitle }}</text><input v-model="scanValue" class="scan-input" :placeholder="scanPhase === 'PALLET' ? '扫描托盘码' : '扫描箱唛'" confirm-type="done" @confirm="submitScan" /><button class="primary" :disabled="busy" @tap="submitScan">确认扫描</button></view>
<view v-if="cargo" class="fact-card"><text class="title">入仓码 {{ cargo.inboundCode }}</text><text>{{ cargo.skuCode }} · {{ cargo.productName || '-' }}</text><text>目的仓:{{ cargo.destinationWarehouseName }}({{ destinationLabel }})</text><text>{{ cargo.totalPallets }} 托 · 总计 {{ cargo.totalPackages }} 箱</text><text>已收 {{ cargo.receivedPackages }} · 待收 {{ cargo.remainingPackages }}</text></view>
<view v-if="pallet" class="fact-card"><text class="title">托盘 {{ pallet.palletCode }}</text><text>{{ pallet.locationCode || '待分配库位' }} · 已装 {{ pallet.currentPackages }} 箱</text></view>
<view v-if="cargo && pallet && cargo.destinationType === 'FBA'" class="action-card"><text class="title">FBA 按箱数收货</text><input v-model.number="packageCount" class="scan-input" type="number" inputmode="numeric" :placeholder="`最多 ${cargo.remainingPackages} 箱`" /><button class="primary" :disabled="busy" @tap="receiveBulk">确认放入 {{ pallet.palletCode }}</button></view>
<view v-if="cargo?.destinationType === 'OVERSEAS' && pallet" class="continuous"><text>海外仓逐箱扫码中</text><text>当前托盘 {{ pallet.palletCode }},每扫一箱立即绑定</text></view>
<button v-if="active.canComplete" class="complete" :disabled="busy" @tap="completeContainer">完成当前柜收货</button>
</template>
<view v-if="lastResult" class="success">{{ lastResult }}</view><view v-if="error" class="error"><text>{{ error.message }}</text><text v-if="error.code">code={{ error.code }}</text><text v-if="error.requestId">requestId={{ error.requestId }}</text><text v-if="error.traceId">traceId={{ error.traceId }}</text></view>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { completeReceivingContainer, getActiveReceivingContainer, receiveContainerCargo, resolveReceivingCarton, resolveReceivingPallet } from '@/api/receiving.js'
const key = (action, identity) => `${action}-${identity}-${Date.now()}-${Math.random().toString(16).slice(2)}`
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, active: null, cargo: null, pallet: null, scanPhase: 'CARTON', scanValue: '', packageCount: 1, busy: false, error: null, lastResult: '', pendingKeys: {} } },
computed: { scanTitle() { if (this.scanPhase === 'PALLET') return '扫描托盘码'; return this.pallet && this.cargo?.destinationType === 'OVERSEAS' ? `继续逐箱扫描至 ${this.pallet.palletCode}` : '扫描任意一箱' }, destinationLabel() { return this.cargo?.destinationType === 'FBA' ? 'FBA仓' : '海外仓' } },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadActive(); this.startScanner() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() },
methods: {
async loadActive() { this.busy = true; this.error = null; try { this.active = await getActiveReceivingContainer() } catch (error) { if (error.code === 'A0404') this.active = null; else this.error = error } finally { this.busy = false } },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.scanValue = value; this.submitScan() } }) }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async submitScan() { const value = this.scanValue.trim(); if (!value || !this.active) return; this.busy = true; this.error = null; try { if (this.scanPhase === 'PALLET') { this.pallet = await resolveReceivingPallet(this.active.containerNo, { palletCode: value, inboundCode: this.cargo.inboundCode }); this.scanPhase = 'CARTON'; if (this.cargo.destinationType === 'OVERSEAS') await this.receiveCarton() } else { this.cargo = await resolveReceivingCarton(this.active.containerNo, { cartonMark: value }); if (this.cargo.destinationType === 'OVERSEAS' && this.pallet) await this.receiveCarton(); else this.scanPhase = 'PALLET'; this.packageCount = Math.max(1, Number(this.cargo.remainingPackages || 1)) } this.scanValue = ''; playSuccessSound() } catch (error) { this.error = error; playErrorSound() } finally { this.busy = false } },
async receiveCarton() { const signature = `carton:${this.cargo.cartonMark}:${this.pallet.palletCode}`; const idempotencyKey = this.pendingKeys[signature] || (this.pendingKeys[signature] = key('carton', signature)); const result = await receiveContainerCargo(this.active.containerNo, { receiptMode: 'CARTON_SCAN', cartonMark: this.cargo.cartonMark, palletCode: this.pallet.palletCode, packageCount: 1, idempotencyKey }); delete this.pendingKeys[signature]; this.lastResult = `箱唛 ${this.cargo.cartonMark} 已放入 ${this.pallet.palletCode}`; this.active = result.container; this.cargo = null },
async receiveBulk() { const count = Number(this.packageCount); if (!Number.isInteger(count) || count <= 0 || count > Number(this.cargo.remainingPackages)) return this.error = new Error('请输入有效收货箱数'); this.busy = true; this.error = null; const signature = `bulk:${this.cargo.inboundCode}:${this.pallet.palletCode}:${count}`; const idempotencyKey = this.pendingKeys[signature] || (this.pendingKeys[signature] = key('bulk', signature)); try { const result = await receiveContainerCargo(this.active.containerNo, { receiptMode: 'BULK_COUNT', sampleCartonMark: this.cargo.cartonMark, inboundCode: this.cargo.inboundCode, palletCode: this.pallet.palletCode, packageCount: count, idempotencyKey }); delete this.pendingKeys[signature]; this.lastResult = `${this.cargo.inboundCode} 已按数量收货 ${count} 箱`; this.active = result.container; this.cargo = null; this.pallet = null; this.scanPhase = 'CARTON'; playSuccessSound() } catch (error) { this.error = error; playErrorSound() } finally { this.busy = false } },
async completeContainer() { this.busy = true; this.error = null; const signature = `complete:${this.active.containerNo}`; const idempotencyKey = this.pendingKeys[signature] || (this.pendingKeys[signature] = key('complete', signature)); try { await completeReceivingContainer(this.active.containerNo, { idempotencyKey }); delete this.pendingKeys[signature]; this.lastResult = `柜号 ${this.active.containerNo} 已完成收货`; this.active = null; this.cargo = null; this.pallet = null; playSuccessSound() } catch (error) { this.error = error; playErrorSound() } finally { this.busy = false } },
goSign() { uni.redirectTo({ url: '/pages/inbound-sign/inbound-sign' }) }
}
}
</script>
<style scoped>
.summary,.scan-card,.fact-card,.action-card,.continuous,.empty,.success,.error{margin-bottom:12px;padding:14px;border-radius:12px;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.07)}.summary text,.fact-card text,.continuous text,.empty text,.error text{display:block;margin-bottom:7px}.title{display:block;margin-bottom:9px;font-weight:700}.locked{color:#b54708}.scan-input{box-sizing:border-box;width:100%;height:46px;margin:8px 0;padding:0 12px;border:1px solid #cfd8e3;border-radius:8px}.primary,.complete{width:100%;color:#fff;background:#135ee8}.complete{margin-top:14px;background:#16834b}.continuous{color:#135ee8;background:#edf4ff}.success{color:#176b3a;background:#eefbf3}.error{color:#b42318;background:#fff1f0}
</style>
+39
View File
@@ -0,0 +1,39 @@
<template>
<view v-if="user" class="page-shell portal-page">
<PortalHeader title="入库签收" :user="user" fallback-url="/pages/home/home" />
<view class="portal-content">
<view v-if="active" class="locked-card"><text class="title">当前柜 {{ active.containerNo }}</text><text>当前柜完成前不能处理其他柜号</text><button class="primary" @tap="continueReceiving">继续收货</button></view>
<template v-else>
<text class="page-title">选择本次处理柜号</text>
<view v-for="item in containers" :key="item.containerNo" class="container-card" :class="{ selected: selected?.containerNo === item.containerNo }" @tap="selectContainer(item)">
<text class="title">{{ item.containerNo }}</text><text>{{ item.inboundCodeCount }} 个入仓码 · {{ item.totalPallets }} 托 · {{ item.totalPackages }} 箱</text><text v-if="item.estimatedWarehouseAt">预计到仓 {{ item.estimatedWarehouseAt }}</text>
</view>
<view v-if="selected" class="sign-panel"><text class="title">签收 {{ selected.containerNo }}</text><button class="secondary" :disabled="busy" @tap="choosePhotos">拍摄到仓/卸柜照片</button><view class="photo-grid"><image v-for="photo in photos" :key="photo.fileId" :src="photo.path" mode="aspectFill" /></view><text class="hint">已上传 {{ photos.length }} 张,确认签收后只能处理此柜,完成前不能切换。</text><button class="primary" :disabled="busy || !photos.length" @tap="confirmSign">确认签收</button></view>
</template>
<view v-if="error" class="error"><text>{{ error.message }}</text><text v-if="error.code">code={{ error.code }}</text><text v-if="error.requestId">requestId={{ error.requestId }}</text><text v-if="error.traceId">traceId={{ error.traceId }}</text></view>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { session, requireSession } from '@/stores/session.js'
import { getActiveReceivingContainer, listReceivingContainers, signReceivingContainer, uploadSignPhoto } from '@/api/receiving.js'
const operationKey = () => `sign-${Date.now()}-${Math.random().toString(16).slice(2)}`
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, containers: [], active: null, selected: null, photos: [], busy: false, error: null, idempotencyKey: operationKey() } },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.load() },
methods: {
async load() { this.busy = true; this.error = null; try { this.active = await getActiveReceivingContainer(); if (!this.active) { const page = await listReceivingContainers(); this.containers = page?.records || [] } } catch (error) { if (error.code !== 'A0404') this.error = error; else this.active = null } finally { this.busy = false } },
selectContainer(item) { if (this.active) return; this.selected = item; this.photos = []; this.idempotencyKey = operationKey() },
choosePhotos() { if (!this.selected) return; uni.chooseImage({ count: 9, sourceType: ['camera','album'], success: async ({ tempFilePaths }) => { this.busy = true; try { for (const path of tempFilePaths) { const uploaded = await uploadSignPhoto(this.selected.containerNo, path); this.photos.push({ fileId: String(uploaded.fileId), path }) } } catch (error) { this.error = error } finally { this.busy = false } } }) },
async confirmSign() { this.busy = true; this.error = null; try { this.active = await signReceivingContainer(this.selected.containerNo, { photoFileIds: this.photos.map((item) => item.fileId), idempotencyKey: this.idempotencyKey }); this.continueReceiving() } catch (error) { this.error = error } finally { this.busy = false } },
continueReceiving() { uni.redirectTo({ url: '/pages/inbound-receiving/inbound-receiving' }) }
}
}
</script>
<style scoped>
.page-title,.title{display:block;font-weight:700}.page-title{margin-bottom:12px;font-size:18px}.container-card,.sign-panel,.locked-card,.error{margin-bottom:12px;padding:14px;border-radius:12px;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.07)}.container-card text,.locked-card text,.sign-panel text,.error text{display:block;margin-bottom:7px}.container-card.selected{border:2px solid #135ee8}.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:10px 0}.photo-grid image{width:100%;height:86px;border-radius:8px}.primary,.secondary{width:100%;margin-top:10px}.primary{color:#fff;background:#135ee8}.secondary{color:#135ee8;border:1px solid #8cb5f5;background:#edf4ff}.hint{color:#52657c;font-size:12px}.error{color:#b42318;background:#fff1f0}
</style>
+1 -1
View File
@@ -21,7 +21,7 @@
onLoad(options) { this.moduleKey = options.key || 'inbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: `/pages/operation/operation?module=${this.moduleKey}&name=${encodeURIComponent(operation.name)}` }) }
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.path || `/pages/operation/operation?module=${this.moduleKey}&name=${encodeURIComponent(operation.name)}` }) }
}
}
</script>
+19
View File
@@ -38,3 +38,22 @@ test('login has no committed demo credentials and inbound operation consumes rea
assert.match(overseas, /replaceTokens|refreshOverseasToken/)
assert.match(overseas, /requestId/)
})
test('container receiving uses sign then locked-container receiving without internal IDs', async () => {
const [pages, workbench, api, sign, receiving] = await Promise.all([
read('../src/pages.json'), read('../src/data/overseas-workbench.js'), read('../src/api/receiving.js'),
read('../src/pages/inbound-sign/inbound-sign.vue'), read('../src/pages/inbound-receiving/inbound-receiving.vue')
])
assert.match(pages, /pages\/inbound-sign\/inbound-sign/)
assert.match(pages, /pages\/inbound-receiving\/inbound-receiving/)
assert.match(workbench, /入库签收/)
assert.match(workbench, /name: '收货'/)
for (const path of ['/active', '/sign-photos/files', '/sign', '/cartons/resolve', '/pallets/resolve', '/receive-batches', '/complete']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
assert.match(sign, /当前柜完成前不能处理其他柜号/)
assert.doesNotMatch(sign, /切换柜号|customerId|planNo|allocationId|warehouseId|locationId/)
assert.match(receiving, /receiptMode: 'BULK_COUNT'/)
assert.match(receiving, /receiptMode: 'CARTON_SCAN'/)
assert.match(receiving, /cargo\.destinationType === 'FBA'/)
assert.match(receiving, /cargo\?\.destinationType === 'OVERSEAS'/)
assert.doesNotMatch(receiving, /切换柜号|customerId|planNo|allocationId|warehouseId|locationId/)
})