Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test
This commit is contained in:
@@ -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 承载。
|
||||
|
||||
## 质量门禁
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template><view :class="{ 'compact-measure': volumeUnit === 'm3' }"><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="disabled" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="disabled" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="disabled" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="disabled" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} {{ volumeUnit === 'm3' ? 'm³' : 'cm³' }}</strong></view>
|
||||
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="disabled || !allowSkuChange || (lockFirstSku && lineIndex === 0)" :range="lineSkuCodes(lineIndex)" :value="Math.max(0, lineSkuCodes(lineIndex).indexOf(line.skuCode))" @change="line.skuCode = lineSkuCodes(lineIndex)[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><slot v-if="lineIndex === 0" name="sku-action" /><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="disabled" placeholder="数量" /><button v-if="!disabled && allowSkuChange && box.items.length > 1 && (!lockFirstSku || lineIndex > 0)" class="link-button" :disabled="disabled" @tap="box.items.splice(lineIndex, 1)">删除</button></view></view></template>
|
||||
<script>
|
||||
export default {
|
||||
props: { box: { type: Object, required: true }, skuCodes: { type: Array, default: () => [] }, volumeUnit: { type: String, default: 'cm3' }, disabled: Boolean, uniqueSkus: Boolean, lockFirstSku: Boolean, allowSkuChange: { type: Boolean, default: true } },
|
||||
methods: {
|
||||
// 混箱中同一 SKU 只出现一次,保留当前行已选择的 SKU。
|
||||
lineSkuCodes(index) { return this.uniqueSkus ? this.skuCodes.filter(sku => !this.box.items.some((line, other) => other !== index && line.skuCode === sku)) : this.skuCodes },
|
||||
// 转运按立方米显示三位;其他作业保留原来的立方厘米显示。
|
||||
boxVolume(box) { const value = Number(box.length) * Number(box.width) * Number(box.height); return Number.isFinite(value) && value > 0 ? (this.volumeUnit === 'm3' ? (value / 1000000).toFixed(3) : value.toFixed(2)) : '—' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:12px;word-break:break-all}.quantity{box-sizing:border-box;flex:none;width:90px;height:34px;text-align:center;font-size:13px;border:1px solid #cbd9e8;border-radius:8px;background:#fbfdff}.link-button{width:auto;font-size:12px;color:#135ee8}
|
||||
.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin:10px 0 6px}.measure-grid label text{display:block;margin-bottom:4px;color:#65768b;font-size:10px}.measure-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #cbd9e8;border-radius:8px;color:#203750;font-size:13px;text-align:center;background:#fbfdff}.volume-chip{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin:4px 0 8px;color:#728197;font-size:10px}.volume-chip strong{color:#135ee8;font-size:14px}
|
||||
.compact-measure .count-line{padding-top:8px;border-top:1px solid #edf1f6}.compact-measure .sku-picker{line-height:18px}.compact-measure .quantity{width:76px;font-weight:600;background:#fff}.compact-measure .measure-grid{gap:10px}.compact-measure .measure-input{height:38px;background:#f8fafd}.compact-measure .volume-chip{margin:8px 0;padding:6px 10px;border-radius:7px;background:#f4f8ff}.compact-measure .volume-chip strong{font-size:13px}</style>
|
||||
@@ -5,18 +5,23 @@
|
||||
<button class="refresh-button" :disabled="busy" @tap="loadSources">刷新</button>
|
||||
</view>
|
||||
<view v-if="currentBindings.length">
|
||||
<text class="hint">已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。</text>
|
||||
<button v-for="binding in currentBindings" :key="binding.mark" class="binding-row" :disabled="busy" @tap="selectRebind(binding)"><text class="binding-mark">{{ binding.mark }}</text><text class="binding-arrow">→</text><text class="binding-pallet">{{ binding.pallet }}</text><text class="binding-action">重新绑定</text></button>
|
||||
</view>
|
||||
<view v-for="pallet in boundPalletTotals" :key="pallet.palletCode" class="hint"><text>{{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}</text></view>
|
||||
<view v-for="pallet in measurements" :key="pallet.palletCode" class="measurement-card">
|
||||
<text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text>
|
||||
<text class="hint">{{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}</text>
|
||||
<text v-if="pallet.volumeCbm != null" class="hint">已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
|
||||
<view class="measurement-inputs">
|
||||
<text v-if="pallet.volumeCbm != null" class="hint">{{ pallet.inherited ? '来源汇总' : '已确认' }}:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
|
||||
<view v-if="pallet.inherited && pallet.sourceCartonSpecs?.length" class="source-carton-specs">
|
||||
<text class="source-caption">来源箱规(长 × 宽 × 高)</text>
|
||||
<text v-for="(spec, index) in pallet.sourceCartonSpecs" :key="index" class="hint">{{ spec.lengthCm }} × {{ spec.widthCm }} × {{ spec.heightCm }} cm · {{ spec.grossWeightKg }} kg/箱 · {{ spec.cartons }} 箱</text>
|
||||
</view>
|
||||
<text v-if="pallet.inherited && pallet.lengthCm > 0 && pallet.widthCm > 0 && pallet.heightCm > 0" class="hint">原整托尺寸:{{ pallet.lengthCm }} × {{ pallet.widthCm }} × {{ pallet.heightCm }} cm</text>
|
||||
<button v-if="!pallet.cartonAggregated && pallet.inherited && !pallet.remeasuring" :disabled="busy" @tap="pallet.remeasuring = true">包装变化,重新测量</button>
|
||||
<view v-if="!pallet.cartonAggregated && (!pallet.inherited || pallet.remeasuring)" class="measurement-inputs">
|
||||
<label v-for="field in measurementFields" :key="field.key"><text>{{ field.label }}</text><input v-model="pallet[field.key]" type="digit" :disabled="busy" :placeholder="field.label" /></label>
|
||||
</view>
|
||||
<button :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
|
||||
<button v-if="!pallet.cartonAggregated && (!pallet.inherited || pallet.remeasuring)" :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
|
||||
<button v-if="!pallet.cartonAggregated && pallet.inherited && pallet.remeasuring" :disabled="busy" @tap="pallet.remeasuring = false">取消重新测量</button>
|
||||
</view>
|
||||
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</button></view>
|
||||
<template v-if="!allBindingsComplete && !rebindSelection">
|
||||
@@ -27,17 +32,13 @@
|
||||
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘或来源箱</text>
|
||||
<ScanTargetInput v-if="!isReturnBulk && !unboundSourceMode" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
|
||||
<ScanTargetInput v-if="unboundSourceMode" v-model="sourceCode" name="relabel-source-carton" label="来源箱唛" placeholder="扫描来源箱唛确认" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
|
||||
<text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
|
||||
<text v-if="source" class="hint">来源已确认:{{ source.palletCode }}</text>
|
||||
<view v-if="selectedSource">
|
||||
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
|
||||
<template v-if="isReturnBulk">
|
||||
<text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text>
|
||||
<text v-if="wholeSource" class="hint">贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。</text>
|
||||
<template v-else-if="allowDisassemble"><input v-model="quantity" type="number" :disabled="busy" :placeholder="`请输入拆箱数量(应为 ${plannedPackages} 件)`" /><text class="hint">贴标数量少于本托盘库存,请按现场实际选择来源托盘并填写拆箱数量;本托盘应拆 {{ plannedPackages }} 件。</text></template>
|
||||
<text v-else class="hint">退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。</text>
|
||||
<text class="hint">退货换标无需扫描箱唛。</text>
|
||||
<input v-if="!wholeSource && allowDisassemble" v-model="quantity" type="number" :disabled="busy" :placeholder="`拆出件数(${plannedPackages} 件)`" />
|
||||
</template>
|
||||
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="marks = []" /><text>箱</text></view></template>
|
||||
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="prepareMeasuredBatch" /><text>箱</text></view></template>
|
||||
</view>
|
||||
<view class="split-progress"><template v-if="!isReturnBulk"><text>本次需换标拆 <strong>{{ requiredSplitPackages }}</strong> 箱</text><text>已拆 <strong>{{ splitPackages }}</strong> 箱</text></template><button class="create-pallet-button" :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
|
||||
</template>
|
||||
@@ -45,8 +46,6 @@
|
||||
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
|
||||
<button v-if="canRetainOriginal" :disabled="busy" @tap="quantity = String(source.totalPackages); targetCode = source.palletCode; confirmTargetAndBind()">整托全部贴标,保留原托盘</button>
|
||||
<button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">{{ rebindSelection ? '确认改绑目标托盘' : '确认绑定目标托盘' }}</button>
|
||||
<text v-if="labelBlockReason" class="hint">{{ labelBlockReason }}</text>
|
||||
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
|
||||
<view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
|
||||
</template>
|
||||
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
|
||||
@@ -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') })
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card">
|
||||
<view v-if="!discardCurrent" class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? (discardTask?.scanMode === 'CARTON' ? '核对废弃箱唛' : '打开废弃托盘') : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
|
||||
@@ -45,8 +45,9 @@
|
||||
|
||||
<template v-else-if="discardCurrent">
|
||||
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><button class="secondary-button" :disabled="!discardPalletCode || discardLoading || discardBusy" @tap="resolveDiscardPallet">{{ discardLoading ? '读取中…' : discardTask?.scanMode === 'CARTON' ? '核对箱唛' : '打开托盘废弃作业' }}</button><text class="scan-help">整托废弃扫描托盘号;部分箱子废弃或散箱作业,核对本单所选箱唛,也可一键废弃所选箱子。未选箱子保留库存。</text></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>本单所选废弃 {{ sku.plannedPackages }} 箱</text></view></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前废弃作业,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
||||
<view class="fulfillment-card"><text class="card-title">本单废弃范围</text><view v-for="task in discardCurrentTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text v-for="mark in task.cartonMarks" :key="mark" class="line-code">{{ mark }}</text><text v-if="!task.cartonMarks.length" class="scan-help">整托废弃,请核对托盘及计划数量。</text></view></view>
|
||||
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text class="task-meta">本次废弃 {{ discardQuantitySummary }}</text><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}</text></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前废弃作业,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
||||
</template>
|
||||
|
||||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
<template v-else>
|
||||
<view class="work-card"><text class="card-title">{{ task.palletCode }}</text><text class="hint">{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}</text><text class="hint">{{ task.returnNo }} · {{ task.boxes.length }} 箱</text><view v-for="row in totals" :key="row.skuCode" class="total-row" :class="{ matched: row.actual === row.quantity }"><text>{{ row.skuCode }}</text><text>签收 {{ row.quantity }} / 已填 {{ row.actual }}</text></view></view>
|
||||
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} cm³</strong></view>
|
||||
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="busy || task.status !== 'COUNTING'" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="busy || task.status !== 'COUNTING'" placeholder="数量" /><button v-if="task.status === 'COUNTING' && box.items.length > 1" class="link-button" :disabled="busy" @tap="box.items.splice(lineIndex, 1)">删除</button></view>
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
||||
</view>
|
||||
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||
</template>
|
||||
@@ -22,13 +21,14 @@
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import CartonCountFields from '@/components/CartonCountFields.vue'
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
components: { PortalHeader, ScanTargetInput, CartonCountFields },
|
||||
data() { return { user: session.currentUser, busy: false, error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
|
||||
computed: {
|
||||
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 }
|
||||
|
||||
@@ -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('<script>')[1].split('</script>')[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('<template>') + 10, source.indexOf('<script>')).trim().replace(/<\/template>$/, '')
|
||||
return renderToString(createSSRApp({ ...component, template,
|
||||
components: { ScanTargetInput: { template: '<view />' } },
|
||||
props: undefined,
|
||||
data() { return { ...component.data(), task: { relabelNo: 'RL-1', items: [], history: [] }, measurements } }
|
||||
}))
|
||||
}
|
||||
test('unchanged source renders real grouped box specs and totals without zero pallet dimension inputs', async () => {
|
||||
const html = await renderMeasurements([
|
||||
{ palletCode: 'FIRST', packages: 2, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
|
||||
grossWeightKg: 5, volumeCbm: 0.3, sourceCartonSpecs: [{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 2 }] },
|
||||
{ palletCode: 'SECOND', packages: 3, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
|
||||
grossWeightKg: 9.7, volumeCbm: 0.31, sourceCartonSpecs: [
|
||||
{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 1 },
|
||||
{ lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6, cartons: 2 }] }
|
||||
])
|
||||
assert.match(html, /60 × 50 × 50 cm · 2.5 kg\/箱 · 2 箱/)
|
||||
assert.match(html, /50 × 40 × 40 cm · 3.6 kg\/箱 · 2 箱/)
|
||||
assert.match(html, /来源汇总:0.31 m³ · 9.7 kg/)
|
||||
assert.doesNotMatch(html, /未测整托长宽高/)
|
||||
assert.equal((html.match(/class="measurement-inputs"/g) || []).length, 0)
|
||||
assert.doesNotMatch(html, /确认本托实测/)
|
||||
})
|
||||
test('changed pallets and explicit packaging changes still render final pallet measurement inputs', async () => {
|
||||
for (const pallet of [{ inherited: false }, { inherited: true, remeasuring: true }]) {
|
||||
const html = await renderMeasurements([{ ...pallet, palletCode: 'P', packages: 1 }])
|
||||
assert.match(html, /class="measurement-inputs"/)
|
||||
assert.match(html, /确认本托实测/)
|
||||
assert.match(html, /长 cm/)
|
||||
}
|
||||
})
|
||||
test('box aggregated pallets render current totals and no manual pallet measurement even after moving', async () => {
|
||||
const html = await renderMeasurements([{ palletCode: 'NEW', packages: 1, inherited: true, cartonAggregated: true,
|
||||
grossWeightKg: 3.6, volumeCbm: 0.08, sourceCartonSpecs: [{lengthCm:50,widthCm:40,heightCm:40,grossWeightKg:3.6,cartons:1}] }])
|
||||
assert.doesNotMatch(html, /移出扣减、移入增加/)
|
||||
assert.match(html, /0.08 m³ · 3.6 kg/)
|
||||
assert.doesNotMatch(html, /确认本托实测|重新测量|measurement-inputs/)
|
||||
})
|
||||
const mixedBoxes = [
|
||||
{ cartonMark: 'OTHER', lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5 },
|
||||
{ cartonMark: 'SMALL1', customerCartonMark: 'CUSTOMER', lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6 }
|
||||
]
|
||||
|
||||
test('mixed specs automatically bind the submitted carton without another picker', async () => {
|
||||
const { value, calls } = setup()
|
||||
Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, marks: ['CUSTOMER'], measuredBoxChoices: mixedBoxes,
|
||||
source: { palletCode: 'OLD', palletVersion: 1 }, target: { palletCode: 'NEW', palletVersion: 1 },
|
||||
selectedSource: { positionId: '1', positionVersion: 1 }, quantity: '1', loadSources: async () => {}, showBindingMessage() {} })
|
||||
await value.bind()
|
||||
assert.deepEqual(calls[0].cartonMarks, ['CUSTOMER'])
|
||||
assert.deepEqual(calls[0].measuredCartons, [{ cartonMark: 'CUSTOMER', sourceCartonMark: 'SMALL1' }])
|
||||
assert.doesNotMatch(source, /<picker|chooseMeasuredBox|点击选择实测箱/)
|
||||
})
|
||||
|
||||
test('warehouse carton marks also match automatically and follow the current batch', () => {
|
||||
const { value } = setup()
|
||||
Object.assign(value, { requiresMeasuredBoxMapping: true, measuredBoxChoices: mixedBoxes, marks: ['SMALL1'] })
|
||||
assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'SMALL1', sourceCartonMark: 'SMALL1' }])
|
||||
value.marks = ['OTHER']
|
||||
assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'OTHER', sourceCartonMark: 'OTHER' }])
|
||||
})
|
||||
|
||||
test('unmatched, ambiguous or duplicate physical boxes cannot bind another carton', async () => {
|
||||
for (const [marks, boxes] of [
|
||||
[['MISSING'], mixedBoxes],
|
||||
[['CUSTOMER'], [...mixedBoxes, { ...mixedBoxes[0], customerCartonMark: 'CUSTOMER' }]],
|
||||
[['CUSTOMER', 'SMALL1'], mixedBoxes]
|
||||
]) {
|
||||
const { value, calls } = setup()
|
||||
Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, measuredBoxChoices: boxes, marks })
|
||||
await value.bind()
|
||||
assert.equal(calls.length, 0)
|
||||
assert.match(value.error, /不匹配/)
|
||||
}
|
||||
})
|
||||
|
||||
test('entering split quantity prepares the batch and clears invalid quantities', () => {
|
||||
const { value } = setup()
|
||||
Object.assign(value, { measuredBoxChoices: [{ cartonMark: 'A' }], eligibleItems: [{ cartonMark: 'X' }],
|
||||
selectedSource: { movablePackages: 1 }, fillBatch() { this.marks = ['X'] } })
|
||||
value.prepareMeasuredBatch({ detail: { value: '1' } })
|
||||
assert.deepEqual(value.marks, ['X'])
|
||||
value.prepareMeasuredBatch({ detail: { value: '0' } })
|
||||
assert.deepEqual(value.marks, [])
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
|
||||
test('quantity disassembly has no extra carton scanner', () => {
|
||||
assert.doesNotMatch(source, /relabel-move-carton/)
|
||||
assert.match(source, /actualSource:/)
|
||||
assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
|
||||
assert.match(source, /来源已确认:\{\{ source.palletCode \}\}<\/text>/)
|
||||
assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
|
||||
})
|
||||
|
||||
@@ -96,7 +96,7 @@ test('rescan same bound pallet does not call stock movement', async () => {
|
||||
value.showBindingMessage = (title, content) => { message = content }
|
||||
value.run = async () => assert.fail('same pallet must not move stock')
|
||||
await value.submitRebind()
|
||||
assert.match(message, /不会重复扣库存/)
|
||||
assert.match(message, /已绑定此托盘/)
|
||||
})
|
||||
|
||||
test('target confirmation submits binding directly, with no second submit button', async () => {
|
||||
@@ -183,7 +183,7 @@ test('missing source scan shows modal and never submits a binding', async () =>
|
||||
value.bind = async () => assert.fail('must not bind without source confirmation')
|
||||
value.resolve = async () => assert.fail('must validate source first')
|
||||
await value.confirmTargetAndBind()
|
||||
assert.match(message, /扫描并确认实际来源托盘/)
|
||||
assert.match(message, /扫描确认来源托盘/)
|
||||
})
|
||||
|
||||
test('target lookup failure cannot submit binding or report success', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { runInNewContext } from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
|
||||
const scope = { PortalHeader: {}, ScanTargetInput: {} }
|
||||
const scope = { PortalHeader: {}, ScanTargetInput: {}, CartonCountFields: {} }
|
||||
runInNewContext(script, scope)
|
||||
|
||||
function state(a = '2', b = '2', c = '2') {
|
||||
|
||||
@@ -36,7 +36,7 @@ test('warehouse transfer page loads detail without exposing internal identifiers
|
||||
assert.match(page, /entryMode === 'work' && taskNo/)
|
||||
assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/)
|
||||
assert.match(page, /v-if="!selected && entryMode === 'list'"/)
|
||||
assert.match(page, /作业详情/)
|
||||
assert.match(page, /返回列表/)
|
||||
assert.match(page, /backToCandidates\(\)/)
|
||||
assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/)
|
||||
assert.match(page, /存在未完结的测量单/)
|
||||
@@ -54,12 +54,12 @@ test('warehouse transfer removes completed work from the pending list', async ()
|
||||
assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/)
|
||||
})
|
||||
|
||||
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', async () => {
|
||||
test('warehouse transfer shows forecast and actual variance and requires counted box reconciliation', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
|
||||
assert.match(page, /cartonDifference/)
|
||||
for (const label of ['客户预报', 'SKU实收', 'SKU签收差异']) assert.match(page, new RegExp(label))
|
||||
assert.match(page, /hasSkuReceiptDifference/)
|
||||
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
|
||||
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
|
||||
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
|
||||
})
|
||||
|
||||
test('warehouse transfer retries reuse the pending idempotency key', async () => {
|
||||
@@ -71,13 +71,13 @@ test('warehouse transfer retries reuse the pending idempotency key', async () =>
|
||||
|
||||
test('warehouse transfer measurement inputs retain explicit placeholders', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
for (const placeholder of ['长度', '宽度', '高度', '毛重']) assert.match(page, new RegExp(`placeholder="${placeholder}"`))
|
||||
assert.doesNotMatch(page, /v-model.number="pallet\.(lengthCm|widthCm|heightCm|grossWeightKg)"/)
|
||||
})
|
||||
|
||||
test('warehouse transfer measurements are grouped and summarized per pallet', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /v-for="\(pallet, index\) in pallets"/)
|
||||
for (const label of ['本托实测', '当前托重量', '当前托箱数', '自动体积']) assert.match(page, new RegExp(label))
|
||||
for (const label of ['本托箱子汇总', '实收箱数', '箱子重量 kg', '箱子体积 m³']) assert.match(page, new RegExp(label))
|
||||
assert.match(page, /palletVolume\(pallet\)/)
|
||||
assert.match(page, /rawPalletVolume\(pallet\)/)
|
||||
})
|
||||
@@ -137,8 +137,8 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
|
||||
assert.match(page, /handlePalletScan/)
|
||||
assert.match(page, /扫描或手输新托盘标签号/)
|
||||
assert.match(page, /已绑定实物托盘/)
|
||||
assert.match(page, /!pallet\.generated.*removePallet/)
|
||||
assert.match(page, /:disabled="pallet\.generated"/)
|
||||
assert.match(page, /@tap\.stop="removePallet\(index\)">×/)
|
||||
assert.match(page, /请先生成托盘号,再填写签收箱子/)
|
||||
assert.match(page, /collapsed: false/)
|
||||
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
|
||||
assert.match(page, /v-show="!pallet\.collapsed"/)
|
||||
@@ -149,5 +149,5 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
|
||||
assert.match(page, /bindWarehouseTransferPallet/)
|
||||
assert.match(page, /deleteWarehouseTransferPallet/)
|
||||
assert.match(page, /请先生成并扫描确认绑定全部托盘/)
|
||||
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
|
||||
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\], discrepancyReason:/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
const source = readFileSync(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue',import.meta.url),'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm,'').replace('export default','page =')
|
||||
function setup() {
|
||||
const storage = new Map()
|
||||
const scope = { CartonCountFields:{},PortalHeader:{},ScanTargetInput:{},session:{apiBase:'LOCAL',currentUser:{id:'OP',warehouseId:'WH'}},uni:{showModal: options=>options.success({confirm:true}),getStorageSync:key=>storage.get(key),setStorageSync:(key,value)=>storage.set(key,JSON.parse(JSON.stringify(value))),removeStorageSync:key=>storage.delete(key)} }
|
||||
runInNewContext(script,scope)
|
||||
const value = scope.page.data()
|
||||
for(const [key,method] of Object.entries(scope.page.methods)) value[key]=method.bind(value)
|
||||
for(const [key,get] of Object.entries(scope.page.computed)) Object.defineProperty(value,key,{get:()=>get.call(value)})
|
||||
value.selected={transferNo:'WT1',packageCount:1,status:'WAIT_PDA_RECEIVING',items:[{id:'I1',skuCode:'A',quantity:4}],cartons:[{cartonMark:'BOX1',transferItemId:'I1',status:'EXPECTED'}]}
|
||||
value.pallets=[{key:'P1',draftId:'P1',palletNo:'PALLET1',generated:true,bound:false}];value.restoreCartons();Object.assign(value.cartonBoxes[0],{length:10,width:20,height:30,weight:2});value.cartonBoxes[0].items[0].quantity=4
|
||||
return {scope,value,storage}
|
||||
}
|
||||
test('deletion needs confirmation and cancel preserves box and measurements',async()=>{
|
||||
const {value,scope}=setup(); let warning;
|
||||
scope.uni.showModal=options=>{warning=options;options.success({confirm:false})};
|
||||
await value.removeCarton(0);
|
||||
assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].weight,2);
|
||||
assert.match(warning.content,/不计入本次实收/);assert.equal(warning.confirmText,'确认删除');
|
||||
assert.equal(value.restorableCartons.length,0);assert.equal(value.deletingCarton,false);
|
||||
})
|
||||
|
||||
test('deleted box survives reload and restores original identity, SKU, measurements and pallet',async()=>{
|
||||
const {value}=setup(); await value.removeCarton(0);
|
||||
assert.equal(value.cartonBoxes.length,0);assert.equal(value.restorableCartons.length,1);
|
||||
value.restoreCartons();await value.restoreDeletedCarton();
|
||||
const box=value.cartonBoxes[0];
|
||||
assert.equal(box.cartonMark,'BOX1');assert.equal(box.weight,2);assert.equal(box.items[0].quantity,4);
|
||||
assert.equal(box.transferPalletId,'P1');assert.equal(value.restorableCartons.length,0);
|
||||
assert.equal(value.selected.packageCount,1);assert.equal(value.cartonConfirmed(box),false);
|
||||
})
|
||||
|
||||
test('saved missing box can be restored after reopening count without increasing forecast',async()=>{
|
||||
const {value,scope}=setup();
|
||||
value.selected.cartons[0].status='NOT_RECEIVED';value.selected.cartons[0].countedAt='2026-10-16';
|
||||
value.restoreCartons();assert.equal(value.cartonBoxes.length,0);
|
||||
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))});
|
||||
await value.restoreDeletedCarton();
|
||||
assert.equal(value.cartonsCounted,false);assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');
|
||||
assert.equal(value.selected.packageCount,1);assert.equal(value.restorableCartons.length,0);
|
||||
value.cartonBoxes[0].weight=9;value.saveCartonDraft();value.restoreCartons();
|
||||
assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');assert.equal(value.cartonBoxes[0].weight,9);
|
||||
})
|
||||
|
||||
test('previously deleted forecast box without an archive can still be restored',async()=>{
|
||||
const {value}=setup();value.cartonBoxes=[];value.saveCartonDraft();value.restoreCartons();
|
||||
assert.equal(value.restorableCartons.length,1);
|
||||
await value.restoreDeletedCarton();
|
||||
assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');assert.equal(value.selected.packageCount,1);
|
||||
})
|
||||
|
||||
test('summary toggle preserves measurements and volume uses three decimals',()=>{
|
||||
const {value}=setup()
|
||||
assert.equal(value.summaryCollapsed,false)
|
||||
value.summaryCollapsed=true
|
||||
assert.equal(value.cartonBoxes[0].weight,2)
|
||||
Object.assign(value.cartonBoxes[0],{length:10,width:10,height:6})
|
||||
value.addExtraCarton()
|
||||
Object.assign(value.cartonBoxes[1],{length:10,width:10,height:6,weight:2})
|
||||
assert.equal(value.palletVolume(value.pallets[0]),'0.001')
|
||||
assert.equal(value.totalVolume,'0.001')
|
||||
assert.match(source,/aria-label="添加 SKU"/)
|
||||
assert.match(source,/aria-label="删除箱子"/)
|
||||
})
|
||||
|
||||
test('remeasure preserves saved measurements and unlocks carton editing',async()=>{
|
||||
const {value,scope}=setup();
|
||||
const box={...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'};
|
||||
value.selected.cartons=[box]; value.cartonRequestKey='old-key';
|
||||
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:[{...box,countedAt:null}]});
|
||||
await value.remeasure();
|
||||
assert.equal(value.cartonsCounted,false); assert.equal(value.cartonBoxes[0].weight,2);
|
||||
assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.cartonRequestKey,'');
|
||||
assert.equal(value.cartonCountReady,true);
|
||||
})
|
||||
|
||||
test('bound pallet deletion retains box values and requires reassignment',async()=>{
|
||||
const {value,scope}=setup();
|
||||
scope.uni.showModal=options=>options.success({confirm:true});
|
||||
value.pallets[0].bound=true;
|
||||
scope.deleteWarehouseTransferPallet=async()=>({...value.selected,pallets:[]});
|
||||
await value.removePallet(0);
|
||||
assert.equal(value.cartonBoxes[0].transferPalletId,''); assert.equal(value.cartonBoxes[0].weight,2);
|
||||
assert.equal(value.generatedPallets.length,0); assert.equal(value.pallets.length,1);
|
||||
assert.equal(value.cartonCountReady,false); assert.equal(value.cartonPalletFilter,'');
|
||||
})
|
||||
|
||||
test('failed pallet deletion retains binding and measurements',async()=>{
|
||||
const {value,scope}=setup();
|
||||
scope.uni.showModal=options=>options.success({confirm:true});
|
||||
scope.deleteWarehouseTransferPallet=async()=>{throw new Error('offline')};
|
||||
await value.removePallet(0);
|
||||
assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.pallets[0].busy,false);
|
||||
assert.equal(value.error.message,'offline');
|
||||
})
|
||||
|
||||
test('transfer counts require valid pieces and measurements and record variance reason',()=>{
|
||||
const {value}=setup();assert.equal(value.cartonCountReady,true)
|
||||
value.cartonBoxes[0].items[0].quantity=0;assert.equal(value.cartonCountReady,false)
|
||||
value.cartonBoxes[0].items[0].quantity=1.5;assert.equal(value.cartonCountReady,false)
|
||||
value.cartonBoxes[0].items[0].quantity=4;value.cartonBoxes[0].height=0;assert.equal(value.cartonCountReady,false)
|
||||
value.cartonBoxes[0].height=30;value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
|
||||
assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
|
||||
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
|
||||
})
|
||||
test('network retry preserves request key and changed content uses a new request',async()=>{
|
||||
const {value,scope}=setup();const requests=[]
|
||||
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
|
||||
await value.confirmCartons();await value.confirmCartons()
|
||||
assert.equal(requests.length,2);assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)
|
||||
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
|
||||
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
|
||||
})
|
||||
test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
|
||||
const {value}=setup();value.addExtraCarton();value.countDiscrepancyReason='超收';value.saveCartonDraft();value.restoreCartons()
|
||||
assert.equal(value.cartonBoxes.length,2);assert.equal(value.countDiscrepancyReason,'超收')
|
||||
value.selected={...value.selected,transferNo:'WT2'};value.restoreCartons();assert.equal(value.cartonBoxes.length,1)
|
||||
})
|
||||
test('received count gates pallet completion and saving cannot switch active order',()=>{
|
||||
const {value}=setup();assert.match(value.validationMessage,/先完成全部箱子/)
|
||||
value.selected.cartons[0].countedAt='2026-10-15';value.cartonBoxes[0].transferPalletId='FOREIGN';assert.match(value.validationMessage,/已清点实收箱数/)
|
||||
value.countingCartons=true;value.backToCandidates();assert.equal(value.selected.transferNo,'WT1')
|
||||
})
|
||||
|
||||
test('batch applies only selected received boxes of one SKU and preserves independent drafts',()=>{
|
||||
const {value}=setup()
|
||||
value.selected.items.push({id:'I2',skuCode:'B'})
|
||||
value.addExtraCarton();value.addExtraCarton();value.addExtraCarton()
|
||||
value.cartonBoxes[2].items[0].skuCode='B'
|
||||
value.cartonBoxes[3].received=false
|
||||
value.selectBatchCartons(true)
|
||||
assert.equal(value.batchTargetBoxes.length,2)
|
||||
value.toggleBatchCarton(0)
|
||||
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
|
||||
value.batchBox.items[0].quantity=6
|
||||
assert.equal(value.batchTotalQuantity,6)
|
||||
value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
|
||||
assert.equal(value.cartonBoxes[2].items[0].quantity,'')
|
||||
assert.equal(value.cartonBoxes[3].items[0].quantity,'')
|
||||
value.batchBox.items[0].quantity=8
|
||||
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
|
||||
value.restoreCartons()
|
||||
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
|
||||
assert.equal(value.batchTargetBoxes.length,0)
|
||||
value.changeBatchSku(1)
|
||||
assert.equal(value.batchSku,'B')
|
||||
assert.equal(value.batchBox.items[0].quantity,'')
|
||||
})
|
||||
|
||||
test('batch guards invalid quantities, busy state, submitted facts and stale mixed SKU selections',()=>{
|
||||
const {value}=setup();value.selectBatchCartons(true)
|
||||
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
|
||||
for(const quantity of [0,1.5,2147483648]){
|
||||
value.batchBox.items[0].quantity=quantity;value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
}
|
||||
value.batchBox.items[0].quantity=6;value.countingCartons=true;value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
value.countingCartons=false;value.cartonBoxes[0].items[0].skuCode='B'
|
||||
assert.equal(value.batchTargetBoxes.length,0);value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
value.cartonBoxes[0].items[0].skuCode='A';value.selected.cartons[0].countedAt='now';value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
})
|
||||
|
||||
test('copy uses preceding received same SKU without sharing quantity objects or changing box identity',()=>{
|
||||
const {value}=setup();value.addExtraCarton();value.addExtraCarton()
|
||||
value.cartonBoxes[1].items[0].skuCode='B'
|
||||
value.copyPreviousCarton(1);assert.equal(value.cartonBoxes[1].items[0].quantity,'')
|
||||
const identity=value.cartonBoxes[2].localKey
|
||||
value.copyPreviousCarton(2)
|
||||
assert.equal(value.cartonBoxes[2].length,10)
|
||||
assert.equal(value.cartonBoxes[2].items[0].quantity,4)
|
||||
assert.equal(value.cartonBoxes[2].localKey,identity)
|
||||
value.cartonBoxes[2].items[0].quantity=9
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
value.cartonBoxes[0].received=false;assert.equal(value.previousMatchingCarton(2),undefined)
|
||||
})
|
||||
|
||||
|
||||
test('pallet totals follow actual box assignments and exclude missing boxes',()=>{
|
||||
const {value}=setup();value.pallets.push({key:'P2',draftId:'P2',palletNo:'PALLET2',generated:true})
|
||||
value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:40,width:20,height:10,weight:3,transferPalletId:'P2'});value.cartonBoxes[1].items[0].quantity=6
|
||||
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,1)
|
||||
assert.equal(value.palletSummary(value.pallets[1]).volume,0.008)
|
||||
assert.equal(value.totalWeight,'5.00')
|
||||
value.assignCartonPallet(value.cartonBoxes[1],0)
|
||||
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,2)
|
||||
assert.equal(value.palletSummary(value.pallets[1]).cartonCount,0)
|
||||
value.cartonBoxes[1].received=false
|
||||
assert.equal(value.totalWeight,'2.00')
|
||||
value.cartonBoxes[0].transferPalletId='FOREIGN';assert.equal(value.cartonCountReady,false)
|
||||
})
|
||||
|
||||
test('pallet number can be created before measurements and new boxes receive that identity',async()=>{
|
||||
const {value,scope}=setup();const requests=[]
|
||||
value.pallets=[{key:'NEW',generated:false,busy:false}];value.cartonBoxes[0].transferPalletId=''
|
||||
const pallet={id:'CREATED',sequenceNo:1,status:'GENERATED',palletNo:'NEWPALLET'}
|
||||
scope.saveWarehouseTransferPalletDraft=async(no,request)=>{requests.push(request);return {pallets:[{...pallet,status:'DRAFT'}]}}
|
||||
scope.generateWarehouseTransferPallet=async()=>({pallets:[pallet]})
|
||||
await value.generatePallet(value.pallets[0])
|
||||
assert.equal(requests.length,1);assert.equal('lengthCm' in requests[0],false);assert.equal('grossWeightKg' in requests[0],false)
|
||||
assert.equal(value.cartonBoxes[0].transferPalletId,'CREATED')
|
||||
assert.equal(value.pallets[0].palletNo,'NEWPALLET')
|
||||
})
|
||||
|
||||
// 最终完成入口需要区分无差异、取消、空白原因和网络重试。
|
||||
for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:true,content:'少到一箱'}]) {
|
||||
test('completion handles discrepancy answer '+JSON.stringify(answer),async()=>{
|
||||
const {value,scope}=setup();let requests=[];let prompts=0
|
||||
value.selected.items[0].quantity=5;value.selected.cartons[0].countedAt='2026-10-16'
|
||||
value.pallets[0].bound=true;value.photoFileIds=[91]
|
||||
scope.uni.showModal=options=>{prompts++;options.success(answer)}
|
||||
scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
|
||||
await value.submit()
|
||||
assert.equal(prompts,1);assert.equal(value.submitting,false)
|
||||
assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
|
||||
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)}
|
||||
})
|
||||
}
|
||||
test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{
|
||||
const {value,scope}=setup();let prompts=0;let requests=[]
|
||||
value.selected.packageCount=9;value.selected.cartons[0].countedAt='2026-10-16';value.pallets[0].bound=true
|
||||
scope.uni.showModal=()=>{prompts++}
|
||||
scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
|
||||
await value.submit();assert.equal(requests.length,0);assert.equal(prompts,0)
|
||||
value.photoFileIds=[91];await value.submit();assert.equal(prompts,0);assert.equal(requests.length,1);assert.equal(requests[0].discrepancyReason,null)
|
||||
})
|
||||
|
||||
// 逐箱确认收起、再次编辑及草稿恢复必须保留其他箱的确认状态。
|
||||
test('individual confirmation collapses only that box and resumes from draft',async()=>{
|
||||
const {value,scope}=setup();value.addExtraCarton()
|
||||
Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
|
||||
let saves=0;scope.countWarehouseTransferCartons=async()=>{saves++;throw new Error('offline')}
|
||||
await value.confirmSingleCarton(value.cartonBoxes[0])
|
||||
assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true);assert.equal(value.allCartonsConfirmed,false);assert.equal(saves,0)
|
||||
value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
|
||||
await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
|
||||
await value.confirmSingleCarton(value.cartonBoxes[0]);await value.confirmSingleCarton(value.cartonBoxes[1])
|
||||
assert.equal(value.allCartonsConfirmed,true);assert.equal(saves,1);assert.equal(value.error.message,'offline')
|
||||
value.cartonBoxes[0].weight=3;assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.allCartonsConfirmed,false)
|
||||
})
|
||||
test('saved boxes restore collapsed and editing reopens only selected box',async()=>{
|
||||
const {value,scope}=setup()
|
||||
const first={...value.cartonBoxes[0],quantity:4,countedAt:'2026-10-16',status:'COUNTED'}
|
||||
const second={...first,cartonMark:'BOX2'}
|
||||
value.selected.cartons=[first,second];value.restoreCartons()
|
||||
assert.equal(value.cartonBoxes.every(box=>value.cartonConfirmed(box)),true)
|
||||
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))})
|
||||
await value.editCarton(value.cartonBoxes[0])
|
||||
assert.equal(value.cartonsCounted,false);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
|
||||
value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
|
||||
})
|
||||
test('failed reopening preserves confirmed data and invalid box cannot confirm',async()=>{
|
||||
const {value,scope}=setup();value.cartonBoxes[0].weight=0
|
||||
await value.confirmSingleCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
|
||||
value.cartonBoxes[0].weight=2;value.selected.cartons=[{...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'}];value.restoreCartons()
|
||||
scope.reopenWarehouseTransferMeasurement=async()=>{throw new Error('offline')}
|
||||
await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonsCounted,true);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
|
||||
})
|
||||
|
||||
// 箱数变化不影响 SKU 门禁,异 SKU 正负差异不能按总件数抵消。
|
||||
test('SKU differences compare each SKU and ignore carton count',()=>{
|
||||
const {value}=setup();value.selected.packageCount=9
|
||||
assert.equal(value.hasSkuReceiptDifference,false)
|
||||
value.selected.items.push({id:'I2',skuCode:'B',quantity:3})
|
||||
value.cartonBoxes[0].items=[{skuCode:'A',quantity:3},{skuCode:'B',quantity:4}]
|
||||
assert.equal(value.cartonQuantity,value.forecastSkuQuantity)
|
||||
assert.equal(value.skuReceiptDifferences.length,2)
|
||||
value.cartonBoxes[0].items=[{skuCode:'A',quantity:4},{skuCode:'B',quantity:3}]
|
||||
assert.equal(value.hasSkuReceiptDifference,false)
|
||||
})
|
||||
|
||||
// 客户补箱后,新增预报箱进入PDA,原本机草稿和删除标记不丢失。
|
||||
test('forecast additions merge into existing draft without losing edited boxes',async()=>{
|
||||
const {value}=setup();value.cartonBoxes[0].weight=7;value.saveCartonDraft()
|
||||
value.selected.packageCount=2;value.selected.cartons.push({cartonMark:'BOX2',transferItemId:'I1',status:'EXPECTED'})
|
||||
value.restoreCartons();assert.equal(value.cartonBoxes.length,2);assert.equal(value.cartonBoxes[0].weight,7);assert.equal(value.cartonBoxes[1].cartonMark,'BOX2')
|
||||
await value.removeCarton(0);value.restoreCartons();assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].cartonMark,'BOX2')
|
||||
})
|
||||
test('count payload carries forecast version for stale snapshot rejection',async()=>{
|
||||
const {value,scope}=setup();let payload
|
||||
scope.countWarehouseTransferCartons=async(no,request)=>{payload=request;throw new Error('offline')}
|
||||
await value.confirmCartons();assert.equal(payload.forecastPackageCount,1);assert.equal(payload.boxes[0].cartonMark,'BOX1')
|
||||
})
|
||||
|
||||
test('新增预报箱无SKU绑定时恢复为可编辑箱,保留已有草稿', () => {
|
||||
const { value } = setup()
|
||||
value.selected.cartons.push({ cartonMark: 'NEW-UNBOUND', cartonIndex: 2, status: 'EXPECTED', transferItemId: null })
|
||||
value.selected.packageCount = 2
|
||||
value.restoreCartons()
|
||||
const added = value.cartonBoxes.find(box => box.cartonMark === 'NEW-UNBOUND')
|
||||
assert.ok(added)
|
||||
assert.ok(value.declaredSkuCodes.includes(added.items[0].skuCode))
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const source = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'return')
|
||||
const page = new Function('CartonCountFields', 'PortalHeader', 'ScanTargetInput', 'session', 'uni', script)({}, {}, {}, { currentUser: null }, { showModal: options => options.success({confirm:true}), getStorageSync: key => globalThis.uni?.getStorageSync(key) })
|
||||
function context() {
|
||||
const ctx = { ...page.data(), selected: { transferNo: 'WT1', packageCount: 2, items: [{ id: '1', skuCode: 'A' }, { id: '2', skuCode: 'B' }], cartons: [] }, saveCartonDraft() {} }
|
||||
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
|
||||
for (const [name, getter] of Object.entries(page.computed)) Object.defineProperty(ctx, name, { get: getter.bind(ctx), configurable: true })
|
||||
ctx.saveCartonDraft = () => {}
|
||||
return ctx
|
||||
}
|
||||
const box = (sku = 'A', quantity = 3) => ({ received: true, length: 10, width: 20, height: 30, weight: 2, items: [{ skuCode: sku, quantity }] })
|
||||
|
||||
test('one carton can add distinct forecast SKUs and validates every quantity', () => {
|
||||
const ctx = context(), current = box()
|
||||
ctx.addCartonSku(current)
|
||||
assert.deepEqual(current.items, [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: '' }])
|
||||
assert.equal(ctx.validCartonSkuLines(current), false)
|
||||
current.items[1].quantity = 7
|
||||
assert.equal(ctx.validCartonSkuLines(current), true)
|
||||
ctx.addCartonSku(current)
|
||||
assert.equal(current.items.length, 2)
|
||||
current.items[1].skuCode = 'A'
|
||||
assert.equal(ctx.validCartonSkuLines(current), false)
|
||||
current.items[1].skuCode = 'FOREIGN'
|
||||
assert.equal(ctx.validCartonSkuLines(current), false)
|
||||
})
|
||||
|
||||
test('single SKU batch changes never overwrite mixed carton contents', () => {
|
||||
const ctx = context(), single = box(), mixed = box()
|
||||
mixed.items.push({ skuCode: 'B', quantity: 7 })
|
||||
ctx.cartonBoxes = [single, mixed]; ctx.batchSku = 'A'
|
||||
ctx.selectBatchCartons(true)
|
||||
assert.deepEqual(ctx.batchSelectedIndexes, [0])
|
||||
ctx.toggleBatchCarton(1)
|
||||
assert.deepEqual(ctx.batchSelectedIndexes, [0])
|
||||
assert.equal(ctx.previousMatchingCarton(1), null)
|
||||
assert.equal(ctx.cartonQuantity, 13)
|
||||
})
|
||||
|
||||
test('delete only changes actual draft and preserves forecast; additions keep their own SKU rows', async () => {
|
||||
const ctx = context()
|
||||
ctx.cartonBoxes = [box(), box('B')]; ctx.batchSelectedIndexes = [1]
|
||||
await ctx.removeCarton(0)
|
||||
assert.equal(ctx.cartonBoxes.length, 1)
|
||||
assert.equal(ctx.selected.packageCount, 2)
|
||||
assert.deepEqual(ctx.batchSelectedIndexes, [])
|
||||
ctx.addExtraCarton()
|
||||
assert.equal(ctx.cartonBoxes.length, 2)
|
||||
assert.equal(ctx.cartonBoxes[1].items[0].skuCode, 'A')
|
||||
ctx.cartonBoxes[1].items[0].quantity = 8
|
||||
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 3)
|
||||
})
|
||||
|
||||
test('draft reload preserves deletions, including an empty actual list', () => {
|
||||
const ctx = context()
|
||||
ctx.selected.cartons = [{ cartonMark: 'BOX1', transferItemId: '1', status: 'EXPECTED' }, { cartonMark: 'BOX2', transferItemId: '2', status: 'EXPECTED' }]
|
||||
let saved = { forecastMarks: ['BOX1','BOX2'], boxes: [{ ...box('B'), cartonMark: 'BOX2', transferItemId: '2' }], discrepancyReason: '少到一箱' }
|
||||
globalThis.uni = { getStorageSync: () => saved }
|
||||
try {
|
||||
ctx.restoreCartons()
|
||||
assert.equal(ctx.cartonBoxes.length, 1)
|
||||
assert.equal(ctx.cartonBoxes[0].cartonMark, 'BOX2')
|
||||
assert.equal(ctx.countDiscrepancyReason, '少到一箱')
|
||||
saved = { forecastMarks: ['BOX1','BOX2'], boxes: [], discrepancyReason: '重新录入' }
|
||||
ctx.restoreCartons()
|
||||
assert.equal(ctx.cartonBoxes.length, 0)
|
||||
assert.equal(ctx.selected.cartons.length, 2)
|
||||
} finally { delete globalThis.uni }
|
||||
})
|
||||
|
||||
test('100 same SKU cartons accept two distinct specification groups and independent exceptions', () => {
|
||||
const ctx = context()
|
||||
ctx.selected.packageCount = 100
|
||||
ctx.pallets = [{ generated: true, draftId: 'P1' }, { generated: true, draftId: 'P2' }]
|
||||
ctx.cartonBoxes = Array.from({ length: 100 }, (_, index) => ({ ...box('A', ''), localKey: `BOX-${index}`, transferItemId: '1', length: '', width: '', height: '', weight: '' }))
|
||||
ctx.batchSku = 'A'; ctx.batchPalletId = 'P1'; ctx.batchBox = { ...box('A', 12), length: 40, width: 30, height: 20, weight: 5 }
|
||||
ctx.selectBatchCartons(true)
|
||||
for (let index = 60; index < 100; index++) ctx.toggleBatchCarton(index)
|
||||
assert.equal(ctx.batchTargetBoxes.length, 60)
|
||||
assert.equal(ctx.batchTotalQuantity, 720)
|
||||
ctx.applyBatchCartons()
|
||||
assert.equal(ctx.cartonBoxes[59].items[0].quantity, 12)
|
||||
assert.equal(ctx.cartonBoxes[60].items[0].quantity, '')
|
||||
ctx.selectBatchCartons(true)
|
||||
for (let index = 0; index < 60; index++) ctx.toggleBatchCarton(index)
|
||||
ctx.batchPalletId = 'P2'; ctx.batchBox = { ...box('A', 8), length: 50, width: 40, height: 30, weight: 7 }
|
||||
ctx.applyBatchCartons()
|
||||
assert.equal(ctx.palletSummary(ctx.pallets[0]).cartonCount, 60)
|
||||
assert.equal(ctx.palletSummary(ctx.pallets[1]).cartonCount, 40)
|
||||
assert.equal(ctx.cartonQuantity, 1040)
|
||||
assert.equal(ctx.totalWeight, '580.00')
|
||||
assert.equal(ctx.totalVolume, '3.840')
|
||||
assert.equal(ctx.cartonCountReady, true)
|
||||
ctx.cartonBoxes[0].items[0].quantity = 11
|
||||
ctx.cartonBoxes[0].weight = 6
|
||||
assert.equal(ctx.cartonBoxes[1].items[0].quantity, 12)
|
||||
assert.equal(ctx.cartonBoxes[1].weight, 5)
|
||||
assert.equal(ctx.batchBox.items[0].quantity, 8)
|
||||
assert.equal(ctx.cartonQuantity, 1039)
|
||||
assert.equal(ctx.totalWeight, '581.00')
|
||||
})
|
||||
|
||||
test('copy previous matching SKU skips other SKUs and preserves the target pallet', () => {
|
||||
const ctx = context()
|
||||
ctx.cartonBoxes = [{ ...box('A', 12), length: 40, width: 30, height: 20, weight: 5, transferPalletId: 'P1' }, box('B', 99), { ...box('A', ''), transferPalletId: 'P2' }]
|
||||
ctx.copyPreviousCarton(2)
|
||||
assert.equal(ctx.cartonBoxes[2].length, 40)
|
||||
assert.equal(ctx.cartonBoxes[2].weight, 5)
|
||||
assert.equal(ctx.cartonBoxes[2].items[0].quantity, 12)
|
||||
assert.equal(ctx.cartonBoxes[2].transferPalletId, 'P2')
|
||||
ctx.cartonBoxes[2].items[0].quantity = 9
|
||||
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 12)
|
||||
})
|
||||
|
||||
test('预报箱可调整原SKU,新增无绑定箱也可清点', () => {
|
||||
const ctx = context(); const current = { ...box('B', 4), cartonMark: 'FORECAST-1', transferItemId: '1' }
|
||||
assert.equal(ctx.validCartonSkuLines(current), true)
|
||||
current.transferItemId = null
|
||||
assert.equal(ctx.validCartonSkuLines(current), true)
|
||||
current.items[0].skuCode = 'OTHER'
|
||||
assert.equal(ctx.validCartonSkuLines(current), false)
|
||||
assert.doesNotMatch(source, /:lock-first-sku="Boolean\(box.cartonMark\)"/)
|
||||
})
|
||||
Reference in New Issue
Block a user