diff --git a/AGENTS.md b/AGENTS.md
index 15a2054..03c741e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,7 +55,7 @@
| 登录与会话 | `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 打印;公网前缀 `/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 明确执行逐箱清点并支持先扫箱或先扫托、归托和逐托封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/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` |
diff --git a/pages/dropship-palletization/dropship-palletization.vue b/pages/dropship-palletization/dropship-palletization.vue
index f32a06e..731e2ba 100644
--- a/pages/dropship-palletization/dropship-palletization.vue
+++ b/pages/dropship-palletization/dropship-palletization.vue
@@ -15,22 +15,37 @@
{{ task.boundCartons || 0 }}已归托
{{ task.unboundCartons || 0 }}待归托
+
+ 预计 {{ task.expectedPalletCount || 0 }} 托
+ 已生成 {{ task.generatedPalletCount || 0 }} 托
+
+ {{ palletSetupMessage }}
- 1. 扫托盘或扫箱
- 顺序不限:先扫托盘后扫箱会直接归托;先扫箱后扫托会把待归托箱绑定到该托盘。
-
+ 1. 箱码清点与归托
+ 先扫箱:核对实际件数并确认清点;已选择托盘时本箱直接归托。也可先扫托盘,再连续扫箱清点归托。
+
+
+ 扫描箱码后核对计划件数与实际件数,再确认完成本箱清点。
+
+
{{ carton.boxCode }}{{ carton.countStatus === 'UNCOUNTED' ? '待清点' : '已处理' }}
{{ carton.skuCode || 'SKU —' }} · {{ carton.cargoName || '品名 —' }}
计划 {{ carton.expectedSkuQuantity || 0 }} 件
实际件数
差异说明
-
+
+
+ 托盘归托(可先扫或后扫)
+
+
+
+ 托盘码未准备完成,当前不能开始箱码清点。
@@ -62,7 +77,10 @@
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', boxCode: '', carton: null, actualQuantity: '', discrepancyRemark: '', activeScan: 'boxCode', loading: false, submitting: false, printing: false, error: '', notice: '' } },
computed: {
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
+ palletSetupReady() { return this.task?.palletSetupReady === true },
+ palletSetupMessage() { return this.task?.palletSetupMessage || `预计 ${this.task?.expectedPalletCount || 0} 托,当前仅生成 ${this.task?.generatedPalletCount || 0} 个托盘码,请联系后台处理后刷新` },
quantityDiffers() { return this.carton && Number(this.actualQuantity) !== Number(this.carton.expectedSkuQuantity) },
+ canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
canCount() { return Boolean(this.carton?.canCount && Number(this.actualQuantity) > 0 && (!this.quantityDiffers || this.discrepancyRemark.trim())) },
currentPallet() { return (this.task?.pallets || []).find((item) => String(item.palletId) === String(this.selectedPalletId) && item.status === 'OPEN') || null }
},
@@ -72,7 +90,7 @@
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) { 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 code = String(value).trim().toUpperCase(); if (code.startsWith('DP-')) { 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 }
@@ -83,6 +101,7 @@
},
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
async resolveCarton(value) {
+ if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
try { this.carton = await resolveDropshipCarton(this.taskId, this.warehouseId, code); this.actualQuantity = String(this.carton.expectedSkuQuantity || '') }
@@ -94,6 +113,7 @@
if (result) { this.boxCode = ''; this.carton = null; this.actualQuantity = ''; this.discrepancyRemark = '' }
},
async selectPallet(value) {
+ 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) { this.error = '托盘码不属于当前一件代发任务'; return }
if (pallet.status !== 'OPEN') { this.error = '托盘已封托,不能继续归托'; return }
@@ -146,8 +166,10 @@
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 10px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
.customer { display: block; margin-top: 6px; color: #667085; font-size: 11px; }
.metrics { display: grid; grid-template-columns: repeat(4,1fr); margin: 12px 0; padding: 10px 0; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; }.metrics view { display: flex; flex-direction: column; align-items: center; border-right: 1px solid #edf1f5; }.metrics view:last-child { border: 0; }.metrics strong { font-size: 17px; }.metrics text { margin-top: 2px; color: #667085; font-size: 10px; }
+ .pallet-readiness { display: flex; justify-content: center; gap: 18px; margin-top: -2px; color: #067647; font-size: 11px; font-weight: 700; }.pallet-readiness.invalid { color: #b42318; }.pallet-setup-error { margin-top: 10px; margin-bottom: 0; text-align: left; }
.section-title { display: block; color: #12233f; font-size: 16px; font-weight: 700; }.section-desc { display: block; margin: 3px 0 11px; color: #667085; font-size: 11px; line-height: 17px; }.section-heading > view { min-width: 0; }
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
+ .count-entry { margin-top: 9px; padding: 10px; border: 1px solid #dbe8f8; border-radius: 9px; color: #52657a; font-size: 11px; line-height: 17px; background: #f6f9ff; }.count-button { margin-top: 8px; }.count-unavailable { padding: 18px 10px; border-radius: 9px; color: #b42318; font-size: 12px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 13px; padding-top: 12px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 7px; color: #344054; font-size: 12px; font-weight: 700; }
.carton-card, .pallet-card { margin-top: 11px; padding: 11px; border: 1px solid #e3eaf2; border-radius: 9px; background: #f8fbff; }.carton-head { color: #12233f; font-size: 13px; font-weight: 700; }.carton-line, .pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 11px; line-height: 17px; }
.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: #344054; font-size: 12px; }.field input { box-sizing: border-box; width: 120px; height: 42px; padding: 0 10px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }.field.stacked { align-items: stretch; flex-direction: column; gap: 6px; }.field textarea { box-sizing: border-box; width: 100%; height: 70px; padding: 9px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }
.pallet-card { background: #fff; }.pallet-code { color: #12233f; font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }.text-button { min-width: 58px; height: 40px; border: 0; color: #155eef; font-size: 12px; line-height: 40px; background: transparent; }.empty { padding: 18px 0 8px; color: #98a2b3; font-size: 12px; text-align: center; }
diff --git a/tests/dropship-palletization.test.mjs b/tests/dropship-palletization.test.mjs
index d9b2ed8..20db555 100644
--- a/tests/dropship-palletization.test.mjs
+++ b/tests/dropship-palletization.test.mjs
@@ -43,7 +43,11 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
assert.match(tasks, /\.search-card\s*\{[^}]*display:\s*flex;[^}]*box-shadow:\s*none;/)
assert.match(tasks, /\.task-list\s*\{[^}]*overflow:\s*hidden;[^}]*border-radius:\s*7px;/)
assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
- for (const text of ['一件代发入库', '领取并开始入库', '扫托盘或扫箱', '差异说明', '预生成托盘码', '打印托盘码', '封托']) assert.match(work, new RegExp(text))
+ for (const text of ['一件代发入库', '领取并开始入库', '箱码清点与归托', '开始清点', '确认清点', '托盘归托(可先扫或后扫)', '差异说明', '预生成托盘码', '打印托盘码', '封托']) assert.match(work, new RegExp(text))
+ assert.match(work, /canResolveCarton\(\)[\s\S]*?boxCode[\s\S]*?!this\.loading[\s\S]*?!this\.submitting/)
+ for (const text of ['expectedPalletCount', 'generatedPalletCount', 'palletSetupReady', 'palletSetupMessage']) assert.match(work, new RegExp(text))
+ assert.match(work, /[\s\S]*?ScanTargetInput[\s\S]*?count-unavailable/)
+ assert.match(work, /if \(!this\.palletSetupReady\) \{ this\.error = this\.palletSetupMessage; return \}/)
for (const text of ['绑定到扫描托盘', '生成 DP 托盘并绑定', '确认封托']) assert.doesNotMatch(work, new RegExp(text))
assert.match(work, /class="task-summary-card"/)
assert.match(work, /\.task-summary-card\s*\{\s*display:\s*block;/)