62 lines
15 KiB
Vue
62 lines
15 KiB
Vue
<template>
|
||
<view v-if="user" class="page-shell portal-page">
|
||
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
||
<view class="portal-content operation-page">
|
||
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text><text class="operation-page-subtitle">{{ inboundMode ? '海外仓真实入仓作业' : prototype.subtitle }}</text></view><text class="prototype-badge">{{ inboundMode ? '真实接口' : '静态原型' }}</text></view>
|
||
<template v-if="inboundMode">
|
||
<view class="work-card"><text class="card-title">任务范围</text><input v-model="form.customerId" class="work-input" placeholder="客户ID(平台作业必填)" inputmode="numeric" /><view class="operation-scan-row"><input v-model="form.planNo" class="operation-scan-input" placeholder="扫描或输入入仓计划号" confirm-type="search" @confirm="loadTask" /><button class="operation-scan-button" :disabled="busy" @tap="loadTask">查询</button></view><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="pushTask">推送/到仓登记</button><button class="secondary-button" :disabled="busy || !task" @tap="loadTask">刷新任务</button></view></view>
|
||
<view v-if="task" class="work-card task-summary"><text class="card-title">{{ task.taskNo }}</text><text>计划:{{ task.planNo }}</text><text>状态:{{ task.status }}</text><text>卸柜照片:{{ task.unloadPhotoFileIds?.length || 0 }} 张</text><text v-if="task.estimatedEmptyReturnAt">预计报空:{{ task.estimatedEmptyReturnAt }}</text><button v-for="item in task.pallets || []" :key="item.planPalletId" class="instruction-row" @tap="selectPallet(item)">托盘 {{ item.palletNo }} · {{ item.assignmentStatus }} · 实收 {{ item.actualPackageCount }} 箱</button><button v-for="item in task.allocations || []" :key="item.allocationId" class="instruction-row" @tap="selectAllocation(item)">入仓 {{ item.inboundCode }} · 计划 {{ item.plannedPackageCount }} · 已收 {{ item.pickedPackageCount }}</button><button v-for="item in task.expectedCartons || []" :key="item.cartonMark" class="instruction-row" @tap="selectCarton(item)">箱唛 {{ item.cartonMark }} · {{ item.skuCode || '无SKU' }} · {{ item.status }}</button></view>
|
||
<view class="work-card"><text class="card-title">托盘资源分配</text><input v-model="form.planPalletId" class="work-input" placeholder="计划托盘ID" inputmode="numeric" /><input v-model="form.warehouseId" class="work-input" placeholder="海外仓ID" inputmode="numeric" /><input v-model="form.palletId" class="work-input" placeholder="实物托盘ID" inputmode="numeric" /><input v-model="form.locationId" class="work-input" placeholder="目标库位ID" inputmode="numeric" /><button class="primary-action" :disabled="busy" @tap="assignPallet">确认分配</button></view>
|
||
<view class="work-card"><text class="card-title">卸柜实收</text><input v-model="form.allocationId" class="work-input" placeholder="分配明细ID" inputmode="numeric" /><input v-model.number="form.packageCount" class="work-input" placeholder="本次箱数" inputmode="numeric" type="number" /><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="countPackages">确认点数</button><button class="secondary-button" :disabled="busy" @tap="pickPackages">确认实收</button></view><input v-model="form.cartonMark" class="work-input" placeholder="扫描国内冻结箱唛" @confirm="scanCarton" /><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="scanCarton">绑定托盘</button><button class="danger-button" :disabled="busy" @tap="removeCarton">移除箱唛</button></view></view>
|
||
<view class="work-card"><text class="card-title">卸柜照片与完成</text><button class="secondary-button full-button" :disabled="busy" @tap="chooseUnloadPhotos">选择并上传卸柜照片</button><text v-if="photoFileIds.length" class="hint-text">已上传文件ID:{{ photoFileIds.join('、') }}</text><button class="secondary-button full-button" :disabled="busy || !photoFileIds.length" @tap="saveUnloadPhotos">保存照片凭证</button><button class="primary-action complete-button" :disabled="busy || !task" @tap="completeTask">完成入仓并入库存</button></view>
|
||
<view v-if="lastResult" class="result-card"><text class="card-title">最近结果</text><text>{{ lastResult }}</text></view><view v-if="error" class="error-card"><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>
|
||
</template>
|
||
<template v-else><view class="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input v-model="scanValue" class="operation-scan-input" :placeholder="prototype.scanPlaceholder" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">确认</button></view><text class="operation-scan-tip">该库内模块仍待后续真实接口接入</text></view></template>
|
||
</view><view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import { getOperationPrototype } from '@/data/overseas-workbench.js'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
import { playSuccessSound } from '@/utils/audio-feedback.js'
|
||
import { assignInboundPallet, bindUnloadPhotos, completeInboundTask, countInboundPackages, getInboundTask, pickInboundPackages, pushInboundTask, removeInboundCarton, scanInboundCarton, uploadUnloadPhotoFile } from '@/api/inbound.js'
|
||
const positive = (value) => /^\d+$/.test(String(value || '')) && Number(value) > 0
|
||
export default {
|
||
components: { PortalHeader },
|
||
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', scanValue: '', busy: false, task: null, error: null, lastResult: '', photoFileIds: [], keys: {}, form: { customerId: '', planNo: '', planPalletId: '', warehouseId: '', palletId: '', locationId: '', allocationId: '', packageCount: 1, cartonMark: '' }, toast: { visible: false, message: '', type: '' } } },
|
||
computed: { prototype() { return getOperationPrototype(this.operationName) }, inboundMode() { return this.moduleKey === 'inbound' } },
|
||
onLoad(options) { this.moduleKey = options.module || 'query'; this.operationName = decodeURIComponent(options.name || '库存查询'); if (session.tenantId && session.tenantId !== '0') this.form.customerId = session.tenantId },
|
||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
||
methods: {
|
||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.inboundMode) { if (!this.form.planNo) this.form.planNo = value; else this.form.cartonMark = value } else this.scanValue = value; playSuccessSound() } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
|
||
validateScope() { if (!this.form.customerId.trim()) throw new Error('请输入目标客户ID'); if (!this.form.planNo.trim()) throw new Error('请扫描或输入入仓计划号') },
|
||
key(action, payload) { const signature = `${action}:${JSON.stringify(payload)}`; if (!this.keys[signature]) this.keys[signature] = `pda-${Date.now()}-${Math.random().toString(16).slice(2)}`; return { signature, value: this.keys[signature] } },
|
||
async execute(action, work) { this.busy = true; this.error = null; try { const value = await work(); this.lastResult = action; this.showToast(`${action}成功`, 'success'); return value } catch (error) { this.error = error; this.showToast(error.message || `${action}失败`, 'warning'); throw error } finally { this.busy = false } },
|
||
operator() { return { operatorId: String(this.user?.id || ''), operatorName: this.user?.name || 'PDA作业员' } },
|
||
selectPallet(item) { this.form.planPalletId = String(item.planPalletId); if (item.warehousePalletId) this.form.palletId = String(item.warehousePalletId); if (item.warehouseLocationId) this.form.locationId = String(item.warehouseLocationId) },
|
||
selectAllocation(item) { this.form.allocationId = String(item.allocationId); this.form.planPalletId = String(item.planPalletId); this.form.packageCount = Math.max(1, Number(item.plannedPackageCount || 0) - Number(item.pickedPackageCount || 0)) },
|
||
selectCarton(item) { this.form.cartonMark = item.cartonMark || ''; if (positive(item.planPalletId)) this.form.planPalletId = String(item.planPalletId) },
|
||
async loadTask() { try { this.validateScope(); this.task = await this.execute('查询入仓任务', () => getInboundTask(this.form.planNo.trim(), this.form.customerId.trim())) } catch (_) {} },
|
||
async pushTask() { try { this.validateScope(); const payload = this.operator(); const key = this.key('push', payload); this.task = await this.execute('推送入仓任务', () => pushInboundTask(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (_) {} },
|
||
async assignPallet() { try { this.validateScope(); for (const field of ['planPalletId','warehouseId','palletId','locationId']) if (!positive(this.form[field])) throw new Error('计划托盘、仓库、实物托盘和库位ID均必须填写'); const payload = { warehouseId: this.form.warehouseId, palletId: this.form.palletId, locationId: this.form.locationId }; const key = this.key('assign', payload); await this.execute('分配托盘资源', () => assignInboundPallet(this.form.planNo.trim(), this.form.customerId.trim(), this.form.planPalletId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||
packagePayload() { if (!positive(this.form.allocationId) || !positive(this.form.packageCount)) throw new Error('分配明细ID和箱数必须大于0'); return { packageCount: Number(this.form.packageCount), ...this.operator() } },
|
||
async countPackages() { try { this.validateScope(); const payload = this.packagePayload(); const key = this.key('count', payload); await this.execute('确认点数', () => countInboundPackages(this.form.planNo.trim(), this.form.customerId.trim(), this.form.allocationId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||
async pickPackages() { try { this.validateScope(); const payload = this.packagePayload(); const key = this.key('pick', payload); await this.execute('确认实收', () => pickInboundPackages(this.form.planNo.trim(), this.form.customerId.trim(), this.form.allocationId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||
async scanCarton() { try { this.validateScope(); if (!this.form.cartonMark.trim() || !positive(this.form.planPalletId)) throw new Error('请扫描箱唛并填写计划托盘ID'); const payload = { cartonMark: this.form.cartonMark.trim(), planPalletId: this.form.planPalletId, ...this.operator() }; const key = this.key('scan', payload); await this.execute('箱唛绑定托盘', () => scanInboundCarton(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature]; this.form.cartonMark = '' } catch (error) { if (!this.error) this.error = error } },
|
||
async removeCarton() { try { this.validateScope(); if (!this.form.cartonMark.trim()) throw new Error('请扫描需要移除的箱唛'); const payload = { cartonMark: this.form.cartonMark.trim(), ...this.operator() }; const key = this.key('remove', payload); await this.execute('移除箱唛', () => removeInboundCarton(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||
chooseUnloadPhotos() { try { this.validateScope(); uni.chooseImage({ count: 9, sourceType: ['camera','album'], success: async ({ tempFilePaths }) => { try { for (const path of tempFilePaths) { const file = await this.execute('上传卸柜照片', () => uploadUnloadPhotoFile(this.form.planNo.trim(), this.form.customerId.trim(), path)); if (file?.fileId && !this.photoFileIds.includes(String(file.fileId))) this.photoFileIds.push(String(file.fileId)) } } catch (_) {} } }) } catch (error) { this.error = error } },
|
||
async saveUnloadPhotos() { try { this.validateScope(); if (!this.photoFileIds.length) throw new Error('请先上传卸柜照片'); const payload = { fileIds: this.photoFileIds, ...this.operator() }; const key = this.key('photos', payload); this.task = await this.execute('保存卸柜照片', () => bindUnloadPhotos(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||
async completeTask() { try { this.validateScope(); this.task = await this.execute('完成入仓', () => completeInboundTask(this.form.planNo.trim(), this.form.customerId.trim())) } catch (_) {} },
|
||
prototypeScan() { const value = String(this.scanValue || '').trim(); if (!value) return this.showToast('请先扫描或输入编码', 'warning'); this.showToast('该模块尚未接入真实业务接口', 'warning') },
|
||
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.work-card,.result-card,.error-card{margin-bottom:12px;padding:14px;border-radius:12px;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.06)}.card-title,.task-summary text,.hint-text,.error-card text{display:block;margin-bottom:8px}.card-title{font-weight:700}.instruction-row{width:100%;min-height:38px;margin-top:6px;padding:7px 9px;color:#23456f;font-size:11px;text-align:left;border:1px solid #d7e4f5;background:#f5f9ff}.work-input{box-sizing:border-box;width:100%;height:44px;margin-bottom:9px;padding:0 11px;border:1px solid #cfd8e3;border-radius:8px;background:#fbfdff}.button-row{display:flex;gap:8px}.secondary-button,.danger-button,.primary-action{min-height:42px;flex:1;font-size:13px}.secondary-button{color:#135ee8;border:1px solid #8cb5f5;background:#edf4ff}.danger-button{color:#b42318;border:1px solid #f2a29b;background:#fff1f0}.primary-action{width:100%;color:#fff;background:#135ee8}.full-button,.complete-button{width:100%;margin-top:8px}.hint-text{margin-top:8px;color:#52657c;font-size:11px;word-break:break-all}.error-card{color:#b42318;background:#fff1f0}.result-card{color:#176b3a;background:#eefbf3}
|
||
</style>
|