Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
|
||||
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
|
||||
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
|
||||
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五张整车照片,照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
|
||||
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
|
||||
| 装车确认、自动派送与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一单一趟,使用 `dispatchNo`、公开 `recordVersion`(0 合法)及本次 `idempotencyKey`。只在请求中锁定,结束即释放;保留表单,不持久化或重放失败命令,下次点击按当前输入和版本生成新键。结果不明或 409 先刷新权威任务,不以超时推断未执行或成功。结果核对单号、动作、请求键和版本;理货审核通过须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;`LOADING_COMPLETE` 返回 `stage=AFTER_LOADING|DELIVERING`、Loaded 结果,提交版本、manifestHash 和幂等键,不逐托填测量。成功后重读任务,版本不得早于已确认结果;重读失败只刷新,不自动重复 POST。司机证照、资源及各客户派送资格由服务端校验;正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不使用独立车次发车请求 |
|
||||
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
|
||||
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
||||
@@ -61,5 +61,5 @@
|
||||
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写请求携带本次幂等键,结束即释放操作态,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
|
||||
- 柜号签收入仓调用 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 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片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`,离线通过不代表实际冻结、扣款和出库联调已验收。
|
||||
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
||||
- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
|
||||
|
||||
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
|
||||
// 保留既有五框标识以维持文件归属,界面按当前装车资料分类展示。
|
||||
export const loadingPhotoLabels = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
|
||||
export const loadingPhotoLimit = 5
|
||||
const path = no => {
|
||||
if (!no || typeof no !== 'string') throw new Error('请先打开排车任务')
|
||||
return `/pda/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
|
||||
@@ -10,16 +13,21 @@ 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 slots = new Set()
|
||||
const files = new Set(), counts = {}
|
||||
for (const photo of value.photos) {
|
||||
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || slots.has(photo.slot)) throw new Error('装车照片引用不完整或重复')
|
||||
slots.add(photo.slot)
|
||||
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || files.has(String(photo.fileId))) throw new Error('装车照片引用不完整或重复')
|
||||
files.add(String(photo.fileId)); counts[photo.slot] = (counts[photo.slot] || 0) + 1
|
||||
if (counts[photo.slot] > loadingPhotoLimit) throw new Error('每类装车照片最多5张')
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
<template>
|
||||
<view class="loading-documents">
|
||||
<view class="heading">
|
||||
<view class="heading-main"><text class="heading-title">装车照片</text><text class="heading-subtitle">每类 1 张,上传后自动保存,可更换</text></view>
|
||||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}</text>
|
||||
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
|
||||
<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="refresh">刷新</button>
|
||||
</view>
|
||||
<text v-if="snapshot?.stage === 'LOADING'" class="section-hint">可边装车边上传;确认装车完成且照片齐全后自动进入派送中。</text>
|
||||
<text v-if="snapshot?.stage === 'AFTER_LOADING'" class="section-hint">上传并保存 5 张装车照片后,本车自动进入派送中。</text>
|
||||
<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">
|
||||
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
|
||||
<view class="photo-card-header">
|
||||
<view class="photo-card-main">
|
||||
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
|
||||
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
|
||||
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ photosFor(slot).length }}/5 张已保存</text></view>
|
||||
</view>
|
||||
<button v-if="editable" class="photo-upload" :aria-label="`${snapshot?.photos.some(item => item.slot === slot) ? '更换' : '上传'}${slotLabels[slot]}`" :disabled="busy" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
|
||||
<button v-if="editable" class="photo-upload" :aria-label="`上传${slotLabels[slot]}`" :disabled="busy || photosFor(slot).length >= limit" @tap="choose(slot)">{{ photosFor(slot).length >= limit ? '已满 5 张' : '+ 上传图片' }}</button>
|
||||
</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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -31,43 +34,84 @@
|
||||
<script>
|
||||
import { session } from '@/stores/session.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { loadingPhotoSlots, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
|
||||
import { loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
|
||||
|
||||
export default {
|
||||
props: { dispatchNo: { type: String, required: true } },
|
||||
emits: ['saved', 'busy-change'],
|
||||
data() { return { slots: loadingPhotoSlots, slotLabels: { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' }, snapshot: null, busy: false, error: null, images: {}, active: true, revision: 0,
|
||||
data() { return { slots: loadingPhotoSlots, slotLabels: loadingPhotoLabels, limit: loadingPhotoLimit, snapshot: null, busy: false, needsRefresh: false, error: null, images: {}, active: true, revision: 0,
|
||||
scope: `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`, number: this.dispatchNo } },
|
||||
computed: { editable() { return this.snapshot?.editable === true } },
|
||||
computed: { editable() { return this.snapshot?.editable === true && !this.needsRefresh }, completedCategories() { return this.slots.filter(slot => this.photosFor(slot).length > 0).length } },
|
||||
mounted() { this.load() },
|
||||
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
|
||||
methods: {
|
||||
photosFor(slot) { return (this.snapshot?.photos || []).filter(photo => photo.slot === slot) },
|
||||
ensureScope() { if (!this.active || this.number !== this.dispatchNo || this.scope !== `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`) throw new Error('作业身份已变化,请重新进入任务') },
|
||||
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
|
||||
}))
|
||||
},
|
||||
async load() { await this.run(async () => { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; await this.previews() }) },
|
||||
async choose(slot) {
|
||||
async refreshFacts() {
|
||||
this.needsRefresh = true
|
||||
const value = await getLoadingDocuments(this.number); this.ensureScope()
|
||||
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
|
||||
await this.run(async () => {
|
||||
const chosen = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
|
||||
const filePath = chosen.tempFilePaths?.[0]; if (!filePath) return
|
||||
if (chosen.tempFiles?.[0]?.size > 10 * 1024 * 1024) throw new Error('请选择不超过10MiB的图片')
|
||||
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
|
||||
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
||||
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
|
||||
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
|
||||
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId }); this.ensureScope()
|
||||
this.snapshot = value; await this.previews()
|
||||
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
|
||||
return true
|
||||
}, true)
|
||||
const chosen = await uni.chooseImage({ count: capacity, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
|
||||
const paths = chosen.tempFilePaths || []; if (!paths.length) return
|
||||
if (paths.length > capacity) throw new Error(`本次最多可选择${capacity}张图片`)
|
||||
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
|
||||
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
|
||||
for (const filePath of paths) {
|
||||
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, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
|
||||
this.snapshot = value
|
||||
} catch (cause) {
|
||||
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
|
||||
try {
|
||||
await this.refreshFacts()
|
||||
reconciled = this.snapshot.recordVersion !== latest.recordVersion || this.snapshot.dispatchRecordVersion !== latest.dispatchRecordVersion || this.snapshot.stage !== latest.stage
|
||||
} catch { /* 保留原错误与刷新入口。 */ }
|
||||
throw cause
|
||||
}
|
||||
saved++; await this.previews()
|
||||
}
|
||||
uni.showToast({ title: this.snapshot.stage === 'DELIVERING' && this.snapshot.complete ? '资料齐全,已进入派送中' : `已保存${saved}张照片`, icon: 'none' })
|
||||
})
|
||||
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
|
||||
@@ -105,6 +149,7 @@ export default {
|
||||
.photo-preview { box-sizing: border-box; width: calc(33.333% - 12rpx); min-width: 128rpx; padding: 8rpx; border: 2rpx solid #dbe5ef; border-radius: 16rpx; background: #fff; }
|
||||
.photo-thumbnail { display: block; width: 100%; height: 136rpx; border-radius: 12rpx; background: #e7edf5; }
|
||||
.photo-preview-caption { display: block; overflow: hidden; margin-top: 8rpx; color: #64748b; font-size: 18rpx; line-height: 28rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.photo-replace { margin: 8rpx 0 0; padding: 0; color: #174d91; font-size: 22rpx; line-height: 64rpx; background: #eef5ff; }
|
||||
.photo-read-retry { display: flex; box-sizing: border-box; width: 100%; min-height: 136rpx; margin: 0; padding: 12rpx; align-items: center; justify-content: center; border: 0; border-radius: 12rpx; color: #174d91; font-size: 20rpx; line-height: 30rpx; background: #e7edf5; }
|
||||
.error { margin-top: 16rpx; padding: 12rpx 16rpx; border-radius: 14rpx; color: #b91c1c; background: #fff1f0; }
|
||||
.error text { display: block; font-size: 22rpx; line-height: 34rpx; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -2,14 +2,14 @@ export const MODULES = [
|
||||
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
|
||||
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
|
||||
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
|
||||
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
|
||||
{ key: 'warehouseTransferWork', name: '转运测量', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
|
||||
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
|
||||
{ key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
|
||||
{ key: 'returnPacking', name: '合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
|
||||
{ key: 'returnCounting', name: '退货清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
|
||||
] },
|
||||
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
|
||||
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '理货审核通过后真实拣货,确认装车完成即进入派送中', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
|
||||
{ key: 'outboundPicking', name: '出库作业', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
|
||||
{ key: 'dispatchPicking', name: '拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '理货审核通过后真实拣货,确认装车完成即进入派送中', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
|
||||
{ key: 'outboundPicking', name: '出库', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
|
||||
] },
|
||||
{ key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '商品移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [
|
||||
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' },
|
||||
|
||||
@@ -36,20 +36,25 @@
|
||||
</view>
|
||||
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
|
||||
<text class="card-title">理货员审核</text>
|
||||
<text class="task-meta">车牌号:{{ current.resources?.vehicle?.plate || '—' }} · 托车公司:{{ current.resources?.vehicle?.carrierName || '—' }}</text>
|
||||
<text class="task-meta">司机:{{ current.resources?.driver?.name || '—' }} · 电话:{{ current.resources?.driver?.phone || '—' }}</text>
|
||||
<text class="task-meta">计划发车(北京时间):{{ reviewTime(current.plannedDepartureAt) }}</text>
|
||||
<text class="task-meta">预计送达(北京时间):{{ reviewTime(current.estimatedArrivalAt) }}</text>
|
||||
<text v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
|
||||
<text class="task-meta">驾驶证号:{{ current.resources?.driver?.evidence?.licenseNo || '—' }} · 有效期:{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text>
|
||||
<text class="task-meta">调度审核:{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ current.reviews?.dispatchReview?.reviewedAt || '—' }}</text>
|
||||
<text v-if="current.reviews?.dispatchReview?.reason" class="task-meta">调度意见:{{ current.reviews.dispatchReview.reason }}</text>
|
||||
<view class="review-facts">
|
||||
<view class="review-fact"><text class="review-label">车牌号</text><text>{{ current.resources?.vehicle?.plate || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">托车公司</text><text>{{ current.resources?.vehicle?.carrierName || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">司机</text><text>{{ current.resources?.driver?.name || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">电话</text><text>{{ current.resources?.driver?.phone || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">计划发车</text><text>{{ reviewTime(current.plannedDepartureAt) }}(北京时间)</text></view>
|
||||
<view class="review-fact"><text class="review-label">预计送达</text><text>{{ reviewTime(current.estimatedArrivalAt) }}(北京时间)</text></view>
|
||||
<view v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="review-fact"><text class="review-label">{{ appointment.destinationWarehouseCode }}</text><view class="review-appointment"><text>{{ appointment.appointmentNo || '—' }}</text><text>{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text></view></view>
|
||||
<view class="review-fact"><text class="review-label">驾驶证号</text><text>{{ current.resources?.driver?.evidence?.licenseNo || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">有效期</text><text>{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">调度审核</text><text>{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}</text></view>
|
||||
<view v-if="current.reviews?.dispatchReview?.reason" class="review-fact"><text class="review-label">调度意见</text><text>{{ current.reviews.dispatchReview.reason }}</text></view>
|
||||
</view>
|
||||
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
|
||||
<template v-if="canReview">
|
||||
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
|
||||
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
|
||||
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
|
||||
<view class="count-row"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
|
||||
<view class="review-actions"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
|
||||
</template>
|
||||
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
|
||||
</view>
|
||||
@@ -72,16 +77,24 @@
|
||||
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
|
||||
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
|
||||
</view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text class="card-title">确认装车完成</text><text class="picking-instruction">{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
|
||||
</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><text class="loading-dialog-number">{{ loadingPreview.dispatchNo }}</text></view>
|
||||
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
|
||||
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
|
||||
<text class="loading-confirm-question">本车货物已全部装车?</text><text class="loading-confirm-note">照片可在“装车拍照”中继续上传</text>
|
||||
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认装车</button></view>
|
||||
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认</button></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -99,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 },
|
||||
@@ -117,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 }) },
|
||||
@@ -129,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)
|
||||
@@ -147,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) {
|
||||
@@ -202,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] || '待核对' },
|
||||
@@ -244,25 +265,36 @@ export default {
|
||||
.stage-tab::after { border: 0; }
|
||||
.stage-tab.active { color: #1262f4; background: #fff; box-shadow: 0 1px 4px rgba(31, 41, 55, .12); }
|
||||
.stage-tab[disabled] { opacity: .55; }
|
||||
.review-card textarea { box-sizing: border-box; width: 100%; min-height: 160rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
|
||||
.review-facts { margin: 16rpx 0; }
|
||||
.review-fact { display: flex; align-items: flex-start; gap: 20rpx; padding: 10rpx 0; color: #243c58; font-size: 26rpx; line-height: 1.5; }
|
||||
.review-label { flex: 0 0 132rpx; color: #718095; overflow-wrap: anywhere; }
|
||||
.review-fact > :last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.review-appointment { display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.review-actions { display: flex; gap: 20rpx; margin-top: 24rpx; }
|
||||
.review-actions button { flex: 1; min-width: 0; margin: 0; padding: 0 16rpx; min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
|
||||
.review-actions .primary-button { min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
|
||||
.review-card textarea { box-sizing: border-box; width: 100%; height: 160rpx; min-height: 160rpx; margin-top: 20rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
|
||||
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
|
||||
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
|
||||
.loading-dialog-number { color: #7a8aa0; font-size: 26rpx; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
|
||||
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
|
||||
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
|
||||
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
|
||||
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
|
||||
.loading-count-label { color: #72839b; font-size: 24rpx; }
|
||||
.loading-confirm-question { display: block; color: #253a54; font-size: 30rpx; font-weight: 600; line-height: 1.5; }
|
||||
.loading-confirm-note { display: block; color: #7a8aa0; font-size: 25rpx; line-height: 1.5; margin-top: 10rpx; }
|
||||
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
|
||||
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
|
||||
.loading-dialog-actions button::after { border: 0; }
|
||||
.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>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { 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'
|
||||
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 = []
|
||||
globalThis.uni = { setStorageSync: (key, value) => values.set(key, value), removeStorageSync: key => values.delete(key), getStorageSync: key => values.get(key), chooseImage: async () => ({ tempFilePaths: ['local-image'], tempFiles: [{ size: 123 }] }), showToast() {} }
|
||||
const dependencies = { session: { currentUser: { id: 'user', warehouseId: 'warehouse' } }, loadingPhotoSlots, toDispatchError: cause => cause,
|
||||
const dependencies = { session: { currentUser: { id: 'user', warehouseId: 'warehouse' } }, loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, toDispatchError: cause => cause,
|
||||
getLoadingDocuments: async () => data(), uploadLoadingPhoto: async () => photo, recoverLoadingPhoto: async () => photo,
|
||||
bindLoadingPhoto: async () => data({ recordVersion: '1', photos: [photo] }), readLoadingPhoto: async () => 'downloaded-image', ...overrides }
|
||||
const definition = new Function(...Object.keys(dependencies), source.replace(/^import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
|
||||
@@ -22,30 +22,72 @@ function component(overrides = {}) {
|
||||
test('five existing slots carry their business names into upload, replace and preview controls', async () => {
|
||||
const { state } = component()
|
||||
assert.deepEqual(state.slots, ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'])
|
||||
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' })
|
||||
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' })
|
||||
const template = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).split('<script>')[0]
|
||||
assert.match(template, /class="photo-label">\{\{ slotLabels\[slot\] \}\}/)
|
||||
assert.match(template, /:aria-label="`\$\{snapshot\?\.photos.some\(item => item.slot === slot\) \? '更换' : '上传'\}\$\{slotLabels\[slot\]\}`"/)
|
||||
assert.match(template, /@tap="choose\(slot, photo.fileId\)"/)
|
||||
assert.match(template, /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/)
|
||||
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'), /不一致/)
|
||||
@@ -68,17 +110,29 @@ test('binding failure releases immediately and the next selection uses a new fil
|
||||
const binds = []; let uploads = 0, version = '0', reads = 0
|
||||
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ recordVersion: version }) }, uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++uploads) }), bindLoadingPhoto: async (_no, _slot, body) => { binds.push(body); if (binds.length === 1) throw { network: true }; return data({ photos: [photo] }) } })
|
||||
await state.load(); await state.choose(photo.slot)
|
||||
assert.equal(state.busy, false); assert.equal(reads, 2); assert.equal(values.size, 0)
|
||||
assert.equal(state.busy, false); assert.equal(reads, 3); assert.equal(values.size, 0)
|
||||
version = '7'; await state.choose(photo.slot)
|
||||
assert.equal(uploads, 2); assert.equal(binds[1].recordVersion, '7'); assert.notEqual(binds[1].fileId, binds[0].fileId)
|
||||
assert.notEqual(binds[1].idempotencyKey, binds[0].idempotencyKey); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||
})
|
||||
test('manual refresh after a lost binding response reflects server success without another write', async () => {
|
||||
test('a lost binding response immediately reloads server success without another write', async () => {
|
||||
let binds = 0, latest = data()
|
||||
const { state } = component({ getLoadingDocuments: async () => latest, bindLoadingPhoto: async () => { binds++; latest = data({ editable: false, complete: true, stage: 'DELIVERING', photos: [photo] }); throw { network: true } } })
|
||||
await state.load(); await state.choose(photo.slot); assert.equal(state.busy, false)
|
||||
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.editable, false)
|
||||
await state.load(); assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(binds, 1); assert.equal(state.editable, false)
|
||||
})
|
||||
|
||||
test('failed reconciliation blocks further uploads until an explicit refresh succeeds', async () => {
|
||||
let reads = 0, uploads = 0
|
||||
const { state } = component({ getLoadingDocuments: async () => { if (++reads === 3) throw new Error('读取失败'); return data() },
|
||||
uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async () => { throw new Error('保存超时') } })
|
||||
await state.load(); await state.choose(photo.slot)
|
||||
assert.equal(state.busy, false); assert.equal(state.needsRefresh, true); assert.equal(state.editable, false)
|
||||
assert.match(state.error.message, /保存超时/)
|
||||
await state.choose(photo.slot); assert.equal(uploads, 1)
|
||||
await state.load(); assert.equal(state.needsRefresh, false); assert.equal(state.editable, true)
|
||||
})
|
||||
test('in-flight upload blocks duplicate clicks and unmount releases the parent busy flag', async () => {
|
||||
let resolveUpload, uploads = 0
|
||||
const { state, events, definition } = component({ uploadLoadingPhoto: () => { uploads++; return new Promise(resolve => { resolveUpload = resolve }) } })
|
||||
@@ -88,3 +142,49 @@ test('in-flight upload blocks duplicate clicks and unmount releases the parent b
|
||||
definition.beforeUnmount.call(state); assert.deepEqual(events.at(-1), ['busy-change', false])
|
||||
resolveUpload(photo); await first; assert.equal(events.some(event => event[0] === 'saved'), false)
|
||||
})
|
||||
|
||||
test('normalization accepts five distinct photos per category but rejects a sixth', () => {
|
||||
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) }))
|
||||
assert.equal(normalizeLoadingDocuments(data({ photos }), 'PC/1').photos.length, 5)
|
||||
assert.throws(() => normalizeLoadingDocuments(data({ photos: [...photos, { ...photo, fileId: '6' }] }), 'PC/1'), /最多5张/)
|
||||
})
|
||||
|
||||
test('multi-select appends every image using fresh versions and counts categories rather than files', async () => {
|
||||
let latest = data(), count = 0, selectedCount
|
||||
const binds = []
|
||||
const { state, events } = component({ getLoadingDocuments: async () => latest,
|
||||
uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++count) }),
|
||||
bindLoadingPhoto: async (_no, _slot, body) => {
|
||||
binds.push(body)
|
||||
latest = data({ recordVersion: String(binds.length), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
|
||||
} })
|
||||
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['a', 'b', 'c', 'd', 'e'] } }
|
||||
await state.load(); await state.choose(photo.slot)
|
||||
assert.equal(selectedCount, 5); assert.equal(state.snapshot.photos.length, 5); assert.equal(state.completedCategories, 1)
|
||||
assert.deepEqual(binds.map(body => body.recordVersion), ['0', '1', '2', '3', '4'])
|
||||
assert.equal(new Set(binds.map(body => body.idempotencyKey)).size, 5)
|
||||
await state.choose(photo.slot); assert.equal(count, 5)
|
||||
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||
})
|
||||
|
||||
test('replacement sends the selected old file and retains the other four photos', async () => {
|
||||
let latest = data({ photos: Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) })) })
|
||||
let selectedCount, request
|
||||
const { state } = component({ getLoadingDocuments: async () => latest, uploadLoadingPhoto: async () => ({ ...photo, fileId: '6' }),
|
||||
bindLoadingPhoto: async (_no, _slot, body) => { request = body; latest = data({ recordVersion: '1', photos: latest.photos.map(item => item.fileId === body.replaceFileId ? { ...photo, fileId: body.fileId } : item) }); return latest } })
|
||||
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['replacement'] } }
|
||||
await state.load(); await state.choose(photo.slot, '3')
|
||||
assert.equal(selectedCount, 1); assert.equal(request.replaceFileId, '3')
|
||||
assert.deepEqual(state.snapshot.photos.map(item => item.fileId), ['1', '2', '6', '4', '5'])
|
||||
})
|
||||
|
||||
test('a partial batch failure preserves successful photos and notifies the task once', async () => {
|
||||
let latest = data(), uploads = 0
|
||||
const { state, events } = component({ getLoadingDocuments: async () => latest,
|
||||
uploadLoadingPhoto: async () => { if (++uploads === 2) throw new Error('网络中断'); return photo },
|
||||
bindLoadingPhoto: async () => { latest = data({ recordVersion: '1', photos: [photo] }); return latest } })
|
||||
uni.chooseImage = async () => ({ tempFilePaths: ['first', 'failed', 'unattempted'] })
|
||||
await state.load(); await state.choose(photo.slot)
|
||||
assert.equal(uploads, 2); assert.equal(state.busy, false); assert.match(state.error.message, /网络中断/)
|
||||
assert.deepEqual(state.snapshot.photos, [photo]); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,7 +31,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
|
||||
const [data, session, runtime] = await Promise.all([
|
||||
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
|
||||
])
|
||||
for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
||||
for (const label of ['入仓作业', '拆柜收货', '退货入库', '合箱', '出库履约', '拣货', '出库', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
||||
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
|
||||
assert.match(session, /overseas-pda-auth-session/)
|
||||
assert.match(runtime, /VITE_API_BASE_URL/)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -62,9 +62,10 @@ test('warehouse transfer shows forecast and actual variance and requires counted
|
||||
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
|
||||
})
|
||||
|
||||
test('warehouse transfer retries reuse the pending idempotency key', async () => {
|
||||
test('warehouse transfer creates a fresh completion key for each explicit attempt', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
|
||||
assert.doesNotMatch(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
|
||||
assert.match(page, /this\.pendingKey = `pda-transfer-/)
|
||||
assert.match(page, /idempotencyKey: this\.pendingKey/)
|
||||
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
|
||||
})
|
||||
|
||||
@@ -107,12 +107,13 @@ test('transfer counts require valid pieces and measurements and record variance
|
||||
assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
|
||||
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
|
||||
})
|
||||
test('network retry preserves request key and changed content uses a new request',async()=>{
|
||||
test('failed count unlocks the draft and each explicit retry sends current contents with a fresh key',async()=>{
|
||||
const {value,scope}=setup();const requests=[]
|
||||
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
|
||||
await value.confirmCartons();await value.confirmCartons()
|
||||
assert.equal(requests.length,2);assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)
|
||||
assert.equal(requests.length,2);assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)
|
||||
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
|
||||
assert.equal(requests[2].boxes[0].items[0].quantity,8);assert.equal(value.cartonBoxes[0].items[0].quantity,8)
|
||||
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
|
||||
})
|
||||
test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
|
||||
@@ -221,7 +222,7 @@ for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:tru
|
||||
await value.submit()
|
||||
assert.equal(prompts,1);assert.equal(value.submitting,false)
|
||||
assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
|
||||
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)}
|
||||
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)}
|
||||
})
|
||||
}
|
||||
test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{
|
||||
|
||||
@@ -7,12 +7,12 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
const enabled = operations.filter((operation) => operation.available)
|
||||
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
||||
{ module: 'inbound', name: '拆柜收货' },
|
||||
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
|
||||
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量' },
|
||||
{ module: 'inbound', name: '退货入库' },
|
||||
{ module: 'inbound', name: '退货合箱' },
|
||||
{ module: 'inbound', name: '退货箱内清点' },
|
||||
{ module: 'outbound', name: '排车拣货' },
|
||||
{ module: 'outbound', name: '出库作业' },
|
||||
{ module: 'inbound', name: '合箱' },
|
||||
{ module: 'inbound', name: '退货清点' },
|
||||
{ module: 'outbound', name: '拣货' },
|
||||
{ module: 'outbound', name: '出库' },
|
||||
{ module: 'inventory', name: '商品移库' },
|
||||
{ module: 'inventory', name: '托盘管理' },
|
||||
{ module: 'relabel', name: '换标列表' },
|
||||
@@ -23,13 +23,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
{ module: 'query', name: '托盘查询' }
|
||||
])
|
||||
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
|
||||
assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
|
||||
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
|
||||
assert.equal(operations.find((item) => item.name === '拣货')?.route, '/pages/dispatch/dispatch')
|
||||
assert.equal(operations.find((item) => item.name === '出库')?.route, '/pages/outbound/outbound')
|
||||
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
||||
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
|
||||
assert.equal(operations.find((item) => item.name === '退货箱内清点')?.route, '/pages/return-counting/return-counting')
|
||||
assert.equal(operations.find((item) => item.name === '合箱')?.route, '/pages/return-packing/return-packing')
|
||||
assert.equal(operations.find((item) => item.name === '退货清点')?.route, '/pages/return-counting/return-counting')
|
||||
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
|
||||
assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
|
||||
assert.equal(operations.find((item) => item.name === '转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
|
||||
|
||||
assert.equal(operations.some((item) => item.name === '库存盘点'), false)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user