This commit is contained in:
2026-10-19 16:40:51 +08:00
parent 54764156bf
commit 53cb68a8bc
7 changed files with 152 additions and 28 deletions
+2
View File
@@ -23,6 +23,8 @@
一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。正式入口沿用 `pages/dropship-tasks/dropship-tasks` 和 `pages/dropship-palletization/dropship-palletization`。箱码先经 `cartons/resolve` 只读识别,确认后才调用 `cartons/count`;先扫托盘时确认箱码直接绑定当前托盘,先扫多箱时箱码进入待绑托区,后扫系统托盘码通过 `pallets/bind-pending` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。
混托作业保持当前实物托盘,允许连续扫描不同客户的箱码。仓库级 `cartons/resolve` 只读返回原任务与箱内 SKU;确认清点时重新读取并领取原任务,再按原 taskId 写入,不将货物改归当前页客户。`pallets/resolve` 返回真实托盘状态和逐客户、逐票组成;未知托盘不能默认开放。切换入仓单携带已校验开放托盘,托内移箱使用箱的原任务;封托前显示各客户入仓码及箱数供核对。混托要求同作业仓、同渠道、同目的仓,封托和解封继续遵守正式后端门禁。合同及行为测试:`node --test tests/dropship*.test.mjs`。
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
+8
View File
@@ -4,6 +4,14 @@ import { session } from '@/stores/session.js'
const ROOT = '/first-leg/pda-dropship'
export function resolveDropshipWorkspaceCarton(warehouseId, boxCode) {
return apiRequest({ url: `${ROOT}/cartons/resolve?warehouseId=${encodeURIComponent(warehouseId)}&boxCode=${encodeURIComponent(String(boxCode || '').trim())}`, method: 'GET' })
}
export function resolveDropshipWorkspacePallet(warehouseId, palletNo) {
return apiRequest({ url: `${ROOT}/pallets/resolve?warehouseId=${encodeURIComponent(warehouseId)}&palletNo=${encodeURIComponent(String(palletNo || '').trim().toUpperCase())}`, method: 'GET' })
}
function stableId(value) {
return String(value ?? '').trim()
}
@@ -9,6 +9,7 @@
<view class="task-summary-card">
<view class="summary-head"><text class="inbound-code">{{ task.inboundCode || '—' }}</text><text class="status">{{ statusLabel }}</text></view>
<text class="warehouse-summary">{{ task.warehouseName || '仓库未关联' }}</text>
<text class="warehouse-summary">当前票 · {{ task.customerName || '客户未关联' }}</text>
<view class="compact-metrics">
<text><strong>{{ task.totalCartons || 0 }}</strong>总箱</text>
<text><strong>{{ task.countedCartons || 0 }}</strong>清点</text>
@@ -20,7 +21,7 @@
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始入库</button>
</view>
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
<view v-if="task.taskStatus === 'CLAIMED' || currentPallet" class="section-card">
<text class="section-title">1. 扫箱清点</text>
<template v-if="palletSetupReady">
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
@@ -29,6 +30,8 @@
<button class="compact-action" :disabled="!canResolveCarton" @tap="resolveCarton()">读取 SKU</button>
</view>
<view v-if="carton" class="carton-card">
<text class="carton-owner">{{ scanTask?.customerName || task.customerName }} · {{ carton.inboundCode }}</text>
<text v-if="carton.blockReason" class="pallet-hint blocked">{{ carton.blockReason }}</text>
<view v-for="(line, index) in skuCounts" :key="line.inboundDetailId" class="sku-count-row">
<view class="sku-copy"><text class="sku-label">SKU {{ index + 1 }}</text><text class="sku-code">{{ line.skuCode || '—' }}</text></view>
<view v-if="!isResolvedPendingCarton" class="quantity-stepper">
@@ -53,7 +56,7 @@
</view>
<view v-if="task.taskStatus === 'CLAIMED' && task.totalCartons > 0 && task.boundCartons === task.totalCartons" class="continue-strip">
<text>本票已全部归托。托盘未装满时先继续装其他票,实物装满后再封托。</text>
<button class="secondary-button" @tap="continueOtherTask">继续装其他入仓单</button>
<button class="secondary-button" :disabled="submitting || loading" @tap="continueOtherTask">继续装其他入仓单</button>
</view>
<view class="section-card">
@@ -61,7 +64,9 @@
<view v-if="currentPallet" class="pallet-card">
<view class="pallet-head"><text class="pallet-code">{{ currentPallet.palletNo }}</text><text class="pallet-status" :class="currentPallet.status === 'SEALED' ? 'sealed' : ''">{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}</text></view>
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
<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>
<text class="pallet-meta">整托 {{ palletCustomerCount }} 个客户 · {{ palletMembers.length }} 票</text>
<view v-for="member in palletMembers" :key="member.taskId" class="member-row"><text>{{ member.customerName || '客户未关联' }} · {{ member.inboundCode }}</text><strong>{{ member.cartonCount }} 箱</strong></view>
<view v-for="item in (currentPalletDetails?.cartons || [])" :key="item.cartonId" class="carton-row"><view><text>{{ item.boxCode }}</text><text class="carton-owner">{{ memberForCarton(item)?.customerName }} · {{ item.inboundCode }}</text></view><button v-if="currentPallet.status === 'OPEN'" class="text-button" :disabled="submitting || loading" @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>
@@ -82,7 +87,7 @@
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, unsealDropshipPallet } from '@/api/pda-dropship.js'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipWorkspaceCarton, resolveDropshipWorkspacePallet, 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'
@@ -93,52 +98,56 @@
export default {
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 } },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, scanTask: null, workspace: null, initialPalletNo: '', 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 },
palletSetupMessage() { return this.task?.palletSetupMessage || `预计 ${this.task?.expectedPalletCount || 0} 托,当前仅生成 ${this.task?.generatedPalletCount || 0} 个托盘码,请联系后台处理后刷新` },
canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
canResolveCarton() { return Boolean(this.palletSetupReady && 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.assignableCurrentPallet?.palletNo) },
canSubmitCarton() { return this.currentPallet?.status === 'SEALED' ? false : (this.isResolvedPendingCarton ? this.canRebindResolvedCarton : this.canCount) },
canSubmitCarton() { return this.currentPallet?.status === 'SEALED' || ((this.selectedPalletNo || this.palletCode) && !this.currentPallet) ? 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 || (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) },
currentPalletDetails() { return this.workspace?.pallet?.palletNo === this.selectedPalletNo ? this.workspace.pallet : null },
currentPallet() { return this.currentPalletDetails },
palletMembers() { return this.workspace?.members || [] },
palletCustomerCount() { return new Set(this.palletMembers.map((member) => String(member.customerId))).size },
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() },
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || ''); this.initialPalletNo = String(options.palletNo || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = this.initialPalletNo || draft?.activePalletNo || ''; this.initialPalletNo = ''; 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 },
handleScan(value) { if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
handleScan(value) { if (this.loading || this.submitting) return; if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
resetMessages() { this.error = ''; this.notice = '' },
async loadTask() {
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
this.loading = true; this.resetMessages()
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); await this.refreshPallet(); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') }
finally { this.loading = false }
},
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
async resolveCarton(value) {
if (this.loading || this.submitting) return
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.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) }
try { const resolved = await resolveDropshipWorkspaceCarton(this.warehouseId, code); this.scanTask = resolved.task; this.carton = resolved.carton; this.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) }
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
finally { this.loading = false }
},
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.assignableCurrentPallet?.palletNo || null, skuLines }), this.assignableCurrentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
const result = await this.runMutation(async () => { await this.activateScanTask(); return 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() {
if (!this.canSubmitCarton || this.loading || this.submitting) return
if (this.isResolvedPendingCarton) { await this.rebindResolvedCarton(); return }
await this.countCarton()
},
@@ -147,35 +156,62 @@
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}`)
const result = await this.runMutation(async () => { await this.activateScanTask(); return moveDropshipCarton(this.taskId, this.carton.cartonId, payload) }, `本箱已归入 ${targetPalletNo}`)
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
},
lineDiffers(line) { return Number(line.actualQuantity) !== Number(line.expectedQuantity) },
adjustQuantity(index, step) { const line = this.skuCounts[index]; if (line) line.actualQuantity = String(Math.max(0, Number(line.actualQuantity || 0) + step)) },
async selectPallet(value) {
if (this.loading || this.submitting) return
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)
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
const code = String(value || this.palletCode || '').trim().toUpperCase()
this.palletCode = code
this.loading = true; this.resetMessages(); this.workspace = null
try { this.workspace = await resolveDropshipWorkspacePallet(this.warehouseId, code); this.selectedPalletNo = code; this.palletCode = code; this.selectedPalletId = String(this.workspace.pallet.palletId); this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code }) }
catch (error) { this.selectedPalletNo = ''; this.error = formatApiError(error, '托盘校验失败'); return }
finally { this.loading = false }
const pallet = this.currentPallet
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},清点箱码时将校验并归托`
else this.notice = `已选择仓库开放托盘 ${code},可连续扫描不同客户的箱码`
},
async refreshPallet() { if (!this.selectedPalletNo) { this.workspace = null; return }; this.workspace = null; this.workspace = await resolveDropshipWorkspacePallet(this.warehouseId, this.selectedPalletNo) },
memberForCarton(carton) { return this.palletMembers.find((member) => String(member.inboundId) === String(carton.inboundId)) },
async activateScanTask() {
const targetId = String(this.scanTask?.taskId || this.taskId)
let target = await getDropshipPalletizationTask(targetId, this.warehouseId)
if (target.taskStatus === 'PENDING') target = await claimDropshipPalletizationTask(targetId, this.warehouseId)
this.taskId = targetId; this.task = target
saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: this.selectedPalletNo })
},
async activatePalletTask() {
const member = this.palletMembers.find((item) => String(item.taskId) === this.taskId) || this.palletMembers[0]
if (!member) throw new Error('托盘没有已归托箱,请先清点归托')
const target = await getDropshipPalletizationTask(member.taskId, this.warehouseId)
this.taskId = String(target.taskId); this.task = target
},
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } },
async moveCarton(carton, targetPalletNo) {
if (this.submitting || !isOnline() || !this.palletSetupReady) return
const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: carton.cartonId, taskVersion: this.task.version, ...payload })
await this.runMutation(() => moveDropshipCarton(this.taskId, carton.cartonId, payload), targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
const member = this.memberForCarton(carton)
if (!member) { this.error = '未找到箱码所属任务,请刷新托盘'; return }
await this.runMutation(async () => { const target = await getDropshipPalletizationTask(member.taskId, this.warehouseId); this.taskId = String(target.taskId); this.task = target; return moveDropshipCarton(this.taskId, carton.cartonId, payload) }, targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
},
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}&palletNo=${encodeURIComponent(this.assignableCurrentPallet?.palletNo || '')}` }) },
async sealPallet(pallet) {
if (this.loading || this.submitting) return
const content = this.palletMembers.map((member) => `${member.customerName || '客户未关联'} / ${member.inboundCode}:${member.cartonCount}箱`).join('\n')
uni.showModal({ title: `确认封托 · ${pallet.cartonCount}箱`, content: `${content}\n请确认实物已装满,封托后按整托装柜。`, success: async (answer) => { if (answer.confirm) await this.runMutation(async () => { await this.activatePalletTask(); return sealDropshipPallet(this.taskId, this.warehouseId, pallet) }, `${pallet.palletNo} 已封托`) } })
},
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}` }) },
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} 已解除封托`)
const result = await this.runMutation(async () => { await this.activatePalletTask(); return unsealDropshipPallet(this.taskId, this.warehouseId, pallet) }, `${pallet.palletNo} 已解除封托`)
this.unsealTarget = null
if (result) { this.selectedPalletNo = pallet.palletNo; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: pallet.palletNo }) }
},
@@ -189,6 +225,7 @@
? result
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
this.notice = message
try { await this.refreshPallet() } catch (error) { this.error = formatApiError(error, '操作已成功,托盘汇总刷新失败,请刷新后继续') }
return result
}
catch (error) { this.error = formatApiError(error, '操作失败'); return null }
@@ -199,6 +236,8 @@
</script>
<style scoped>
.member-row { display: flex; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; font-size: 12px; }
.carton-owner { display: block; color: #667085; font-size: 12px; line-height: 20px; overflow-wrap: anywhere; }
.content { padding: 0 0 32px; background: #fff; }
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
+4 -2
View File
@@ -2,6 +2,7 @@
<view v-if="user" class="page-shell dropship-task-page">
<PortalHeader title="一件代发" :user="user" :fallback-url="'/pages/home/home'" />
<view class="content">
<view v-if="palletNo" class="state">继续装入托盘 {{ palletNo }} · 选择其他票,或进入后直接扫描其他客户箱码</view>
<view class="search-card">
<ScanTargetInput v-model="keyword" name="taskKeyword" label="入仓码查询" placeholder="扫描或输入入仓码" :active="true" @submit="loadTasks" />
<button class="primary-button search-button" :disabled="loading" @tap="loadTasks">{{ loading ? '查询中…' : '查询任务' }}</button>
@@ -38,7 +39,8 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '' } },
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '', palletNo: '' } },
onLoad(options) { this.palletNo = String(options.palletNo || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
methods: {
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
@@ -54,7 +56,7 @@
catch (error) { this.tasks = []; this.error = formatApiError(error, '一件代发入库任务加载失败') }
finally { this.loading = false }
},
openTask(task) { uni.navigateTo({ url: `/pages/dropship-palletization/dropship-palletization?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` }) }
openTask(task) { uni.navigateTo({ url: `/pages/dropship-palletization/dropship-palletization?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}&palletNo=${encodeURIComponent(this.palletNo)}` }) }
}
}
</script>
+3 -2
View File
@@ -46,7 +46,7 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
assert.match(work, /减少 \$\{line\.skuCode\} 清点数量/)
assert.match(work, /增加 \$\{line\.skuCode\} 清点数量/)
for (const text of ['compact-metrics', 'compact-action']) assert.match(work, new RegExp(text))
assert.doesNotMatch(work, /task\.customerName/)
assert.match(work, /task\.customerName/)
assert.match(work, /\.task-summary-card, \.section-card \{[^}]*border:\s*0;[^}]*border-bottom:\s*6px solid[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
assert.match(work, /\.carton-card, \.pallet-card \{[^}]*border:\s*0;[^}]*border-top:\s*1px solid[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/)
for (const text of ['计划 {{ carton.expectedSkuQuantity', "carton.cargoName", "carton.countStatus === 'UNCOUNTED'"]) assert.doesNotMatch(work, new RegExp(text))
@@ -54,7 +54,8 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
for (const text of ['expectedPalletCount', 'generatedPalletCount', 'palletSetupReady', 'palletSetupMessage']) assert.match(work, new RegExp(text))
assert.match(work, /<template v-if="palletSetupReady">[\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))
for (const text of ['绑定到扫描托盘', '生成 DP 托盘并绑定']) assert.doesNotMatch(work, new RegExp(text))
assert.match(work, /uni\.showModal\([\s\S]*?确认封托/)
assert.match(work, /class="task-summary-card"/)
assert.match(work, /\.task-summary-card\s*\{\s*display:\s*block;/)
assert.doesNotMatch(work, /class="summary-card"/)
+1 -1
View File
@@ -21,7 +21,7 @@ test('palletization uses the test workbench and canonical routes', async () => {
assert.match(await read('../pages/home/home.vue'), /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
})
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))
for (const symbol of ['resolveDropshipWorkspaceCarton', 'resolveDropshipWorkspacePallet', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'unsealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
assert.match(page, /移回待绑托/)
assert.match(page, /palletSetupReady/)
assert.match(page, /pallet\?\.status === 'SEALED'/)
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
const source = await readFile(new URL('../pages/dropship-palletization/dropship-palletization.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
function harness(overrides = {}) {
const calls = []
const target = { taskId: '9007199254740999', customerName: '客户B', taskStatus: 'PENDING', palletSetupReady: true, pallets: [] }
const pallet = { palletId: '9007199254740998', palletNo: 'DP-261019-0001', status: 'OPEN', cartonCount: 2 }
const context = {
PortalHeader: {}, PopupHeader: {}, ScanTargetInput: {}, session: { currentUser: { id: '7' } },
getDropshipPalletizationTask: async (id) => { calls.push(['get', id]); return { ...target, taskId: id } },
claimDropshipPalletizationTask: async (id) => { calls.push(['claim', id]); return { ...target, taskId: id, taskStatus: 'CLAIMED' } },
resolveDropshipWorkspaceCarton: async () => ({ task: target, carton: { boxCode: 'BOX-B', canCount: true, skuLines: [{ skuCode: 'S', inboundDetailId: '44', expectedQuantity: 3 }] } }),
resolveDropshipWorkspacePallet: async () => ({ pallet, members: [] }),
countDropshipCarton: async (id, payload) => { calls.push(['count', id, payload]); return { carton: {} } },
moveDropshipCarton: async (id, cartonId, payload) => { calls.push(['move', id, cartonId, payload]); return { ...target, taskId: id } },
saveDropshipPalletizingDraft: () => {}, clearDropshipPalletizingDraft: () => {}, stableOperationId: () => 'stable-key',
isOnline: () => true, formatApiError: (error) => error.message, uni: { redirectTo: (value) => calls.push(['redirect', value]) },
...overrides
}
vm.runInNewContext(script, context)
const state = context.page.data()
Object.assign(state, context.page.methods, { taskId: '10', warehouseId: '100', task: { taskStatus: 'CLAIMED', palletSetupReady: true, version: 1 }, loading: false })
for (const [key, value] of Object.entries(context.page.computed)) Object.defineProperty(state, key, { get: () => value.call(state), configurable: true })
return { state, calls, pallet, target }
}
test('cross customer scan remains read only until confirmation and then claims original task', async () => {
const { state, calls, pallet, target } = harness()
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
await state.resolveCarton('BOX-B')
assert.equal(state.taskId, '10')
assert.equal(state.scanTask.taskId, target.taskId)
assert.equal(calls.length, 0)
await state.confirmCarton()
assert.equal(calls[0][0], 'get')
assert.equal(calls[1][0], 'claim')
const counted = calls.find((call) => call[0] === 'count')
assert.equal(counted[1], target.taskId)
assert.equal(counted[2].palletNo, pallet.palletNo)
assert.equal(state.taskId, target.taskId)
})
test('failed pallet lookup removes stale active pallet and blocks counting', async () => {
const { state, pallet } = harness({ resolveDropshipWorkspacePallet: async () => { throw new Error('托盘不存在') } })
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
state.carton = { canCount: true }; state.skuCounts = [{ actualQuantity: 1, expectedQuantity: 1 }]
await state.selectPallet('DP-261019-9999')
assert.equal(state.currentPallet, null)
assert.equal(state.canSubmitCarton, false)
})
test('moving another customers carton submits its own task id', async () => {
const { state, calls, pallet, target } = harness()
state.workspace = { pallet, members: [{ taskId: target.taskId, inboundId: '31', customerId: '2', customerName: '客户B' }] }
state.selectedPalletNo = pallet.palletNo
await state.moveCarton({ cartonId: '99', inboundId: '31' }, null)
assert.equal(calls.find((call) => call[0] === 'move')[1], target.taskId)
})
test('switching tickets carries only validated open pallet and scanner cannot race submission', async () => {
const { state, calls, pallet } = harness()
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
state.continueOtherTask()
assert.match(calls[0][1].url, /palletNo=DP-261019-0001/)
state.submitting = true
await state.selectPallet('DP-261019-0002')
assert.equal(state.currentPallet.palletNo, pallet.palletNo)
})