diff --git a/AGENTS.md b/AGENTS.md
index 8919130..28343fd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 |
diff --git a/README.md b/README.md
index e07f3b2..08270c8 100644
--- a/README.md
+++ b/README.md
@@ -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`。
diff --git a/api/pda-dropship.js b/api/pda-dropship.js
new file mode 100644
index 0000000..609e061
--- /dev/null
+++ b/api/pda-dropship.js
@@ -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 })
+}
diff --git a/data/demo-data.js b/data/demo-data.js
index ba2fdde..40abe27 100644
--- a/data/demo-data.js
+++ b/data/demo-data.js
@@ -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: '完成装箱并登记交接信息' }
] },
diff --git a/pages.json b/pages.json
index bada3f1..2f6ee23 100644
--- a/pages.json
+++ b/pages.json
@@ -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",
diff --git a/pages/dropship-palletizing/dropship-palletizing.vue b/pages/dropship-palletizing/dropship-palletizing.vue
new file mode 100644
index 0000000..ba30bf1
--- /dev/null
+++ b/pages/dropship-palletizing/dropship-palletizing.vue
@@ -0,0 +1,81 @@
+
+
+
+ 当前网络不可用,不能扫码、确认、换托或封托;联网后请刷新。
+
+ {{ error }}
+ {{ notice }}
+ 正在加载真实任务...
+
+ 入仓码{{ task.inboundCode || '—' }}{{ task.customerName || '客户 —' }} · {{ task.warehouseName || '仓库 —' }}{{ statusLabel(task.taskStatus) }}
+ {{ task.countedCartons || 0 }}/{{ task.totalCartons || 0 }}已清点{{ task.unboundCartons || 0 }}待绑托{{ sealedPalletCount }}已封托
+
+ 扫描箱码或系统托盘码先扫托盘:后续箱确认后直接绑定;先扫多箱:后扫托盘一次批量绑定。
+
+ 当前托盘{{ activePalletNo || '未选择(箱将进入待绑托区)' }}
+
+
+ {{ resolvedCarton.boxCode }}入仓码 {{ resolvedCarton.inboundCode || '—' }}待确认
+ SKU{{ resolvedCarton.skuCode || '—' }}应收{{ resolvedCarton.expectedSkuQuantity }}
+ SKU 实收数量
+ 实收与应收不一致,请确认接受差异并填写处理结果。
+
+
+ 待绑托箱{{ task.unboundCartons || 0 }}暂无待绑托箱{{ carton.boxCode }}{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}
+ 托盘{{ pallets.length }} 托扫描 DP 托盘码开始组托
+
+ {{ pallet.palletNo }}{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU{{ pallet.status === 'SEALED' ? '已封托' : '组托中' }}
+ 重量 {{ pallet.weightKg ?? '—' }} kg体积 {{ pallet.volumeCbm ?? '—' }} m³
+ {{ carton.boxCode }}{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}
+ {{ sealBlockReason }}
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/dropship-palletizing/tasks.vue b/pages/dropship-palletizing/tasks.vue
new file mode 100644
index 0000000..9d310bc
--- /dev/null
+++ b/pages/dropship-palletizing/tasks.vue
@@ -0,0 +1,71 @@
+
+
+
+
+ 待清点组托{{ loading ? '正在同步任务' : `共 ${tasks.length} 个任务` }}
+ {{ error }}
+ 正在加载真实任务...
+ 暂无待处理的一件代发清点组托任务
+
+
+
+
+
+
+
+
diff --git a/pages/home/home.vue b/pages/home/home.vue
index fa74fef..b21ee98 100644
--- a/pages/home/home.vue
+++ b/pages/home/home.vue
@@ -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' }) }
}
}
diff --git a/pages/module/module.vue b/pages/module/module.vue
index f36d69a..dbcf1b3 100644
--- a/pages/module/module.vue
+++ b/pages/module/module.vue
@@ -5,7 +5,7 @@
{{ currentModule.icon }}{{ currentModule.name }}{{ currentModule.description }}
业务功能
- 第一版本仅开放“出库 → 集装箱拣货”,其他业务将在后续版本接入。
+ 当前开放“入库 → 一件代发清点组托”和“出库 → 集装箱拣货”,其余业务将在后续版本接入。
@@ -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' }) }
}
}
diff --git a/stores/dropship-palletizing.js b/stores/dropship-palletizing.js
new file mode 100644
index 0000000..d20a993
--- /dev/null
+++ b/stores/dropship-palletizing.js
@@ -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
+}
diff --git a/tests/dropship-palletizing-page.test.mjs b/tests/dropship-palletizing-page.test.mjs
new file mode 100644
index 0000000..d8fa6d8
--- /dev/null
+++ b/tests/dropship-palletizing-page.test.mjs
@@ -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/)
+})
diff --git a/tests/dropship-palletizing-state.test.mjs b/tests/dropship-palletizing-state.test.mjs
new file mode 100644
index 0000000..580c9b0
--- /dev/null
+++ b/tests/dropship-palletizing-state.test.mjs
@@ -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)
+})
diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs
index ce1441e..25db090 100644
--- a/tests/page-contract.test.mjs
+++ b/tests/page-contract.test.mjs
@@ -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))
})
diff --git a/utils/dropship-palletizing-state.js b/utils/dropship-palletizing-state.js
new file mode 100644
index 0000000..7b09a21
--- /dev/null
+++ b/utils/dropship-palletizing-state.js
@@ -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: '' }
+}
diff --git a/utils/user-facing.js b/utils/user-facing.js
index d4c19f3..57304f1 100644
--- a/utils/user-facing.js
+++ b/utils/user-facing.js
@@ -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 = '—') {