feat: add dropship palletization workflow

This commit is contained in:
2026-09-08 17:24:52 +08:00
parent 86e49d5edc
commit 0745e9600c
15 changed files with 405 additions and 13 deletions
+2
View File
@@ -26,6 +26,7 @@
- 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。
- 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 自建权限语义。
## 设备、安全与可用性
@@ -57,6 +58,7 @@
| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
| 业务编号展示与错误脱敏 | `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/dropship-palletizing/tasks.vue`、`pages/dropship-palletizing/dropship-palletizing.vue` | `api/pda-dropship.js`、`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` |
| 装柜取证与完成 | `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 |
+5 -1
View File
@@ -8,7 +8,7 @@
国内货柜履约装柜取证已具备接口适配基础:拣货页展示空仓、半仓、满仓、锁仓和带锁码照五类证据,经 File 服务上传取得真实 `fileId`,并展示待上传、上传中、失败、成功和重试状态。产品已确认 PDA 只能现场拍照,Admin 可人工上传;每类至少 1 张有效照片、同类型可多张追加,错误或重复照片只作废不硬删除。当前页面和上传队列仍按会话内单类型记录设计,尚未完整达到“多图追加+作废审计”契约。
登录后默认进入首页,不再直接进入拣货。首页采用入库、出库、库内作业、查询四个分组的三列宫格,不设置常驻底部导航;作业记录收纳在左上角功能菜单,个人中心从右上角人员入口进入。第一版本只有“出库 → 集装箱拣货”可点击并具备完整本地状态交互,其余业务入口统一置灰并禁用,保留在界面中用于确认后续信息架构。
登录后默认进入首页,不再直接进入拣货。首页采用入库、出库、库内作业、查询四个分组的三列宫格,不设置常驻底部导航;作业记录收纳在左上角功能菜单,个人中心从右上角人员入口进入。当前正式入口包括“出库 → 集装箱拣货”和“入库 → 一件代发清点组托”,其余业务入口统一置灰并禁用。
## 页面与状态结构
@@ -20,6 +20,10 @@
- `pages/profile/profile`:人员、班次、仓库信息及退出登录;
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
- `pages/picking/picking`:集装箱拣货完整交互。
- `pages/dropship-palletizing/tasks`:一件代发待清点组托任务、领取与继续作业;
- `pages/dropship-palletizing/dropship-palletizing`:逐箱识别、SKU 实收确认、待绑托批量绑定、换托与封托。
一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。箱码先经 `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`。
+76
View File
@@ -0,0 +1,76 @@
import { apiRequest } from '@/api/http.js'
const ROOT = '/first-leg/pda-dropship'
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 listDropshipPalletizingTasks(keyword = '') {
const query = keyword ? `?keyword=${encodeURIComponent(String(keyword).trim())}` : ''
return apiRequest({ url: `${ROOT}/tasks/pending${query}`, method: 'GET' })
}
export function getDropshipPalletizingTask(taskId, warehouseId) {
requireValue(taskId, '缺少清点组托任务标识')
requireValue(warehouseId, '缺少作业仓库标识')
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`, method: 'GET' })
}
export function resolveDropshipCarton(taskId, warehouseId, boxCode) {
requireValue(taskId, '缺少清点组托任务标识')
requireValue(warehouseId, '缺少作业仓库标识')
requireValue(boxCode, '缺少箱码')
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/resolve?warehouseId=${encodeURIComponent(warehouseId)}&boxCode=${encodeURIComponent(String(boxCode).trim())}`, method: 'GET' })
}
export function claimDropshipPalletizingTask(taskId, warehouseId) {
requireValue(taskId, '缺少清点组托任务标识')
requireValue(warehouseId, '缺少作业仓库标识')
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/claim`, method: 'POST', data: { warehouseId } })
}
export function countDropshipCarton(taskId, payload = {}) {
requireValue(taskId, '缺少清点组托任务标识')
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['boxCode', '缺少箱码'], ['actualSkuQuantity', '缺少 SKU 实收数量'], ['discrepancyAction', '缺少差异处理动作'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message)
return apiRequest({
url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`,
method: 'POST',
header: operationHeader(payload.operationId),
data: {
warehouseId: payload.warehouseId,
boxCode: payload.boxCode,
actualSkuQuantity: payload.actualSkuQuantity,
discrepancyAction: payload.discrepancyAction,
discrepancyRemark: payload.discrepancyRemark || null,
palletNo: payload.palletNo || null,
operationId: payload.operationId
}
})
}
export function bindPendingDropshipCartons(taskId, payload = {}) {
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['palletNo', '缺少托盘码'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message)
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', header: operationHeader(payload.operationId), data: payload })
}
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 }
})
}
export function sealDropshipPallet(taskId, palletId, payload = {}) {
requireValue(palletId, '缺少托盘标识')
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['operationId', '缺少操作幂等标识'], ['expectedVersion', '缺少托盘版本']]) requireValue(payload[field], message)
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(palletId)}/seal`, method: 'POST', header: operationHeader(payload.operationId), data: payload })
}
+3 -2
View File
@@ -7,13 +7,14 @@ export const DEMO_ACCOUNTS = [
]
export const MODULES = [
{ key: 'inbound', name: '入库', icon: '入', description: '收货、上架与质检', meta: '3 个静态页面', operations: [
{ key: 'inbound', name: '入库', icon: '入', description: '收货、上架与质检', meta: '1 个正式入口 · 3 个未开放', operations: [
{ name: '一件代发清点组托', icon: '托', description: '逐箱清点并按任意扫码顺序完成组托', available: true, route: '/pages/dropship-palletizing/tasks' },
{ name: '到货收货', icon: '收', description: '扫描到货单并确认实收数量' },
{ name: '商品上架', icon: '上', description: '按推荐库位完成商品上架' },
{ name: '入库质检', icon: '检', description: '登记质检结果与异常信息' }
] },
{ key: 'outbound', name: '出库', icon: '出', description: '拣货、复核与装箱', meta: '3 个待办', operations: [
{ name: '集装箱拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true },
{ name: '集装箱拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true, route: '/pages/tasks/tasks' },
{ name: '出库复核', icon: '核', description: '复核拣货商品与出库数量' },
{ name: '装箱交接', icon: '装', description: '完成装箱并登记交接信息' }
] },
+3 -1
View File
@@ -8,7 +8,9 @@
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/picking/picking", "style": { "navigationStyle": "custom", "navigationBarTitleText": "集装箱拣货", "backgroundColor": "#F5F7FA" } }
{ "path": "pages/picking/picking", "style": { "navigationStyle": "custom", "navigationBarTitleText": "集装箱拣货", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/dropship-palletizing/tasks", "style": { "navigationStyle": "custom", "navigationBarTitleText": "一件代发任务", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/dropship-palletizing/dropship-palletizing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "一件代发清点组托", "backgroundColor": "#F5F7FA" } }
],
"globalStyle": {
"navigationBarTextStyle": "white",
@@ -0,0 +1,81 @@
<template>
<view v-if="user" class="page-shell dropship-page">
<PortalHeader title="一件代发清点组托" :user="user" fallback-url="/pages/dropship-palletizing/tasks" />
<view v-if="!online" class="network-warning" role="status">当前网络不可用,不能扫码、确认、换托或封托;联网后请刷新。</view>
<scroll-view scroll-y class="work-scroll">
<view v-if="error" class="message-card error" role="alert">{{ error }}</view>
<view v-if="notice" class="message-card" role="status">{{ notice }}</view>
<view v-if="loading && !task" class="loading-card">正在加载真实任务...</view>
<template v-if="task">
<view class="task-card"><view><text class="minor">入仓码</text><text class="task-code">{{ task.inboundCode || '—' }}</text><text class="minor">{{ task.customerName || '客户 —' }} · {{ task.warehouseName || '仓库 —' }}</text></view><text class="badge">{{ statusLabel(task.taskStatus) }}</text></view>
<view class="summary-grid"><view><text>{{ task.countedCartons || 0 }}/{{ task.totalCartons || 0 }}</text><text>已清点</text></view><view><text>{{ task.unboundCartons || 0 }}</text><text>待绑托</text></view><view><text>{{ sealedPalletCount }}</text><text>已封托</text></view></view>
<view class="panel">
<text class="panel-title">扫描箱码或系统托盘码</text><text class="panel-copy">先扫托盘:后续箱确认后直接绑定;先扫多箱:后扫托盘一次批量绑定。</text>
<ScanTargetInput v-model="scanValue" name="dropshipCode" label="箱码或托盘码" placeholder="扫描箱码 / DP 托盘码" :active="true" @submit="handleManualScan" />
<view class="active-pallet"><text>当前托盘</text><text>{{ activePalletNo || '未选择(箱将进入待绑托区)' }}</text></view>
</view>
<view v-if="resolvedCarton" class="panel">
<view class="heading"><view><text class="panel-title">{{ resolvedCarton.boxCode }}</text><text class="minor">入仓码 {{ resolvedCarton.inboundCode || '—' }}</text></view><text class="badge">待确认</text></view>
<view class="sku-card"><view><text class="minor">SKU</text><text class="sku-code">{{ resolvedCarton.skuCode || '—' }}</text></view><view class="expected"><text class="minor">应收</text><text>{{ resolvedCarton.expectedSkuQuantity }}</text></view></view>
<text class="quantity-label">SKU 实收数量</text><view class="quantity-control"><button @tap="changeQuantity(-1)">−</button><input v-model="actualSkuQuantity" type="number" aria-label="SKU 实收数量" /><button @tap="changeQuantity(1)">+</button></view>
<view v-if="hasDifference" class="difference-box"><text>实收与应收不一致,请确认接受差异并填写处理结果。</text><label><checkbox :checked="acceptVariance" @tap="acceptVariance = !acceptVariance" />接受本次数量差异</label><textarea v-model="discrepancyRemark" maxlength="500" placeholder="填写差异原因及处理结果" /></view>
<button class="primary-button" :disabled="busy || !online || !canConfirmCarton" @tap="confirmCarton">确认本箱实收</button>
</view>
<view class="panel"><view class="heading"><text class="panel-title">待绑托箱</text><text class="badge">{{ task.unboundCartons || 0 }}</text></view><view v-if="!unboundCartons.length" class="empty">暂无待绑托箱</view><view v-for="carton in unboundCartons" :key="carton.cartonId" class="carton-row"><view><text>{{ carton.boxCode }}</text><text>{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}</text></view></view></view>
<view class="panel"><view class="heading"><text class="panel-title">托盘</text><text class="minor">{{ pallets.length }} 托</text></view><view v-if="!pallets.length" class="empty">扫描 DP 托盘码开始组托</view>
<view v-for="pallet in pallets" :key="pallet.palletId" class="pallet-card" :class="{ active: pallet.palletNo === activePalletNo }">
<view class="heading"><view><text class="pallet-code">{{ pallet.palletNo }}</text><text class="minor">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU</text></view><text class="badge" :class="{ sealed: pallet.status === 'SEALED' }">{{ pallet.status === 'SEALED' ? '已封托' : '组托中' }}</text></view>
<view v-if="pallet.weightKg != null || pallet.volumeCbm != null" class="measure-row"><text>重量 {{ pallet.weightKg ?? '—' }} kg</text><text>体积 {{ pallet.volumeCbm ?? '—' }} m³</text></view>
<view v-for="carton in palletCartons(pallet)" :key="carton.cartonId || carton.boxCode" class="carton-row"><view><text>{{ carton.boxCode }}</text><text>{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}</text></view><button v-if="pallet.status !== 'SEALED'" :disabled="busy || !online" @tap="moveCarton(carton, activePalletNo && activePalletNo !== pallet.palletNo ? activePalletNo : null)">{{ activePalletNo && activePalletNo !== pallet.palletNo ? '换至当前托' : '移回待绑托' }}</button></view>
<button v-if="pallet.status !== 'SEALED'" class="secondary-button" :disabled="busy || !online || !canSeal" @tap="sealPallet(pallet)">封托</button><text v-if="pallet.status !== 'SEALED' && !canSeal" class="guard">{{ sealBlockReason }}</text>
</view>
</view>
</template><view class="bottom-space"></view>
</scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { bindPendingDropshipCartons, countDropshipCarton, getDropshipPalletizingTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { createDropshipScanState, DROPSHIP_SCAN_INTENT, planDropshipScan, releaseDropshipScan } from '@/utils/dropship-palletizing-state.js'
import { watchNetwork } from '@/utils/network.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { formatApiError } from '@/utils/user-facing.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, loading: false, busy: false, online: true, error: '', notice: '', scanValue: '', scanState: createDropshipScanState(), activePalletNo: '', resolvedCarton: null, actualSkuQuantity: '', acceptVariance: false, discrepancyRemark: '', _stopNetwork: null, _stopScanner: null } },
computed: {
pallets() { return this.task?.pallets || [] }, unboundCartons() { return this.task?.unbound || [] }, sealedPalletCount() { return this.pallets.filter((pallet) => pallet.status === 'SEALED').length },
hasDifference() { return this.resolvedCarton ? Number(this.actualSkuQuantity) !== Number(this.resolvedCarton.expectedSkuQuantity) : false },
canConfirmCarton() { return Boolean(this.resolvedCarton && Number.isInteger(Number(this.actualSkuQuantity)) && Number(this.actualSkuQuantity) > 0 && (!this.hasDifference || (this.acceptVariance && this.discrepancyRemark.trim()))) },
canSeal() { return Boolean(this.task && Number(this.task.countedCartons) === Number(this.task.totalCartons) && Number(this.task.unboundCartons) === 0) },
sealBlockReason() { if (Number(this.task?.countedCartons) !== Number(this.task?.totalCartons)) return '仍有箱码未完成清点'; if (Number(this.task?.unboundCartons) > 0) return '仍有已清点箱未绑定托盘'; return '' },
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } }
},
onLoad(options) { if (!requireSession()) return; this.user = session.currentUser; this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || ''); const draft = restoreDropshipPalletizingDraft(this.draftIdentity); if (draft?.activePalletNo) this.activePalletNo = draft.activePalletNo; this.scanState = createDropshipScanState({ activePalletNo: this.activePalletNo }); this._stopNetwork = watchNetwork((value) => { this.online = value; this.scanState.online = value }) },
onShow() { if (!this.taskId || !this.warehouseId) { this.error = '任务参数缺失,请返回任务列表刷新'; return }; this.startScanner(); this.loadTask() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner(); if (this._stopNetwork) this._stopNetwork() },
methods: {
statusLabel(status) { return status === 'COMPLETED' ? '已完成' : status === 'CLAIMED' ? '清点组托中' : '待领取' }, palletCartons(pallet) { return pallet.cartons || [] },
startScanner() { if (!this._stopScanner) this._stopScanner = startScanBroadcast({ onScan: (value) => this.processScan(value), onError: () => playErrorSound() }) }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async loadTask() { if (!this.online) { this.error = '当前网络不可用,请联网后刷新'; return }; this.loading = true; this.error = ''; try { this.task = await getDropshipPalletizingTask(this.taskId, this.warehouseId); if (this.task?.taskStatus === 'COMPLETED') { this.activePalletNo = ''; this.scanState = createDropshipScanState(); clearDropshipPalletizingDraft(this.draftIdentity) } } catch (error) { this.error = formatApiError(error, '清点组托任务加载失败') } finally { this.loading = false } },
handleManualScan(value) { this.processScan(value) },
async processScan(value) { if (this.busy) return; const plan = planDropshipScan(this.scanState, value, { unboundCartons: this.task?.unboundCartons }); this.scanState = plan.state; this.scanValue = String(value || '').trim().toUpperCase(); this.error = ''; this.notice = ''; if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID, DROPSHIP_SCAN_INTENT.DUPLICATE].includes(plan.intent)) { this.error = plan.message; playErrorSound(); return }; if ([DROPSHIP_SCAN_INTENT.SELECT_PALLET, DROPSHIP_SCAN_INTENT.BIND_PENDING].includes(plan.intent)) { await this.selectPallet(plan.palletNo); this.scanState = releaseDropshipScan(this.scanState); return }; this.busy = true; try { const carton = await resolveDropshipCarton(this.taskId, this.warehouseId, plan.boxCode); if (carton.countStatus === 'BOUND') { this.notice = `箱码 ${plan.boxCode} 已绑定托盘 ${carton.palletNo || '—'},请勿重复扫描`; playErrorSound(); return this.loadTask() }; if (carton.countStatus === 'COUNTED_UNBOUND') { this.notice = `箱码 ${plan.boxCode} 已清点,当前位于待绑托区`; playErrorSound(); return this.loadTask() }; if (carton.canCount === false) { this.error = carton.blockReason || '当前箱码不可清点,请刷新任务'; playErrorSound(); return }; this.resolvedCarton = carton; this.actualSkuQuantity = String(carton.expectedSkuQuantity); this.acceptVariance = false; this.discrepancyRemark = ''; playSuccessSound() } catch (error) { this.error = formatApiError(error, '箱码识别失败'); playErrorSound() } finally { this.busy = false; this.scanState = releaseDropshipScan(this.scanState) } },
async selectPallet(palletNo) { this.activePalletNo = palletNo; this.scanState.activePalletNo = palletNo; this.persistDraft(); if (!Number(this.task?.unboundCartons || 0)) { this.notice = `当前托盘已切换为 ${palletNo}`; playSuccessSound(); return }; this.busy = true; const operationId = stableOperationId('dropship-bind', `${this.user?.id}-${this.taskId}`, { palletNo, cartonIds: this.unboundCartons.map((carton) => carton.cartonId) }); try { this.task = await bindPendingDropshipCartons(this.taskId, { warehouseId: this.warehouseId, palletNo, operationId }); this.notice = `待绑托箱已批量绑定到 ${palletNo}`; playSuccessSound() } catch (error) { const message = formatApiError(error, '批量绑定失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
changeQuantity(delta) { this.actualSkuQuantity = String(Math.max(1, Number(this.actualSkuQuantity || 0) + delta)) },
async confirmCarton() { if (!this.canConfirmCarton || this.busy) return; const payload = { warehouseId: this.warehouseId, boxCode: this.resolvedCarton.boxCode, actualSkuQuantity: Number(this.actualSkuQuantity), discrepancyAction: this.hasDifference ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.hasDifference ? this.discrepancyRemark.trim() : null, palletNo: this.activePalletNo || null }; payload.operationId = stableOperationId('dropship-count', `${this.user?.id}-${this.taskId}`, payload); this.busy = true; this.error = ''; try { await countDropshipCarton(this.taskId, payload); this.notice = this.activePalletNo ? `本箱已清点并绑定到 ${this.activePalletNo}` : '本箱已清点,等待绑定托盘'; this.resolvedCarton = null; this.actualSkuQuantity = ''; this.scanValue = ''; await this.loadTask(); playSuccessSound() } catch (error) { const message = formatApiError(error, '实收确认失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
async moveCarton(carton, targetPalletNo) { if (targetPalletNo === carton.palletNo) return; const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }; payload.operationId = stableOperationId('dropship-move', `${this.user?.id}-${this.taskId}-${carton.cartonId}`, payload); this.busy = true; this.error = ''; try { this.task = await moveDropshipCarton(this.taskId, carton.cartonId, payload); this.notice = targetPalletNo ? `箱码 ${carton.boxCode} 已换至 ${targetPalletNo}` : `箱码 ${carton.boxCode} 已移回待绑托区`; playSuccessSound() } catch (error) { const message = formatApiError(error, '换托操作失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
async sealPallet(pallet) { if (!this.canSeal || this.busy) return; const payload = { warehouseId: this.warehouseId, expectedVersion: Number(pallet.version) }; payload.operationId = stableOperationId('dropship-seal', `${this.user?.id}-${this.taskId}-${pallet.palletId}`, payload); this.busy = true; this.error = ''; try { this.task = await sealDropshipPallet(this.taskId, pallet.palletId, payload); if (this.activePalletNo === pallet.palletNo) this.activePalletNo = ''; this.persistDraft(); if (this.task?.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity); this.notice = `托盘 ${pallet.palletNo} 已封托,后续按整托不可拆分`; playSuccessSound() } catch (error) { const message = formatApiError(error, '封托失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
persistDraft() { saveDropshipPalletizingDraft(this.draftIdentity, { activePalletNo: this.activePalletNo }) }
}
}
</script>
<style scoped>
.dropship-page{min-width:0;overflow:hidden;background:#f5f7fa}.work-scroll{box-sizing:border-box;height:calc(100vh - 56px);padding:10px 10px 32px}.network-warning{padding:8px 10px;color:#8a2c0d;font-size:12px;line-height:18px;background:#fff1e8}.message-card,.loading-card{margin-bottom:9px;padding:10px 12px;border-radius:8px;color:#155724;font-size:12px;line-height:18px;background:#edf8f0}.message-card.error{color:#9b2c2c;background:#fff0f0}.loading-card{color:#64748b;text-align:center;background:#fff}.panel,.task-card{box-sizing:border-box;width:100%;margin-bottom:9px;padding:12px;border:1px solid #e1e7ef;border-radius:11px;background:#fff}.task-card,.heading{display:flex;min-width:0;align-items:flex-start;justify-content:space-between;gap:8px}.minor,.panel-copy,.guard{display:block;color:#667085;font-size:11px;line-height:17px}.task-code,.panel-title,.pallet-code,.sku-code{display:block;min-width:0;color:#22344a;font-size:16px;font-weight:800;overflow-wrap:anywhere}.badge{flex-shrink:0;padding:4px 7px;border-radius:999px;color:#155eef;font-size:10px;font-weight:700;background:#eff4ff}.badge.sealed{color:#137333;background:#e8f5ec}.summary-grid{display:grid;margin-bottom:9px;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.summary-grid view{padding:9px 4px;border-radius:9px;text-align:center;background:#fff}.summary-grid text{display:block;color:#22344a;font-size:17px;font-weight:800}.summary-grid text+text{color:#718096;font-size:10px;font-weight:500}.panel-copy{margin:3px 0 8px}.active-pallet{display:flex;margin-top:8px;padding:8px;border-radius:7px;justify-content:space-between;gap:8px;color:#64748b;font-size:11px;background:#f6f8fb}.active-pallet text:last-child{min-width:0;color:#155eef;font-weight:700;text-align:right;overflow-wrap:anywhere}.sku-card{display:flex;margin:10px 0;padding:10px;border-radius:8px;align-items:center;justify-content:space-between;gap:8px;background:#f6f8fb}.expected{text-align:right}.expected text:last-child{display:block;color:#172b45;font-size:20px;font-weight:800}.quantity-label{display:block;margin-bottom:5px;color:#475467;font-size:12px;font-weight:700}.quantity-control{display:grid;width:100%;grid-template-columns:52px minmax(0,1fr) 52px}.quantity-control button{height:48px;margin:0;padding:0;color:#155eef;font-size:22px;line-height:48px;background:#edf3ff}.quantity-control input{box-sizing:border-box;width:100%;height:48px;border:1px solid #d8e0ea;text-align:center;font-size:17px;background:#fff}.difference-box{margin-top:10px;padding:10px;border-radius:8px;color:#8a2c0d;font-size:12px;line-height:18px;background:#fff6ed}.difference-box label{display:flex;min-height:42px;align-items:center;gap:6px;font-weight:700}.difference-box textarea{box-sizing:border-box;width:100%;height:68px;padding:8px;border:1px solid #e4c7ad;border-radius:7px;font-size:12px;background:#fff}.primary-button,.secondary-button{box-sizing:border-box;width:100%;min-height:48px;margin:10px 0 0;border-radius:8px;font-size:14px;font-weight:700;line-height:48px}.primary-button{color:#fff;background:#155eef}.secondary-button{color:#155eef;background:#edf3ff}.empty{padding:13px 0 3px;color:#8793a3;font-size:12px;text-align:center}.carton-row{display:flex;min-width:0;min-height:46px;border-bottom:1px solid #edf0f4;align-items:center;justify-content:space-between;gap:7px}.carton-row view{min-width:0}.carton-row text{display:block;color:#334155;font-size:12px;font-weight:700;overflow-wrap:anywhere}.carton-row text+text{color:#667085;font-size:10px;font-weight:500}.carton-row button{flex-shrink:0;min-width:82px;height:38px;margin:0;padding:0 7px;color:#155eef;font-size:10px;line-height:38px;background:#edf3ff}.pallet-card{margin-top:9px;padding:10px;border:1px solid #dfe5ed;border-radius:9px}.pallet-card.active{border:2px solid #155eef;background:#f8fbff}.measure-row{display:flex;margin-top:7px;padding:7px;border-radius:6px;justify-content:space-between;color:#475467;font-size:10px;background:#f6f8fb}.guard{margin-top:5px;text-align:center}.bottom-space{height:20px}button::after{border:0}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<view v-if="user" class="page-shell dropship-task-page">
<PortalHeader title="一件代发任务" :user="user" fallback-url="/pages/home/home" />
<view class="task-content">
<view class="task-toolbar"><view><text class="task-title">待清点组托</text><text class="task-summary">{{ loading ? '正在同步任务' : `共 ${tasks.length} 个任务` }}</text></view><button :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中' : '刷新' }}</button></view>
<view v-if="error" class="state-card state-error" role="alert">{{ error }}</view>
<view v-else-if="loading" class="state-card">正在加载真实任务...</view>
<view v-else-if="!tasks.length" class="state-card">暂无待处理的一件代发清点组托任务</view>
<button v-for="task in tasks" :key="task.taskId" class="dropship-task-card" :disabled="claimingTaskId === task.taskId || !task.taskId || !task.warehouseId" @tap="enterTask(task)">
<view class="task-card-head"><text class="task-code">{{ task.inboundCode || '入仓码 —' }}</text><text class="status-badge">{{ statusLabel(task.taskStatus) }}</text></view>
<text class="customer-name">{{ task.customerName || '客户 —' }}</text>
<text class="warehouse-name">仓库 {{ task.warehouseName || '—' }}</text>
<view class="progress-row"><text>清点 {{ task.countedCartons || 0 }}/{{ task.totalCartons || 0 }}</text><text>待绑托 {{ task.unboundCartons || 0 }}</text><text>差异 {{ task.discrepancyCartons || 0 }}</text></view>
<view class="enter-copy">{{ claimingTaskId === task.taskId ? '领取中...' : taskAction(task) }} ›</view>
</button>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { claimDropshipPalletizingTask, listDropshipPalletizingTasks } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { isOnline } from '@/utils/network.js'
import { formatApiError } from '@/utils/user-facing.js'
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, tasks: [], loading: false, error: '', claimingTaskId: null } },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
methods: {
statusLabel(status) { return status === 'CLAIMED' ? '清点组托中' : status === 'COMPLETED' ? '已完成' : '待领取' },
taskAction(task) { return task.taskStatus === 'CLAIMED' ? '继续作业' : '领取并开始' },
async loadTasks() {
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return }
this.loading = true; this.error = ''
try { this.tasks = await listDropshipPalletizingTasks() || [] }
catch (error) { this.error = formatApiError(error, '一件代发任务加载失败') }
finally { this.loading = false }
},
async enterTask(task) {
if (!isOnline()) { this.error = '当前网络不可用,不能开始正式作业'; return }
this.claimingTaskId = task.taskId; this.error = ''
try {
if (task.taskStatus !== 'CLAIMED') await claimDropshipPalletizingTask(task.taskId, task.warehouseId)
uni.navigateTo({ url: `/pages/dropship-palletizing/dropship-palletizing?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` })
} catch (error) { this.error = formatApiError(error, '任务领取失败,请刷新后重试') }
finally { this.claimingTaskId = null }
}
}
}
</script>
<style scoped>
.task-content { padding: 12px; }
.task-toolbar { display: flex; margin-bottom: 10px; align-items: center; justify-content: space-between; gap: 10px; }
.task-title { display: block; color: #172b45; font-size: 18px; font-weight: 800; }
.task-summary { display: block; margin-top: 2px; color: #667085; font-size: 11px; }
.task-toolbar button { width: 76px; height: 44px; margin: 0; color: #fff; font-size: 14px; line-height: 44px; background: #1a73e8; }
.state-card { padding: 24px 12px; border-radius: 10px; color: #667085; font-size: 13px; text-align: center; background: #fff; }
.state-error { color: #b42318; background: #fff1f0; }
.dropship-task-card { box-sizing: border-box; width: 100%; margin: 0 0 10px; padding: 13px; border: 1px solid #dce5ef; border-radius: 11px; color: #27384d; text-align: left; background: #fff; }
.task-card-head, .progress-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.task-code { min-width: 0; font-size: 17px; font-weight: 800; overflow-wrap: anywhere; }
.status-badge { flex-shrink: 0; padding: 4px 7px; border-radius: 999px; color: #155eef; font-size: 10px; background: #eff4ff; }
.customer-name { display: block; margin-top: 7px; font-size: 13px; font-weight: 700; }
.warehouse-name { display: block; margin-top: 2px; color: #667085; font-size: 11px; }
.progress-row { margin-top: 10px; padding: 8px; border-radius: 8px; color: #475467; font-size: 11px; background: #f6f8fb; }
.enter-copy { min-height: 42px; margin-top: 4px; color: #155eef; font-size: 13px; font-weight: 700; line-height: 42px; text-align: right; }
button::after { border: 0; }
</style>
+2 -1
View File
@@ -23,6 +23,7 @@
import { session, requireSession } from '@/stores/session.js'
const ICON_TYPES = {
'一件代发清点组托': 'download',
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
'集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
'库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose',
@@ -46,7 +47,7 @@
return true
},
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) }
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || '/pages/operation/operation' }) }
}
}
</script>
+2 -2
View File
@@ -5,7 +5,7 @@
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view></view>
<text class="group-title">业务功能</text>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-count">{{ overview.pendingBoxCount }}</text><text v-else class="operation-prototype">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">第一版本仅开放“出库 → 集装箱拣货”,其他业务将在后续版本接入。</view>
<view class="module-empty-note">当前开放“入库 → 一件代发清点组托”和“出库 → 集装箱拣货”,其余业务将在后续版本接入。</view>
</view>
</view>
</template>
@@ -22,7 +22,7 @@
onLoad(options) { this.moduleKey = options.key || 'outbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) }
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || '/pages/operation/operation' }) }
}
}
</script>
+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
}
+49
View File
@@ -0,0 +1,49 @@
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')
test('dropship palletizing route and workbench entry are registered', async () => {
const [pages, data, home, modulePage] = await Promise.all([read('../pages.json'), read('../data/demo-data.js'), read('../pages/home/home.vue'), read('../pages/module/module.vue')])
assert.match(pages, /pages\/dropship-palletizing\/tasks/)
assert.match(pages, /pages\/dropship-palletizing\/dropship-palletizing/)
assert.match(data, /一件代发清点组托[\s\S]*?route: '\/pages\/dropship-palletizing\/tasks'/)
assert.match(home, /operation\.route \|\| '\/pages\/operation\/operation'/)
assert.match(modulePage, /operation\.route \|\| '\/pages\/operation\/operation'/)
})
test('page exposes both scan orders, receiving confirmation, pending boxes, pallet changes, and sealing guards', async () => {
const page = await read('../pages/dropship-palletizing/dropship-palletizing.vue')
for (const text of ['先扫托盘', '先扫多箱', '确认本箱实收', '待绑托箱', '移回待绑托', '封托']) assert.match(page, new RegExp(text))
assert.match(page, /startScanBroadcast/)
assert.match(page, /watchNetwork/)
assert.match(page, /saveDropshipPalletizingDraft/)
assert.match(page, /restoreDropshipPalletizingDraft/)
assert.match(page, /resolveDropshipCarton/)
assert.match(page, /countDropshipCarton/)
assert.match(page, /bindPendingDropshipCartons/)
assert.match(page, /moveDropshipCarton/)
assert.match(page, /sealDropshipPallet/)
assert.match(page, /pallet\.cartons \|\| \[\]/)
assert.match(page, /carton\.canCount === false[\s\S]*?carton\.blockReason/)
assert.doesNotMatch(page, /\(pallet\.items \|\| \[\]\)\.filter/)
assert.match(page, /:disabled="busy \|\| !online \|\| !canSeal"/)
assert.doesNotMatch(page, /warehouseId:\s*Number\(this\.warehouseId\)/)
assert.doesNotMatch(page, /模拟成功|DEMO_|palletStatus/)
})
test('390px work layout uses bounded grids and touch-sized primary actions', async () => {
const page = await read('../pages/dropship-palletizing/dropship-palletizing.vue')
assert.match(page, /\.dropship-page\{min-width:0;overflow:hidden;/)
assert.match(page, /\.summary-grid\{[^}]*grid-template-columns:repeat\(3,minmax\(0,1fr\)\);/)
assert.match(page, /\.primary-button,\.secondary-button\{[^}]*width:100%;min-height:48px;/)
assert.match(page, /\.quantity-control\{[^}]*grid-template-columns:52px minmax\(0,1fr\) 52px/)
})
test('dropship API uses only the frozen cartons/count contract', async () => {
const api = await read('../api/pda-dropship.js')
for (const path of ['tasks/pending', 'cartons/resolve', 'cartons/count', 'pallets/bind-pending', '/move', '/seal']) assert.match(api, new RegExp(path.replace('/', '\\/')))
for (const field of ['actualSkuQuantity', 'discrepancyAction', 'discrepancyRemark', 'palletNo', 'operationId', 'expectedVersion']) assert.match(api, new RegExp(field))
assert.doesNotMatch(api, /pallets\/open|pallets\/\$\{encodeURIComponent\(palletId\)\}\/scan|palletStatus/)
})
+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)
})
+6 -5
View File
@@ -10,7 +10,8 @@ test('page configuration starts at login and registers independent PDA routes',
assert.equal(pages.pages[0].style.navigationStyle, 'custom')
assert.deepEqual(pages.pages.map((page) => page.path), [
'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation',
'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking'
'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking',
'pages/dropship-palletizing/tasks', 'pages/dropship-palletizing/dropship-palletizing'
])
})
@@ -76,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, /operation\.route \|\| '\/pages\/operation\/operation'/)
assert.match(modulePage, /operation\.route \|\| '\/pages\/operation\/operation'/)
})
test('workbench, module, history and profile are independent sources', async () => {
@@ -87,7 +88,7 @@ test('workbench, module, history and profile are independent sources', async ()
for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', '开始作业', 'uni-icons']) assert.match(home, new RegExp(text))
assert.doesNotMatch(home, /getPickingOverview|pendingBoxCount|箱待拣/)
assert.doesNotMatch(home, /BottomNav|status-strip|task-card/)
for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text))
for (const text of ['业务模块', '业务功能', '一件代发清点组托', '集装箱拣货']) assert.match(module, new RegExp(text))
assert.match(home, /:disabled="!operation\.available"/)
assert.match(home, /home-operation-disabled/)
assert.match(home, /home-operation-meta home-operation-pending/)
@@ -106,7 +107,7 @@ test('workbench, module, history and profile are independent sources', async ()
test('demo model contains all four modules and picking belongs to outbound', async () => {
const data = await read('../data/demo-data.js')
for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货']) assert.match(data, new RegExp(text))
for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货', '一件代发清点组托']) assert.match(data, new RegExp(text))
assert.match(data, /key: 'outbound'[\s\S]*?name: '集装箱拣货'[\s\S]*?available: true/)
for (const boxNo of ['CBX-20260709-001', 'CBX-20260709-002', 'CBX-20260709-003']) assert.match(data, new RegExp(boxNo))
})
+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: '' }
}
+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 = '—') {