feat(pda): support five loading photos per category and reconcile saves
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 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
||||
- 排车和出库入口只调用专用 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`,离线通过不代表实际冻结、扣款和出库联调已验收。
|
||||
|
||||
@@ -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`
|
||||
@@ -12,10 +15,11 @@ function slotPath(no, slot) {
|
||||
export function normalizeLoadingDocuments(value, no) {
|
||||
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !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 }
|
||||
}
|
||||
|
||||
@@ -2,24 +2,27 @@
|
||||
<view class="loading-documents">
|
||||
<view class="heading">
|
||||
<view class="heading-main"><text class="heading-title">装车照片</text></view>
|
||||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}</text>
|
||||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
|
||||
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
|
||||
</view>
|
||||
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</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 v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-status">已保存 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-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>
|
||||
@@ -29,17 +32,18 @@
|
||||
<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() {
|
||||
@@ -48,22 +52,44 @@ export default {
|
||||
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 choose(slot, replaceFileId = null) {
|
||||
if (this.busy || !this.editable) return
|
||||
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 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 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)
|
||||
try {
|
||||
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, ...(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 run(work, notify = false) {
|
||||
@@ -103,6 +129,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; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 })
|
||||
@@ -9,7 +9,7 @@ const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.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,10 +22,10 @@ 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 \}\}/)
|
||||
@@ -68,17 +68,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 +100,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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user