Compare commits
6
Commits
main
...
fcba6b6f25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcba6b6f25 | ||
|
|
70367afef8 | ||
|
|
493fa7c2e9 | ||
|
|
105c7825b4 | ||
|
|
9408e9e5f5 | ||
|
|
3934ad57f8 |
@@ -13,7 +13,7 @@ function slotPath(no, slot) {
|
|||||||
return `${path(no)}/${slot}`
|
return `${path(no)}/${slot}`
|
||||||
}
|
}
|
||||||
export function normalizeLoadingDocuments(value, no) {
|
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('装车资料与当前排车事实不一致')
|
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
|
||||||
const files = new Set(), counts = {}
|
const files = new Set(), counts = {}
|
||||||
for (const photo of value.photos) {
|
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 }
|
return { ...value, editable: value.editable === true, complete: value.complete === true }
|
||||||
}
|
}
|
||||||
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
|
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 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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
<view class="heading">
|
<view class="heading">
|
||||||
<view class="heading-main"><text class="heading-title">装车照片</text></view>
|
<view class="heading-main"><text class="heading-title">装车照片</text></view>
|
||||||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
|
<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>
|
</view>
|
||||||
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
|
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
|
||||||
|
<text class="section-hint">司机驾驶证、车牌照片在理货审核通过后自动带入,可补拍或更换;PDF 驾驶证可点击查看。</text>
|
||||||
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</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 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">
|
<view class="photo-list">
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
</view>
|
</view>
|
||||||
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
|
<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">
|
<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>
|
<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>
|
<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>
|
<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)}` },
|
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
|
||||||
async previews() {
|
async previews() {
|
||||||
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
|
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
|
||||||
|
if (photo.contentType === 'application/pdf') return
|
||||||
if (this.images[photo.fileId]) return
|
if (this.images[photo.fileId]) return
|
||||||
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
|
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()
|
this.snapshot = value; this.needsRefresh = false; await this.previews()
|
||||||
},
|
},
|
||||||
async load() { await this.run(() => this.refreshFacts()) },
|
async load() { await this.run(() => this.refreshFacts()) },
|
||||||
|
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
|
||||||
async choose(slot, replaceFileId = null) {
|
async choose(slot, replaceFileId = null) {
|
||||||
if (this.busy || !this.editable) return
|
if (this.busy || !this.editable) return
|
||||||
|
const reviewRound = this.snapshot.reviewRound
|
||||||
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
|
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
|
||||||
if (capacity <= 0) return
|
if (capacity <= 0) return
|
||||||
let saved = 0, reconciled = false
|
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的图片')
|
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
|
||||||
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
|
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
|
||||||
for (const filePath of paths) {
|
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('上传结果与装车照片框不一致')
|
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
||||||
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
|
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('当前任务不可修改装车照片,请刷新任务查看')
|
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
|
||||||
try {
|
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
|
this.snapshot = value
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
|
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
|
||||||
@@ -92,6 +99,19 @@ export default {
|
|||||||
if ((saved || reconciled) && this.active) this.$emit('saved')
|
if ((saved || reconciled) && this.active) this.$emit('saved')
|
||||||
},
|
},
|
||||||
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
|
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) {
|
async run(work, notify = false) {
|
||||||
if (this.busy) return
|
if (this.busy) return
|
||||||
const revision = ++this.revision
|
const revision = ++this.revision
|
||||||
|
|||||||
@@ -81,6 +81,15 @@
|
|||||||
</view>
|
</view>
|
||||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
||||||
</view></scroll-view>
|
</view></scroll-view>
|
||||||
|
<view v-if="scanNotice" class="loading-overlay scan-notice-overlay">
|
||||||
|
<view class="loading-dialog scan-notice-dialog" role="status" aria-live="polite">
|
||||||
|
<text class="loading-dialog-title">托盘扫描成功</text>
|
||||||
|
<text class="scan-notice-pallet">{{ scanNotice.palletNo }}</text>
|
||||||
|
<text class="scan-notice-label">本次可拣箱数</text>
|
||||||
|
<text class="scan-notice-count">{{ scanNotice.packages }}<text class="loading-count-unit"> 箱</text></text>
|
||||||
|
<text class="scan-notice-hint">3 秒后自动关闭</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view v-if="loadingPreview" class="loading-overlay">
|
<view v-if="loadingPreview" class="loading-overlay">
|
||||||
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
|
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
|
||||||
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
|
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
|
||||||
@@ -103,7 +112,7 @@ import { toDispatchError } from '@/utils/dispatch-error.js'
|
|||||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
|
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
|
||||||
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', scanNotice: null, loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
||||||
computed: {
|
computed: {
|
||||||
busy() { return this.requestBusy || this.photoBusy },
|
busy() { return this.requestBusy || this.photoBusy },
|
||||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
||||||
@@ -121,9 +130,15 @@ export default {
|
|||||||
},
|
},
|
||||||
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
||||||
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
|
onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ },
|
||||||
methods: {
|
methods: {
|
||||||
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
|
clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
|
||||||
|
showScanNotice(item) {
|
||||||
|
this.clearScanNotice()
|
||||||
|
this.scanNotice = { palletNo: item.palletNo, packages: this.selectedRemaining }
|
||||||
|
this._scanNoticeTimer = setTimeout(() => this.clearScanNotice(), 3000)
|
||||||
|
},
|
||||||
|
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.clearScanNotice(); this.activeTab = tab },
|
||||||
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
||||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
||||||
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
||||||
@@ -133,6 +148,7 @@ export default {
|
|||||||
handleScan(raw) {
|
handleScan(raw) {
|
||||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
||||||
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
||||||
|
this.clearScanNotice()
|
||||||
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
||||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
||||||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||||
@@ -151,6 +167,7 @@ export default {
|
|||||||
this.selectPallet(item)
|
this.selectPallet(item)
|
||||||
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
||||||
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
|
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
|
||||||
|
else this.showScanNotice(item)
|
||||||
this.scanCode = ''; playSuccessSound()
|
this.scanCode = ''; playSuccessSound()
|
||||||
},
|
},
|
||||||
pickablePackages(item) {
|
pickablePackages(item) {
|
||||||
@@ -206,8 +223,8 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
|
async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
|
||||||
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
|
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||||
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
||||||
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
||||||
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
|
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
|
||||||
@@ -273,5 +290,11 @@ export default {
|
|||||||
.loading-cancel { color: #52657d; background: #f0f3f8; }
|
.loading-cancel { color: #52657d; background: #f0f3f8; }
|
||||||
.loading-confirm { color: #fff; background: #1764ec; }
|
.loading-confirm { color: #fff; background: #1764ec; }
|
||||||
.loading-dialog-actions button[disabled] { opacity: .5; }
|
.loading-dialog-actions button[disabled] { opacity: .5; }
|
||||||
|
.scan-notice-overlay { pointer-events: none; }
|
||||||
|
.scan-notice-dialog { text-align: center; }
|
||||||
|
.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
|
||||||
|
.scan-notice-label { display: block; margin-top: 28rpx; color: #536982; font-size: 28rpx; }
|
||||||
|
.scan-notice-count { display: block; margin-top: 12rpx; color: #1764ec; font-size: 72rpx; font-weight: 700; line-height: 1.3; }
|
||||||
|
.scan-notice-hint { display: block; margin-top: 24rpx; color: #72839b; font-size: 24rpx; }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -353,7 +353,10 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
|
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
|
||||||
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||||
{ this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
|
if (!this.cartonRequestKey || this.cartonSignature !== signature) {
|
||||||
|
this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
this.cartonSignature = signature
|
||||||
|
}
|
||||||
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason });
|
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason });
|
||||||
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||||
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
|
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
|
||||||
@@ -544,7 +547,7 @@ export default {
|
|||||||
this.countDiscrepancyReason = reason
|
this.countDiscrepancyReason = reason
|
||||||
this.saveCartonDraft()
|
this.saveCartonDraft()
|
||||||
}
|
}
|
||||||
this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds], discrepancyReason: this.hasSkuReceiptDifference ? this.countDiscrepancyReason : null }
|
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds], discrepancyReason: this.hasSkuReceiptDifference ? this.countDiscrepancyReason : null }
|
||||||
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
|
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
|
||||||
} catch (e) { this.error = e } finally { this.submitting = false }
|
} catch (e) { this.error = e } finally { this.submitting = false }
|
||||||
|
|||||||
@@ -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 { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
|
||||||
import { session } from '../src/stores/session.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 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' }
|
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
|
||||||
function component(overrides = {}) {
|
function component(overrides = {}) {
|
||||||
const values = new Map(), events = []
|
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.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
|
||||||
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
|
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 () => {
|
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'
|
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() } }) } }
|
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
|
||||||
await getLoadingDocuments('PC/1')
|
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)
|
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
|
||||||
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
|
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)
|
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 () => {
|
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
|
||||||
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
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 }) }) } }
|
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')
|
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key', '1')
|
||||||
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
|
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key&reviewRound=1$/)
|
||||||
assert.equal(calls[0].filePath, 'local-image')
|
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', () => {
|
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
|
||||||
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
|
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
|
||||||
|
|||||||
@@ -43,6 +43,43 @@ function page(dependencies = {}) {
|
|||||||
}
|
}
|
||||||
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
|
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
|
||||||
|
|
||||||
|
test('pallet scan notice reports the current pickable quantity and closes after three seconds without submitting', t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
storage(); let writes = 0, sounds = 0
|
||||||
|
const state = page({ pickDispatchItem: async () => { writes++ }, playSuccessSound: () => { sounds++ } })
|
||||||
|
state.current = task({ items: [item({ scheduledPackages: 12, pickedPackages: 4, positions: [{ positionId: 'position-1', availablePackages: 6, allocatedPackages: 0, pickedPackages: 0 }] })] })
|
||||||
|
state.handleScan('PALLET-1')
|
||||||
|
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-1', packages: 6 })
|
||||||
|
assert.equal(sounds, 1)
|
||||||
|
t.mock.timers.tick(2999); assert.ok(state.scanNotice)
|
||||||
|
t.mock.timers.tick(1); assert.equal(state.scanNotice, null)
|
||||||
|
assert.equal(writes, 0); assert.equal(state.packages, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('consecutive pallet scans replace the notice and restart its full display time', t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
storage(); const state = page()
|
||||||
|
state.current = task({ items: [item(), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', palletNo: 'PALLET-2', scheduledPackages: 1 })] })
|
||||||
|
state.handleScan('PALLET-1'); t.mock.timers.tick(2000)
|
||||||
|
state.handleScan('PALLET-2')
|
||||||
|
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-2', packages: 1 })
|
||||||
|
t.mock.timers.tick(1000); assert.ok(state.scanNotice)
|
||||||
|
t.mock.timers.tick(2000); assert.equal(state.scanNotice, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan errors, carton scans and switching tabs cannot retain a stale pallet notice', t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
storage(); const state = page()
|
||||||
|
state.handleScan('PALLET-1'); state.handleScan('UNKNOWN')
|
||||||
|
assert.equal(state.scanNotice, null); assert.ok(state.error)
|
||||||
|
state.handleScan('PALLET-1'); state.selectTab('photos')
|
||||||
|
assert.equal(state.scanNotice, null)
|
||||||
|
state.selectTab('picking'); state.handleScan('PALLET-1')
|
||||||
|
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
|
||||||
|
state.handleScan('RC-1'); assert.equal(state.scanNotice, null)
|
||||||
|
t.mock.timers.tick(3000); assert.equal(state.scanNotice, null)
|
||||||
|
})
|
||||||
|
|
||||||
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
|
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
|
||||||
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
|
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
|
||||||
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
|
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
|
||||||
|
|||||||
Reference in New Issue
Block a user