Merge pull request 'Release/test' (#2) from release/test into dev
Reviewed-on: https://git.buggoglobal.com/html/pda-overseas-uniapp/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
|
||||
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
|
||||
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
|
||||
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | 工作台键 `warehouseTransfer`;候选→详情→逐托测量草稿→生成新托盘号→扫码绑定→照片与完成打托。API 模块路径 `/pda/v1/last-leg/warehouse-transfers` 经统一海外请求层处理;不是 Admin 转运审核或排车转派送。回归 `tests/warehouse-transfer-api.test.mjs` |
|
||||
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
|
||||
| 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 |
|
||||
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
|
||||
@@ -56,6 +57,14 @@
|
||||
|
||||
## 海外业务接口边界
|
||||
|
||||
- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `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` 并清空上一轮输入和照片草稿。
|
||||
@@ -79,6 +88,7 @@
|
||||
## 质量门禁
|
||||
|
||||
- 安装:`npm install`;检查:`npm test`。
|
||||
- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
|
||||
- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
|
||||
- 当前 uni-app H5 编译链要求 `vue-i18n` 精确固定为 `9.1.9`;不得改回范围版本或单独升级。依赖变更必须同步 `package.json`、`package-lock.json` 与现有 `pnpm-lock.yaml`,并至少执行 H5 编译门禁。
|
||||
- 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
|
||||
|
||||
+3
-2
@@ -14,8 +14,9 @@ export const getCurrentWarehouseProfile = () => {
|
||||
}
|
||||
return warehouseProfileRequest
|
||||
}
|
||||
export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`))
|
||||
export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request))
|
||||
const receivingIdentityQuery = (sourceContainerBookingId) => `sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`
|
||||
export const getReceivingContainer = async (containerNo, sourceContainerBookingId) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}?${receivingIdentityQuery(sourceContainerBookingId)}`))
|
||||
export const prepareReceivingContainer = async (containerNo, sourceContainerBookingId, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare?${receivingIdentityQuery(sourceContainerBookingId)}`, request))
|
||||
export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath)
|
||||
export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
|
||||
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
|
||||
|
||||
+48
-2
@@ -42,6 +42,13 @@ async function tokenAfterUnauthorized(failedToken) {
|
||||
|
||||
function overseasUrl(path) {
|
||||
const base = String(session.apiBase || '').replace(/\/$/, '')
|
||||
const nativeRuntime = typeof plus !== 'undefined'
|
||||
|| (typeof location !== 'undefined' && location.protocol === 'file:')
|
||||
if (nativeRuntime && !/^https?:\/\//i.test(base)) {
|
||||
const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_OVERSEAS_API_BASE_URL')
|
||||
error.code = 'PDA_OVERSEAS_API_BASE_REQUIRED'
|
||||
throw error
|
||||
}
|
||||
const value = `/${String(path || '').replace(/^\/+/, '')}`
|
||||
const normalized = /\/(?:api|overseas-api)$/.test(base)
|
||||
? value.replace(/^\/api(?=\/)/, '')
|
||||
@@ -79,14 +86,53 @@ export async function overseasRequest(method, path, data, retry = true) {
|
||||
}
|
||||
|
||||
function rawUpload(path, filePath, name, token) {
|
||||
const browserFile = typeof Blob !== 'undefined' && filePath instanceof Blob
|
||||
if (!browserFile && !String(filePath || '').trim()) {
|
||||
const error = new Error('未获取到可上传的照片路径,请重新拍摄')
|
||||
error.code = 'PDA_UPLOAD_FILE_PATH_REQUIRED'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
let url
|
||||
try { url = overseasUrl(path) } catch (error) { return Promise.reject(error) }
|
||||
const h5Runtime = typeof window !== 'undefined' && typeof plus === 'undefined'
|
||||
if (h5Runtime && browserFile) return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url, files: [{ name, file: filePath }],
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
success: resolve,
|
||||
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
|
||||
})
|
||||
})
|
||||
if (h5Runtime && /^blob:/i.test(filePath)) {
|
||||
const selectedFile = fetch(filePath).then((source) => {
|
||||
if (!source.ok) throw new Error('无法读取已选择的照片')
|
||||
return source.blob()
|
||||
})
|
||||
return selectedFile.then(async (blob) => {
|
||||
const form = new FormData()
|
||||
const suffix = String(blob.type || '').split('/')[1] || 'jpg'
|
||||
form.append(name, blob, blob.name || `pda-photo.${suffix}`)
|
||||
if (typeof XMLHttpRequest !== 'undefined') return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest()
|
||||
request.open('POST', url)
|
||||
request.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
|
||||
request.onerror = () => reject(new Error('海外作业文件上传失败'))
|
||||
request.send(form)
|
||||
})
|
||||
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
|
||||
return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
|
||||
})
|
||||
.catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: overseasUrl(path),
|
||||
url,
|
||||
filePath,
|
||||
name,
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
success: resolve,
|
||||
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true }))
|
||||
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
|
||||
const BASE = '/pda/v1/last-leg/pallet-operations'
|
||||
export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
|
||||
export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
|
||||
export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
|
||||
|
||||
export function resolvePallet(code) {
|
||||
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js
|
||||
|
||||
const BASE = '/pda/v1/last-leg/relabel-tasks'
|
||||
const encoded = (value) => encodeURIComponent(String(value || '').trim())
|
||||
export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
|
||||
|
||||
function asList(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js'
|
||||
|
||||
const BASE = '/pda/v1/last-leg/warehouse-transfers'
|
||||
const encoded = (value) => encodeURIComponent(String(value || '').trim())
|
||||
|
||||
export async function listWarehouseTransferCandidates() {
|
||||
const value = await overseasRequest('GET', `${BASE}/candidates`)
|
||||
return Array.isArray(value) ? value : (Array.isArray(value?.records) ? value.records : [])
|
||||
}
|
||||
|
||||
export function getWarehouseTransferDetail(transferNo) {
|
||||
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}`)
|
||||
}
|
||||
|
||||
export function palletizeWarehouseTransfer(transferNo, payload) {
|
||||
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/palletize`, payload)
|
||||
}
|
||||
|
||||
export function saveWarehouseTransferPalletDraft(transferNo, payload) {
|
||||
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/drafts`, payload)
|
||||
}
|
||||
|
||||
export function generateWarehouseTransferPallet(transferNo, palletId, payload) {
|
||||
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/generate`, payload)
|
||||
}
|
||||
|
||||
export function bindWarehouseTransferPallet(transferNo, palletId, payload) {
|
||||
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/bind`, payload)
|
||||
}
|
||||
|
||||
export function deleteWarehouseTransferPallet(transferNo, palletId) {
|
||||
return overseasRequest('DELETE', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}`)
|
||||
}
|
||||
|
||||
export function uploadWarehouseTransferMeasurementPhoto(transferNo, filePath) {
|
||||
return overseasUpload(`${BASE}/${encoded(transferNo)}/measurement-photos/files`, filePath)
|
||||
}
|
||||
|
||||
export function listWarehouseTransferMeasurementPhotos(transferNo) {
|
||||
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/measurement-photos/files`)
|
||||
}
|
||||
|
||||
export function downloadWarehouseTransferMeasurementPhoto(transferNo, fileId) {
|
||||
return overseasDownload(`${BASE}/${encoded(transferNo)}/measurement-photos/files/${encoded(fileId)}/download`)
|
||||
}
|
||||
|
||||
export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
|
||||
const path = String(labelDownloadPath || '').trim()
|
||||
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
|
||||
return overseasDownload(path)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<view class="pallet-work">
|
||||
<text class="heading">换标后组托</text>
|
||||
<view v-if="currentBindings.length">
|
||||
<text class="hint">已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。</text>
|
||||
<button v-for="binding in currentBindings" :key="binding.mark" :disabled="busy" @tap="selectRebind(binding)">{{ binding.mark }} → {{ binding.pallet }} · 重新绑定</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>
|
||||
<view v-for="(entry, index) in bindingHistory" :key="index" class="hint">已记录组托:{{ entry.cartonMarks.join('、') }} → {{ entry.targetPallets.join('、') }}(含历史轮次)</view>
|
||||
<text class="hint">按原入仓单拆托,再合入同新目的仓的托盘。下方为入仓单在各托盘的箱数,并非历史箱唛定位;合托后仍归属原入仓单。</text>
|
||||
<button :disabled="busy" @tap="loadSources">刷新来源托盘</button>
|
||||
<view v-for="row in sourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
|
||||
<text>{{ row.palletCode }} · {{ row.inboundCode }}</text>
|
||||
<text class="hint">{{ row.locationCode || '未维护库位' }} · 在库 {{ row.stockPackages }} 箱 · 可拆 {{ row.movablePackages }} 箱</text>
|
||||
</view>
|
||||
<text v-if="!busy && !sourceRows.length" class="hint">当前没有可操作的来源托盘</text>
|
||||
<ScanTargetInput v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="resolve('source')" />
|
||||
<button :disabled="busy || !sourceCode.trim()" @tap="resolve('source')">确认来源托盘</button>
|
||||
<text v-if="source" class="hint">已确认:{{ source.palletCode }}</text>
|
||||
<view v-if="selectedSource">
|
||||
<text class="hint">本次拆出箱数(可分多个来源托盘,逐批提交)</text>
|
||||
<input v-model="quantity" type="number" :disabled="busy" placeholder="输入实际拆出箱数" @input="marks = []" />
|
||||
<text class="hint">选择本批新目的仓</text>
|
||||
<view class="line"><button v-for="dest in destinations" :key="dest" :disabled="busy" @tap="selectedDestination = dest; marks = []">{{ selectedDestination === dest ? '✓ ' : '' }}{{ dest }}</button></view>
|
||||
<button :disabled="busy" @tap="fillBatch">按箱数填入本批换标箱</button>
|
||||
<text class="hint">请核对下面勾选的箱与现场搬动一致;数量不符时不能提交。此处确认的是新目标托盘绑定,不推断旧箱唛位置。</text>
|
||||
<view v-for="item in eligibleItems" :key="item.cartonMark" class="line" @tap="toggleMark(item.cartonMark)"><text>{{ marks.includes(item.cartonMark) ? '☑' : '☐' }} {{ item.cartonMark }} → {{ item.fbaCartonMark || item.destinationFbaWarehouseCode }}</text></view>
|
||||
<text class="hint">本批确认 {{ marks.length }} 箱 / 拆出 {{ quantity || 0 }} 箱</text>
|
||||
</view>
|
||||
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
|
||||
<view class="line"><button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">确认目标托盘并绑定</button><button :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
|
||||
<text class="hint">确认后直接提交本批拆托合托,只有提示“绑定成功”才表示已保存。</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>
|
||||
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources } from '@/api/pallet.js'
|
||||
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
|
||||
export default {
|
||||
components: { ScanTargetInput },
|
||||
props: { task: { type: Object, required: true } },
|
||||
emits: ['activate', 'busy', 'saved'],
|
||||
data() { return { 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: {
|
||||
currentBindings() {
|
||||
const latest = new Map()
|
||||
for (const entry of this.task.history || []) {
|
||||
if (entry.action === 'REWORK_ARCHIVE') latest.clear()
|
||||
if (entry.action !== 'PALLET_BIND') continue
|
||||
try {
|
||||
const data = JSON.parse(entry.detail)
|
||||
if (data.targetPallets?.length !== 1) continue
|
||||
for (const mark of data.cartonMarks || []) latest.set(mark, { mark, pallet: data.targetPallets[0] })
|
||||
} catch { /* Invalid history is not used to infer a binding. */ }
|
||||
}
|
||||
return [...latest.values()]
|
||||
},
|
||||
bindingHistory() { return (this.task.history || []).filter(entry => entry.action === 'PALLET_BIND').flatMap(entry => { try { const data = JSON.parse(entry.detail); return Array.isArray(data.cartonMarks) && Array.isArray(data.targetPallets) ? [data] : [] } catch { return [] } }) },
|
||||
destinations() { return [...new Set(this.completedItems().map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
|
||||
eligibleItems() { return this.completedItems().filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
|
||||
readyToBind() { return this.source && this.target && this.selectedSource && this.source.palletCode === this.sourceCode.trim().toUpperCase() && this.target.palletCode === this.targetCode.trim().toUpperCase() && this.source.palletCode === this.selectedSource.palletCode && this.source.palletCode !== this.target.palletCode && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.marks.length === Number(this.quantity) }
|
||||
},
|
||||
methods: {
|
||||
selectRebind(binding) {
|
||||
if (this.busy) return
|
||||
uni.showModal({ title: '重新绑定托盘', content: `箱唛 ${binding.mark} 当前在 ${binding.pallet}。请扫描新的目标托盘。`, showCancel: false })
|
||||
this.rebindSelection = binding
|
||||
this.targetCode = ''; this.target = null; this.message = ''; this.error = ''
|
||||
this.activate('target')
|
||||
},
|
||||
async submitRebind() {
|
||||
const binding = this.rebindSelection
|
||||
const targetCode = this.targetCode.trim().toUpperCase()
|
||||
if (!binding || !targetCode || this.busy) 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) }))
|
||||
this.busy = false; this.$emit('busy', false)
|
||||
if (!confirmed) return
|
||||
await this.run(async () => {
|
||||
const source = await resolvePallet(binding.pallet)
|
||||
const target = await resolvePallet(targetCode)
|
||||
const payload = { relabelNo: this.task.relabelNo, source: { palletCode: source.palletCode, expectedVersion: source.palletVersion }, target: { palletCode: target.palletCode, expectedVersion: target.palletVersion }, cartonMarks: [binding.mark], reason: 'PDA 已绑定箱唛确认改绑托盘' }
|
||||
const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
|
||||
await bindRelabelPallet({ ...payload, idempotencyKey: key.value })
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
this.rebindSelection = null; this.targetCode = ''; this.target = null
|
||||
this.$emit('saved')
|
||||
this.showBindingMessage('绑定成功', `${binding.mark} 已从 ${binding.pallet} 移至 ${target.palletCode}`)
|
||||
})
|
||||
if (this.error) this.showBindingMessage('改绑失败', this.error)
|
||||
},
|
||||
completedItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark) && Number(item.relabelledPackages) >= 1) },
|
||||
async loadSources() { await this.run(async () => { this.sourceRows = await findRelabelSources(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = '' }) },
|
||||
selectSource(row) { if (this.busy) return; this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []; this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''; this.activate('source') },
|
||||
fillBatch() { const count = Number(this.quantity); if (!Number.isSafeInteger(count) || count <= 0 || count > this.selectedSource.movablePackages || count > this.eligibleItems.length) { this.error = '拆出数量必须为正整数,且不超过来源托盘可拆箱数和已完成换标箱数'; return } this.marks = this.eligibleItems.slice(0, count).map(item => item.cartonMark); this.error = '' },
|
||||
toggleMark(mark) { if (this.busy) return; this.marks = this.marks.includes(mark) ? this.marks.filter(value => value !== mark) : [...this.marks, mark] },
|
||||
activate(value) { this.active = value; this.$emit('activate') },
|
||||
handleScan(value) { if (this.busy) return; this[this.active + 'Code'] = value; return this.active === 'target' ? this.confirmTargetAndBind() : this.resolve(this.active) },
|
||||
showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
|
||||
async confirmTargetAndBind() {
|
||||
if (this.busy) return
|
||||
if (this.rebindSelection) return this.submitRebind()
|
||||
this.message = ''
|
||||
let reason = ''
|
||||
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘。'
|
||||
else if (!this.source || this.source.palletCode !== this.sourceCode.trim().toUpperCase() || this.source.palletCode !== this.selectedSource.palletCode) reason = '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
|
||||
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
|
||||
else if (!this.selectedDestination || this.marks.length !== Number(this.quantity) || this.marks.some(mark => !this.eligibleItems.some(item => item.cartonMark === mark))) 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 }
|
||||
await this.bind()
|
||||
},
|
||||
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
|
||||
async run(work) { if (this.busy) return; this.busy = true; this.$emit('busy', true); this.error = ''; try { await work() } catch (error) { this.error = error?.message || '托盘作业失败,请重新核对'; if (['409', 'A0409'].includes(String(error?.code))) { this.source = null; this.target = null; this.selectedSource = null; this.marks = []; this.error += ';请刷新来源托盘并重新扫码'; } } finally { this.busy = false; this.$emit('busy', false) } },
|
||||
async resolve(kind) { const code = this[kind + 'Code'].trim(); if (!code) return; this[kind] = null; await this.run(async () => { const value = await resolvePallet(code); if (kind === 'source' && value.palletCode !== this.selectedSource?.palletCode) throw new Error('请扫描所选原入仓单的实际来源托盘'); if (this[kind + 'Code'].trim() === code) this[kind] = value }) },
|
||||
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.activate('target') }) },
|
||||
async bind() {
|
||||
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
|
||||
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
|
||||
const payload = { relabelNo: this.task.relabelNo, source: expectation(this.source), target: expectation(this.target), cartonMarks: [...this.marks], actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) }, reason: 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
|
||||
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.marks.length} 箱已从 ${this.source.palletCode} 合入 ${this.target.palletCode},仍归属 ${this.selectedSource.inboundCode}`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
|
||||
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
|
||||
else if (this.error) this.showBindingMessage('绑定失败', this.error)
|
||||
},
|
||||
printPallet() {
|
||||
// #ifdef H5
|
||||
const view = window.open('', '_blank'); if (view) { view.document.write(this.newPallet.printableHtml); view.document.close(); view.focus(); view.print() } else this.error = '请允许打开标签打印窗口'
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
this.error = '当前设备需通过已适配的打印服务打印托盘标签;请先在后台打印此托盘号,不要虚报打印成功。'
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
|
||||
.heading{display:block;font-size:18px;font-weight:700}
|
||||
.hint{display:block;font-size:13px;color:#52647c;margin:10px 0;white-space:normal}
|
||||
.source-row{padding:12px;margin:10px 0;border:1px solid #d7e1ef;border-radius:8px}
|
||||
.source-row.selected{border-color:#2674d9;background:#eff6ff}
|
||||
.line{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:8px 0}.line text{flex:1;overflow-wrap:anywhere}
|
||||
input{padding:12px;background:#f3f6fa;border-radius:6px}button{font-size:13px;margin:8px 0}
|
||||
.error{display:block;color:#bc3030;font-size:13px}
|
||||
</style>
|
||||
@@ -18,7 +18,10 @@ selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/
|
||||
// root there, even if a stale H5 bundle or compiler flag was reused.
|
||||
if (nativeRuntime) {
|
||||
selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot
|
||||
selectedOverseasApiRoot = configuredOverseasApiRoot || selectedOverseasApiRoot
|
||||
// Native file:// builds cannot use the H5-only /overseas-api Vite proxy.
|
||||
// Keep the missing value explicit so the request layer can report a useful
|
||||
// configuration error instead of surfacing an opaque uploadFile:fail.
|
||||
selectedOverseasApiRoot = configuredOverseasApiRoot || ''
|
||||
}
|
||||
|
||||
export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const MODULES = [
|
||||
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
|
||||
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
|
||||
{ key: 'warehouseTransfer', name: '仓储转运测量', nameKey: 'modules.warehouseTransfer.name', icon: '转', iconType: 'compose', description: '回填箱规、箱数并完成托盘打托', descriptionKey: 'modules.warehouseTransfer.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer' },
|
||||
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
|
||||
] },
|
||||
|
||||
+5
-3
@@ -2,8 +2,8 @@
|
||||
"name" : "柏悦物流 PDA 海外版",
|
||||
"appid" : "__UNI__936D079",
|
||||
"description" : "柏悦物流海外仓 PDA 作业端",
|
||||
"versionName" : "0.0.2",
|
||||
"versionCode" : 2,
|
||||
"versionName" : "0.0.3",
|
||||
"versionCode" : 3,
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
@@ -83,5 +83,7 @@
|
||||
"devServer" : {
|
||||
"port" : 5176
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale" : "zh-Hans",
|
||||
"fallbackLocale" : "zh-Hans"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<view v-if="candidateError" class="error-card candidate-error-card"><text class="error-message">{{ candidateError.message || '待卸货集装箱加载失败' }}</text><text v-if="candidateError.code">code={{ candidateError.code }}</text><text v-if="candidateError.requestId">requestId={{ candidateError.requestId }}</text><text v-if="candidateError.traceId">traceId={{ candidateError.traceId }}</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
|
||||
<view v-if="candidateLoading && !candidates.length" class="state-card"><uni-load-more status="loading" /><text>正在刷新待卸货集装箱…</text></view>
|
||||
<view v-else-if="!candidateError && !candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
|
||||
<view v-for="item in candidates" :key="item.containerNo" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
|
||||
<view v-for="item in candidates" :key="candidateKey(item)" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
|
||||
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}</text></view><view class="container-card-actions"><text class="status-tag" :class="item.operable ? statusTone(item.status) : 'status-disabled'">{{ item.operable ? statusLabel(item.status) : '待齐全' }}</text><text class="container-detail-link">{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›</text></view></view>
|
||||
<view class="candidate-facts"><view><strong>{{ item.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ item.pendingPlanCount }}</strong><text>未操作</text></view></view>
|
||||
<text class="candidate-progress-copy">已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作<template v-if="item.missingInboundCodeCount">,其中 {{ item.missingInboundCodeCount }} 个缺入仓码</template></text>
|
||||
@@ -144,6 +144,8 @@
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
|
||||
const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024
|
||||
const MAX_PACKAGES_PER_PALLET = 66
|
||||
const PALLET_CAPACITY_MESSAGE = '一个托盘最多只能收66箱,请拆分到其他托盘'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
|
||||
@@ -168,6 +170,7 @@
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
||||
methods: {
|
||||
candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` },
|
||||
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status },
|
||||
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' },
|
||||
formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' },
|
||||
@@ -190,9 +193,9 @@
|
||||
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } },
|
||||
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
|
||||
async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
|
||||
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
|
||||
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); const sourceContainerBookingId = String(candidate?.sourceContainerBookingId || '').trim(); if (!containerNo || !sourceContainerBookingId || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo, sourceContainerBookingId), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, sourceContainerBookingId, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
|
||||
cancelDetail() { this.detailOpen = false; receiving.selected = null },
|
||||
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
|
||||
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
|
||||
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) },
|
||||
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
|
||||
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
|
||||
@@ -200,7 +203,7 @@
|
||||
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) },
|
||||
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
|
||||
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
|
||||
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
|
||||
async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
|
||||
async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
|
||||
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
|
||||
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
|
||||
<view v-if="stage === 'execute'" class="work-card"><button class="secondary" :disabled="busy" @tap="goToClaimList">返回换标列表</button></view>
|
||||
|
||||
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="loadCandidates">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<template v-if="stage !== 'execute' || !showCurrent">
|
||||
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
||||
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
||||
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo }" @tap="openExecution(item)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">执行 ›</text></view></view></view>
|
||||
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view></view></view></view>
|
||||
|
||||
</template>
|
||||
<view v-if="showCurrent" class="work-card">
|
||||
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text>
|
||||
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
|
||||
@@ -22,28 +24,26 @@
|
||||
<template v-else>
|
||||
<view class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view>
|
||||
<text class="card-title section-gap">{{ taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
|
||||
<view v-if="taskTab === 'carton'" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描或输入箱唛" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /><button class="primary" :disabled="busy || !quickCarton.trim()" @tap="submitCarton(quickCarton)">确认本箱</button><view v-if="lastCarton" class="success-box"><text class="meta-line">仓库唛头:{{ lastCarton.cartonMark }}</text><text class="card-title">请贴 FBA 箱唛:{{ lastCarton.fbaCartonMark }}</text></view></view>
|
||||
<view v-if="!visibleItems.length" class="empty-box"><text class="empty-copy">{{ taskTab === 'sku' ? '本单没有 SKU 换标明细' : '本单没有箱唛换标明细' }}</text></view>
|
||||
<view v-else class="item-list"><view v-for="item in visibleItems" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
|
||||
<view class="row-head"><text class="business-code">仓库唛头:{{ item.cartonMark || '—' }}</text><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }} 箱</text></view>
|
||||
<template v-if="taskTab === 'sku'"><view v-for="(sku, index) in skuDetails(item)" :key="index" class="sku-detail"><text class="meta-line">旧 SKU:{{ sku.skuCode || '—' }}</text><text class="meta-line">新 SKU:{{ sku.newSkuCode || '—' }}</text><text class="meta-line">数量:{{ sku.quantity == null ? '—' : sku.quantity }} 件</text></view></template>
|
||||
<template v-else><text class="meta-line">新 FBA 箱唛:{{ item.fbaCartonMark || '—' }}</text><text class="meta-line">箱数:{{ item.expectedPackages }}</text></template>
|
||||
<text v-if="item.productNameZh" class="meta-line">{{ item.productNameZh }}</text><text v-if="item.labelRequirement" class="meta-line">{{ item.labelRequirement }}</text>
|
||||
<view v-if="taskTab === 'carton'" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描或输入箱唛" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /><button class="primary" :disabled="busy || !quickCarton.trim()" @tap="submitCarton(quickCarton)">确认本箱</button></view>
|
||||
<view v-if="taskTab === 'sku'" class="section-gap">
|
||||
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描或输入旧 SKU 标签" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
|
||||
<button class="primary" :disabled="busy || !quickSku.trim()" @tap="submitSku(quickSku)">确认 SKU</button>
|
||||
<text v-if="skuChoices.length" class="hint">同一旧 SKU 有多条明细,请核对实际箱选择本次作业:</text>
|
||||
<button v-for="row in skuChoices" :key="`${row.item.itemId}:${row.index}`" :disabled="busy" @tap="commitSku(row)">{{ row.item.cartonMark }}:{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }} · {{ row.sku.quantity }} 件</button>
|
||||
<view v-for="row in pendingSkuRows" :key="`${row.item.itemId}:${row.index}`" class="item-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">{{ row.item.cartonMark }} · {{ row.sku.quantity }} 件</text><button class="link-button" @tap="selectItem(row.item); changeAction('EXCEPTION')">异常 / 跳过</button></view>
|
||||
</view>
|
||||
<view v-if="taskTab === 'carton'" class="item-list"><view v-for="item in visibleItems" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
|
||||
<view class="row-head"><text class="business-code">{{ item.cartonMark || '—' }} → {{ item.fbaCartonMark || '—' }}</text></view>
|
||||
<text class="meta-line">FBA 目的仓:{{ item.destinationFbaWarehouseCode || '待维护' }}</text>
|
||||
</view></view>
|
||||
<template v-if="taskTab === 'sku' && selectedItem && remaining(selectedItem) > 0">
|
||||
<template v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining(selectedItem) > 0">
|
||||
<view class="mode-row section-gap"><button v-for="mode in actionModes" :key="mode.key" class="mode-button" :class="{ active: action === mode.key }" @tap="changeAction(mode.key)">{{ mode.name }}</button></view>
|
||||
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="仓库唛头" placeholder="扫描或输入仓库唛头" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
|
||||
<template v-if="taskTab === 'sku' && action === 'RELABEL'"><view v-for="(sku, index) in unitDraft.skuVerifications" :key="index" class="sku-detail">
|
||||
<text class="meta-line">{{ skuDetails(selectedItem)[index]?.skuCode }} → {{ skuDetails(selectedItem)[index]?.newSkuCode }} · {{ sku.quantity }} 件</text>
|
||||
<ScanTargetInput :name="`sku-old-${index}`" v-model="sku.skuCode" label="旧 SKU" placeholder="扫描或输入旧 SKU" :active="activeTarget === `sku-old-${index}`" @activate="activeTarget = `sku-old-${index}`" @submit="activeTarget = `sku-new-${index}`" />
|
||||
<ScanTargetInput :name="`sku-new-${index}`" v-model="sku.newSkuCode" label="新 SKU" placeholder="扫描或输入新 SKU" :active="activeTarget === `sku-new-${index}`" @activate="activeTarget = `sku-new-${index}`" />
|
||||
</view></template>
|
||||
<ScanTargetInput v-if="taskTab === 'carton' && requiresNewCartonLabel" name="newLabel" v-model="newLabel" label="新 FBA 箱唛" placeholder="扫描或输入新 FBA 箱唛" :active="activeTarget === 'new'" @activate="activeTarget = 'new'" @submit="handleNewLabel" />
|
||||
<text v-if="action === 'RELABEL' && selectedItem.operationType === 'CARTON_AND_SKU'" class="hint">箱唛作业:{{ selectedItem.cartonProcessedPackages > 0 ? '已完成' : '待处理' }} · SKU 作业:{{ selectedItem.skuProcessedPackages > 0 ? '已完成' : '待处理' }}</text>
|
||||
<template v-if="action !== 'RELABEL'"><textarea v-model="reason" class="reason-input section-gap" maxlength="1000" placeholder="必填:跳过或异常原因" /><view v-if="action === 'EXCEPTION'" class="mode-row section-gap"><button v-for="item in exceptionCodes" :key="item.key" class="mode-button" :class="{ active: exceptionCode === item.key }" @tap="exceptionCode = item.key">{{ item.name }}</button></view></template>
|
||||
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
|
||||
</template>
|
||||
<view class="section-gap">
|
||||
<RelabelPalletWork v-if="taskTab === 'carton'" ref="palletWork" :key="current.relabelNo" :task="current" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
|
||||
<view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view>
|
||||
<button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button>
|
||||
<view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile">
|
||||
@@ -53,7 +53,8 @@
|
||||
<view class="photo-actions"><button class="link-button" :disabled="busy" @tap="retakePhoto(index)">重拍</button><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view>
|
||||
</view></view>
|
||||
<textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" />
|
||||
<button class="primary" :disabled="busy || !photoFileIds.length || (!allProcessed && !remark.trim())" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
|
||||
<view v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view>
|
||||
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
|
||||
</view>
|
||||
</template>
|
||||
</template>
|
||||
@@ -68,21 +69,22 @@
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import RelabelPalletWork from '@/components/RelabelPalletWork.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { scanRelabelCarton, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { scanRelabelSku, scanRelabelCarton, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
|
||||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
|
||||
data() { return { user: session.currentUser, stage: 'receive', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
|
||||
computed: {
|
||||
showCurrent() { if (!this.current || this.stage === 'review') return false; if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && this.current.claimedByCurrentOperator; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && this.current.claimedByCurrentOperator) },
|
||||
showCurrent() { if (!this.current || this.stage === 'review') return false; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
|
||||
stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待复核的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '点击任意换标单直接进入执行;同一账号必须先完成当前任务才能处理其他换标单' },
|
||||
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
|
||||
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
|
||||
@@ -92,7 +94,8 @@
|
||||
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
|
||||
emptyText() { return `暂无${this.candidateHeading}` },
|
||||
visibleCandidates() { const candidates = this.candidates; if (this.stage === 'review') return candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return candidates.filter((item) => !item.taskStatus || item.taskStatus === 'AVAILABLE' || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
|
||||
visibleItems() { return (this.current?.items || []).filter((item) => this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) },
|
||||
visibleItems() { return (this.current?.items || []).filter((item) => (this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) && this.remaining(item) > 0) },
|
||||
pendingSkuRows() { return this.visibleItems.flatMap(item => this.skuDetails(item).map((sku, index) => ({ item, sku, index })).filter(row => !this.skuDone(row.item.itemId, row.index))) },
|
||||
requiresNewCartonLabel() { return this.action === 'RELABEL' && this.selectedItem && this.selectedItem.operationType !== 'SKU_LABEL' },
|
||||
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } },
|
||||
oldLabel: { get() { return this.unitDraft.cartonMark }, set(value) { if (this.selectedItem) this.unitDraft.cartonMark = value } },
|
||||
@@ -102,11 +105,25 @@
|
||||
remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
|
||||
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] },
|
||||
allProcessed() { return Boolean(this.current?.expectedPackages) && this.current.relabelledPackages + this.current.skippedPackages + this.current.exceptionPackages === this.current.expectedPackages },
|
||||
completionBlockReason() {
|
||||
if (!this.current) return '请先选择换标任务'
|
||||
if (!this.allProcessed && !this.remark.trim()) return '仍有未扫描明细,请继续处理;需要异常完工时必须填写异常作业备注'
|
||||
const requiresPalletBinding = (this.current.items || []).some((item) => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType))
|
||||
if (requiresPalletBinding && Number(this.current.relabelledPackages || 0) > 0) {
|
||||
const round = this.current.reviewedAt || 'INITIAL'
|
||||
const marks = new Set((this.current.history || []).filter((entry) => entry.action === 'PALLET_BIND').flatMap((entry) => {
|
||||
try { const detail = JSON.parse(entry.detail); return detail.round === round ? detail.cartonMarks || [] : [] } catch { return [] }
|
||||
}))
|
||||
if (marks.size < Number(this.current.relabelledPackages)) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
|
||||
}
|
||||
if (!this.photoFileIds.length) return '请至少上传 1 张换标完成照片'
|
||||
return ''
|
||||
},
|
||||
canSubmitUnit() { if (!this.selectedItem || !this.visibleItems.some((item) => item.itemId === this.selectedItem.itemId) || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return this.cartonVerified && this.skuVerified; return Boolean(this.reason.trim()) },
|
||||
unitActionText() { return ({ RELABEL: '确认本箱换标完成', SKIP: '确认跳过本箱', EXCEPTION: '登记本箱异常' })[this.action] }
|
||||
},
|
||||
onLoad(options) { this.stage = STAGES[options.op] || 'receive' },
|
||||
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, photoUrls: {}, photoErrors: {} } } }],
|
||||
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, quickSku: '', lastSku: '', skuChoices: [], photoUrls: {}, photoErrors: {} } } }],
|
||||
watch: {
|
||||
'current.reviewedAt'(value) { if (this.current?.reviewDecision === 'REWORK' && warehouseWork.relabel.reworkRound !== `${this.current.relabelNo}:${value}`) { this.unitDrafts = {}; this.selectedItem = null; this.lastCarton = null; this.quickCarton = ''; updateWarehouseWork('relabel', { reworkRound: `${this.current.relabelNo}:${value}`, photoFileIds: [], photoNames: [], remark: '' }) } },
|
||||
photoFileIds: { deep: true, handler(ids) { for (const id of ids) if (!this.photoUrls[id]) this.loadPhoto(id) } },
|
||||
@@ -115,6 +132,30 @@
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); if (this.stage === 'execute') this.startScanner(); this.bootstrap() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
skuDone(itemId, index) { return (this.current?.history || []).some(entry => { if (entry.action !== 'SKU_SCAN') return false; try { const data = JSON.parse(entry.detail); return String(data.itemId) === String(itemId) && data.skuIndex === index && data.round === (this.current.reviewedAt || 'INITIAL') } catch { return false } }) },
|
||||
async submitSku(raw) {
|
||||
const oldSku = String(raw?.detail?.value ?? raw ?? this.quickSku).trim()
|
||||
if (!oldSku || this.busy || !this.current) return
|
||||
this.quickSku = oldSku
|
||||
const matches = this.pendingSkuRows.filter(row => row.sku.skuCode === oldSku)
|
||||
this.skuChoices = []
|
||||
if (!matches.length) return this.setError(new Error('该旧 SKU 不在待处理明细中,或已处理完成'))
|
||||
if (matches.length > 1) { this.skuChoices = matches; return }
|
||||
await this.commitSku(matches[0])
|
||||
},
|
||||
async commitSku(row) {
|
||||
if (this.busy || this.quickSku.trim() !== row.sku.skuCode) return
|
||||
const payload = { itemId: row.item.itemId, skuIndex: row.index, oldSku: this.quickSku.trim(), expectedVersion: this.current.version }
|
||||
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('sku-scan', async () => {
|
||||
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
|
||||
this.applyCurrent(value); releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||||
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
|
||||
this.quickSku = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
|
||||
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||||
}, true, key)
|
||||
},
|
||||
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)' })[type] || '换标类型待确认' },
|
||||
async loadPhoto(fileId) {
|
||||
const no = this.current?.relabelNo
|
||||
@@ -162,33 +203,54 @@
|
||||
this.applyCurrent(result)
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmedItem = result.items.find((row) => row.itemId === item.itemId)
|
||||
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark }
|
||||
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
|
||||
this.quickCarton = ''
|
||||
this.activeTarget = 'quick-carton'
|
||||
this.successMessage = `本箱已处理,请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}`
|
||||
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||||
this.successMessage = ''
|
||||
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}\nFBA 目的仓:${this.lastCarton.destinationFbaWarehouseCode || '待维护'}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||||
}, true, key)
|
||||
},
|
||||
removePhoto(index) { updateWarehouseWork('relabel', { photoFileIds: this.photoFileIds.filter((_, i) => i !== index), photoNames: this.photoNames.filter((_, i) => i !== index) }) },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.busy) return; if (this.activeTarget === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.busy) return; if (this.activeTarget === 'pallet-work') { this.$refs.palletWork?.handleScan(value); return } if (this.activeTarget === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleHardwareScan(value) { const skuTarget = /^sku-(old|new)-(\d+)$/.exec(this.activeTarget); if (skuTarget && this.selectedItem) { const row = this.unitDraft.skuVerifications[Number(skuTarget[2])]; if (row) row[skuTarget[1] === 'old' ? 'skuCode' : 'newSkuCode'] = String(value).trim(); if (skuTarget[1] === 'old') this.activeTarget = `sku-new-${skuTarget[2]}`; return } if (this.activeTarget === 'old') { this.oldLabel = value; this.activeTarget = this.taskTab === 'sku' ? 'sku-old-0' : this.action === 'RELABEL' ? 'new' : 'old'; return } if (this.activeTarget === 'new') { this.newLabel = value; return } this.taskCode = value; this.openTask(value) },
|
||||
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-old-0' : 'new' },
|
||||
handleHardwareScan(value) { if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
|
||||
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' },
|
||||
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
|
||||
async bootstrap() { await this.loadCandidates(); if (this.stage === 'execute' && this.taskCode) await this.openTask(this.taskCode, false) },
|
||||
async bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute') return; const owned = this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); const target = owned.find((item) => item.relabelNo === this.taskCode) || (owned.length === 1 ? owned[0] : null); if (target) await this.openTask(target.relabelNo, false) },
|
||||
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
|
||||
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
|
||||
applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
|
||||
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.activeTarget = value === 'carton' ? 'quick-carton' : 'task' },
|
||||
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = value === 'carton' ? 'quick-carton' : 'quick-sku' },
|
||||
skuDetails(item) { return item.skuItems?.length ? item.skuItems : [{ skuCode: item.skuCode, newSkuCode: item.newSkuCode, quantity: null }] },
|
||||
selectItem(item) { if (this.taskTab === 'carton') { this.activeTarget = 'quick-carton'; return } if (this.remaining(item) <= 0) return; if (!this.unitDrafts[item.itemId]) this.unitDrafts[item.itemId] = { cartonMark: '', fbaCartonMark: '', skuVerifications: this.skuDetails(item).map((sku) => ({ skuCode: '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'sku-old-0' },
|
||||
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; return Math.max(0, item.expectedPackages - Number(processed ?? item.relabelledPackages) - item.skippedPackages - item.exceptionPackages) },
|
||||
selectItem(item) { if (this.taskTab === 'carton') { this.activeTarget = 'quick-carton'; return } if (this.remaining(item) <= 0) return; if (!this.unitDrafts[item.itemId]) this.unitDrafts[item.itemId] = { cartonMark: '', fbaCartonMark: '', skuVerifications: this.skuDetails(item).map((sku) => ({ skuCode: sku.skuCode || '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'old' },
|
||||
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
|
||||
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; const completed = item.operationType === 'CARTON_AND_SKU' ? Number(processed || 0) : Number(processed ?? item.relabelledPackages); return Math.max(0, item.expectedPackages - completed - item.skippedPackages - item.exceptionPackages) },
|
||||
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
|
||||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||||
async promptActiveTask(active) {
|
||||
if (this.busy) return
|
||||
this.busy = true
|
||||
this.error = null
|
||||
try {
|
||||
const confirmed = await new Promise((resolve) => uni.showModal({
|
||||
title: '已有正在执行的换标任务',
|
||||
content: `当前账号正在执行 ${active.relabelNo},请先完成该任务。是否前往继续作业?`,
|
||||
confirmText: '前往任务', cancelText: '取消',
|
||||
success: (result) => resolve(result.confirm), fail: () => resolve(false)
|
||||
}))
|
||||
if (!confirmed) return
|
||||
this.taskCode = active.relabelNo
|
||||
uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
|
||||
} finally { this.busy = false }
|
||||
},
|
||||
async openExecution(item) {
|
||||
if (this.busy || this.stage !== 'receive') return
|
||||
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator)
|
||||
if (active && active.relabelNo !== item.relabelNo) return this.setError(new Error(`当前账号正在执行 ${active.relabelNo},请先完成后再操作其他换标单`))
|
||||
if (this.busy || this.stage === 'review') return
|
||||
if (this.stage === 'execute') {
|
||||
if (item.taskStatus !== 'PROCESSING' || item.claimedByCurrentOperator !== true) return
|
||||
return this.openTask(item.relabelNo)
|
||||
}
|
||||
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
|
||||
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active)
|
||||
this.taskCode = item.relabelNo
|
||||
if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
|
||||
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
|
||||
@@ -196,7 +258,7 @@
|
||||
if (claimed) uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
|
||||
},
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||||
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadRelabelPhoto(no, path); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
|
||||
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
|
||||
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
|
||||
async completeTask() { if (this.busy || !this.photoFileIds.length || (!this.allProcessed && !this.remark.trim())) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -116,7 +116,7 @@ test('container UI locks and signs once, then renders the resolved destination g
|
||||
|
||||
assert.match(inboundApi, /\$\{RECEIVING_BASE\}\/\$\{encodeURIComponent\(containerNo\)\}\/prepare/)
|
||||
assert.match(inboundApi, /current\('\/sign'\)/)
|
||||
assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, \{ idempotencyKey: key\.value \}\)/)
|
||||
assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, this\.selected\.sourceContainerBookingId, \{ idempotencyKey: key\.value \}\)/)
|
||||
assert.match(operation, /getCurrentReceiving\(\)[\s\S]*applyCurrentReceiving\(current\)/)
|
||||
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
|
||||
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
|
||||
@@ -139,7 +139,7 @@ test('PDA does not infer container eligibility from partial plan workflow states
|
||||
assert.match(adapter, /allPlansReady: value\.allPlansReady === true/)
|
||||
assert.match(adapter, /operable: value\.operable === true/)
|
||||
assert.match(operation, /if \(!candidate\.operable\) \{ receiving\.selected = candidate; this\.detailOpen = true; return \}/)
|
||||
assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
|
||||
assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.sourceContainerBookingId \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
|
||||
assert.match(operation, /recoverCurrentAfterConflict\(error, true\)/)
|
||||
assert.match(operation, /preserveEvidence \? error : null[\s\S]*this\.candidateError = preserveEvidence \? error : null/)
|
||||
assert.doesNotMatch(inbound, /sort\(/)
|
||||
|
||||
@@ -162,6 +162,106 @@ test('photo upload refreshes and replays once while retaining the file payload',
|
||||
])
|
||||
})
|
||||
|
||||
test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile', async () => {
|
||||
const value = prepareSession()
|
||||
const originalWindow = globalThis.window
|
||||
const originalLocation = globalThis.location
|
||||
const originalFetch = globalThis.fetch
|
||||
let uploadFileCalls = 0
|
||||
let uploaded
|
||||
globalThis.window = {}
|
||||
globalThis.location = { protocol: 'http:' }
|
||||
globalThis.uni.uploadFile = () => { uploadFileCalls += 1 }
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
|
||||
uploaded = { url, options }
|
||||
return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 })
|
||||
}
|
||||
try {
|
||||
session.apiBase = '/overseas-api'
|
||||
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' })
|
||||
assert.equal(uploadFileCalls, 0)
|
||||
assert.equal(uploaded.url, '/overseas-api/pda/v1/last-leg/photos/files')
|
||||
assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.overseasAccessToken}`)
|
||||
assert.ok(uploaded.options.body instanceof FormData)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
if (originalWindow === undefined) delete globalThis.window
|
||||
else globalThis.window = originalWindow
|
||||
if (originalLocation === undefined) delete globalThis.location
|
||||
else globalThis.location = originalLocation
|
||||
}
|
||||
})
|
||||
|
||||
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
|
||||
const value = prepareSession()
|
||||
const originalWindow = globalThis.window
|
||||
const originalLocation = globalThis.location
|
||||
globalThis.window = {}
|
||||
globalThis.location = { protocol: 'http:' }
|
||||
const selected = new Blob(['photo'], { type: 'image/png' })
|
||||
let calls = 0
|
||||
globalThis.uni.uploadFile = (options) => {
|
||||
calls += 1
|
||||
assert.equal(options.url, '/overseas-api/pda/photo')
|
||||
assert.equal(options.header.Authorization, `Bearer ${value.overseasAccessToken}`)
|
||||
assert.equal(options.files[0].file, selected)
|
||||
assert.equal(options.files[0].name, 'file')
|
||||
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} })
|
||||
}
|
||||
try {
|
||||
session.apiBase = '/overseas-api'
|
||||
assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' })
|
||||
assert.equal(calls, 1)
|
||||
} finally {
|
||||
if (originalWindow === undefined) delete globalThis.window
|
||||
else globalThis.window = originalWindow
|
||||
if (originalLocation === undefined) delete globalThis.location
|
||||
else globalThis.location = originalLocation
|
||||
}
|
||||
})
|
||||
|
||||
test('native file runtime rejects an H5-relative overseas root before upload with a clear configuration error', async () => {
|
||||
prepareSession()
|
||||
const originalLocation = globalThis.location
|
||||
globalThis.location = { protocol: 'file:' }
|
||||
session.apiBase = '/overseas-api'
|
||||
let uploadCalls = 0
|
||||
globalThis.uni.uploadFile = () => { uploadCalls += 1 }
|
||||
try {
|
||||
await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => {
|
||||
assert.equal(error.code, 'PDA_OVERSEAS_API_BASE_REQUIRED')
|
||||
assert.match(error.message, /VITE_OVERSEAS_API_BASE_URL/)
|
||||
assert.match(error.message, /HTTP\(S\)/)
|
||||
return true
|
||||
})
|
||||
assert.equal(uploadCalls, 0)
|
||||
} finally {
|
||||
if (originalLocation === undefined) delete globalThis.location
|
||||
else globalThis.location = originalLocation
|
||||
}
|
||||
})
|
||||
|
||||
test('native file runtime uploads to an absolute overseas root', async () => {
|
||||
prepareSession()
|
||||
const originalLocation = globalThis.location
|
||||
globalThis.location = { protocol: 'file:' }
|
||||
session.apiBase = 'https://overseas.example.com/api/'
|
||||
let call
|
||||
globalThis.uni.uploadFile = (options) => {
|
||||
call = options
|
||||
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'native-1' } }), header: {} })
|
||||
}
|
||||
try {
|
||||
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', '_doc/photo.jpg'), { fileId: 'native-1' })
|
||||
assert.equal(call.url, 'https://overseas.example.com/api/pda/v1/last-leg/photos/files')
|
||||
assert.equal(call.filePath, '_doc/photo.jpg')
|
||||
} finally {
|
||||
if (originalLocation === undefined) delete globalThis.location
|
||||
else globalThis.location = originalLocation
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => {
|
||||
const value = prepareSession(); const calls = []
|
||||
globalThis.uni.downloadFile = (options) => {
|
||||
|
||||
@@ -49,6 +49,10 @@ test('first scan and receipt requests confirm server reservations without resour
|
||||
assert.ok(gate)
|
||||
assert.doesNotMatch(gate, /mergeAllowed|assigned|urgent/)
|
||||
assert.match(gate, /resolvedPallet\?\.compatible/)
|
||||
assert.match(page, /const MAX_PACKAGES_PER_PALLET = 66/)
|
||||
assert.match(page, /Number\(this\.packageCount\) > MAX_PACKAGES_PER_PALLET/)
|
||||
assert.match(page, /一个托盘最多只能收66箱,请拆分到其他托盘/)
|
||||
assert.match(page, /this\.showToast\(PALLET_CAPACITY_MESSAGE, 'error'\)/)
|
||||
})
|
||||
|
||||
test('one-click dropship inbound is marked only by an explicit server boolean', () => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
function setup(confirm) {
|
||||
const calls = []
|
||||
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
|
||||
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
|
||||
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
|
||||
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
|
||||
}
|
||||
test('conflicting task opens modal and confirm resumes owned task without claiming another', async () => {
|
||||
const { calls, context, open } = setup(true)
|
||||
await open()
|
||||
assert.equal(calls[0].confirmText, '前往任务')
|
||||
assert.equal(calls[0].cancelText, '取消')
|
||||
assert.match(calls[0].content, /RL-ACTIVE/)
|
||||
assert.equal(context.taskCode, 'RL-ACTIVE')
|
||||
assert.equal(calls[1].url, '/pages/relabel/relabel?op=relabelExecute')
|
||||
assert.equal(context.error, null)
|
||||
assert.equal(context.busy, false)
|
||||
})
|
||||
test('cancel stays on list and preserves existing task selection', async () => {
|
||||
const { calls, context, open } = setup(false)
|
||||
await open()
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(context.taskCode, 'PREVIOUS')
|
||||
assert.equal(context.busy, false)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('PDA relabel completion explains pallet and photo blockers instead of silently disabling submit', () => {
|
||||
assert.match(page, /还不能完成换标/)
|
||||
assert.match(page, /请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定/)
|
||||
assert.match(page, /请至少上传 1 张换标完成照片/)
|
||||
assert.match(page, /:disabled="busy \|\| Boolean\(completionBlockReason\)"/)
|
||||
assert.match(page, /entry\.action === 'PALLET_BIND'/)
|
||||
assert.match(page, /detail\.round === round/)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
|
||||
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
|
||||
assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
|
||||
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
|
||||
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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')
|
||||
const component = new Function('ScanTargetInput', script)({})
|
||||
function context() {
|
||||
const value = { ...component.data(), $emit() {}, task: { items: [
|
||||
{ cartonMark: 'A', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' },
|
||||
{ cartonMark: 'B', relabelledPackages: 1, destinationFbaWarehouseCode: 'JFK8' },
|
||||
{ cartonMark: 'C', relabelledPackages: 0, destinationFbaWarehouseCode: 'ONT8' },
|
||||
{ cartonMark: 'OTHER', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }
|
||||
] } }
|
||||
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
|
||||
for (const [key, method] of Object.entries(component.computed)) Object.defineProperty(value, key, { get: method.bind(value) })
|
||||
value.selectSource({ positionId: '1', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
|
||||
return value
|
||||
}
|
||||
test('quantity batch only includes completed boxes from same inbound and destination', () => {
|
||||
const value = context()
|
||||
value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['A'])
|
||||
value.quantity = '2'; value.fillBatch(); assert.match(value.error, /不超过/)
|
||||
value.quantity = '1.5'; value.fillBatch(); assert.match(value.error, /正整数/)
|
||||
value.selectedDestination = 'JFK8'; value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['B'])
|
||||
})
|
||||
|
||||
test('submission requires exact count and scanned distinct source and target pallets', () => {
|
||||
const value = context(); value.quantity = '1'; value.fillBatch()
|
||||
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'source'
|
||||
value.target = { palletCode: 'TARGET' }; value.targetCode = 'TARGET'
|
||||
assert.equal(value.readyToBind, true)
|
||||
value.toggleMark('A'); assert.equal(value.readyToBind, false)
|
||||
value.toggleMark('A'); value.sourceCode = 'OTHER'; assert.equal(value.readyToBind, false)
|
||||
value.sourceCode = 'SOURCE'; value.target = { palletCode: 'SOURCE' }; value.targetCode = 'SOURCE'
|
||||
assert.equal(value.readyToBind, false)
|
||||
})
|
||||
test('quantity disassembly has no extra carton scanner', () => {
|
||||
assert.doesNotMatch(source, /relabel-move-carton/)
|
||||
assert.match(source, /actualSource:/)
|
||||
assert.match(source, /仍归属/)
|
||||
})
|
||||
|
||||
test('latest current-round binding is used for rebind source', () => {
|
||||
const value = context()
|
||||
value.task.history = ['OLD', 'NEW'].map(pallet => ({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['A'], targetPallets: [pallet] }) }))
|
||||
assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
|
||||
value.task.reviewedAt = 'NEW-ROUND'
|
||||
assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
|
||||
value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
|
||||
assert.deepEqual(value.currentBindings, [])
|
||||
})
|
||||
|
||||
test('rescan same bound pallet does not call stock movement', async () => {
|
||||
const value = context(); value.rebindSelection = { mark: 'A', pallet: 'TARGET' }; value.targetCode = 'target'
|
||||
let message = ''
|
||||
value.showBindingMessage = (title, content) => { message = content }
|
||||
value.run = async () => assert.fail('same pallet must not move stock')
|
||||
await value.submitRebind()
|
||||
assert.match(message, /不会重复扣库存/)
|
||||
})
|
||||
|
||||
test('target confirmation submits binding directly, with no second submit button', async () => {
|
||||
const value = context(); value.quantity = '1'; value.fillBatch()
|
||||
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
|
||||
let binds = 0
|
||||
value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletCode: 'TARGET' } }
|
||||
value.bind = async () => { binds++ }
|
||||
await value.confirmTargetAndBind()
|
||||
assert.equal(binds, 1)
|
||||
assert.match(source, /@tap="confirmTargetAndBind"/)
|
||||
assert.doesNotMatch(source, /@tap="bind"/)
|
||||
})
|
||||
|
||||
test('missing source scan shows modal and never submits a binding', async () => {
|
||||
const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
|
||||
let message = ''
|
||||
value.showBindingMessage = (title, content) => { message = content }
|
||||
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, /扫描并确认实际来源托盘/)
|
||||
})
|
||||
|
||||
test('target lookup failure cannot submit binding or report success', async () => {
|
||||
const value = context(); value.quantity = '1'; value.fillBatch()
|
||||
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
|
||||
value.resolve = async () => { value.error = '目标托盘不可用' }
|
||||
let title = ''
|
||||
value.showBindingMessage = heading => { title = heading }
|
||||
value.bind = async () => assert.fail('must not bind after lookup failure')
|
||||
await value.confirmTargetAndBind()
|
||||
assert.equal(title, '绑定失败')
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
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/relabel/relabel.vue', import.meta.url), 'utf8')
|
||||
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
|
||||
function context() {
|
||||
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
|
||||
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
|
||||
for (const name of ['visibleItems', 'pendingSkuRows']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
|
||||
return value
|
||||
}
|
||||
test('each persisted SKU hides independently; mixed carton progress is separate', () => {
|
||||
const value = context(); assert.equal(value.pendingSkuRows.length, 2)
|
||||
value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: 0 }) })
|
||||
assert.equal(value.pendingSkuRows.length, 1); assert.equal(value.pendingSkuRows[0].sku.skuCode, 'OTHER')
|
||||
value.taskTab = 'carton'; assert.equal(value.visibleItems.length, 0)
|
||||
value.taskTab = 'sku'; value.current.reviewedAt = '2026-09-15T18:00:00'; assert.equal(value.pendingSkuRows.length, 2)
|
||||
value.current.items[0].skuProcessedPackages = 1; assert.equal(value.visibleItems.length, 0)
|
||||
})
|
||||
test('normal SKU input is always visible in SKU tab and never requests the new label', () => {
|
||||
assert.match(source, /name="quickSku"/)
|
||||
assert.doesNotMatch(source, /placeholder="扫描或输入新 SKU 标签号"/)
|
||||
assert.match(source, /matches.length > 1/)
|
||||
assert.match(source, /scanRelabelSku/)
|
||||
})
|
||||
test('pending list removes one confirmed row at a time without persistent empty or last-scan banners', () => {
|
||||
const value = context()
|
||||
assert.equal(value.pendingSkuRows.length, 2)
|
||||
for (const index of [0, 1]) {
|
||||
value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: index }) })
|
||||
assert.equal(value.pendingSkuRows.length, 1 - index)
|
||||
}
|
||||
const template = source.split('<script>')[0]
|
||||
assert.doesNotMatch(template, /待处理明细已清空|v-if="lastSku"|v-if="lastCarton"/)
|
||||
assert.match(source, /uni.showModal/)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
test('warehouse transfer PDA API exposes candidates and palletize operations', async () => {
|
||||
const source = await import('../src/api/warehouse-transfers.js')
|
||||
assert.equal(typeof source.listWarehouseTransferCandidates, 'function')
|
||||
assert.equal(typeof source.getWarehouseTransferDetail, 'function')
|
||||
assert.equal(typeof source.palletizeWarehouseTransfer, 'function')
|
||||
assert.equal(typeof source.saveWarehouseTransferPalletDraft, 'function')
|
||||
assert.equal(typeof source.generateWarehouseTransferPallet, 'function')
|
||||
assert.equal(typeof source.bindWarehouseTransferPallet, 'function')
|
||||
assert.equal(typeof source.deleteWarehouseTransferPallet, 'function')
|
||||
assert.equal(typeof source.uploadWarehouseTransferMeasurementPhoto, 'function')
|
||||
assert.equal(typeof source.listWarehouseTransferMeasurementPhotos, 'function')
|
||||
assert.equal(typeof source.downloadWarehouseTransferMeasurementPhoto, 'function')
|
||||
assert.equal(typeof source.downloadWarehouseTransferPalletLabel, 'function')
|
||||
})
|
||||
|
||||
test('warehouse transfer workbench entry is registered', async () => {
|
||||
const { MODULES } = await import('../src/data/overseas-workbench.js')
|
||||
const operation = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransfer')
|
||||
assert.equal(operation?.available, true)
|
||||
assert.equal(operation?.route, '/pages/warehouse-transfer/warehouse-transfer')
|
||||
})
|
||||
|
||||
test('warehouse transfer page loads detail without exposing internal identifiers', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /getWarehouseTransferDetail\(item\.transferNo\)/)
|
||||
assert.doesNotMatch(page, /转运明细 ID|manualItems/)
|
||||
})
|
||||
|
||||
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', 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/)
|
||||
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
|
||||
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
|
||||
})
|
||||
|
||||
test('warehouse transfer retries reuse the pending idempotency key', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
|
||||
assert.match(page, /idempotencyKey: this\.pendingKey/)
|
||||
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
|
||||
})
|
||||
|
||||
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}"`))
|
||||
})
|
||||
|
||||
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))
|
||||
assert.match(page, /palletVolume\(pallet\)/)
|
||||
assert.match(page, /rawPalletVolume\(pallet\)/)
|
||||
})
|
||||
|
||||
test('warehouse transfer requires a camera photo and submits its file id with palletization', async () => {
|
||||
const [page, api] = await Promise.all([
|
||||
readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../src/api/warehouse-transfers.js', import.meta.url), 'utf8')
|
||||
])
|
||||
assert.match(page, /sourceType: \['camera', 'album'\]/)
|
||||
assert.match(page, /请至少拍摄 1 张测量现场照片/)
|
||||
assert.match(page, /photoFileIds: \[\.\.\.this\.photoFileIds\]/)
|
||||
assert.match(api, /measurement-photos\/files/)
|
||||
assert.match(page, /tempFilePaths \|\| \[\]/)
|
||||
assert.match(page, /未获取到可上传的照片路径,请重新拍摄/)
|
||||
assert.match(page, /所选转运单已切换,请重新拍摄/)
|
||||
})
|
||||
|
||||
test('warehouse transfer restores uploaded photos and exposes honest pallet label printing', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /listWarehouseTransferMeasurementPhotos/)
|
||||
assert.match(page, /downloadWarehouseTransferMeasurementPhoto/)
|
||||
assert.match(page, /completedPallets = \(result\?\.pallets \|\| \[\]\)/)
|
||||
assert.match(page, /downloadWarehouseTransferPalletLabel/)
|
||||
assert.match(page, /pallet\.labelDownloadPath/)
|
||||
assert.match(page, /pallet\.fbaWarehouseCode \|\| completedDestination \|\| '目的仓待定'/)
|
||||
assert.match(page, /目的仓待定/)
|
||||
assert.match(page, /uni\.openDocument\(\{ filePath, fileType: 'pdf', showMenu: true/)
|
||||
assert.doesNotMatch(page, /批量打印|printAllPallets/)
|
||||
})
|
||||
|
||||
test('warehouse transfer always requests newly generated pallets', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(page, /托盘码\s*<small>选填|扫描或输入当前托盘码/)
|
||||
assert.doesNotMatch(page, /palletCode: String\(p\.palletCode/)
|
||||
assert.match(page, /completedPallets = \(result\?\.pallets \|\| \[\]\)/)
|
||||
})
|
||||
|
||||
test('warehouse transfer pallet cards prepare generated and server-bound states', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /生成托盘号/)
|
||||
assert.match(page, /pallet\.generated && !pallet\.bound/)
|
||||
assert.match(page, /ScanTargetInput/)
|
||||
assert.match(page, /startScanBroadcast/)
|
||||
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, /collapsed: false/)
|
||||
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
|
||||
assert.match(page, /v-show="!pallet\.collapsed"/)
|
||||
assert.match(page, /this\.selected\.pallets\?\.length \? this\.selected\.pallets\.map\(restoredPallet\)/)
|
||||
assert.doesNotMatch(page, /if \(!this\.completedTransferNo \|\| this\.openingLabel\) return/)
|
||||
assert.match(page, /saveWarehouseTransferPalletDraft/)
|
||||
assert.match(page, /generateWarehouseTransferPallet/)
|
||||
assert.match(page, /bindWarehouseTransferPallet/)
|
||||
assert.match(page, /deleteWarehouseTransferPallet/)
|
||||
assert.match(page, /请先生成并扫描确认绑定全部托盘/)
|
||||
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
|
||||
})
|
||||
@@ -80,7 +80,8 @@ test('relabel API covers claim, unit outcomes, completion and review with encode
|
||||
test('carton relabel uses one scan input and shows the confirmed FBA mapping after saving', async () => {
|
||||
const page = await read('../src/pages/relabel/relabel.vue')
|
||||
assert.equal((page.match(/name="quickCarton"/g) || []).length, 1)
|
||||
assert.match(page, /v-if="taskTab === 'sku' && selectedItem && remaining\(selectedItem\) > 0"/)
|
||||
assert.match(page, /name="quickSku"/)
|
||||
assert.match(page, /v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining\(selectedItem\) > 0"/)
|
||||
assert.match(page, /selectItem\(item\) \{ if \(this\.taskTab === 'carton'\) \{ this\.activeTarget = 'quick-carton'; return \}/)
|
||||
const start = page.indexOf('async submitCarton(raw)')
|
||||
const scan = page.slice(start, page.indexOf('removePhoto(index)', start))
|
||||
@@ -90,6 +91,17 @@ test('carton relabel uses one scan input and shows the confirmed FBA mapping aft
|
||||
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
|
||||
})
|
||||
|
||||
test('mixed relabel keeps SKU work open after carton work and only carton tab mounts pallet work', async () => {
|
||||
const page = await read('../src/pages/relabel/relabel.vue')
|
||||
const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1]
|
||||
const remaining = new Function('item', body)
|
||||
const mixed = { operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 1,
|
||||
cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 }
|
||||
assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1)
|
||||
assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0)
|
||||
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton'"/)
|
||||
})
|
||||
|
||||
test('relabel list opens a row directly in execution and completion returns to the list', async () => {
|
||||
const page = await read('../src/pages/relabel/relabel.vue')
|
||||
assert.doesNotMatch(page, /name="relabelTask"/)
|
||||
@@ -105,13 +117,23 @@ test('relabel list opens a row directly in execution and completion returns to t
|
||||
|
||||
test('relabel review is a read-only list and completed execution details stay hidden', async () => {
|
||||
const page = await read('../src/pages/relabel/relabel.vue')
|
||||
assert.match(page, /showCurrent\(\) \{ if \(!this\.current \|\| this\.stage === 'review'\) return false; if \(this\.stage === 'execute'\) return this\.current\.taskStatus === 'PROCESSING' && this\.current\.claimedByCurrentOperator/)
|
||||
const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1]
|
||||
const visible = new Function(body)
|
||||
const context = { stage: 'execute', current: { relabelNo: 'RL-1', taskStatus: 'PROCESSING' }, candidates: [{ relabelNo: 'RL-1', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
|
||||
assert.equal(visible.call(context), true, 'detail without ownership field must use server candidate ownership')
|
||||
assert.equal(visible.call({ ...context, candidates: [] }), false)
|
||||
assert.equal(visible.call({ ...context, stage: 'review' }), false)
|
||||
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false)
|
||||
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待复核的单据'/)
|
||||
const reviewList = page.slice(page.indexOf('<view v-else class="task-list">'), page.indexOf('<view v-if="showCurrent"'))
|
||||
assert.doesNotMatch(reviewList, /@tap|详情/)
|
||||
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="loadCandidates"/)
|
||||
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/)
|
||||
assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
|
||||
assert.match(page, /if \(this\.stage === 'execute' && this\.taskCode\) await this\.openTask/)
|
||||
const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1]
|
||||
const bootstrap = new (Object.getPrototypeOf(async function () {}).constructor)(bootstrapBody)
|
||||
const opened = []
|
||||
await bootstrap.call({ ...context, taskCode: 'STALE', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
|
||||
assert.deepEqual(opened, ['RL-1'])
|
||||
})
|
||||
|
||||
test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
const enabled = operations.filter((operation) => operation.available)
|
||||
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
||||
{ module: 'inbound', name: '拆柜收货' },
|
||||
{ module: 'inbound', name: '仓储转运测量' },
|
||||
{ module: 'inbound', name: '退货入库' },
|
||||
{ module: 'inbound', name: '退货合箱' },
|
||||
{ module: 'outbound', name: '排车拣货' },
|
||||
@@ -25,6 +26,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
|
||||
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
||||
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
|
||||
assert.equal(operations.find((item) => item.name === '仓储转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer')
|
||||
|
||||
assert.equal(operations.some((item) => item.name === '库存盘点'), false)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user