This commit is contained in:
2026-08-24 19:29:41 +08:00
parent 1b129c4cdb
commit bc986f1eef
10 changed files with 107 additions and 32 deletions
+3 -2
View File
@@ -25,7 +25,7 @@
- 工作台定义:`src/data/overseas-workbench.js`。
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
- 当前通用作业页中的入仓作业已接真实海外 API;其他库内、换标与查询模块仍是静态原型,不得将原型提示改成模拟远程成功。
- 当前通用作业页中的入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、FBA 按 SKU 与箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;其他库内、换标与查询模块仍是静态原型,不得将原型提示改成模拟远程成功。
## 页面与能力快速索引
@@ -41,7 +41,8 @@
## 海外业务接口边界
- `src/api/inbound.js` 已接入入仓任务、推送、托盘分配、清点、拣货、箱码扫描/移除、卸柜照片和完成入库;双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、入仓码下 SKU/箱唛解析、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 库内、换标与现场查询仍是静态原型;新增真实作业必须建立明确的业务 API/DTO、错误态和契约测试,不得把原型任务改成模拟成功。
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
+3 -2
View File
@@ -2,7 +2,7 @@
独立的 uni-app Vue 3 工程,面向海外仓现场人员。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布。
首版工作台按四期边界提供真实入仓作业,以及库内作业、换标作业和现场查询原型。登录使用国内 Auth 统一签发的双 Token;海外入仓请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
首版工作台按四期边界提供柜号级真实入仓作业,以及库内作业、换标作业和现场查询原型。登录使用国内 Auth 统一签发的双 Token;海外入仓请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
## 本地运行与检查
@@ -37,6 +37,7 @@ npm run build:app
- 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
- 入仓页面已接任务、推送、托盘分配、清点、拣货、箱码扫描/移除、卸柜照片和完成入库的真实 API;错误会保留业务码、requestId 与 traceId。
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、SKU/箱唛与既有物理托盘解析、FBA 按 SKU 和箱数收货、OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
+7 -6
View File
@@ -1,17 +1,18 @@
import { overseasRequest, overseasUpload } from '@/api/overseas.js'
import { normalizeReceivingCandidate, normalizeReceivingSession } from '@/api/receiving-adapter.js'
const RECEIVING_BASE = '/pda/v1/last-leg/receiving-containers'
const current = (suffix = '') => `${RECEIVING_BASE}/current${suffix}`
export const listReceivingCandidates = () => overseasRequest('GET', `${RECEIVING_BASE}/candidates`)
export const getReceivingContainer = (containerNo) => overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`)
export const prepareReceivingContainer = (containerNo, request) => overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request)
export const listReceivingCandidates = async () => (await overseasRequest('GET', `${RECEIVING_BASE}/candidates`)).map(normalizeReceivingCandidate)
export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`))
export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request))
export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath)
export const signCurrentReceiving = (request) => overseasRequest('POST', current('/sign'), request)
export const getCurrentReceiving = () => overseasRequest('GET', current())
export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request))
export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current()))
export const resolveCurrentCarton = (cartonMark) => overseasRequest('POST', current('/cartons/resolve'), { cartonMark })
export const resolveCurrentSku = (skuCode) => overseasRequest('POST', current('/skus/resolve'), { skuCode })
export const resolveCurrentPallet = (palletCode, inboundCode) => overseasRequest('POST', current('/pallets/resolve'), { palletCode, inboundCode })
export const receiveCurrentBulk = (request) => overseasRequest('POST', current('/receipts/bulk'), request)
export const receiveCurrentCarton = (request) => overseasRequest('POST', current('/receipts/carton'), request)
export const completeCurrentReceiving = (request) => overseasRequest('POST', current('/complete'), request)
export const completeCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/complete'), request))
+45
View File
@@ -0,0 +1,45 @@
export const RECEIVING_PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
const number = (value) => Math.max(0, Number(value || 0))
export function normalizeReceivingInbound(value = {}) {
return {
...value,
plannedPalletCount: number(value.plannedPalletCount),
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
pendingPackages: number(value.pendingPackages)
}
}
export function normalizeReceivingCandidate(value = {}) {
return {
...value,
destinationCodes: Array.isArray(value.destinationCodes) ? value.destinationCodes.filter(Boolean) : [],
plannedPalletCount: number(value.plannedPalletCount),
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
currentAccount: value.currentAccount === true
}
}
export function normalizeReceivingSession(value) {
if (!value) return null
const photos = Array.isArray(value.photos) ? value.photos : []
return {
...value,
photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role)).filter(Boolean),
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
pendingPackages: number(value.pendingPackages),
inbounds: Array.isArray(value.inbounds) ? value.inbounds.map(normalizeReceivingInbound) : []
}
}
export function isNoCurrentReceiving(error) {
return ['404', 'A0404', 'B0404'].includes(String(error?.code || ''))
}
export function isReceivingConflict(error) {
return ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
}
+2 -2
View File
@@ -1,6 +1,6 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', icon: '入', description: '柜号签收、卸柜收货与入库', operations: [
{ name: '拆柜收货', icon: '卸', iconType: 'download', description: '待卸柜拍照签收,按目的类型扫码收货', available: true, badge: '真实接口' }
{ key: 'inbound', name: '入仓作业', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', operations: [
{ name: '拆柜收货', icon: '卸', iconType: 'download', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货', available: true, badge: '真实接口' }
] },
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: true, badge: '原型' },
+1 -1
View File
@@ -3,7 +3,7 @@
{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柏悦物流海外版", "backgroundColor": "#F5F7FA" } },
{ "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/operation/operation", "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" } }
+11 -7
View File
@@ -88,7 +88,7 @@
</view>
<InboundList :items="current?.inbounds || []" />
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || progress.pending !== 0" @tap="completeContainer">{{ progress.pending ? `尚有 ${progress.pending} 箱未收` : '数量已核平,完成入库' }}</button></view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? '每个入仓码已核平,完成整柜入库' : `尚有 ${progress.pending} 箱未收` }}</button></view>
</template>
<template v-else-if="phase === 'completed'">
@@ -113,6 +113,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
import { getOperationPrototype } from '@/data/overseas-workbench.js'
import { session, requireSession } from '@/stores/session.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, selectedInbound, setCurrentReceiving } from '@/stores/receiving.js'
@@ -133,7 +134,7 @@
export default {
components: { PortalHeader, ScanTargetInput, SessionSummary, InboundList },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, detailOpen: false, containerInput: '', cartonInput: '', skuInput: '', palletInput: '', packageCount: 1, prototypeInput: '', error: null, toast: { visible: false, message: '', type: '' }, photoSteps: [{ role: 'LICENSE_PLATE', label: '车牌与柜号' }, { role: 'CONTAINER_SEAL', label: '集装箱封签' }, { role: 'DOOR_OPEN', label: '开柜现场' }] } },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, detailOpen: false, containerInput: '', cartonInput: '', skuInput: '', palletInput: '', packageCount: 1, prototypeInput: '', error: null, toast: { visible: false, message: '', type: '' }, photoSteps: RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) } },
computed: {
prototype() { return getOperationPrototype(this.operationName) }, inboundMode() { return this.moduleKey === 'inbound' },
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
@@ -142,7 +143,8 @@
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
phase() { return this.detailOpen && !this.current ? 'detail' : receivingPhase(this.current) }, progress() { return receivingProgress(this.current) }, activeInbound() { return selectedInbound(this.current) },
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; return this.activeInbound.destinationType === 'FBA' ? Boolean(this.resolvedSku?.skuCode) && Number(this.packageCount) > 0 && Number(this.packageCount) <= Number(this.activeInbound.pendingPackages) : Boolean(this.resolvedCarton?.cartonMark) },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return this.resolvedSku?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedSku?.skuCode) && Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return this.resolvedCarton?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedCarton?.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对全部入仓码、目的仓与箱数', sign: '完成现场取证后进入收货', receive: '扫码箱唛与托盘,按目的类型收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
@@ -156,11 +158,13 @@
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (!this.inboundMode) { this.prototypeInput = value; return this.prototypeScan() } if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'carton' && this.activeInbound?.destinationType === 'OVERSEAS') { this.cartonInput = value; return this.resolveCarton(value) } if (this.activeTarget === 'sku' && this.activeInbound?.destinationType === 'FBA') { this.skuInput = value; return this.resolveSku(value) } this.palletInput = value; return this.resolvePallet(value) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } },
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); receiving.activeScanTarget = current?.status === 'WAIT_SIGN' ? 'container' : (selectedInbound(current)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku') } catch (error) { if (String(error.code) === '404' || String(error.code) === 'A0404' || String(error.code) === 'B0404') { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : (selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku') },
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current) } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.detailOpen = false } catch (_) {} },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: async ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (!path) return; try { const file = await this.run('上传签收照片', () => uploadCurrentUnloadPhoto(path)); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true }) } catch (_) {} } }) },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: Number(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); receiving.activeScanTarget = selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku' } catch (_) {} },
chooseInbound(item) { receiving.selectedInboundCode = item.inboundCode; receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; this.packageCount = Math.max(1, Number(item.pendingPackages || 1)); receiving.activeScanTarget = item.destinationType === 'OVERSEAS' ? 'carton' : 'sku' },
@@ -168,7 +172,7 @@
async resolveSku(raw) { const skuCode = String(raw?.detail?.value ?? raw ?? this.skuInput).trim(); if (!skuCode || this.busy) return; try { const value = await this.run('解析 SKU', () => resolveCurrentSku(skuCode)); receiving.resolvedSku = value; receiving.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.skuInput = value.skuCode; this.palletInput = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error('托盘与当前入仓码目的仓不匹配'), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, skuCode: this.resolvedSku.skuCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.resolvedCarton.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; const inbound = selectedInbound(); this.packageCount = Math.max(1, Number(inbound?.pendingPackages || 1)); receiving.activeScanTarget = inbound?.destinationType === 'OVERSEAS' ? 'carton' : 'sku'; playSuccessSound() } catch (_) {} },
async completeContainer() { if (this.progress.pending !== 0) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
prototypeScan() { if (!String(this.prototypeInput || '').trim()) 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) }
+5 -7
View File
@@ -1,4 +1,5 @@
import { reactive } from 'vue'
import { RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
export const receiving = reactive({
candidates: [], selected: null, current: null, photoSlots: [null, null, null],
@@ -33,13 +34,10 @@ export function setCurrentReceiving(value) {
if (!chosen || Number(chosen.pendingPackages || 0) <= 0) {
receiving.selectedInboundCode = (value?.inbounds || []).find((item) => Number(item.pendingPackages || 0) > 0)?.inboundCode || value?.inbounds?.[0]?.inboundCode || ''
}
if (value?.photos?.length) {
const roles = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
receiving.photoSlots = roles.map((role) => {
const photo = value.photos.find((item) => item.role === role)
return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true } : null
})
}
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
const photo = value?.photos?.find((item) => item.role === role)
return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true } : null
})
}
export function resetReceivingDraft({ keepCurrent = false } = {}) {
+8 -5
View File
@@ -25,16 +25,19 @@ test('overseas workbench contains only confirmed inbound and in-warehouse domain
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
})
test('login has no committed demo credentials and inbound operation consumes real overseas APIs', async () => {
const [login, operation, inbound, overseas] = await Promise.all([
test('login has no committed demo credentials and inbound operation implements the locked container flow', async () => {
const [login, operation, inbound, adapter, overseas] = await Promise.all([
read('../src/pages/login/login.vue'), read('../src/pages/operation/operation.vue'),
read('../src/api/inbound.js'), read('../src/api/overseas.js')
read('../src/api/inbound.js'), read('../src/api/receiving-adapter.js'), read('../src/api/overseas.js')
])
assert.match(login, /柏悦物流海外版/)
assert.doesNotMatch(login, /ChangeMe|platform_admin/)
assert.match(operation, /startScanBroadcast/)
for (const action of ['pushInboundTask', 'assignInboundPallet', 'countInboundPackages', 'pickInboundPackages', 'scanInboundCarton', 'uploadUnloadPhotoFile', 'bindUnloadPhotos', 'completeInboundTask']) assert.match(operation, new RegExp(action))
for (const path of ['/push', '/allocations/', '/cartons/scan', '/unload-photos', '/complete']) assert.match(inbound, new RegExp(path.replace('/', '\\/')))
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentSku', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action))
for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/skus/resolve', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.match(adapter, new RegExp(role))
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) assert.doesNotMatch(inbound, new RegExp(forbidden))
assert.match(operation, /isReceivingConflict|recoverCurrentAfterConflict/)
assert.match(overseas, /replaceTokens|refreshOverseasToken/)
assert.match(overseas, /requestId/)
})
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { isNoCurrentReceiving, isReceivingConflict, normalizeReceivingCandidate, normalizeReceivingSession, RECEIVING_PHOTO_ROLES } from '../src/api/receiving-adapter.js'
test('receiving adapter preserves the three signed photo roles in workflow order', () => {
const session = normalizeReceivingSession({
status: 'WAIT_SIGN', totalPackages: 8, receivedPackages: 2, pendingPackages: 6,
photos: [{ role: 'DOOR_OPEN', fileId: 3 }, { role: 'LICENSE_PLATE', fileId: 1 }, { role: 'CONTAINER_SEAL', fileId: 2 }],
inbounds: [{ inboundCode: 'IN-1', plannedPalletCount: 2, totalPackages: 8, receivedPackages: 2, pendingPackages: 6 }]
})
assert.deepEqual(session.photos.map(({ role }) => role), RECEIVING_PHOTO_ROLES)
assert.equal(session.inbounds[0].plannedPalletCount, 2)
})
test('receiving adapter exposes calculable candidate facts and workflow errors', () => {
const candidate = normalizeReceivingCandidate({ destinationCodes: ['ONT8', 'LAX'], plannedPalletCount: 4, currentAccount: true })
assert.deepEqual(candidate.destinationCodes, ['ONT8', 'LAX'])
assert.equal(candidate.plannedPalletCount, 4)
assert.equal(candidate.currentAccount, true)
assert.equal(isNoCurrentReceiving({ code: 404 }), true)
assert.equal(isReceivingConflict({ code: 'B0409' }), true)
})