Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
|
||||
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
|
||||
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、托盘业务码扫码拣货、幂等完成;发车必须同时完成拣货核平、平台转派送并严格消费服务端 `canDepart`、`departBlockReason`,不得按本地数量自行推断;公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
|
||||
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、稳定业务码扫码拣货和幂等完成;公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
|
||||
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、逐箱扫码、扫描 DP 托盘码原子拣取整托剩余箱件及幂等完成;公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
|
||||
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
||||
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
|
||||
| 业务标识与内部 ID 隔离 | `tests/business-identifiers-ui.test.mjs`;页面入口 `src/pages/`、`src/components/`,会话映射 `src/stores/session.js` | 页面只展示集装箱号、入仓码、箱码、托盘码、库位码、仓库名称等业务值;不得以 orderId、inboundId、planId、warehouseId、palletId、locationId、user.id 等内部 ID 或截断值回退,错误诊断保留 code、requestId、traceId |
|
||||
|
||||
@@ -112,6 +112,10 @@ export function pickOutboundTask(taskNo, request) {
|
||||
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
|
||||
}
|
||||
|
||||
export function pickOutboundPallet(taskNo, palletNo, request) {
|
||||
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
|
||||
}
|
||||
|
||||
export function outboundPickPayload(item, position, packages) {
|
||||
return {
|
||||
outboundPalletItemId: item.outboundPalletItemId,
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@ export const getCurrentWarehouseProfile = () => {
|
||||
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 downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath)
|
||||
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath)
|
||||
export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
|
||||
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
|
||||
export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request))
|
||||
export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current()))
|
||||
export const resolveCurrentCarton = async (cartonMark) => normalizeReceivingInbound(await overseasRequest('POST', current('/cartons/resolve'), { cartonMark }))
|
||||
|
||||
+6
-1
@@ -162,7 +162,8 @@ export async function overseasUpload(path, filePath, name = 'file', retry = true
|
||||
return body.data
|
||||
}
|
||||
|
||||
export async function overseasDownload(path, retry = true) {
|
||||
export async function overseasDownload(path, options = {}) {
|
||||
const { retry = true, expectedContentTypePrefix = '' } = options
|
||||
const attemptedToken = session.token
|
||||
let response = await rawDownload(path, attemptedToken)
|
||||
if (retry && unauthorized(response)) {
|
||||
@@ -172,6 +173,10 @@ export async function overseasDownload(path, retry = true) {
|
||||
if (response?.statusCode !== 200 || !response?.tempFilePath) {
|
||||
throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
|
||||
}
|
||||
const contentType = String(response?.header?.['content-type'] || response?.header?.['Content-Type'] || '').toLowerCase()
|
||||
if (expectedContentTypePrefix && !contentType.startsWith(expectedContentTypePrefix.toLowerCase())) {
|
||||
throw errorFrom({ ...response, data: {} }, '照片服务返回了无效内容,请稍后重试')
|
||||
}
|
||||
return response.tempFilePath
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -50,4 +50,4 @@ export const reportRelabelException = (relabelNo, request) => detailAction(relab
|
||||
export const completeRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/complete', request)
|
||||
export const reviewRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/review', request)
|
||||
export const uploadRelabelPhoto = (relabelNo, filePath) => overseasUpload(`${BASE}/${encoded(relabelNo)}/photos/files`, filePath)
|
||||
export const downloadRelabelPhoto = (relabelNo, fileId) => overseasDownload(`${BASE}/${encoded(relabelNo)}/photos/files/${encoded(fileId)}/download`)
|
||||
export const downloadRelabelPhoto = (relabelNo, fileId) => overseasDownload(`${BASE}/${encoded(relabelNo)}/photos/files/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<PortalHeader title="出库作业" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y>
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">列表仅显示普通/一件代发出库任务;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">一件代发扫描托盘码可整托出库;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" label="任务号、出库单号、排车单号、托盘码或箱码" placeholder="扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;出库单号或排车单号先由服务端解析流程,不会作为出库任务号误查。</text>
|
||||
<text class="scan-help">任务号直接恢复;进入任务后扫描 DP 托盘码,将一次拣取该托全部未拣箱件。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
||||
@@ -49,7 +49,7 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { completeOutboundTask, getOutboundTask, listOutboundTasks, outboundPickPayload, pickOutboundTask, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { completeOutboundTask, getOutboundTask, listOutboundTasks, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
@@ -98,6 +98,7 @@
|
||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||
if (!this.current) return this.openScannedTask(code)
|
||||
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
|
||||
if ((this.current.items || []).some((item) => item.palletNo === code)) return this.submitPalletPick(code)
|
||||
if (this.findAndSelectPosition(code)) return
|
||||
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
|
||||
},
|
||||
@@ -108,6 +109,11 @@
|
||||
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
},
|
||||
async submitPalletPick(palletNo) {
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.current) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
},
|
||||
async completeTask() {
|
||||
if (!this.canComplete) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFul
|
||||
import {
|
||||
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
|
||||
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
|
||||
pickDispatchItem, pickOutboundTask, resolveOutboundTask, outboundPickPayload
|
||||
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
|
||||
} from '../src/api/fulfillment.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
import { toDispatchError } from '../src/utils/dispatch-error.js'
|
||||
@@ -22,6 +22,8 @@ test('outbound partial pick submits the selected carton and distinguishes shared
|
||||
assert.match(page, /:key="item.outboundPalletItemId"/)
|
||||
assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages\)/)
|
||||
assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/)
|
||||
assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/)
|
||||
assert.match(page, /item\.palletNo === code/)
|
||||
})
|
||||
|
||||
function storageMock() {
|
||||
@@ -74,13 +76,15 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
|
||||
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
|
||||
await resolveOutboundTask('OUT/1')
|
||||
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
|
||||
await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
|
||||
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
|
||||
|
||||
assert.equal(calls.length, 10)
|
||||
assert.equal(calls.length, 11)
|
||||
assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING')))
|
||||
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
|
||||
assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart')))
|
||||
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick')))
|
||||
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pallets/DP%2F1/pick')))
|
||||
assert.ok(calls.some((call) => call.url.endsWith('/outbound-tasks/resolve/OUT%2F1')))
|
||||
assert.ok(calls.every((call) => call.authorization === 'Bearer overseas-token'))
|
||||
assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
|
||||
@@ -155,7 +159,8 @@ test('pages expose loading empty error offline and permission diagnostics with g
|
||||
assert.doesNotMatch(dispatch, /departedNo/)
|
||||
assert.match(outbound, /completeOutboundTask/)
|
||||
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
|
||||
assert.match(outbound, /列表仅显示普通\/一件代发出库任务/)
|
||||
assert.match(outbound, /一件代发扫描托盘码可整托出库/)
|
||||
assert.match(outbound, /扫描 DP 托盘码,将一次拣取该托全部未拣箱件/)
|
||||
assert.match(outbound, /if \(!this\.current\) return this\.openScannedTask\(code\)/)
|
||||
assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/)
|
||||
assert.match(outbound, /this\.remainingPackages === 0/)
|
||||
|
||||
@@ -274,6 +274,21 @@ test('authenticated photo download maps the public api path through the H5 overs
|
||||
assert.equal(calls[0].header.Authorization, `Bearer ${value.overseasAccessToken}`)
|
||||
})
|
||||
|
||||
test('image download rejects a JSON error envelope returned with HTTP 200', async () => {
|
||||
prepareSession()
|
||||
globalThis.uni.downloadFile = (options) => options.success({
|
||||
statusCode: 200,
|
||||
tempFilePath: '/tmp/error.json',
|
||||
header: { 'Content-Type': 'application/json', 'X-Request-Id': 'photo-request', 'X-Trace-Id': 'photo-trace' }
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }),
|
||||
(error) => error.message === '照片服务返回了无效内容,请稍后重试'
|
||||
&& error.requestId === 'photo-request' && error.traceId === 'photo-trace'
|
||||
)
|
||||
})
|
||||
|
||||
test('business and network errors retain diagnostics without refreshing or clearing a valid session', async () => {
|
||||
prepareSession(); let refreshCount = 0
|
||||
globalThis.uni.request = (options) => {
|
||||
|
||||
Reference in New Issue
Block a user