Release/test #1

Merged
admin merged 102 commits from release/test into main 2026-09-17 11:53:23 +08:00
17 changed files with 328 additions and 24 deletions
Showing only changes of commit 9933b96189 - Show all commits
+5 -3
View File
@@ -19,7 +19,7 @@
## 作业流程原则
- 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。
- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。
- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣;只按权威 `unitType=SEALED_PALLET` 识别整托,不能从资源 ID 或 `indivisible` 猜测。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。
- 拣货明细卡片按现场找货优先级展示库位、托盘码或入仓码、待拣箱数和处理状态;库位从明细 `inventoryResources.locationCode` 去重汇总,缺失时明确显示“库位 —”。商品名称和 SKU 数据可以保留在响应模型中,但不占用 PDA 拣货卡片主区域。
- 进入任务时由服务端标记“拣货中”;每次有效箱码或托盘码扫描,以及确认拣出、部分拣货、跳拣操作,都必须立即写入服务端,设备持久化草稿只用于恢复页面状态。底部提交只原子收尾尚未处理的明细并进入装柜取证,不重复提交已落库动作。
- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。
@@ -27,6 +27,8 @@
- 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。
- PDA 仅允许现场拍照;Admin 可通过独立受权入口人工上传。五类证据各至少 1 张有效照片,同类型允许多张追加;错误或重复照片标记作废并保留原因、操作人和时间,不硬删除。设备端证据查询、上传、预览和作废统一按当前 `taskId + warehouseId` 走任务专用 API,服务端再绑定真实柜 `containerBookingId`;不得调用旧入仓单/客户作用域证据接口。
- 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
- 一件代发清点组托使用独立 `/first-leg/pda-dropship` 任务链:箱码先经只读识别再确认实收;系统托盘码可先于或晚于箱码扫描。`count` 携托盘码时直接绑定,箱码先扫则进入待绑托区并由 `bind-pending` 批量绑定;换托只使用服务端 `cartonId`,封托后按整托不可拆。只消费 `platform:firstleg:pda:view` 与 `platform:firstleg:pda:work` 权限结果,不在 PDA 自建权限语义。
- 清点组托使用测量审核预生成的 DP 托盘;扫描托盘通过 `bind-pending` 原子绑定待绑箱。幂等键按操作员、任务、载荷和版本稳定生成,失败重试复用;换托先移回待绑区再扫描目标托盘,封托不可拆分。装柜拣货扫描后等待正式整托 API 成功再更新本地结果。
## 设备、安全与可用性
@@ -56,10 +58,10 @@
| 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 |
| 工作台、仓库选择与模块入口 | `pages/home/`、`components/PortalHeader.vue`、`stores/session.js`、`pages/module/`、`pages/operation/` | 左上角只提供启用国内仓选择,选择结果持久化;`data/demo-data.js` 的 `visible` 控制展示、`available` 控制可操作;隐藏能力不得删除源码或路由 |
| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
| 一件代发 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;作为入库作业,测量审核按预计托数预生成 DP 托盘码,PDA 不展开全部托盘卡片,直接扫码并只展示当前操作托盘;全部预生成托盘码合并为每托一页的多页 A4 PDF,含 SKU 明细但不直接驱动打印机;PDA 明确执行逐箱清点并支持先扫箱或先扫托、归托和逐托封托;同一国内作业仓的多票可继续装入同一开放托盘,单票全部归托不等于托盘装满,实物装满后才封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` |
| 一件代发 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;作为入库作业,测量审核按预计托数预生成 DP 托盘码,PDA 不展开全部托盘卡片,直接扫码并只展示当前操作托盘;全部预生成托盘码合并为每托一页的多页 A4 PDF,含 SKU 明细但不直接驱动打印机;PDA 明确执行逐箱清点并支持先扫箱或先扫托、归托、换托/移回待绑托和逐托封托;同一国内作业仓的多票可继续装入同一开放托盘,单票全部归托不等于托盘装满,实物装满后才封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` |
| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId |
| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` |
| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs` |
| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` |
| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` |
| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra |
| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
+2
View File
@@ -21,6 +21,8 @@
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
- `pages/picking/picking`:出库拣货完整交互。
一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。正式入口沿用 `pages/dropship-tasks/dropship-tasks` 和 `pages/dropship-palletization/dropship-palletization`。箱码先经 `cartons/resolve` 只读识别,确认后才调用 `cartons/count`;先扫托盘时确认箱码直接绑定当前托盘,先扫多箱时箱码进入待绑托区,后扫系统托盘码通过 `pallets/bind-pending` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
+27 -7
View File
@@ -1,4 +1,5 @@
import { apiDownload, apiRequest } from '@/api/http.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { session } from '@/stores/session.js'
const ROOT = '/first-leg/pda-dropship'
@@ -7,9 +8,8 @@ function stableId(value) {
return String(value ?? '').trim()
}
function operationId(action, taskId) {
const random = Math.random().toString(36).slice(2, 10)
return `pda-dropship-${action}-${taskId}-${Date.now()}-${random}`.slice(0, 128)
function operationId(action, taskId, payload = {}) {
return stableOperationId('dropship-' + action, String(session.currentUser?.id) + '-' + taskId, payload)
}
export function listDropshipPalletizationTasks(keyword = '', warehouseId) {
@@ -33,15 +33,15 @@ export function resolveDropshipCarton(taskId, warehouseId, boxCode) {
}
export function countDropshipCarton(taskId, payload) {
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`, method: 'POST', data: { ...payload, warehouseId: stableId(payload.warehouseId), operationId: payload.operationId || operationId('count', taskId) } })
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`, method: 'POST', data: { ...payload, warehouseId: stableId(payload.warehouseId), operationId: payload.operationId || operationId('count', taskId, payload) } })
}
export function bindDropshipPendingCartons(taskId, warehouseId, palletNo) {
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId: stableId(warehouseId), palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId) } })
export function bindDropshipPendingCartons(taskId, warehouseId, palletNo, taskVersion) {
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId: stableId(warehouseId), palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId, { warehouseId, palletNo, taskVersion }) } })
}
export function sealDropshipPallet(taskId, warehouseId, pallet) {
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('seal', taskId) } })
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('seal', taskId, { warehouseId, palletId: pallet.palletId, version: pallet.version }) } })
}
export function downloadDropshipPalletLabel(taskId, warehouseId, palletId) {
@@ -69,3 +69,23 @@ export function downloadAllDropshipPalletLabels(taskId, warehouseId) {
return response.tempFilePath
})
}
function requireValue(value, message) {
if (value === null || value === undefined || value === '') throw new Error(message)
}
function operationHeader(operationId) {
return operationId ? { 'Idempotency-Key': String(operationId) } : {}
}
export function moveDropshipCarton(taskId, cartonId, payload = {}) {
requireValue(cartonId, '缺少箱明细标识')
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message)
return apiRequest({
url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/${encodeURIComponent(cartonId)}/move`,
method: 'POST',
header: operationHeader(payload.operationId),
data: { warehouseId: payload.warehouseId, targetPalletNo: payload.targetPalletNo || null, operationId: payload.operationId }
})
}
+1
View File
@@ -126,6 +126,7 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId,
const items = (actions || []).map((action) => ({
itemId: action.itemId,
action: action.type,
palletNo: action.type === 'PICK_PALLET' ? action.palletNo : null,
pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null,
finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null,
version: action.version,
@@ -54,12 +54,13 @@
</view>
<view class="section-card">
<view class="section-heading"><view><text class="section-title">2. 托盘操作</text><text class="section-desc">直接扫描托盘码,只显示当前操作的托盘</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
<view class="section-heading"><view><text class="section-title">2. 托盘操作</text><text class="section-desc">直接扫描托盘码,只显示当前操作的托盘;待绑托箱扫描目标托盘后批量归托</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
<view class="batch-print-row"><text>已生成 {{ task.generatedPalletCount || 0 }} 个托盘码</text><button class="text-button batch-print" :disabled="printing || !task.generatedPalletCount" @tap="printAllPallets">打开全部 A4 托盘单</button></view>
<view v-if="currentPallet" class="pallet-card">
<view class="pallet-head"><text class="pallet-code">{{ currentPallet.palletNo }}</text><text class="pallet-status" :class="currentPallet.status === 'SEALED' ? 'sealed' : ''">{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}</text></view>
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
<button v-if="currentPallet.palletId" class="secondary-button" :disabled="printing" @tap="printPallet(currentPallet)">{{ printing ? '正在生成 A4 文件…' : '打开当前托盘 A4 单' }}</button>
<view v-for="item in (currentPalletDetails?.cartons || [])" :key="item.cartonId" class="carton-row"><text>{{ item.boxCode }}</text><button v-if="currentPallet.status === 'OPEN'" class="text-button" :disabled="submitting" @tap="moveCarton(item, null)">移回待绑托</button></view>
<button v-if="currentPallet.palletId && currentPallet.status === 'OPEN'" class="primary-button" :disabled="!currentPallet.cartonCount || submitting" @tap="sealPallet(currentPallet)">封托</button>
</view>
</view>
@@ -71,9 +72,13 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, downloadAllDropshipPalletLabels, downloadDropshipPalletLabel, getDropshipPalletizationTask, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, downloadAllDropshipPalletLabels, downloadDropshipPalletLabel, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { formatApiError } from '@/utils/user-facing.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { isOnline } from '@/utils/network.js'
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
import { createDropshipScanState, planDropshipScan, DROPSHIP_SCAN_INTENT } from '@/utils/dropship-palletizing-state.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
@@ -89,17 +94,17 @@
currentPallet() { return this.currentPalletDetails?.status === 'OPEN' ? this.currentPalletDetails : (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) }
},
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTask() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = draft?.activePalletNo || ''; this.startScanner(); this.loadTask() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const code = String(value).trim().toUpperCase(); if (code.startsWith('DP-')) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
handleScan(value) { if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
resetMessages() { this.error = ''; this.notice = '' },
async loadTask() {
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
this.loading = true; this.resetMessages()
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId) }
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') }
finally { this.loading = false }
},
@@ -123,10 +128,17 @@
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
const code = String(value || this.palletCode || '').trim().toUpperCase(); const pallet = (this.task?.pallets || []).find((item) => item.palletNo === code)
if (pallet && pallet.status !== 'OPEN') { this.error = '托盘已封托,不能继续归托'; return }
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code), `待归托箱已绑定至 ${code}`)
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code, this.task.version), `待归托箱已绑定至 ${code}`)
else this.notice = pallet ? `当前托盘:${code}` : `已选择仓库开放托盘 ${code},清点箱码时将校验并归托`
},
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } },
async moveCarton(carton, targetPalletNo) {
if (this.submitting || !isOnline() || !this.palletSetupReady) return
const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: carton.cartonId, taskVersion: this.task.version, ...payload })
await this.runMutation(() => moveDropshipCarton(this.taskId, carton.cartonId, payload), targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
},
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}` }) },
async sealPallet(pallet) { const result = await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`); if (result && this.selectedPalletNo === pallet.palletNo) { this.selectedPalletId = ''; this.selectedPalletNo = ''; this.palletCode = '' } },
async printPallet(pallet) {
@@ -161,6 +173,7 @@
},
async runMutation(action, message) {
if (this.submitting) return null
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return null }
this.submitting = true; this.resetMessages()
try {
const result = await action()
+1
View File
@@ -24,6 +24,7 @@
import { session, requireSession, selectWarehouse } from '@/stores/session.js'
const ICON_TYPES = {
'一件代发清点组托': 'download',
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
'一件代发': 'map', '出库拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
'库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose',
+14 -2
View File
@@ -524,6 +524,10 @@
if (!requireSession()) return
this.currentUser = session.currentUser
if (options.taskId) {
// Session display state can belong to the task opened before this route.
// Only the scoped draft restored after loading this task may restore its history.
this.scanValue = ''
this.scanHistory = []
// A local completion marker belongs to the previously opened task. The
// canonical server detail decides whether this task is complete.
this.completedBoxes = {}
@@ -540,6 +544,8 @@
},
async onShow() {
if (!requireSession()) return
if (this._pageVisible) return this._showRefreshPromise
this._pageVisible = true
this.currentUser = session.currentUser
this.startHardwareScanner()
if (this.cameraPicking) {
@@ -551,10 +557,16 @@
this.debugEvidence('preview.resume', { evidenceIdentity: this.evidenceIdentity })
return
}
await this.refreshAvailableBoxes()
await this.syncRemoteTask()
if (this._showRefreshPromise) return this._showRefreshPromise
this._showRefreshPromise = (async () => {
await this.refreshAvailableBoxes()
await this.syncRemoteTask()
})()
try { await this._showRefreshPromise }
finally { this._showRefreshPromise = null }
},
onHide() {
this._pageVisible = false
if (this.evidencePreviewOpening) {
this.evidencePreviewOpening = false
this.evidencePreviewReturning = true
+36
View File
@@ -0,0 +1,36 @@
const STORAGE_PREFIX = 'pda-dropship-palletizing-draft'
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
function text(value) {
return value == null ? '' : String(value)
}
export function getDropshipPalletizingDraftKey(identity = {}) {
const parts = [text(identity.operatorId), text(identity.taskId), text(identity.warehouseId)]
return parts.every(Boolean) ? `${STORAGE_PREFIX}:${parts.map(encodeURIComponent).join(':')}` : ''
}
export function saveDropshipPalletizingDraft(identity, state) {
const key = getDropshipPalletizingDraftKey(identity)
if (!key || typeof uni === 'undefined' || typeof uni.setStorageSync !== 'function') return false
uni.setStorageSync(key, { identity: { ...identity }, state: clone(state), updatedAt: new Date().toISOString() })
return true
}
export function restoreDropshipPalletizingDraft(identity) {
const key = getDropshipPalletizingDraftKey(identity)
if (!key || typeof uni === 'undefined' || typeof uni.getStorageSync !== 'function') return null
const saved = uni.getStorageSync(key)
if (!saved?.state || text(saved.identity?.operatorId) !== text(identity.operatorId) || text(saved.identity?.taskId) !== text(identity.taskId) || text(saved.identity?.warehouseId) !== text(identity.warehouseId)) return null
return clone(saved.state)
}
export function clearDropshipPalletizingDraft(identity) {
const key = getDropshipPalletizingDraftKey(identity)
if (!key || typeof uni === 'undefined' || typeof uni.removeStorageSync !== 'function') return false
uni.removeStorageSync(key)
return true
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
const page = await read('../pages/dropship-palletization/dropship-palletization.vue')
const api = await read('../api/pda-dropship.js')
test('pending cartons bind to pregenerated pallets with stable retry identity', async () => {
const { stableOperationId } = await import('../utils/idempotency.js')
const payload = { warehouseId: '9007199254740993', palletNo: 'DP-260914-0001', taskVersion: 7 }
assert.equal(stableOperationId('dropship-bind', 'u-task', payload), stableOperationId('dropship-bind', 'u-task', { ...payload }))
assert.notEqual(stableOperationId('dropship-bind', 'u-task', payload), stableOperationId('dropship-bind', 'u-task', { ...payload, taskVersion: 8 }))
assert.match(api, /pallets\/bind-pending/)
assert.doesNotMatch(api, /create-and-bind/)
assert.match(page, /bindDropshipPendingCartons\(this.taskId, this.warehouseId, code, this.task.version\)/)
})
test('palletization uses the test workbench and canonical routes', async () => {
const pages = await read('../pages.json')
assert.match(pages, /pages\/dropship-tasks\/dropship-tasks/)
assert.match(pages, /pages\/dropship-palletization\/dropship-palletization/)
assert.doesNotMatch(pages, /dropship-palletizing/)
assert.match(await read('../pages/home/home.vue'), /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
})
test('formal palletization preserves both scan orders, moves and sealing guards', () => {
for (const symbol of ['resolveDropshipCarton', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
assert.match(page, /移回待绑托/)
assert.match(page, /palletSetupReady/)
assert.match(page, /pallet.status !== 'OPEN'/)
assert.match(page, /currentPalletDetails\?\.cartons/)
assert.doesNotMatch(page, /模拟成功|actualSkuQuantity/)
})
test('palletization preserves compact multi-SKU input and printing', () => {
assert.match(page, /v-for="\(line, index\) in skuCounts"/)
assert.match(page, /quantity-stepper/)
assert.match(page, /printAllPallets/)
assert.match(page, /printPallet/)
})
test('count and move use the formal multi-SKU contract with versioned retries', () => {
for (const path of ['cartons/count', 'pallets/bind-pending', '/move', '/seal']) assert.ok(api.includes(path))
for (const field of ['skuLines', 'actualQuantity', 'discrepancyAction', 'discrepancyRemark']) assert.ok(page.includes(field))
assert.match(api, /expectedVersion: pallet.version/)
assert.match(api, /stableOperationId/)
assert.doesNotMatch(api, /actualSkuQuantity|create-and-bind/)
})
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../utils/dropship-palletizing-state.js', import.meta.url), 'utf8')
const state = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
test('pallet first selects the pallet and later carton resolution carries it', () => {
let scanState = state.createDropshipScanState()
let plan = state.planDropshipScan(scanState, 'dp-260908-0001')
assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.SELECT_PALLET)
scanState = state.releaseDropshipScan(plan.state)
plan = state.planDropshipScan(scanState, 'box-001')
assert.deepEqual({ intent: plan.intent, boxCode: plan.boxCode, palletNo: plan.palletNo }, { intent: state.DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: 'BOX-001', palletNo: 'DP-260908-0001' })
})
test('boxes first requests batch binding when a pallet is scanned with pending cartons', () => {
const plan = state.planDropshipScan(state.createDropshipScanState(), 'DP-260908-0002', { unboundCartons: 3 })
assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.BIND_PENDING)
assert.equal(plan.palletNo, 'DP-260908-0002')
})
test('offline, empty, and immediate repeated scans are blocked before API intent', () => {
assert.equal(state.planDropshipScan(state.createDropshipScanState({ online: false }), 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.BLOCKED)
assert.equal(state.planDropshipScan(state.createDropshipScanState(), ' ').intent, state.DROPSHIP_SCAN_INTENT.INVALID)
const first = state.planDropshipScan(state.createDropshipScanState(), 'BOX-001')
assert.equal(state.planDropshipScan(first.state, 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.DUPLICATE)
})
+2 -2
View File
@@ -77,8 +77,8 @@ test('picking task list uses remote loading orders and never falls back to demo
assert.match(tasks, /task\.taskId[\s\S]*?task\.loadingPlanId[\s\S]*?task\.containerBookingId[\s\S]*?task\.currentContainerNo[\s\S]*?task\.version[\s\S]*?task\.assignmentVersion/)
assert.doesNotMatch(tasks, /task\.taskStatus === 'LOADING'/)
assert.doesNotMatch(tasks, /demo-data|DEMO_/)
assert.match(home, /pages\/tasks\/tasks/)
assert.match(modulePage, /pages\/tasks\/tasks/)
assert.match(home, /routes\[operation.name\]/)
assert.match(modulePage, /routes\[operation.name\]/)
})
test('workbench, module, history and profile are independent sources', async () => {
+24
View File
@@ -6,6 +6,30 @@ const source = await readFile(new URL('../api/pda-loading.js', import.meta.url),
const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('sealed pallets use a single authoritative pallet command and prohibit partial picking', () => {
assert.match(source, /pallets\/pick/)
assert.match(page, /submitImmediateItemAction\(item, 'PICK_PALLET', \{ palletNo: item.palletNo \}\)/)
assert.match(page, /整托货物不能跳拣/)
assert.match(page, /整托货物不能部分拣货/)
assert.match(page, /expectedTaskVersion: Number\(this.pickingVersion\)/)
})
test('DP scan records success only after the atomic server command succeeds', async () => {
const method = page.match(/async pickScannedPallet\(item, palletNo\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method)
const behavior = new Function('ITEM_STATUS', 'return ({' + method + '})')({ PICKED: 'picked' })
const item = { id: '1', palletNo: 'DP-260914-0001', qty: 5 }
const calls = []
let succeeds = false
const context = { itemStates: {}, partialQuantities: {}, scannedBoxCodes: {}, async submitImmediateItemAction(...args) { calls.push(args); return succeeds }, recordScanResult() {}, showToast() {}, persistSession() {} }
assert.equal(await behavior.pickScannedPallet.call(context, item, item.palletNo), false)
assert.deepEqual(context.itemStates, {})
succeeds = true
assert.equal(await behavior.pickScannedPallet.call(context, item, item.palletNo), true)
assert.deepEqual(context.itemStates, { 1: 'picked' })
assert.deepEqual(calls[0], [item, 'PICK_PALLET', { palletNo: item.palletNo }])
assert.deepEqual(calls[0], calls[1])
})
test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => {
for (const path of [
'/first-leg/pda-loading/tasks/pending'
+64
View File
@@ -4,6 +4,70 @@ import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('opening another task clears prior scan display without deleting scoped draft or submission identity', async () => {
const page = await read('../pages/picking/picking.vue')
const load = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
const record = page.match(/recordScanResult\([^\n]+\) \{[\s\S]*?\r?\n\t\t\t\},/)?.[0]
assert.ok(load && record)
const hooks = new Function('requireSession', 'session', `return ({${load}${record}})`)(() => true, { currentUser: { id: '7' } })
const savedDraft = { 0: { scannedBoxCodes: { 'DP-OLD': 'old-item' }, itemStates: { 'old-item': 'picked' } } }
const context = { scanValue: 'DP-OLD', scanHistory: [{ id: 'old', boxCode: 'DP-OLD', title: '整托已记入草稿' }],
boxDrafts: savedDraft, submitId: 'existing-operation', itemStates: { 'old-item': 'picked' } }
hooks.onLoad.call(context, { taskId: 'new-task', warehouseId: 'warehouse' })
assert.equal(context.scanValue, '')
assert.deepEqual(context.scanHistory, [])
assert.equal(context.remoteTaskId, 'new-task')
assert.equal(context.boxDrafts, savedDraft)
assert.equal(context.submitId, 'existing-operation')
assert.deepEqual(context.itemStates, { 'old-item': 'picked' })
hooks.recordScanResult.call(context, 'success', '整托已记入草稿', 'DP-NEW', '整托 1 箱,提交后保存')
assert.equal(context.scanHistory.length, 1)
assert.equal(context.scanHistory[0].boxCode, 'DP-NEW')
// A same-task route reopen also clears input; canonical scoped restoration still owns draft history.
hooks.onLoad.call(context, { taskId: 'new-task', warehouseId: 'warehouse' })
assert.deepEqual(context.scanHistory, [])
assert.equal(context.boxDrafts, savedDraft)
const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\r?\n\t\t\t\},/)?.[0] || ''
assert.match(restore, /restorePickingSession\(this\.draftIdentity\(\)\)/)
assert.match(restore, /this\.scanHistory = this\.normalizeScanHistory\(restored\.scanHistory\)/)
})
test('duplicate onShow shares one load chain while background return refreshes again', async () => {
const page = await read('../pages/picking/picking.vue')
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
assert.ok(show && hide)
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } })
const calls = []
let release
const pending = new Promise((resolve) => { release = resolve })
const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
const first = hooks.onShow.call(context)
const duplicate = hooks.onShow.call(context)
assert.deepEqual(calls, ['pending'])
release()
await Promise.all([first, duplicate])
assert.deepEqual(calls, ['pending', 'detail+evidence'])
await hooks.onShow.call(context)
assert.equal(calls.length, 2)
hooks.onHide.call(context)
await hooks.onShow.call(context)
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
assert.doesNotMatch(onLoad, /refreshAvailableBoxes|syncRemoteTask/)
})
test('camera and evidence preview resume still avoid unnecessary refreshes', async () => {
const page = await read('../pages/picking/picking.vue')
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
const hooks = new Function('requireSession', 'session', `return ({${show}})`)(() => true, { currentUser: {} })
for (const flag of ['cameraPicking', 'evidencePreviewReturning']) {
const context = { [flag]: true, startHardwareScanner() {}, debugEvidence() {}, refreshAvailableBoxes() { throw new Error('unexpected refresh') } }
await hooks.onShow.call(context)
assert.equal(context._pageVisible, true)
}
})
test('direct H5 refresh restores the page user before rendering picking content', async () => {
const page = await read('../pages/picking/picking.vue')
+20
View File
@@ -16,6 +16,26 @@ test('inventory resources expose distinct locations and total available cartons'
assert.deepEqual(state.summarizeInventoryResources(null), { location: '', availablePackageCount: null })
})
test('sealed pallet matches its DP code and resolves member cartons as an atomic pallet', () => {
const pallet = { id: '1', unitType: 'SEALED_PALLET', indivisible: true, palletNo: 'DP-260909-0143', boxCodes: ['IN#2-1', 'IN#2-2'], qty: 2 }
assert.equal(state.classifyScan([pallet], {}, 'IN#2-1').type, 'pallet')
assert.equal(state.classifyScan([pallet], {}, pallet.palletNo).type, 'pallet')
assert.equal(state.classifyScan([pallet], { 1: state.ITEM_STATUS.PICKED }, pallet.palletNo).type, 'processed')
})
test('only the authoritative sealed-pallet unit type selects pallet behavior', () => {
assert.equal(state.isSealedPalletItem({ unitType: 'SEALED_PALLET', indivisible: true }), true)
assert.equal(state.isSealedPalletItem({ unitType: 'PALLET', indivisible: true }), false)
assert.equal(state.isSealedPalletItem({ unitType: 'PACKAGE', indivisible: true }), false)
})
test('mixed task keeps package scanning separate from sealed pallet scanning', () => {
const mixed = [{ id: '1', unitType: 'SEALED_PALLET', palletNo: 'DP-260909-0001', boxCodes: ['BOX-1'] }, { id: '2', unitType: 'PACKAGE', boxCodes: ['BOX-2'] }]
assert.equal(state.classifyScan(mixed, {}, 'BOX-2').item.id, '2')
assert.equal(state.classifyScan(mixed, {}, 'DP-260909-0001').item.id, '1')
assert.equal(state.classifyScan(mixed, {}, 'BOX-1').type, 'pallet')
})
test('createItemStates initializes every item as pending', () => {
assert.deepEqual(state.createItemStates(items), {
1: state.ITEM_STATUS.PENDING,
+40
View File
@@ -0,0 +1,40 @@
export const DROPSHIP_SCAN_INTENT = Object.freeze({
BLOCKED: 'BLOCKED',
INVALID: 'INVALID',
DUPLICATE: 'DUPLICATE',
RESOLVE_CARTON: 'RESOLVE_CARTON',
SELECT_PALLET: 'SELECT_PALLET',
BIND_PENDING: 'BIND_PENDING'
})
export const PALLET_NO_PATTERN = /^DP-[0-9]{6}-[0-9]{4}$/
function normalize(value) {
return value == null ? '' : String(value).trim().toUpperCase()
}
export function createDropshipScanState(value = {}) {
return {
online: value.online !== false,
activePalletNo: normalize(value.activePalletNo),
pendingBoxCode: normalize(value.pendingBoxCode),
lastScanCode: normalize(value.lastScanCode)
}
}
export function planDropshipScan(currentState, rawCode, { unboundCartons = 0 } = {}) {
const state = createDropshipScanState(currentState)
const scanCode = normalize(rawCode)
if (!state.online) return { state, intent: DROPSHIP_SCAN_INTENT.BLOCKED, message: '当前网络不可用,不能执行正式扫码' }
if (!scanCode) return { state, intent: DROPSHIP_SCAN_INTENT.INVALID, message: '扫码内容不能为空' }
if (scanCode === state.lastScanCode) return { state, intent: DROPSHIP_SCAN_INTENT.DUPLICATE, message: '请勿重复扫描同一码' }
if (PALLET_NO_PATTERN.test(scanCode)) {
const next = { ...state, activePalletNo: scanCode, lastScanCode: scanCode }
return { state: next, intent: Number(unboundCartons) > 0 ? DROPSHIP_SCAN_INTENT.BIND_PENDING : DROPSHIP_SCAN_INTENT.SELECT_PALLET, palletNo: scanCode }
}
return { state: { ...state, pendingBoxCode: scanCode, lastScanCode: scanCode }, intent: DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: scanCode, palletNo: state.activePalletNo || null }
}
export function releaseDropshipScan(state, { clearBox = false } = {}) {
return { ...createDropshipScanState(state), pendingBoxCode: clearBox ? '' : state.pendingBoxCode, lastScanCode: '' }
}
-2
View File
@@ -7,8 +7,6 @@ export const ITEM_STATUS = Object.freeze({
export function isSealedPalletItem(item) {
return item?.unitType === 'SEALED_PALLET'
|| item?.palletId !== null && item?.palletId !== undefined
|| item?.indivisible === true
}
export function summarizeInventoryResources(resources) {
+1 -1
View File
@@ -1,4 +1,4 @@
const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi
const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|cartonId|palletId|warehouseId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID|箱明细ID|托盘ID|仓库ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi
const LONG_INTERNAL_NUMBER_PATTERN = /(^|[^A-Za-z0-9])\d{18,20}(?=$|[^A-Za-z0-9])/g
export function displayBusinessValue(value, missing = '—') {