Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
493fa7c2e9 | ||
|
|
105c7825b4 | ||
|
|
9408e9e5f5 | ||
|
|
f0e2560652 | ||
|
|
902d7bda86 |
@@ -62,4 +62,4 @@
|
||||
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
|
||||
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
||||
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
||||
- 排车拣货、普通出库、整托出库和废弃确认在实际写库存前,先用原扫码 `idempotencyKey` 调用 `POST /api/pda/v1/last-leg/storage-rent/preparations`。四种模式分别为 `DISPATCH/OUTBOUND/OUTBOUND_PALLET/DISCARD`,货源、客户、计租范围和可用资金由服务端校验。仅明确 `ready=true` 且状态为 `NOT_REQUIRED/READY/CONSUMED` 才继续原扫码命令;待冻结、失败、过期、响应异常或网络未知均不发库存写请求。预校验与紧随其后的库存写请求使用同一个键和同一份货源参数;请求结束释放操作态,保留表单但不自动重放失败命令。再次点击按当前输入和版本生成新键,仍必须重新通过服务端费用预校验;结果不明时先刷新权威任务,不以换键跳过费用确认。定向回归为 `tests/storage-rent-preflight.test.mjs`,离线通过不代表实际冻结、扣款和出库联调已验收。
|
||||
- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
+7
-28
@@ -3,7 +3,6 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js
|
||||
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
|
||||
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
|
||||
const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards'
|
||||
const STORAGE_PREPARATION_BASE = '/pda/v1/last-leg/storage-rent/preparations'
|
||||
|
||||
const encoded = (value) => encodeURIComponent(String(value || '').trim())
|
||||
|
||||
@@ -98,30 +97,14 @@ function dispatchWrite(path, request) {
|
||||
requireDispatchVersion(request?.recordVersion)
|
||||
return overseasRequest('POST', path, request)
|
||||
}
|
||||
async function prepareStorageRent(mode, businessNo, request, extra = {}) {
|
||||
try {
|
||||
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务')
|
||||
const result = await overseasRequest('POST', STORAGE_PREPARATION_BASE, {
|
||||
mode, businessNo, idempotencyKey: request.idempotencyKey, ...extra
|
||||
})
|
||||
if (result?.ready !== true || !['NOT_REQUIRED', 'READY', 'CONSUMED'].includes(result?.status)) {
|
||||
const error = new Error(result?.message || (result?.status === 'FREEZE_PENDING' ? '费用确认中,请稍后重试' : '费用尚未确认,请稍后重试'))
|
||||
error.code = `STORAGE_RENT_${result?.status || 'INVALID_RESPONSE'}`
|
||||
throw error
|
||||
}
|
||||
} catch (cause) {
|
||||
// 预扣可能已受理,保留完整原请求及幂等键继续核对。
|
||||
cause.storageRentPreparation = true
|
||||
throw cause
|
||||
}
|
||||
// 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。
|
||||
function requireStockCommandIdentity(request) {
|
||||
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务')
|
||||
}
|
||||
|
||||
export async function pickDispatchItem(dispatchNo, dispatchItemId, request) {
|
||||
requireDispatchVersion(request?.recordVersion)
|
||||
await prepareStorageRent('DISPATCH', dispatchNo, request, {
|
||||
itemId: dispatchItemId, positionId: request.positionId, packages: request.packages,
|
||||
cartonMark: request.cartonMark, productQuantity: request.productQuantity
|
||||
})
|
||||
requireStockCommandIdentity(request)
|
||||
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
|
||||
}
|
||||
export function completeDispatchLoading(dispatchNo, request) {
|
||||
@@ -145,16 +128,12 @@ export function resolveOutboundTask(businessNo) {
|
||||
}
|
||||
|
||||
export async function pickOutboundTask(taskNo, request) {
|
||||
await prepareStorageRent('OUTBOUND', taskNo, request, {
|
||||
itemId: request.outboundPalletItemId, positionId: request.inventoryPositionId,
|
||||
packages: request.packages, cartonMarks: request.cartonMarks,
|
||||
skuCode: request.skuCode, productQuantity: request.productQuantity
|
||||
})
|
||||
requireStockCommandIdentity(request)
|
||||
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
|
||||
}
|
||||
|
||||
export async function pickOutboundPallet(taskNo, palletNo, request) {
|
||||
await prepareStorageRent('OUTBOUND_PALLET', taskNo, request, { palletNo })
|
||||
requireStockCommandIdentity(request)
|
||||
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
|
||||
}
|
||||
|
||||
@@ -208,7 +187,7 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) {
|
||||
|
||||
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
|
||||
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
|
||||
await prepareStorageRent('DISCARD', discardNo, request, { palletNo, lines, photoFileIds, scannedCartonMarks, confirmAllCartons })
|
||||
requireStockCommandIdentity(request)
|
||||
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,6 +81,15 @@
|
||||
</view>
|
||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</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 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>
|
||||
@@ -103,7 +112,7 @@ import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
export default {
|
||||
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: {
|
||||
busy() { return this.requestBusy || this.photoBusy },
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
||||
@@ -121,9 +130,15 @@ export default {
|
||||
},
|
||||
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() },
|
||||
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: {
|
||||
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 },
|
||||
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 }) },
|
||||
@@ -133,6 +148,7 @@ export default {
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
||||
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
||||
this.clearScanNotice()
|
||||
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
||||
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)
|
||||
@@ -151,6 +167,7 @@ export default {
|
||||
this.selectPallet(item)
|
||||
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
||||
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
|
||||
else this.showScanNotice(item)
|
||||
this.scanCode = ''; playSuccessSound()
|
||||
},
|
||||
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 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 } },
|
||||
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||
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.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] || '状态待核实' },
|
||||
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] || '待核对' },
|
||||
@@ -273,5 +290,11 @@ export default {
|
||||
.loading-cancel { color: #52657d; background: #f0f3f8; }
|
||||
.loading-confirm { color: #fff; background: #1764ec; }
|
||||
.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>
|
||||
|
||||
@@ -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'), /不一致/)
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
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 () => {
|
||||
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) }
|
||||
@@ -267,7 +304,7 @@ test('thirty scanned pallets confirm once without weight volume or pallet entry'
|
||||
assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
|
||||
})
|
||||
|
||||
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }, { storageRentPreparation: true, code: 'A0409', message: '仓租费用尚未确认' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => {
|
||||
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => {
|
||||
const sent = []; let reads = 0
|
||||
storage()
|
||||
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } })
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
// 模拟当前生产:仓租预检查接口不存在,既有库存命令仍正常受理。
|
||||
function transport(failure) {
|
||||
const calls = []
|
||||
session.apiBase = '/api'; session.token = 'overseas-token'
|
||||
globalThis.uni = { request(options) {
|
||||
calls.push({ method: options.method, url: options.url, data: options.data })
|
||||
if (failure?.network) return options.fail({ errMsg: 'network down' })
|
||||
const error = options.url.includes('/storage-rent/') ? { code: 'A0404', message: 'Resource not found' } : failure
|
||||
options.success({ statusCode: 200, data: error || { code: '200', data: { success: true } }, header: {} })
|
||||
} }
|
||||
return calls
|
||||
}
|
||||
|
||||
const dispatch = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 21, cartonMark: null, productQuantity: 0, idempotencyKey: 'dispatch-key' }
|
||||
const outbound = { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'outbound-key' }
|
||||
const pallet = { idempotencyKey: 'pallet-key' }
|
||||
const discard = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
|
||||
const modes = [
|
||||
{ name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) },
|
||||
{ name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) },
|
||||
{ name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) },
|
||||
{ name: 'discard', path: '/cargo-discards/CD%2F1/pallets/P%2F1/confirm', body: discard, submit: () => confirmCargoDiscard('CD/1', 'P/1', discard.lines, discard.photoFileIds, discard.scannedCartonMarks, false, { idempotencyKey: discard.idempotencyKey }) }
|
||||
]
|
||||
|
||||
for (const mode of modes) {
|
||||
test(`${mode.name} works without the unpublished rent endpoint and preserves its stock payload`, async () => {
|
||||
const calls = transport()
|
||||
await mode.submit()
|
||||
assert.deepEqual(calls, [{ method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body }])
|
||||
})
|
||||
test(`${mode.name} propagates stock failures without fallback or automatic resubmission`, async () => {
|
||||
for (const failure of [{ code: 'A0404', message: 'missing' }, { code: 'A0409', message: 'conflict' }, { code: 'S0503', message: 'unavailable' }, { network: true }]) {
|
||||
const calls = transport(failure)
|
||||
await assert.rejects(mode.submit(), error => failure.network ? error.network === true : error.code === failure.code)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].url, '/api/pda/v1/last-leg' + mode.path)
|
||||
assert.deepEqual(calls[0].data, mode.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('invalid dispatch version and missing command keys fail before any request', async () => {
|
||||
const calls = transport()
|
||||
await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/)
|
||||
await assert.rejects(pickDispatchItem('DSP-1', '11', { recordVersion: 1 }), /标识/)
|
||||
await assert.rejects(pickOutboundTask('TASK-1', {}), /标识/)
|
||||
await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/)
|
||||
await assert.rejects(confirmCargoDiscard('CD-1', 'P-1', [], [], [], false, {}), /标识/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
@@ -116,7 +116,6 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
|
||||
request(options) {
|
||||
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
|
||||
let data = {}
|
||||
if (options.url.endsWith('/storage-rent/preparations')) data = { status: 'READY', ready: true }
|
||||
if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = []
|
||||
else if (options.method === 'GET' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = []
|
||||
else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] }
|
||||
@@ -136,7 +135,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
|
||||
await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
|
||||
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
|
||||
|
||||
assert.equal(calls.length, 16)
|
||||
assert.equal(calls.length, 13)
|
||||
assert.deepEqual(calls.find(call => call.url.endsWith('/dispatches/DSP%2F1/loading-review')).data, { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
|
||||
assert.ok(calls.some((call) => call.url.endsWith('/dispatches')))
|
||||
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
|
||||
|
||||
@@ -257,12 +257,12 @@ test('partial bound pallet confirmation sends only selected marks and preserves
|
||||
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }])
|
||||
})
|
||||
|
||||
test('discard confirmation preserves inputs after preflight failure and starts a new checked request', async () => {
|
||||
test('discard confirmation preserves inputs after stock command failure and starts a new explicit request', async () => {
|
||||
const submitted = []
|
||||
const retry = deferred()
|
||||
let attempt = 0
|
||||
const { vm } = page({
|
||||
confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('费用确认中'); await retry.promise },
|
||||
confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('库存状态已变化'); await retry.promise },
|
||||
fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
|
||||
})
|
||||
vm.discardCurrent = { discardNo: 'CD-RETRY' }
|
||||
@@ -271,7 +271,7 @@ test('discard confirmation preserves inputs after preflight failure and starts a
|
||||
await vm.confirmDiscard()
|
||||
assert.equal(vm.discardBusy, false)
|
||||
assert.ok(vm.discardTask)
|
||||
assert.equal(vm.error.message, '费用确认中')
|
||||
assert.equal(vm.error.message, '库存状态已变化')
|
||||
vm.successMessage = '上次作业已完成'
|
||||
const retried = vm.confirmDiscard()
|
||||
assert.equal(vm.error, null)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
function transport(reply) {
|
||||
const calls = []
|
||||
session.apiBase = '/api'; session.token = 'overseas-token'
|
||||
globalThis.uni = { request(options) {
|
||||
calls.push({ url: options.url, data: options.data })
|
||||
if (reply instanceof Error) return options.fail({ errMsg: reply.message })
|
||||
options.success({ statusCode: 200, data: { code: '200', data: options.url.endsWith('/preparations') ? reply : { success: true } }, header: {} })
|
||||
} }
|
||||
return calls
|
||||
}
|
||||
|
||||
test('all stock scan modes require ready preparation with the original command key and source identifiers', async () => {
|
||||
const calls = transport({ status: 'READY', ready: true })
|
||||
const request = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 2, cartonMark: 'RC-1', productQuantity: 7, idempotencyKey: 'original-dispatch-key' }
|
||||
await pickDispatchItem('DSP-1', '9007199254740995', request)
|
||||
assert.deepEqual(calls[0].data, { mode: 'DISPATCH', businessNo: 'DSP-1', itemId: '9007199254740995', positionId: request.positionId, packages: 2, cartonMark: 'RC-1', productQuantity: 7, idempotencyKey: request.idempotencyKey })
|
||||
assert.deepEqual(calls[1].data, request)
|
||||
await pickOutboundTask('TASK-1', { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'original-sku-key' })
|
||||
assert.deepEqual(calls[2].data, { mode: 'OUTBOUND', businessNo: 'TASK-1', itemId: '9007199254740996', positionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'original-sku-key' })
|
||||
await pickOutboundPallet('TASK-1', 'DP-1', { idempotencyKey: 'original-pallet-key' })
|
||||
assert.deepEqual(calls[4].data, { mode: 'OUTBOUND_PALLET', businessNo: 'TASK-1', palletNo: 'DP-1', idempotencyKey: 'original-pallet-key' })
|
||||
assert.equal(calls.length, 6)
|
||||
for (let i = 0; i < calls.length; i += 2) assert.ok(calls[i].url.endsWith('/preparations'))
|
||||
})
|
||||
|
||||
test('carton outbound preparation uses the same identity as the backend stock command', async () => {
|
||||
const calls = transport({ status: 'READY', ready: true })
|
||||
const request = { outboundPalletItemId: '21', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], idempotencyKey: 'carton-key' }
|
||||
await pickOutboundTask('TASK-2', request)
|
||||
assert.deepEqual(calls[0].data.cartonMarks, request.cartonMarks)
|
||||
// 出库后台消费只使用 cartonMarks,不能额外派生 cartonMark 导致幂等摘要不一致。
|
||||
assert.equal(Object.hasOwn(calls[0].data, 'cartonMark'), false)
|
||||
assert.deepEqual(calls[1].data, request)
|
||||
})
|
||||
|
||||
test('pending, failed, expired, malformed and network responses never send a stock mutation', async () => {
|
||||
for (const response of [{ status: 'FREEZE_PENDING', ready: false }, { status: 'FAILED', message: '余额及授信不足' }, { status: 'EXPIRED', ready: false }, { status: 'READY', ready: false }, { status: 'UNKNOWN', ready: true }, {}, new Error('network down')]) {
|
||||
const calls = transport(response)
|
||||
await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', { idempotencyKey: 'stable-key' }), error => error.storageRentPreparation === true)
|
||||
assert.equal(calls.length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('retry reuses the preparation key; exempt and already consumed commands can recover the stock result', async () => {
|
||||
const request = { idempotencyKey: 'stable-key' }
|
||||
let calls = transport({ status: 'FREEZE_PENDING', ready: false })
|
||||
await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', request))
|
||||
const original = calls[0].data
|
||||
for (const status of ['NOT_REQUIRED', 'READY', 'CONSUMED']) {
|
||||
calls = transport({ status, ready: true })
|
||||
await pickOutboundPallet('TASK-1', 'DP-1', request)
|
||||
assert.deepEqual(calls[0].data, original)
|
||||
assert.equal(calls.length, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('invalid dispatch version or missing command identity cannot start a reservation', async () => {
|
||||
const calls = transport({ status: 'READY', ready: true })
|
||||
await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/)
|
||||
await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('discard requires funds before stock confirmation and preserves the complete source payload on retry', async () => {
|
||||
const lines = [{ itemId: '9007199254740993', actualPackages: 2 }]
|
||||
const submit = () => confirmCargoDiscard('CD-1', 'P-1', lines, ['101'], ['BOX-1'], false, { idempotencyKey: 'discard-stable-key' })
|
||||
let calls = transport({ status: 'FREEZE_PENDING', ready: false })
|
||||
await assert.rejects(submit(), error => error.storageRentPreparation === true)
|
||||
assert.equal(calls.length, 1)
|
||||
const original = calls[0].data
|
||||
assert.deepEqual(original, { mode: 'DISCARD', businessNo: 'CD-1', palletNo: 'P-1', lines, photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-stable-key' })
|
||||
calls = transport({ status: 'READY', ready: true })
|
||||
await submit()
|
||||
assert.deepEqual(calls[0].data, original)
|
||||
assert.equal(calls.length, 2)
|
||||
assert.ok(calls[1].url.endsWith('/cargo-discards/CD-1/pallets/P-1/confirm'))
|
||||
assert.deepEqual(calls[1].data, { lines, photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-stable-key' })
|
||||
})
|
||||
Reference in New Issue
Block a user