This commit is contained in:
2026-09-15 20:51:06 +08:00
parent 9307a861cf
commit d46a0ee6d2
5 changed files with 51 additions and 19 deletions
+1 -1
View File
@@ -58,7 +58,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 托盘码,由 Admin 电脑端合并打印后贴到空托盘;PDA 不承载打印,只直接扫码并展示当前操作托盘;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 托盘码,由 Admin 电脑端合并打印后贴到空托盘;PDA 不承载打印,只直接扫码并展示当前操作托盘;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/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` |
+4
View File
@@ -44,6 +44,10 @@ 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, { warehouseId, palletId: pallet.palletId, version: pallet.version }) } })
}
export function unsealDropshipPallet(taskId, warehouseId, pallet) {
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/unseal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('unseal', taskId, { warehouseId, palletId: pallet.palletId, version: pallet.version }) } })
}
function requireValue(value, message) {
if (value === null || value === undefined || value === '') throw new Error(message)
@@ -39,7 +39,8 @@
<text v-else class="counted-quantity">已清点 {{ line.actualQuantity }} 件</text>
<view v-if="!isResolvedPendingCarton && lineDiffers(line)" class="field stacked difference-field"><text>差异说明</text><textarea v-model="line.discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
</view>
<text v-if="currentPallet" class="pallet-hint">归入 {{ currentPallet.palletNo }}</text>
<text v-if="assignableCurrentPallet" class="pallet-hint">归入 {{ assignableCurrentPallet.palletNo }}</text>
<text v-else-if="currentPallet?.status === 'SEALED'" class="pallet-hint blocked">当前托盘已封托,请先解除封托或扫描其他托盘</text>
<text v-else-if="isResolvedPendingCarton" class="pallet-hint">本箱已清点,请先扫描目标托盘</text>
<button class="primary-button" :disabled="!canSubmitCarton || submitting" @tap="confirmCarton">{{ cartonActionLabel }}</button>
</view>
@@ -62,17 +63,26 @@
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
<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>
<button v-if="currentPallet.palletId && currentPallet.status === 'SEALED'" class="secondary-button unseal-button" :disabled="submitting" @tap="openUnseal(currentPallet)">解除封托</button>
</view>
</view>
</template>
</view>
<uni-popup ref="unsealPopup" type="center" :is-mask-click="true" @change="handleUnsealPopupChange">
<view class="unseal-dialog" role="dialog" aria-modal="true" aria-label="确认解除封托">
<PopupHeader title="确认解除封托?" tone="danger" @close="closeUnseal" />
<text class="unseal-copy">托盘 {{ unsealTarget?.palletNo || '—' }} 将恢复为开放状态,可继续移箱、归托和重新封托。</text>
<view class="unseal-actions"><button class="secondary-button" @tap="closeUnseal">取消</button><button class="danger-button" :disabled="submitting" @tap="confirmUnseal">确认解除</button></view>
</view>
</uni-popup>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet, unsealDropshipPallet } 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'
@@ -82,8 +92,8 @@
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', selectedPalletNo: '', boxCode: '', carton: null, skuCounts: [], activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '' } },
components: { PopupHeader, PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', selectedPalletNo: '', boxCode: '', carton: null, skuCounts: [], activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '', unsealOpen: false, unsealTarget: null } },
computed: {
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
palletSetupReady() { return this.task?.palletSetupReady === true },
@@ -91,15 +101,17 @@
canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
canCount() { return Boolean(this.carton?.canCount && this.skuCounts.length && this.skuCounts.every((line) => Number.isInteger(Number(line.actualQuantity)) && Number(line.actualQuantity) >= 0 && (!this.lineDiffers(line) || String(line.discrepancyRemark || '').trim()))) },
isResolvedPendingCarton() { return this.carton?.countStatus === 'COUNTED_UNBOUND' },
canRebindResolvedCarton() { return Boolean(this.isResolvedPendingCarton && this.carton?.cartonId && this.currentPallet?.palletNo) },
canSubmitCarton() { return this.isResolvedPendingCarton ? this.canRebindResolvedCarton : this.canCount },
canRebindResolvedCarton() { return Boolean(this.isResolvedPendingCarton && this.carton?.cartonId && this.assignableCurrentPallet?.palletNo) },
canSubmitCarton() { return this.currentPallet?.status === 'SEALED' ? false : (this.isResolvedPendingCarton ? this.canRebindResolvedCarton : this.canCount) },
cartonActionLabel() { return this.isResolvedPendingCarton ? '确认归入当前托盘' : '确认清点' },
currentPalletDetails() { return (this.task?.pallets || []).find((item) => item.palletNo === this.selectedPalletNo) || null },
currentPallet() { return this.currentPalletDetails?.status === 'OPEN' ? this.currentPalletDetails : (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) }
currentPallet() { return this.currentPalletDetails || (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) },
assignableCurrentPallet() { return this.currentPallet?.status === 'OPEN' ? this.currentPallet : null }
},
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
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() },
onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
@@ -123,7 +135,7 @@
},
async countCarton() {
const skuLines = this.skuCounts.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, actualQuantity: Number(line.actualQuantity), discrepancyAction: this.lineDiffers(line) ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.lineDiffers(line) ? String(line.discrepancyRemark || '').trim() : null }))
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.currentPallet?.palletNo || null, skuLines }), this.currentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.assignableCurrentPallet?.palletNo || null, skuLines }), this.assignableCurrentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
},
async confirmCarton() {
@@ -132,7 +144,7 @@
},
async rebindResolvedCarton() {
if (!this.canRebindResolvedCarton) return
const targetPalletNo = this.currentPallet.palletNo
const targetPalletNo = this.assignableCurrentPallet.palletNo
const payload = { warehouseId: this.warehouseId, targetPalletNo }
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: this.carton.cartonId, taskVersion: this.task.version, ...payload })
const result = await this.runMutation(() => moveDropshipCarton(this.taskId, this.carton.cartonId, payload), `本箱已归入 ${targetPalletNo}`)
@@ -143,8 +155,8 @@
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 && pallet.status !== 'OPEN') { this.error = '托盘已封托,不能继续归托'; return }
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
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},清点箱码时将校验并归托`
},
@@ -156,7 +168,17 @@
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 sealPallet(pallet) { await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`) },
openUnseal(pallet) { this.unsealTarget = pallet; this.unsealOpen = true; this.$refs.unsealPopup.open() },
closeUnseal() { this.unsealOpen = false; this.$refs.unsealPopup?.close(); this.unsealTarget = null },
handleUnsealPopupChange(event) { this.unsealOpen = Boolean(event.show); if (!event.show) this.unsealTarget = null },
async confirmUnseal() {
const pallet = this.unsealTarget; if (!pallet || this.submitting) return
this.$refs.unsealPopup.close(); this.unsealOpen = false
const result = await this.runMutation(() => unsealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已解除封托`)
this.unsealTarget = null
if (result) { this.selectedPalletNo = pallet.palletNo; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: pallet.palletNo }) }
},
async runMutation(action, message) {
if (this.submitting) return null
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return null }
@@ -190,6 +212,8 @@
.compact-metrics { display: grid; grid-template-columns: repeat(5,1fr); margin-top: 8px; padding-top: 7px; border-top: 1px solid #edf1f5; }.compact-metrics > text { display: flex; align-items: center; justify-content: center; gap: 3px; color: #667085; font-size: 11px; white-space: nowrap; }.compact-metrics strong { color: #12233f; font-size: 15px; }.compact-metrics .pallet-readiness { color: #067647; }.compact-metrics .pallet-readiness.invalid,.compact-metrics .pallet-readiness.invalid strong { color: #b42318; }.pallet-setup-error { margin-top: 8px; margin-bottom: 0; text-align: left; }
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-desc { display: block; margin: 2px 0 8px; color: #667085; font-size: 13px; line-height: 18px; }.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; }
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
.count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
+4 -2
View File
@@ -19,7 +19,7 @@ test('dropship palletization adapter uses only the dedicated production PDA cont
const api = await read('../api/pda-dropship.js')
for (const route of [
'/tasks/pending', '/cartons/resolve', '/cartons/count',
'/pallets/bind-pending', '/seal'
'/pallets/bind-pending', '/seal', '/unseal'
]) assert.match(api, new RegExp(route.replaceAll('/', '\\/')))
assert.doesNotMatch(api, /apiDownload|downloadAllDropshipPalletLabels|pallets\/print/)
assert.match(api, /tasks\/pending\?\$\{params\.join\('&'\)\}/)
@@ -61,12 +61,14 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
assert.match(work, /startScanBroadcast/)
assert.match(work, /lineDiffers[\s\S]*?ACCEPT_VARIANCE/)
assert.match(work, /bindDropshipPendingCartons/)
assert.match(work, /palletNo: this\.currentPallet/)
assert.match(work, /palletNo: this\.assignableCurrentPallet/)
assert.match(work, /继续装其他入仓单/)
assert.match(work, /selectedPalletNo/)
assert.match(work, /已选择仓库开放托盘/)
assert.match(work, /直接扫描托盘码,只显示当前操作的托盘/)
assert.match(work, /currentPalletDetails/)
assert.match(work, /解除封托/)
assert.match(work, /<uni-popup ref="unsealPopup"/)
assert.doesNotMatch(work, /v-for="pallet in task\.pallets"/)
assert.doesNotMatch(work, /downloadDropshipPalletLabel|downloadAllDropshipPalletLabels|打开当前托盘 A4 单|打开全部 A4 托盘单/)
assert.match(work, /formatApiError/)
+7 -5
View File
@@ -20,12 +20,14 @@ test('palletization uses the test workbench and canonical routes', async () => {
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))
test('formal palletization preserves both scan orders, moves, sealing and safe unsealing', () => {
for (const symbol of ['resolveDropshipCarton', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'unsealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
assert.match(page, /移回待绑托/)
assert.match(page, /palletSetupReady/)
assert.match(page, /pallet.status !== 'OPEN'/)
assert.match(page, /pallet\?\.status === 'SEALED'/)
assert.match(page, /currentPalletDetails\?\.cartons/)
assert.match(page, /<uni-popup ref="unsealPopup"/)
assert.match(page, /确认解除封托/)
assert.doesNotMatch(page, /模拟成功|actualSkuQuantity/)
})
test('palletization preserves compact multi-SKU input without desktop printing actions', () => {
@@ -33,8 +35,8 @@ test('palletization preserves compact multi-SKU input without desktop printing a
assert.match(page, /quantity-stepper/)
assert.doesNotMatch(page, /printPallet|printAllPallets|打开当前托盘 A4 单|打开全部 A4 托盘单/)
})
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))
test('count, move, seal and unseal use the formal contract with versioned retries', () => {
for (const path of ['cartons/count', 'pallets/bind-pending', '/move', '/seal', '/unseal']) 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/)