diff --git a/AGENTS.md b/AGENTS.md
index 19a782f..6ef7477 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,33 @@
- 库内、换标、收货、排车与出库只调用专用 `/api/pda/**`,客户身份、绑定仓及资源范围由服务端校验;不得借用平台、客户或内部接口绕过授权。
- 前端只展示服务端业务标识与操作资格;写操作保留稳定幂等键,失败/409 后重新读取权威状态,不把网络超时判成操作未执行或已成功。
- 修改收货、退货、移库、托盘、换标、发车、照片或任务恢复前,必须定向读取 `docs/business-index.md` 的对应页面行及“海外业务接口边界”条款;专项检查仍按下文质量门禁执行。
+- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;SKU件数逐种核对差异,不以预报箱数强制核平。箱唛沿用客户预报编号,PDA不得另造新箱号;测量完成前客户与平台可增加整单预报箱数,不选SKU,箱内SKU以PDA实点为准,PDA刷新时合并新箱并保留本机草稿,清点提交携带forecastPackageCount拦截旧快照。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求提交稳定幂等键、照片文件 ID,SKU实收件数有差异时在完成入口弹窗必填差异原因,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
+
+- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。
+
+- SKU 正常作业固定显示一个旧SKU扫描框,通过 `relabel-tasks/{relabelNo}/skus/scan` 提交 itemId、skuIndex 和旧SKU,不要求扫描新标签;多箱同SKU先选择具体明细。服务端每条保存 `SKU_SCAN` 历史,页面按当前返工轮次隐藏已完成子项;全部SKU完成才完成箱的SKU阶段,混合单仍需箱唛阶段。箱唛待处理列表同样隐藏已完成项。回归 `tests/relabel-quick-sku.test.mjs`。
+
+- 换标执行页内嵌 `src/components/RelabelPalletWork.vue`:`relabel-sources` 展示原入仓单各托盘箱数;选择来源库存行并扫描实托、输入拆出箱数、核对本批已完成换标箱,再扫描目标托盘合托。每次一个来源库存行,可分批处理多个托盘,无需为拆托额外逐箱扫码。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源并移动库存,目标仍归原入仓单,不新建入库。仅同新目的仓合托;新增托盘不等待客户确认,409后刷新重扫。H5 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。
+
+- 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
+
+- 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
+
+- 换标执行完成照片以缩略图展示并支持大图预览、移除和重拍。重新进入任务通过 `relabel-tasks/{relabelNo}/photos/files/{fileId}/download` 鉴权读取,不持久化设备临时图片地址;重拍先上传成功再替换待提交文件 ID,失败保留原照片,移除只解绑待提交引用。
+
+- 退货查询空态集中在 `src/api/return-receipt.js`、`src/api/return-packing.js`:仅 `startReturnReceipt`、`findReturnSkuCandidates`、`findPackingOrders` 将 `A0404/404` 转为无匹配结果;签收确认、合箱写入和标签读取不得套用此处理,权限、网络与服务错误仍须保留诊断并报错。回归见 `tests/return-receipt-api.test.mjs`、`tests/return-packing-api.test.mjs`,不得将查询无记录误报为写入成功。
+- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、普通 FBA bulk/普通 OVERSEAS carton、一件代发 source pallet 整托收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;一件代发只提交国内冻结的来源托盘号,不允许逐箱或拆托收货。响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,统一 Token 刷新与业务 401 单次重放由 `src/api/overseas.js` 统一处理。
+- 入仓摘要、箱码解析和实收响应的 `assigned/mergeAllowed/urgent/mergeHint` 只消费 Lastleg 返回值,布尔标志只有 `true` 生效,`mergeAllowed` 缺省为 `false`;合托仅作提示,不能改变收货资格或触发分配。首扫实物托盘只确认服务端已有托盘、库位与资源锁预留,不在设备端重新分配托盘、库位或资源锁;只提交业务箱码、托盘码及当前收货数量,最终资格由服务端决定。
+- `src/api/overseas.js` 的 `overseasUrl` 统一拼接普通请求、上传和下载地址:先移除运行时 `apiBase` 末尾斜杠;当基址已以 `/api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。
+- 待卸柜候选只消费服务端已按 Token 绑定仓过滤的结果,并严格校验每柜正式 `overseasWarehouseCode`、`overseasWarehouseName` 与 `warehouse-profile` 一致;字段缺失或仓不一致一律 fail-closed,不得从 FBA/业务目的仓或内部 ID 推导。候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端“可操作优先、同组计划到仓时间升序、时间为空最后”的排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。
+- 候选详情、prepare 响应和 current 恢复都必须重复校验同一接柜仓编码;prepare 只建立已分配柜的作业会话,不得当作首次认领。服务端仓冲突的 `409/A0409` 中文错误保留 `requestId`/`traceId`;会话建立后仍按既有 current 恢复与完成前禁止切柜逻辑处理。
+- `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。
+- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
+- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
+- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
+- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
+- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
+- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
## 质量门禁
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index cf0e8fd..dfe224a 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -159,6 +159,7 @@ export function normalizeCargoDiscardTask(value) {
itemId: String(sku?.itemId || ''),
skuCode: String(sku?.skuCode || ''),
productName: String(sku?.productName || ''),
+ quantityUnit: sku?.quantityUnit === '件' ? '件' : '箱',
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
status: String(sku?.status || 'PENDING')
diff --git a/src/api/warehouse-transfers.js b/src/api/warehouse-transfers.js
index 90e7479..156b48d 100644
--- a/src/api/warehouse-transfers.js
+++ b/src/api/warehouse-transfers.js
@@ -49,3 +49,15 @@ export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
return overseasDownload(path)
}
+
+export function countWarehouseTransferCartons(transferNo, payload) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/count`, payload)
+}
+
+export function reopenWarehouseTransferMeasurement(transferNo) {
+ return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/reopen`)
+}
+
+export function getWarehouseTransferCartonLabel(transferNo) {
+ return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/carton-label`)
+}
diff --git a/src/components/CartonCountFields.vue b/src/components/CartonCountFields.vue
new file mode 100644
index 0000000..481b29b
--- /dev/null
+++ b/src/components/CartonCountFields.vue
@@ -0,0 +1,17 @@
+体积{{ boxVolume(box) }} {{ volumeUnit === 'm3' ? 'm³' : 'cm³' }}
+ {{ line.skuCode || '选择 SKU' }} ▾
+
+
diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue
index 9661914..9b1a5df 100644
--- a/src/components/RelabelPalletWork.vue
+++ b/src/components/RelabelPalletWork.vue
@@ -5,18 +5,23 @@
- 已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。
{{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}
{{ pallet.palletCode }} · {{ pallet.packages }} 箱
- {{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}
- 已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg
-
+ {{ pallet.inherited ? '来源汇总' : '已确认' }}:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg
+
+ 来源箱规(长 × 宽 × 高)
+ {{ spec.lengthCm }} × {{ spec.widthCm }} × {{ spec.heightCm }} cm · {{ spec.grossWeightKg }} kg/箱 · {{ spec.cartons }} 箱
+
+ 原整托尺寸:{{ pallet.lengthCm }} × {{ pallet.widthCm }} × {{ pallet.heightCm }} cm
+
+
-
+
+
正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}
@@ -27,17 +32,13 @@
当前没有可操作的来源托盘或来源箱
- 来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。
+ 来源已确认:{{ source.palletCode }}
- 本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)
{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}
- 贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。
- 贴标数量少于本托盘库存,请按现场实际选择来源托盘并填写拆箱数量;本托盘应拆 {{ plannedPackages }} 件。
- 退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。
- 退货换标无需扫描箱唛。
+
- 本次拆出箱数箱
+ 本次拆出箱数箱
本次需换标拆 {{ requiredSplitPackages }} 箱已拆 {{ splitPackages }} 箱
@@ -45,8 +46,6 @@
- {{ labelBlockReason }}
- 已识别目标:{{ target.palletCode }},尚未绑定成功
托盘 {{ newPallet.palletCode }} 待绑定
{{ message }}{{ error }}
@@ -63,6 +62,8 @@ export default {
data() { return { measurements: [], measurementFields: [{ key: 'lengthCm', label: '长 cm' }, { key: 'widthCm', label: '宽 cm' }, { key: 'heightCm', label: '高 cm' }, { key: 'grossWeightKg', label: '毛重 kg' }], palletTotals: {}, rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
mounted() { this.loadSources() },
computed: {
+ measuredBoxChoices() { return this.measurements.find(p => p.palletCode === this.selectedSource?.palletCode)?.cartons || [] },
+ requiresMeasuredBoxMapping() { return new Set(this.measuredBoxChoices.map(box => [box.lengthCm,box.widthCm,box.heightCm,box.grossWeightKg].join(':'))).size > 1 },
canRetainOriginal() { return !this.isReturnBulk && !this.unboundSourceMode && this.source && this.selectedSource && this.plannedPackages === Number(this.source.totalPackages) },
boundPalletTotals() { const codes = new Set(this.currentBindings.map(binding => binding.pallet)); return Object.values(this.palletTotals).filter(pallet => codes.has(pallet.palletCode) && !this.measurements.some(measured => measured.palletCode === pallet.palletCode)) },
allBindingsComplete() {
@@ -95,14 +96,31 @@ export default {
const pending = this.sourceItems().filter(item => Number(item.relabelledPackages || 0) < Number(item.expectedPackages || 1))
if (!pending.length) return ''
const missingSku = pending.some(item => item.operationType === 'CARTON_AND_SKU' && Number(item.skuProcessedPackages || 0) < Number(item.expectedPackages || 1))
- if (missingSku) return '本托盘还有未完成的 SKU 贴标登记。混合换标须完成箱唛和 SKU 两项,请返回 SKU 页签处理后再确认绑定。'
- return '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。'
+ if (missingSku) return '请先完成 SKU 贴标登记'
+ return '请先完成贴标登记'
},
wholeSource() { return this.isReturnBulk && this.selectedSource && (!this.allowDisassemble || this.plannedPackages === Number(this.selectedSource.stockPackages || 0)) },
unboundSourceMode() { return !this.isReturnBulk && this.returnCartonItems.length > 0 && (this.selectedSource ? (!this.selectedSource.palletCode || this.selectedSource.palletCode === 'UNBOUND') : !this.operableSourceRows.some(row => row.palletCode && row.palletCode !== 'UNBOUND')) },
readyToBind() { return this.target && this.selectedSource && (this.unboundSourceMode || (this.source && this.samePallet(this.source, this.selectedSource) && (!this.samePallet(this.source, this.target) || this.canRetainOriginal))) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
},
methods: {
+ prepareMeasuredBatch(event) {
+ this.quantity = String(event.detail.value)
+ this.marks = []
+ const count = Number(this.quantity)
+ if (this.measuredBoxChoices.length && Number.isSafeInteger(count) && count > 0 && count <= this.selectedSource.movablePackages && count <= this.eligibleItems.length) this.fillBatch()
+ },
+ // 按申请时的仓库箱唛或客户箱唛对应原实测箱,禁止重新选择其他箱。
+ measuredCartonBindings() {
+ if (!this.requiresMeasuredBoxMapping) return []
+ const used = new Set()
+ return this.marks.map(mark => {
+ const matches = this.measuredBoxChoices.filter(box => box.cartonMark === mark || box.customerCartonMark === mark)
+ const box = matches.length === 1 && !used.has(matches[0].cartonMark) ? matches[0] : null
+ if (box) used.add(box.cartonMark)
+ return { cartonMark: mark, sourceCartonMark: box?.cartonMark }
+ })
+ },
async saveMeasurement(pallet) {
const dimensions = Object.fromEntries(this.measurementFields.map(field => [field.key, Number(pallet[field.key])]))
if (!Object.values(dimensions).every(value => Number.isFinite(value) && value > 0)) { this.error = '请填写有效的长、宽、高和毛重'; return }
@@ -143,9 +161,9 @@ export default {
const binding = this.rebindSelection
const targetCode = this.targetCode.trim().toUpperCase()
if (!binding || !targetCode || this.busy) return
- if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已经绑定此托盘,不会重复扣库存。'); return }
+ if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已绑定此托盘'); return }
this.busy = true; this.$emit('busy', true)
- const confirmed = await new Promise(resolve => uni.showModal({ title: '确认改绑', content: `将 ${binding.mark} 从 ${binding.pallet} 移至 ${targetCode}?旧托盘扣箱,新托盘加箱。`, confirmText: '是', cancelText: '否', success: result => resolve(result.confirm), fail: () => resolve(false) }))
+ const confirmed = await new Promise(resolve => uni.showModal({ title: '确认改绑', content: `将 ${binding.mark} 从 ${binding.pallet} 移至 ${targetCode}?`, confirmText: '是', cancelText: '否', success: result => resolve(result.confirm), fail: () => resolve(false) }))
this.busy = false; this.$emit('busy', false)
if (!confirmed) return
await this.run(async () => {
@@ -237,16 +255,17 @@ export default {
let reason = ''
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。'
else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。'
- else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
- else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成本托盘的 SKU 贴标登记,再绑定新托盘;整托无需填写拆箱数量。'
+ else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请扫描确认来源托盘'
+ else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成 SKU 贴标登记'
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = this.isReturnBulk ? '本托盘可操作件数不足,请刷新来源托盘。' : '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
+ else if (this.measuredCartonBindings().some(row => !row.sourceCartonMark)) reason = '换标箱与来源实测箱不匹配,请核对来源托盘'
else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
await this.resolve('target')
if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
if (!this.readyToBind) { this.error = '请核对来源箱、目标托盘和换标数量后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
- if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?${this.samePallet(this.source, this.target) ? '整托保留原托盘且货物和包装未变可沿用原测量;包装变化请重新补测。' : ''}`)) { this.target = null; return }
+ if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?`)) { this.target = null; return }
await this.bind()
},
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
@@ -280,8 +299,11 @@ export default {
async createPallet() { const payload = { relabelNo: this.task.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'new-pallet', payload); await this.run(async () => { this.newPallet = await createRelabelPallet({ ...payload, idempotencyKey: key.value }); this.target = null; this.targetCode = this.newPallet.palletCode || ''; this.activate('target') }) },
async bind() {
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
+ const measuredCartons = this.measuredCartonBindings()
+ if (measuredCartons.some(row => !row.sourceCartonMark)) { this.error = '换标箱与来源实测箱不匹配,请核对来源托盘'; return }
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
const payload = { relabelNo: this.task.relabelNo, ...(this.unboundSourceMode ? {} : { source: expectation(this.source), actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) } }), target: expectation(this.target), cartonMarks: [...this.marks], reason: this.unboundSourceMode ? 'PDA 扫描无来源托盘退货箱并绑定目的托盘' : 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
+ if (measuredCartons.length) payload.measuredCartons = measuredCartons
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
let saved = false
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index 65cdc72..bca0969 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -5,7 +5,7 @@
出库任务整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”
当前任务出库作业详情
-
+
任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
@@ -45,8 +45,9 @@
废弃订单{{ discardCurrent.discardNo }}出库单 {{ discardCurrent.outboundNo }}
- 整托废弃扫描托盘号;部分箱子废弃或散箱作业,核对本单所选箱唛,也可一键废弃所选箱子。未选箱子保留库存。
- {{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱本单所选废弃 {{ sku.plannedPackages }} 箱已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱{{ mark }}{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。废弃现场照片(至少 1 张)照片关联当前废弃作业,完成出库后可在废弃单详情查看。
+ 本单废弃范围{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ mark }}整托废弃,请核对托盘及计划数量。
+
+ {{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}本次废弃 {{ discardQuantitySummary }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱{{ mark }}{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。废弃现场照片(至少 1 张)照片关联当前废弃作业,完成出库后可在废弃单详情查看。
{{ successMessage }}
@@ -101,6 +102,14 @@
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
discardOrderCount() { return this.discardOrders.length },
+ discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
+ discardQuantitySummary() {
+ const task = this.discardTask
+ if (!task) return ''
+ const pieces = task.skus.filter(sku => sku.quantityUnit === '件').reduce((total, sku) => total + sku.plannedPackages, 0)
+ const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
+ return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
+ },
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => Number(this.discardActuals[sku.itemId]) === sku.plannedPackages) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && (this.discardAllCartons || task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))))) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
@@ -133,10 +142,11 @@
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
updateDiscardPalletCode(value) { this.discardPalletCode = value },
async resolveDiscardPallet(raw) {
- const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
+ const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]; this.error = null; playSuccessSound(); return }
- if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && task.scanMode !== 'CARTON' && task.palletNo === code)) { this.setError(new Error('箱唛不属于当前废弃作业'), '箱唛核对失败'); return }
+ // 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
+ if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
const order = this.discardCurrent
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try {
diff --git a/src/pages/return-counting/return-counting.vue b/src/pages/return-counting/return-counting.vue
index 335375f..4b6eb5c 100644
--- a/src/pages/return-counting/return-counting.vue
+++ b/src/pages/return-counting/return-counting.vue
@@ -13,8 +13,7 @@
{{ task.palletCode }}{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}{{ task.returnNo }} · {{ task.boxes.length }} 箱{{ row.skuCode }}签收 {{ row.quantity }} / 已填 {{ row.actual }}
- 第 {{ index + 1 }} 箱{{ box.cartonMark }}体积{{ boxVolume(box) }} cm³
- {{ line.skuCode || '选择 SKU' }} ▾
+ 第 {{ index + 1 }} 箱{{ box.cartonMark }}
各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。箱内清点已完成,可以继续换标或出库。
@@ -22,13 +21,14 @@
diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs
index 787fee3..d7d92df 100644
--- a/tests/outbound-lifecycle.test.mjs
+++ b/tests/outbound-lifecycle.test.mjs
@@ -27,6 +27,16 @@ test('scanning a discard outbound document opens the discard workflow with its l
assert.equal(vm.discardCurrent.outboundNo, 'OB-1')
})
+test('退货废弃一箱五件分别展示,混装SKU按箱唛去重', () => {
+ const { vm } = page()
+ vm.discardTask = { cartonMarks: ['RETURN-BOX-1'], skus: [{ quantityUnit: '件', plannedPackages: 5 }] }
+ assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
+ vm.discardTask.skus = [{ quantityUnit: '件', plannedPackages: 2 }, { quantityUnit: '件', plannedPackages: 3 }]
+ assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
+ vm.discardTask = { cartonMarks: ['BOX-1', 'BOX-1'], skus: [{ quantityUnit: '箱', plannedPackages: 1 }] }
+ assert.equal(vm.discardQuantitySummary, '1 箱')
+})
+
test('discard pallet resolution passes the selected discard number', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
@@ -38,6 +48,45 @@ test('discard pallet resolution passes the selected discard number', async () =>
assert.deepEqual(received, ['P-1', 'CD-2'])
})
+test('点击打开废弃作业使用输入框值,不把 tap 事件当作托盘号', async () => {
+ let received
+ const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
+ received = args
+ return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
+ } })
+ vm.discardCurrent = { discardNo: 'CD-2' }
+ vm.discardPalletCode = ' P-1 '
+ await vm.resolveDiscardPallet({ type: 'tap', detail: { x: 10, y: 20 } })
+ assert.deepEqual(received, ['P-1', 'CD-2'])
+ assert.equal(vm.discardPalletCode, 'P-1')
+})
+
+test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,范围不扩展到其他单据', async () => {
+ const requests = []
+ const tasks = [
+ { discardNo: 'CD-1', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-1'], skus: [], status: 'PENDING' },
+ { discardNo: 'CD-1', palletNo: 'P-2', scanMode: 'CARTON', cartonMarks: ['BOX-2'], skus: [], status: 'PENDING' },
+ { discardNo: 'CD-OTHER', palletNo: 'P-3', scanMode: 'CARTON', cartonMarks: ['BOX-3'], skus: [], status: 'PENDING' },
+ ]
+ const { vm } = page({ resolveCargoDiscardPallet: async code => {
+ requests.push(code)
+ return tasks.find(task => task.palletNo === code || task.cartonMarks.includes(code))
+ } })
+ vm.discardTasks = tasks
+ vm.openDiscardOrder('CD-1')
+ await Promise.resolve()
+ assert.equal(vm.discardCurrentTasks.length, 2)
+ await vm.resolveDiscardPallet('P-1')
+ assert.equal(vm.error, null)
+ await vm.resolveDiscardPallet('BOX-2')
+ assert.equal(vm.discardTask.palletNo, 'P-2')
+ assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
+ await vm.resolveDiscardPallet('BOX-3')
+ assert.deepEqual(requests, ['P-1', 'BOX-2'])
+ assert.equal(vm.discardTask.palletNo, 'P-2')
+ assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
+})
+
test('discard requires restored photos and retains task while native camera returns', async () => {
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 3 }
diff --git a/tests/relabel-pallet-measurement.test.mjs b/tests/relabel-pallet-measurement.test.mjs
index 943c891..bfe9647 100644
--- a/tests/relabel-pallet-measurement.test.mjs
+++ b/tests/relabel-pallet-measurement.test.mjs
@@ -1,14 +1,16 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
+import { createSSRApp } from 'vue'
+import { renderToString } from 'vue/server-renderer'
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
const script = source.split('')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = []
- const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
+ const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'bindRelabelPallet', script)(
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
- () => ({ value: 'stable-key', signature: 'signature' }), () => {})
+ () => ({ value: 'stable-key', signature: 'signature' }), () => {}, async payload => calls.push(payload))
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
return { component, value, calls }
@@ -33,3 +35,91 @@ test('only whole physical pallet quantities offer original pallet reuse', () =>
ctx.plannedPackages = 10;ctx.isReturnBulk = true
assert.equal(canRetain.call(ctx), false)
})
+
+async function renderMeasurements(measurements) {
+ const { component } = setup()
+ const template = source.slice(source.indexOf('') + 10, source.indexOf('