This commit is contained in:
2026-09-15 15:20:43 +08:00
parent 6f07d17c1d
commit bf760f1abd
7 changed files with 211 additions and 49 deletions
+7 -1
View File
@@ -45,7 +45,7 @@
| 托盘管理真实作业 | `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` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;支持扫码、拍照、异常/返工和幂等恢复 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、托盘业务码扫码拣货、幂等完成;发车必须同时完成拣货核平、平台转派送并严格消费服务端 `canDepart`、`departBlockReason`,不得按本地数量自行推断;公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、稳定业务码扫码拣货和幂等完成;公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
@@ -56,6 +56,12 @@
## 海外业务接口边界
- 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
- 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
- 换标执行完成照片以缩略图展示并支持大图预览、移除和重拍。重新进入任务通过 `relabel-tasks/{relabelNo}/photos/files/{fileId}/download` 鉴权读取,不持久化设备临时图片地址;重拍先上传成功再替换待提交文件 ID,失败保留原照片,移除只解绑待提交引用。
- 退货查询空态集中在 `src/api/return-receipt.js`、`src/api/return-packing.js`:仅 `startReturnReceipt`、`findReturnSkuCandidates`、`findPackingOrders` 将 `A0404/404` 转为无匹配结果;签收确认、合箱写入和标签读取不得套用此处理,权限、网络与服务错误仍须保留诊断并报错。回归见 `tests/return-receipt-api.test.mjs`、`tests/return-packing-api.test.mjs`,不得将查询无记录误报为写入成功。
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、普通 FBA bulk/普通 OVERSEAS carton、一件代发 source pallet 整托收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;一件代发只提交国内冻结的来源托盘号,不允许逐箱或拆托收货。响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- 入仓摘要、箱码解析和实收响应的 `assigned/mergeAllowed/urgent/mergeHint` 只消费 Lastleg 返回值,布尔标志只有 `true` 生效,`mergeAllowed` 缺省为 `false`;合托仅作提示,不能改变收货资格或触发分配。首扫实物托盘只确认服务端已有托盘、库位与资源锁预留,不在设备端重新分配托盘、库位或资源锁;只提交业务箱码、托盘码及当前收货数量,最终资格由服务端决定。
+15 -7
View File
@@ -1,4 +1,4 @@
import { overseasRequest, overseasUpload } from './overseas.js'
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
const BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
@@ -6,18 +6,22 @@ const encoded = (value) => encodeURIComponent(String(value || '').trim())
function asList(value) { return Array.isArray(value) ? value : [] }
export function normalizeRelabel(value = {}) {
const items = asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
newSkuCode: String(item.newSkuCode || ''), operationType: String(item.operationType || 'CARTON_LABEL'), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
}))
const inferredOperationType = items.some((item) => item.operationType === 'CARTON_AND_SKU') ? 'CARTON_AND_SKU' : items.some((item) => item.operationType === 'SKU_LABEL')
? (items.some((item) => item.operationType !== 'SKU_LABEL') ? 'CARTON_AND_SKU' : 'SKU_LABEL') : 'CARTON_LABEL'
return {
...value,
relabelNo: String(value.relabelNo || ''), customerOrderNo: String(value.customerOrderNo || ''),
version: Number(value.version || 0), expectedPackages: Number(value.expectedPackages || 0),
operationType: String(value.operationType || inferredOperationType),
relabelledPackages: Number(value.relabelledPackages || 0), skippedPackages: Number(value.skippedPackages || 0),
exceptionPackages: Number(value.exceptionPackages || 0),
items: asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
newSkuCode: String(item.newSkuCode || ''), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
})),
items,
unitFacts: asList(value.unitFacts), history: asList(value.history),
completionPhotoFileIds: asList(value.completionPhotoFileIds).map(String)
}
@@ -36,9 +40,13 @@ async function detailAction(relabelNo, suffix, request) {
}
export const claimRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/claim', request)
export const scanRelabelCarton = (relabelNo, request) => detailAction(relabelNo, '/cartons/scan', request)
export const listRelabelDocuments = (relabelNo) => overseasRequest('GET', `${BASE}/${encoded(relabelNo)}/documents`)
export const downloadRelabelDocument = (relabelNo, file) => overseasDownload(`${BASE}/${encoded(relabelNo)}/documents/${encoded(file.fileId)}/download?scope=${encoded(file.scope)}${file.itemId ? `&itemId=${encoded(file.itemId)}` : ''}`)
export const relabelUnit = (relabelNo, request) => detailAction(relabelNo, '/units', request)
export const skipRelabelUnit = (relabelNo, request) => detailAction(relabelNo, '/units/skip', request)
export const reportRelabelException = (relabelNo, request) => detailAction(relabelNo, '/exceptions', request)
export const completeRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/complete', request)
export const reviewRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/review', request)
export const uploadRelabelPhoto = (relabelNo, filePath) => overseasUpload(`${BASE}/${encoded(relabelNo)}/photos/files`, filePath)
export const downloadRelabelPhoto = (relabelNo, fileId) => overseasDownload(`${BASE}/${encoded(relabelNo)}/photos/files/${encoded(fileId)}/download`)
+2 -2
View File
@@ -12,8 +12,8 @@ export const MODULES = [
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' },
{ key: 'pallet', name: '托盘管理', nameKey: 'modules.pallet.name', icon: '托', iconType: 'map', description: '扫码查询、组托、拆托、换托及解绑', descriptionKey: 'modules.pallet.description', available: true, route: '/pages/pallet/pallet' }
] },
{ key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标收货、执行与复核', descriptionKey: 'modules.relabel.description', operations: [
{ key: 'relabelReceive', name: '换标收货', nameKey: 'modules.relabelReceive.name', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', descriptionKey: 'modules.relabelReceive.description', available: true, route: '/pages/relabel/relabel?op=relabelReceive' },
{ key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标列表、执行与复核', descriptionKey: 'modules.relabel.description', operations: [
{ key: 'relabelReceive', name: '换标列表', nameKey: 'modules.relabelReceive.name', icon: '列', iconType: 'list', description: '选择换标单并直接进入执行', descriptionKey: 'modules.relabelReceive.description', available: true, route: '/pages/relabel/relabel?op=relabelReceive' },
{ key: 'relabelExecute', name: '换标执行', nameKey: 'modules.relabelExecute.name', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', descriptionKey: 'modules.relabelExecute.description', available: true, route: '/pages/relabel/relabel?op=relabelExecute' },
{ key: 'relabelReview', name: '换标复核', nameKey: 'modules.relabelReview.name', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', descriptionKey: 'modules.relabelReview.description', available: true, route: '/pages/relabel/relabel?op=relabelReview' }
] },
+1 -1
View File
@@ -143,7 +143,7 @@
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标收货', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
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: {
+147 -36
View File
@@ -2,36 +2,62 @@
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">任务和仓库范围由服务端按当前 PDA Token 校验;扫描与人工输入使用同一正式接口</text></view>
<view class="work-card"><ScanTargetInput name="relabelTask" v-model="taskCode" label="换标单号" placeholder="扫描或输入换标单号" :active="activeTarget === 'task'" @activate="activeTarget = 'task'" @submit="openTask" /><button class="primary" :disabled="!taskCode.trim() || busy" @tap="openTask(taskCode)">{{ busyAction === 'detail' ? '查询中…' : '打开换标任务' }}</button></view>
<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 class="link-button" :disabled="loading" @tap="loadCandidates">{{ loading ? '刷新中…' : '刷新' }}</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>
<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 class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo }" @tap="openTask(item.relabelNo)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</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-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>
<view v-if="current" class="work-card">
<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>
<view class="metric-row"><view class="metric"><strong>{{ current.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ current.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ current.skippedPackages }}</strong><text>跳过</text></view><view class="metric"><strong>{{ current.exceptionPackages }}</strong><text>异常</text></view></view>
<template v-if="stage === 'receive'"><text class="hint">认领后任务进入处理中,并锁定当前操作人;其他账号不可并发作业。</text><button class="primary" :disabled="busy || current.taskStatus === 'WAIT_REVIEW' || current.taskStatus === 'COMPLETED'" @tap="claimTask">{{ busyAction === 'claim' ? '认领中…' : current.claimedByCurrentOperator ? '恢复当前换标任务' : '确认收货并认领任务' }}</button></template>
<template v-else-if="stage === 'execute'">
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请先在“换标收货”认领,或等待复核退回。</text></view>
<view class="section-gap"><view class="row-head"><text class="card-title">标签文件</text><button class="link-button" :disabled="busy" @tap="loadDocuments">刷新文件</button></view><text v-if="!documents.length" class="meta-line">暂无标签文件</text><view v-for="file in documents" :key="`${file.scope}:${file.fileId}`" class="row-head"><text class="meta-line">{{ file.scope === 'CARTON_TOTAL' ? '总箱唛' : file.scope === 'SKU_TOTAL' ? '总 SKU 标签' : '明细标签' }}:{{ file.fileName }}</text><button class="secondary" :disabled="busy" @tap="printDocument(file)">查看 / 打印</button></view></view>
<template v-if="stage === 'execute'">
<view v-if="current.reviewDecision === 'REWORK' && current.reviewRemark" class="error-box section-gap"><text class="error-title">退回重新贴标</text><text>{{ current.reviewRemark }}</text></view>
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请返回换标列表选择可执行任务,或等待复核退回。</text></view>
<template v-else>
<text class="card-title section-gap">选择商品明细</text>
<view class="item-list"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)"><view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未关联' }}</text><text class="meta-line">新 SKU:{{ item.newSkuCode || '—' }}</text></view><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }}</text></view><text class="meta-line">{{ item.labelRequirement || '无额外标签要求' }}</text></view></view>
<template v-if="selectedItem && remaining(selectedItem) > 0">
<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></view>
<template v-if="taskTab === 'sku' && selectedItem && 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" />
<ScanTargetInput v-if="action === 'RELABEL'" name="newLabel" v-model="newLabel" label="新标签" :placeholder="`应匹配 ${selectedItem.newSkuCode || '审批新 SKU'}`" :active="activeTarget === 'new'" @activate="activeTarget = 'new'" @submit="handleNewLabel" />
<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="work-card section-gap"><view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} 张</text></view><button class="secondary" :disabled="busy" @tap="choosePhoto">拍照或从相册选择</button><text v-for="(name,index) in photoNames" :key="`${name}:${index}`" class="meta-line">{{ index + 1 }}. {{ name || '换标现场照片' }}</text><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="完成备注(选填)" /><button class="primary" :disabled="busy || !allProcessed || !photoFileIds.length" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : '完成现场换标并等待复核' }}</button><text class="hint">每件货物必须换标、跳过或登记异常,且至少上传一张现场照片。</text></view>
<view class="section-gap">
<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">
<image v-if="photoUrls[fileId]" class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" />
<button v-else class="photo-thumbnail photo-placeholder" @tap="loadPhoto(fileId)">{{ photoErrors[fileId] ? '加载失败,点击重试' : '照片加载中' }}</button>
<text class="photo-name">{{ photoNames[index] || '换标现场照片' }}</text>
<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>
</template>
</template>
<template v-else><view v-if="current.taskStatus !== 'WAIT_REVIEW'" class="empty-box section-gap"><text class="empty-copy">当前任务尚未进入待复核状态。</text></view><template v-else><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="复核备注;退回重做时建议填写原因" /><view class="action-row"><button class="danger" :disabled="busy" @tap="review('REWORK')">退回重做</button><button class="primary" :disabled="busy" @tap="review('APPROVE')">复核通过</button></view></template></template>
</view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="retry">重新读取任务</button></view>
@@ -47,50 +73,133 @@
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 { 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 },
data() { return { user: session.currentUser, stage: 'receive', 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: '其他' }] } },
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: {
title() { return ({ receive: '换标收货', execute: '换标执行', review: '换标复核' })[this.stage] },
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '可认领任务' },
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) },
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: [] } },
skuVerified() { if (!this.selectedItem || this.selectedItem.operationType === 'CARTON_LABEL') return true; const expected = this.skuDetails(this.selectedItem); return expected.length > 0 && expected.every((sku, index) => { const actual = this.unitDraft.skuVerifications[index]; return actual && actual.skuCode.trim() === sku.skuCode && actual.newSkuCode.trim() === sku.newSkuCode && Number(actual.quantity) > 0 && Number(actual.quantity) === Number(sku.quantity) }) },
cartonVerified() { return Boolean(this.selectedItem && this.oldLabel.trim() === this.selectedItem.cartonMark && (this.taskTab === 'sku' || this.newLabel.trim() === this.selectedItem.fbaCartonMark && this.newLabel.trim())) },
title() { return ({ receive: '换标列表', execute: '换标执行', review: '换标复核' })[this.stage] },
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
emptyText() { return `暂无${this.candidateHeading}` },
visibleCandidates() { if (this.stage === 'review') return this.candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return this.candidates.filter((item) => !item.taskStatus || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
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)) },
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 warehouseWork.relabel.oldLabel }, set(value) { updateWarehouseWork('relabel', { oldLabel: value }) } },
newLabel: { get() { return warehouseWork.relabel.newLabel }, set(value) { updateWarehouseWork('relabel', { newLabel: value }) } },
oldLabel: { get() { return this.unitDraft.cartonMark }, set(value) { if (this.selectedItem) this.unitDraft.cartonMark = value } },
newLabel: { get() { return this.unitDraft.fbaCartonMark }, set(value) { if (this.selectedItem) this.unitDraft.fbaCartonMark = value } },
action: { get() { return warehouseWork.relabel.action }, set(value) { updateWarehouseWork('relabel', { action: value }) } },
reason: { get() { return warehouseWork.relabel.reason }, set(value) { updateWarehouseWork('relabel', { reason: value }) } },
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 },
canSubmitUnit() { if (!this.selectedItem || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return Boolean(this.newLabel.trim()); return Boolean(this.reason.trim()) },
unitActionText() { return ({ RELABEL: '确认本件换标', SKIP: '确认跳过本件', EXCEPTION: '登记本件异常' })[this.action] }
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' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, 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) } },
'current.relabelNo'() { this.photoUrls = {}; this.photoErrors = {}; for (const id of this.photoFileIds) this.loadPhoto(id) }
},
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: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)' })[type] || '换标类型待确认' },
async loadPhoto(fileId) {
const no = this.current?.relabelNo
if (!no || this.photoUrls[fileId] || !this.photoFileIds.includes(fileId)) return
this.photoErrors[fileId] = false
try { const path = await downloadRelabelPhoto(no, fileId); if (this.current?.relabelNo === no && this.photoFileIds.includes(fileId)) this.photoUrls[fileId] = path }
catch (_) { if (this.current?.relabelNo === no) this.photoErrors[fileId] = true }
},
previewPhoto(fileId) { const urls = this.photoFileIds.map((id) => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
retakePhoto(index) {
if (this.busy || !this.current) return
const no = this.current.relabelNo; const oldId = this.photoFileIds[index]
uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => {
if (this.current?.relabelNo !== no || !this.photoFileIds.includes(oldId) || !tempFilePaths?.[0]) return
this.run('photo', async () => {
if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadRelabelPhoto(no, tempFilePaths[0])
const ids = [...this.photoFileIds]; const names = [...this.photoNames]; const position = ids.indexOf(oldId)
if (position < 0 || this.current?.relabelNo !== no) return
ids[position] = String(file.fileId); names[position] = file.fileName || '换标现场照片'
this.photoUrls[String(file.fileId)] = tempFilePaths[0]
updateWarehouseWork('relabel', { photoFileIds: ids, photoNames: names })
this.successMessage = '照片已重新拍摄并替换'
}, false)
} })
},
async loadDocuments() { if (!this.current) return; const no = this.current.relabelNo; try { const files = await listRelabelDocuments(no); if (this.current?.relabelNo === no) this.documents = files || [] } catch (error) { this.setError(error, '标签文件加载失败') } },
async printDocument(file) { await this.run('document', async () => { const path = await downloadRelabelDocument(this.current.relabelNo, file);
// #ifdef H5
const frame = document.createElement('iframe'); frame.style.cssText = 'position:fixed;width:1px;height:1px;border:0'; frame.src = path; frame.onload = () => { try { frame.contentWindow.focus(); frame.contentWindow.print() } catch (_) { window.open(path, '_blank', 'noopener') } }; document.body.appendChild(frame); setTimeout(() => frame.remove(), 120000);
// #endif
// #ifndef H5
await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }));
// #endif
}, false) },
async submitCarton(raw) {
const mark = String(raw?.detail?.value ?? raw ?? this.quickCarton).trim()
if (!mark || this.busy || !this.current) return
const item = this.current.items.find((row) => row.cartonMark === mark && row.operationType !== 'SKU_LABEL')
if (!item) return this.setError(new Error('该仓库唛头不属于本单箱唛换标明细'))
const payload = { relabelNo: this.current.relabelNo, cartonMark: mark, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'carton-scan', payload)
await this.run('carton-scan', async () => {
const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value })
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.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 }))
}, 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, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (this.activeTarget === 'old') { this.oldLabel = value; this.activeTarget = 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 = 'new' },
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' },
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
async bootstrap() { await this.loadCandidates(); if (this.taskCode) await this.openTask(this.taskCode, false) },
async bootstrap() { await this.loadCandidates(); if (this.stage === 'execute' && this.taskCode) await this.openTask(this.taskCode, 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) { this.current = value; this.taskCode = value.relabelNo; if (this.selectedItem) this.selectedItem = value.items.find((item) => item.itemId === this.selectedItem.itemId) || null },
selectItem(item) { if (this.remaining(item) <= 0) return; this.selectedItem = item; this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
remaining(item) { return Math.max(0, item.expectedPackages - item.relabelledPackages - item.skippedPackages - item.exceptionPackages) },
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' },
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) },
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 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(), 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(), 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) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) return this.setError(new Error('照片不能超过 10MB')); if (path) this.uploadPhoto(path) } }) },
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},请先完成后再操作其他换标单`))
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
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key)
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) } }) },
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.allProcessed || !this.photoFileIds.length) return; const payload = { relabelNo: this.current.relabelNo, photoFileIds: this.photoFileIds, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'complete', payload); await this.run('complete', async () => { this.applyCurrent(await completeRelabelTask(this.current.relabelNo, { photoFileIds: this.photoFileIds, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { photoFileIds: [], photoNames: [], remark: '' }); this.successMessage = '现场换标已提交复核'; await this.loadCandidates() }, true, key) },
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' }) },
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
@@ -102,4 +211,6 @@
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}</style>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.tab-row{display:flex;gap:10px;margin:14px 0 10px}.tab-button{flex:1;border:1px solid #d7e1ef;border-radius:10px;background:#fff;color:#52647c;font-size:15px;padding:10px 8px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}
</style>
+37
View File
@@ -77,6 +77,43 @@ test('relabel API covers claim, unit outcomes, completion and review with encode
assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
})
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, /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))
assert.ok(scan.indexOf('await scanRelabelCarton') < scan.indexOf('this.applyCurrent(result)'))
assert.ok(scan.indexOf('this.applyCurrent(result)') < scan.indexOf('uni.showModal'))
assert.match(scan, /showCancel: false/)
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
})
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"/)
assert.match(page, /@tap="openExecution\(item\)"/)
assert.match(page, /async openExecution\(item\)/)
assert.match(page, /uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelExecute' \}\)/)
assert.match(page, /当前账号正在执行 \$\{active\.relabelNo\}/)
assert.match(page, /v-if="stage === 'execute'" class="work-card"><button class="secondary"[^>]*@tap="goToClaimList">返回换标列表<\/button>/)
assert.match(page, /updateWarehouseWork\('relabel', \{ currentNo: '', photoFileIds: \[\], photoNames: \[\], remark: '' \}\)/)
assert.match(page, /if \(completed\) this\.goToClaimList\(\)/)
assert.match(page, /goToClaimList\(\) \{ this\.taskCode = ''; uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelReceive' \}\) \}/)
})
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/)
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, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
assert.match(page, /if \(this\.stage === 'execute' && this\.taskCode\) await this\.openTask/)
})
test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
const values = new Map()
globalThis.uni = {
+2 -2
View File
@@ -13,7 +13,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
{ module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '商品移库' },
{ module: 'inventory', name: '托盘管理' },
{ module: 'relabel', name: '换标收货' },
{ module: 'relabel', name: '换标列表' },
{ module: 'relabel', name: '换标执行' },
{ module: 'relabel', name: '换标复核' },
{ module: 'query', name: '库存查询' },
@@ -28,7 +28,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
assert.equal(operations.some((item) => item.name === '库存盘点'), false)
for (const name of ['商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
for (const name of ['商品移库', '换标列表', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
const operation = operations.find((item) => item.name === name)
assert.equal(operation?.available, true)
assert.match(operation?.route || '', /^\/pages\//)