12 changed files with 215 additions and 136 deletions
+1 -1
View File
@@ -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。 - 柜号签收入仓调用 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 代替授权和资源归属校验。 - 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。 - 排车和出库入口只调用专用 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`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
+7 -3
View File
@@ -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 })
}
+7 -28
View File
@@ -3,7 +3,6 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches' const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks' const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards' 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()) const encoded = (value) => encodeURIComponent(String(value || '').trim())
@@ -98,30 +97,14 @@ function dispatchWrite(path, request) {
requireDispatchVersion(request?.recordVersion) requireDispatchVersion(request?.recordVersion)
return overseasRequest('POST', path, request) return overseasRequest('POST', path, request)
} }
async function prepareStorageRent(mode, businessNo, request, extra = {}) { // 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。
try { function requireStockCommandIdentity(request) {
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务') 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
}
} }
export async function pickDispatchItem(dispatchNo, dispatchItemId, request) { export async function pickDispatchItem(dispatchNo, dispatchItemId, request) {
requireDispatchVersion(request?.recordVersion) requireDispatchVersion(request?.recordVersion)
await prepareStorageRent('DISPATCH', dispatchNo, request, { requireStockCommandIdentity(request)
itemId: dispatchItemId, positionId: request.positionId, packages: request.packages,
cartonMark: request.cartonMark, productQuantity: request.productQuantity
})
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request) return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
} }
export function completeDispatchLoading(dispatchNo, request) { export function completeDispatchLoading(dispatchNo, request) {
@@ -145,16 +128,12 @@ export function resolveOutboundTask(businessNo) {
} }
export async function pickOutboundTask(taskNo, request) { export async function pickOutboundTask(taskNo, request) {
await prepareStorageRent('OUTBOUND', taskNo, request, { requireStockCommandIdentity(request)
itemId: request.outboundPalletItemId, positionId: request.inventoryPositionId,
packages: request.packages, cartonMarks: request.cartonMarks,
skuCode: request.skuCode, productQuantity: request.productQuantity
})
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
} }
export async function pickOutboundPallet(taskNo, palletNo, 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) 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 = {}) { export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey } 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)) return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
} }
+24 -4
View File
@@ -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
+28 -5
View File
@@ -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 }
+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 { 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'), /不一致/)
+38 -1
View File
@@ -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) }
@@ -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) 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 const sent = []; let reads = 0
storage() storage()
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } }) const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } })
+55
View File
@@ -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)
})
+1 -2
View File
@@ -116,7 +116,6 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
request(options) { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
let data = {} let data = {}
if (options.url.endsWith('/storage-rent/preparations')) data = { status: 'READY', ready: true }
if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = [] 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' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = []
else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] } 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 pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-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.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.endsWith('/dispatches')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick'))) assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
+3 -3
View File
@@ -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' }]) 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 submitted = []
const retry = deferred() const retry = deferred()
let attempt = 0 let attempt = 0
const { vm } = page({ 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 () => [] fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
}) })
vm.discardCurrent = { discardNo: 'CD-RETRY' } vm.discardCurrent = { discardNo: 'CD-RETRY' }
@@ -271,7 +271,7 @@ test('discard confirmation preserves inputs after preflight failure and starts a
await vm.confirmDiscard() await vm.confirmDiscard()
assert.equal(vm.discardBusy, false) assert.equal(vm.discardBusy, false)
assert.ok(vm.discardTask) assert.ok(vm.discardTask)
assert.equal(vm.error.message, '费用确认中') assert.equal(vm.error.message, '库存状态已变化')
vm.successMessage = '上次作业已完成' vm.successMessage = '上次作业已完成'
const retried = vm.confirmDiscard() const retried = vm.confirmDiscard()
assert.equal(vm.error, null) assert.equal(vm.error, null)
-83
View File
@@ -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' })
})