fix(dispatch): synchronize loading evidence and review round validation

This commit is contained in:
2026-10-26 19:21:15 +08:00
parent 9408e9e5f5
commit 105c7825b4
3 changed files with 77 additions and 11 deletions
+7 -3
View File
@@ -13,7 +13,7 @@ function slotPath(no, slot) {
return `${path(no)}/${slot}`
}
export function normalizeLoadingDocuments(value, no) {
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !Array.isArray(value.photos)
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !/^[1-9]\d*$/.test(String(value.reviewRound ?? '')) || !Array.isArray(value.photos)
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
const files = new Set(), counts = {}
for (const photo of value.photos) {
@@ -24,6 +24,10 @@ export function normalizeLoadingDocuments(value, no) {
return { ...value, editable: value.editable === true, complete: value.complete === true }
}
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
export function uploadLoadingPhoto(no, slot, filePath, key) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}`, filePath) }
export function uploadLoadingPhoto(no, slot, filePath, key, reviewRound) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}&reviewRound=${encodeURIComponent(reviewRound)}`, filePath) }
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
export function readLoadingPhoto(no, photo) { return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: 'image/' }) }
export function readLoadingPhoto(no, photo) {
const pdf = photo.slot === 'LOADING_SCENE' && photo.contentType === 'application/pdf'
if (!pdf && !['image/jpeg', 'image/png'].includes(photo.contentType)) throw new Error('装车资料格式无效')
return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: pdf ? 'application/pdf' : photo.contentType })
}
+24 -4
View File
@@ -3,9 +3,10 @@
<view class="heading">
<view class="heading-main"><text class="heading-title">装车照片</text></view>
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
<button class="heading-refresh" :disabled="busy" @tap="refresh">刷新</button>
</view>
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
<text class="section-hint">司机驾驶证、车牌照片在理货审核通过后自动带入,可补拍或更换;PDF 驾驶证可点击查看。</text>
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
<view class="photo-list">
@@ -19,7 +20,8 @@
</view>
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
<button v-if="photo.contentType === 'application/pdf'" class="photo-read-retry" :disabled="busy" @tap="viewPdf(photo)">查看驾驶证 PDF</button>
<image v-else-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
<button v-if="editable" class="photo-replace" :disabled="busy" :aria-label="`更换${slotLabels[slot]}图片`" @tap="choose(slot, photo.fileId)">更换</button>
@@ -48,6 +50,7 @@ export default {
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
async previews() {
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
if (photo.contentType === 'application/pdf') return
if (this.images[photo.fileId]) return
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
}))
@@ -58,8 +61,10 @@ export default {
this.snapshot = value; this.needsRefresh = false; await this.previews()
},
async load() { await this.run(() => this.refreshFacts()) },
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
async choose(slot, replaceFileId = null) {
if (this.busy || !this.editable) return
const reviewRound = this.snapshot.reviewRound
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
if (capacity <= 0) return
let saved = 0, reconciled = false
@@ -70,12 +75,14 @@ export default {
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
for (const filePath of paths) {
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
const uploadKey = this.key()
const photo = await uploadLoadingPhoto(this.number, slot, filePath, uploadKey, reviewRound); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
if (latest.reviewRound !== reviewRound) { reconciled = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
try {
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
this.snapshot = value
} catch (cause) {
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
@@ -92,6 +99,19 @@ export default {
if ((saved || reconciled) && this.active) this.$emit('saved')
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
async viewPdf(photo) {
await this.run(async () => {
const browser = typeof window !== 'undefined' && typeof document !== 'undefined'
const popup = browser ? window.open('', '_blank') : null
if (browser && !popup) throw new Error('请允许打开驾驶证预览窗口')
if (popup) popup.opener = null
try {
const path = await readLoadingPhoto(this.number, photo); this.ensureScope()
if (popup) popup.location.href = path
else await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
} catch (cause) { if (popup) popup.close(); throw cause }
})
},
async run(work, notify = false) {
if (this.busy) return
const revision = ++this.revision
+46 -4
View File
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
import { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
import { session } from '../src/stores/session.js'
const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
function component(overrides = {}) {
const values = new Map(), events = []
@@ -30,22 +30,64 @@ test('five existing slots carry their business names into upload, replace and pr
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
})
test('refresh reads all photos saved by PC and refreshes the task stage without another write', async () => {
let latest = data({ photos: [photo] }), writes = 0, fail = false
const { state, events } = component({ getLoadingDocuments: async () => { if (fail) throw new Error('刷新失败'); return latest },
uploadLoadingPhoto: async () => { writes++; return photo }, bindLoadingPhoto: async () => { writes++; return latest } })
await state.load()
latest = data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos: loadingPhotoSlots.flatMap((slot, category) =>
Array.from({ length: 5 }, (_, index) => ({ ...photo, slot, fileId: `${category + 2}${index}` }))) })
await state.refresh()
assert.deepEqual(state.snapshot.photos, latest.photos)
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.completedCategories, 5)
assert.ok(!state.snapshot.photos.some(item => item.fileId === photo.fileId))
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
fail = true; await state.refresh()
assert.deepEqual(state.snapshot.photos, latest.photos)
assert.equal(state.needsRefresh, true); assert.equal(state.busy, false)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
assert.equal(writes, 0)
})
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
await getLoadingDocuments('PC/1')
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', fileId: '1001' }
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', uploadKey: 'original-upload-key', fileId: '1001' }
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
assert.equal(calls[1].method, 'PUT'); assert.deepEqual(calls[1].data, body)
})
test('imported PDF license is opened as a document on demand, never as a photo', async () => {
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
let downloads = 0, opened
const { state } = component({ getLoadingDocuments: async () => data({ photos: [license] }), readLoadingPhoto: async () => { downloads++; return 'private-license.pdf' } })
uni.openDocument = options => { opened = options; options.success() }
await state.load()
assert.equal(downloads, 0); assert.equal(state.completedCategories, 1)
await state.viewPdf(license)
assert.equal(downloads, 1); assert.equal(opened.filePath, 'private-license.pdf'); assert.equal(opened.fileType, 'pdf')
assert.equal(state.busy, false)
})
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { uploadFile(options) { calls.push(options); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: photo }) }) } }
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key')
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key', '1')
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key&reviewRound=1$/)
assert.equal(calls[0].filePath, 'local-image')
})
test('re-review during photo upload stops old files instead of adopting the latest round', async () => {
let latest = data(), uploads = 0, binds = 0, originalRound
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async (_no, _slot, _path, _key, round) => {
uploads++; originalRound = round; latest = data({ recordVersion: '8', reviewRound: '2' }); return photo
}, bindLoadingPhoto: async () => { binds++; return latest } })
uni.chooseImage = async () => ({ tempFilePaths: ['old-photo', 'not-uploaded'] })
await state.load(); await state.choose(photo.slot)
assert.equal(originalRound, '1'); assert.equal(uploads, 1); assert.equal(binds, 0)
assert.match(state.error.message, /审核轮次已变化/); assert.equal(state.busy, false)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)