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 @@ + + + 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 }} @@ -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 @@ {{ 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 @@ @@ -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('