优化
This commit is contained in:
@@ -9,15 +9,18 @@
|
||||
## 当前阶段门禁
|
||||
|
||||
- 当前已有 uni-app Vue 3 静态原型和 `uni_modules/`,没有项目根级依赖清单或可核实的 HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
|
||||
- 目标 PDA 型号、操作系统、扫描头接入方式、离线要求、发布方式及一期同步范围仍需确认。设备与方案确认前,不绑定单一厂商 SDK,也不假设摄像头扫码可替代硬件扫描头。
|
||||
- Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。目标 PDA 型号、Android 版本、打包发布方式和真实设备验收仍待确认;继续通过设备适配层接入,不绑定单一厂商 SDK。
|
||||
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
||||
- 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。
|
||||
|
||||
## 作业流程原则
|
||||
|
||||
- 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。
|
||||
- 明确区分整单拣货、部分拣货、异常挂起和完成提交;关键状态转换需服务端确认,不能只依赖本地页面状态。
|
||||
- 作业提交应具备幂等标识,防止弱网、重复点击或重放造成重复扣减与重复完成。
|
||||
- 离线队列只有在业务批准后才能实现;队列必须可观察、可重试、可取消,并能识别冲突而不是静默覆盖服务端状态。
|
||||
- 进入任务时由服务端标记“拣货中”;扫描结果先写入绑定作业员、任务和柜次的设备持久化草稿,仅在点击提交时批量写后端,禁止逐码提交。
|
||||
- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。
|
||||
- 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。
|
||||
- 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。
|
||||
- PDA 仅允许现场拍照;Admin 可通过独立受权入口人工上传。五类证据各至少 1 张有效照片,同类型允许多张追加;错误或重复照片标记作废并保留原因、操作人和时间,不硬删除。
|
||||
- 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
|
||||
|
||||
## 设备、安全与可用性
|
||||
@@ -32,6 +35,18 @@
|
||||
|
||||
## 质量门禁
|
||||
|
||||
- 在真实目标 PDA 上验证扫描速度、连续扫描、断网重连、低电量、前后台切换和重复提交。
|
||||
- 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。
|
||||
- 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。
|
||||
- 当前原型没有可核实的 HBuilderX/Android 构建命令,不得声称打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
|
||||
|
||||
## 一期/二期/三期快速索引
|
||||
|
||||
按阶段处理 PDA 任务时,先核对阶段文档,再从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查;本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。
|
||||
|
||||
| 阶段 | 当前状态与边界 | PDA 入口 | 开始前核对 |
|
||||
|---|---|---|---|
|
||||
| 一期:头程订单基础闭环 | PDA 仅保留静态登录、工作台和出库拣货原型;是否与一期头程订单/仓库任务同步上线尚未确认,不得将本地状态当作服务端闭环。 | `pages/login/`、`pages/home/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`;页面注册见 `pages.json`。 | `../../docs/一期/README.md`、`../../docs/一期/一期头程订单闭环设计.md`、`README.md`;确认设备、同步范围、鉴权和服务端任务契约后再接 API。 |
|
||||
| 二期:到仓与计费 | 不在二期实现 PDA 到仓测量、称重、托盘/库位或费用确认;这些能力由 Admin/Client/Backend 完成,PDA 继续维持静态原型。 | 现有 `pages/picking/` 不扩展二期业务;扫码适配保持在 `utils/scan-broadcast.js` 与 `components/ScanTargetInput.vue`。 | `../../docs/二期/README.md`、`../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;确认二期终点 `WAIT_SPLIT + APPROVED + CONFIRMED`,不得提前接入分柜。 |
|
||||
| 三期:国内出仓 | PDA 承接分柜后拣货和装柜证据。在线作业、进入即开始、持久化草稿、批量提交、异常阻断、轻量幂等/版本和五类多图规则已确认;一柜多人、换班释放、最终 `LOADED` 触发和设备发布仍待确认。 | 复用 `pages/picking/`、`stores/picking.js`、`utils/scan-broadcast.js` 和远程 API 适配;当前内存草稿、单类型照片替换等实现不得作为最终契约。 | `../../docs/三期/README.md`、`../../docs/三期/三期开发规划.md`、`../../docs/三期/三期需求确认表.md`、`README.md`;按已确认子规则继续开发,待定项保持可扩展,不自行固定。 |
|
||||
|
||||
阶段边界:PDA 不承担客户工作台或后台配置;一期仅验证原型与同步范围,二期不提前引入 PDA/分柜/装柜。三期已确认的 PDA 子规则可进入开发和契约测试,仍待确认的多人协作、完成触发和真机发布不得自行定稿。
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
当前静态原型范围:人员登录、模块化工作台、入库/出库/库存/查询入口、作业记录、个人中心,以及出库模块内的箱号选择、扫码输入、整单拣货、部分拣货、异常原因、撤销和完成提交。
|
||||
|
||||
三期装柜取证已具备接口适配基础:拣货页展示空仓、半仓、满仓、锁仓和带锁码照五类证据,经 File 服务上传取得真实 `fileId`,并展示待上传、上传中、失败、成功和重试状态。产品已确认 PDA 只能现场拍照,Admin 可人工上传;每类至少 1 张有效照片、同类型可多张追加,错误或重复照片只作废不硬删除。当前页面和上传队列仍按会话内单类型记录设计,尚未完整达到“多图追加+作废审计”契约。
|
||||
|
||||
登录后默认进入首页,不再直接进入拣货。首页采用入库、出库、库内作业、查询四个分组的三列宫格,不设置常驻底部导航;作业记录收纳在左上角功能菜单,个人中心从右上角人员入口进入。第一版本只有“出库 → 集装箱拣货”可点击并具备完整本地状态交互,其余业务入口统一置灰并禁用,保留在界面中用于确认后续信息架构。
|
||||
|
||||
## 页面与状态结构
|
||||
@@ -19,11 +21,13 @@
|
||||
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
|
||||
- `pages/picking/picking`:集装箱拣货完整交互。
|
||||
|
||||
登录会话集中在 `stores/session.js`,拣货会话集中在 `stores/picking.js`;两者都是内存态,不写入设备。退出登录会同时清理当前人员和全部拣货草稿。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||
登录会话集中在 `stores/session.js`,拣货会话集中在 `stores/picking.js`;当前实现仍是内存态,不写入设备。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||
|
||||
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。切换存在进度的箱号、带待拣项提交以及退出登录均需要明确确认;待拣项提交还必须选择未完成原因。上述状态不持久化到设备,退出登录后会主动清理,不能视为正式离线队列或服务端提交机制。
|
||||
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。已确认的正式流程是:进入任务即由后端标记“拣货中”;扫码只更新本地草稿,员工点击提交时才将整批明细写入后端,不逐码提交。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
|
||||
|
||||
拣货页面和本地交互以 `../../docs/悦/PDA拣货/pda-picking.html` 为依据。Android App 端通过统一适配层接收 `android.intent.action.SCANRESULT` 广播,并从字符串 Extra `value` 读取硬件扫描结果;拣货页的扫码框仅显示广播值和当前接收目标样式,不获取输入焦点或弹出软键盘,页面隐藏或销毁时会注销接收器。当前不接后端接口、不实现真实鉴权、不绑定厂商 SDK,也不持久化演示状态。
|
||||
正式 PDA 作业必须联网。断网时不能开始正式拣货、提交、完成任务或推进 `LOADED`;网络恢复后由员工刷新并重试。提交使用对员工不可见的轻量幂等键和服务端版本:重复提交返回原结果,版本冲突提示刷新。项目不建设离线业务提交队列。
|
||||
|
||||
拣货页面和本地交互以 `../../docs/悦/PDA拣货/pda-picking.html` 为依据。Android App 端已按广播模式接入:Action=`android.intent.action.SCANRESULT`,字符串 Extra=`value`;拣货页的扫码框仅显示广播值和当前接收目标样式,不获取输入焦点或弹出软键盘,页面隐藏或销毁时会注销接收器。当前保留演示任务,同时已有三期拣货、装柜证据和完成操作的远程接口适配;只有会话包含真实 Token、客户标识和入仓单标识时才会请求服务端,不会把缺少契约字段的本地操作模拟为远程成功。具体 PDA 型号、Android 版本、正式鉴权、任务领取、打包发布流程和目标设备真机验收仍待完成。
|
||||
|
||||
扫码输入统一使用 `components/ScanTargetInput.vue` 展示和选择目标,组件本身不注册广播。每个业务页面只在 `onShow` 注册一次广播、在 `onHide`/`onUnload` 注销,并通过页面内的 `activeScanField` 将广播值路由到当前选中的字段。多输入框页面应由用户点击切换蓝色“当前扫码”目标,避免多个输入框或隐藏页面同时消费同一次扫描。
|
||||
|
||||
@@ -48,10 +52,21 @@ node --test tests/*.test.mjs
|
||||
|
||||
当前环境验证使用 Node.js 22。测试同时约束 Vue 3 入口、页面契约和本地拣货状态规则。项目尚无 HBuilderX CLI、可核实的 Android 构建命令或自动化 UI 测试设施。
|
||||
|
||||
## 仍需真机验证
|
||||
## 已确认但尚待实现/验收
|
||||
|
||||
- Android PDA 的状态栏、导航栏和软键盘避让;
|
||||
- 硬件扫描头的输入方式、连续扫描和重复码反馈;
|
||||
- 低电量、前后台切换、断网重连和正式打包发布。
|
||||
- 将未提交草稿持久化到设备,并按作业员、任务和柜次隔离及恢复。
|
||||
- 提交真实批量拣货明细,并覆盖幂等重放、版本冲突刷新和异常挂起。
|
||||
- 将五类证据从单槽位模型改为多图追加,补错误记录作废及 Admin 人工上传。
|
||||
- 在目标 Android PDA 上验证状态栏、导航栏、软键盘避让、连续扫描、重复码、低电量和前后台切换。
|
||||
- 确认具体设备型号、Android 版本、HBuilderX/Android 打包和正式发布流程。
|
||||
|
||||
## 仍待产品确认
|
||||
|
||||
- 同一柜次是否允许多人并行拣货,以及任务拆分/合并口径。
|
||||
- 退出任务或换班时是否释放任务、由谁接管草稿。
|
||||
- 批量提交超时、业务错误和网络恢复后的具体提示与重试交互。
|
||||
- 谁触发最终 `LOADED`,是否需要 Admin 二次复核。
|
||||
- 哪些 Admin 角色可人工上传照片,以及照片可见范围和留存期限。
|
||||
- 锁码格式、长度和唯一性规则。
|
||||
|
||||
一期需要确认是否与管理后台头程订单及仓库任务同步上线。
|
||||
|
||||
@@ -1102,7 +1102,12 @@
|
||||
.evidence-card.filled { border-style: solid; border-color: #69b88a; background: #f0fbf4; }
|
||||
.evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; }
|
||||
.evidence-file { display: block; overflow: hidden; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; }
|
||||
.evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; }
|
||||
.evidence-upload-state.failed { color: $danger; }
|
||||
.evidence-upload-state.succeeded { color: $success; }
|
||||
.evidence-btn { min-height: 40px; margin-top: 8px; padding: 0 9px; border: 1px solid #9db7d9; border-radius: 7px; color: #174d91; font-size: 12px; line-height: 40px; background: #ffffff; }
|
||||
.evidence-retry-btn { min-height: 40px; margin-top: 6px; padding: 0 9px; border: 1px solid $danger; border-radius: 7px; color: $danger; font-size: 12px; line-height: 40px; background: #fff5f4; }
|
||||
.seal-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.seal-input { flex: 1; height: 42px; box-sizing: border-box; padding: 0 10px; border: 1px solid $border; border-radius: 7px; font-size: 12px; background: #f8fafc; }
|
||||
.seal-btn { flex-shrink: 0; margin-top: 0; }
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const { createEvidenceUploadQueue } = await import(new URL('../utils/evidence-upload-queue.js', import.meta.url).href)
|
||||
|
||||
test('evidence queue exposes upload progress and successful durable result', async () => {
|
||||
const changes = []
|
||||
const queue = createEvidenceUploadQueue({
|
||||
upload: async (item, progress) => {
|
||||
progress(45)
|
||||
return { fileId: 81, fileName: `${item.evidenceType}.jpg` }
|
||||
},
|
||||
onChange: (items) => changes.push(items)
|
||||
})
|
||||
|
||||
queue.add({ id: 'empty-1', inboundId: 12, evidenceType: 'PHOTO_STEP_1', localPath: 'tmp/empty.jpg' })
|
||||
const result = await queue.flush()
|
||||
|
||||
assert.equal(result[0].status, 'SUCCEEDED')
|
||||
assert.equal(result[0].progress, 100)
|
||||
assert.equal(result[0].result.fileId, 81)
|
||||
assert.ok(changes.some((items) => items[0].status === 'UPLOADING' && items[0].progress === 45))
|
||||
})
|
||||
|
||||
test('failed evidence remains observable and can be retried without a duplicate entry', async () => {
|
||||
let attempts = 0
|
||||
const queue = createEvidenceUploadQueue({
|
||||
upload: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('网络中断')
|
||||
return { fileId: 82 }
|
||||
}
|
||||
})
|
||||
|
||||
const entry = queue.add({ id: 'half-1', inboundId: 12, evidenceType: 'PHOTO_STEP_2', localPath: 'tmp/half.jpg' })
|
||||
await queue.flush()
|
||||
assert.equal(queue.latest('PHOTO_STEP_2').status, 'FAILED')
|
||||
assert.equal(queue.latest('PHOTO_STEP_2').error, '网络中断')
|
||||
|
||||
assert.equal(queue.retry(entry.id), true)
|
||||
await queue.flush()
|
||||
assert.equal(queue.latest('PHOTO_STEP_2').status, 'SUCCEEDED')
|
||||
assert.equal(queue.latest('PHOTO_STEP_2').attempts, 2)
|
||||
assert.equal(queue.snapshot().length, 1)
|
||||
})
|
||||
|
||||
test('same evidence type appends and explicit void preserves the old photo', async () => {
|
||||
const queue = createEvidenceUploadQueue({ upload: async () => { throw new Error('失败') } })
|
||||
queue.add({ id: 'old', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/old.jpg' })
|
||||
await queue.flush()
|
||||
queue.add({ id: 'new', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/new.jpg' })
|
||||
|
||||
assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'FAILED')
|
||||
assert.equal(queue.latest('SEAL').id, 'new')
|
||||
assert.equal(queue.voidEntry('old', '照片模糊', 'worker-1'), true)
|
||||
assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'VOIDED')
|
||||
assert.equal(queue.snapshot().find((item) => item.id === 'old').voidReason, '照片模糊')
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Small, observable upload queue for PDA evidence photos.
|
||||
* The queue deliberately keeps local paths only for the active session; callers
|
||||
* decide when it is safe to persist/clear them (see PDA session policy).
|
||||
*/
|
||||
export function createEvidenceUploadQueue({ upload, onChange } = {}) {
|
||||
const entries = []
|
||||
const notify = () => onChange?.(entries.map((item) => ({ ...item })))
|
||||
const snapshot = () => entries.map((item) => ({ ...item }))
|
||||
const add = (payload) => {
|
||||
if (!payload?.evidenceType || !payload?.localPath) throw new Error('缺少证据类型或本地照片')
|
||||
// Evidence is append-only by type. Incorrect or duplicate photos are
|
||||
// explicitly voided, preserving the local audit trail instead of replacing.
|
||||
const entry = { id: payload.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ...payload, status: 'PENDING', attempts: 0, progress: 0, error: '' }
|
||||
entries.push(entry); notify(); return entry
|
||||
}
|
||||
const cancel = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || item.status === 'UPLOADING') return false; item.status = 'CANCELLED'; notify(); return true }
|
||||
const voidEntry = (id, reason, actor) => {
|
||||
const item = entries.find((entry) => entry.id === id)
|
||||
if (!item || item.status === 'UPLOADING' || item.status === 'VOIDED') return false
|
||||
if (!String(reason || '').trim()) throw new Error('作废原因不能为空')
|
||||
item.status = 'VOIDED'; item.voidReason = String(reason).trim(); item.voidedBy = actor || null; item.voidedAt = new Date().toISOString(); notify(); return true
|
||||
}
|
||||
const retry = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || !['FAILED', 'CANCELLED'].includes(item.status)) return false; item.status = 'PENDING'; item.error = ''; notify(); return true }
|
||||
async function flush() {
|
||||
if (typeof upload !== 'function') throw new Error('未配置文件上传器')
|
||||
for (const item of entries) {
|
||||
if (item.status !== 'PENDING') continue
|
||||
item.status = 'UPLOADING'; item.attempts += 1; item.error = ''; notify()
|
||||
try {
|
||||
const result = await upload(item, (progress) => { item.progress = progress; notify() })
|
||||
item.status = 'SUCCEEDED'; item.progress = 100; item.result = result; notify()
|
||||
} catch (error) {
|
||||
item.status = 'FAILED'; item.error = error?.message || '上传失败'; item.network = Boolean(error?.network); notify()
|
||||
}
|
||||
}
|
||||
return snapshot()
|
||||
}
|
||||
const latest = (evidenceType) => [...entries].reverse().find((item) => item.evidenceType === evidenceType && item.status !== 'VOIDED') || null
|
||||
const active = (evidenceType) => entries.filter((item) => item.evidenceType === evidenceType && item.status === 'SUCCEEDED')
|
||||
return { add, cancel, voidEntry, retry, flush, latest, active, snapshot }
|
||||
}
|
||||
Reference in New Issue
Block a user