Merge latest release/test into dev [skip ci]

This commit is contained in:
2026-10-14 17:26:39 +08:00
48 changed files with 2178 additions and 242 deletions
+1
View File
@@ -44,6 +44,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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `A0` 共享溢出位提示,不在设备端重新分配。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/return-counting/return-counting.vue`、`src/api/return-counting.js` | 扫描箱号或托盘号定位清点任务;各箱 SKU 汇总与签收数量核平,支持恢复当前任务;页面注册见 `src/pages.json` |
| 仓储转运测量、打托与标签 | `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` 不注册库存盘点,不向现场开放入口 |
@@ -0,0 +1,16 @@
{
"base": "3133aac01953a2b7034517171cb098148407cd6e",
"commit": "a7388914cdd1ae654efa08867edb51fc4e0b6e5c",
"files": [
"src/pages/dispatch/dispatch.vue",
"src/utils/dispatch-error.js",
"tests/fulfillment-workflows.test.mjs"
],
"supersedes": "cf984ab4812d64b33e1ad2c98bc2804a272c6c77",
"deployed": false,
"checks": {
"tests": 215,
"h5Build": "PASS",
"browser": "local corrected H5 with gray API"
}
}
+10 -3
View File
@@ -1,4 +1,4 @@
import { overseasRequest } from './overseas.js'
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
@@ -43,6 +43,7 @@ export function normalizeDispatchTask(value) {
warehouseRouteLabel: String(item?.warehouseRouteLabel || ''),
scheduledPackages: count(item?.scheduledPackages),
pickedPackages: count(item?.pickedPackages),
returnCartons: asList(item?.returnCartons).map((carton) => ({ ...carton, id: String(carton.id || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: count(carton.productQuantity) })),
positions: asList(item?.positions).map((position) => ({
...position,
id: String(position?.id || ''),
@@ -137,6 +138,7 @@ export function outboundPickPayload(item, position, packages, scannedCode = '')
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
...(item.returnSkuQuantity != null ? { productQuantity: Number(item.returnSkuQuantity) } : {}),
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
}
@@ -173,6 +175,11 @@ export async function resolveCargoDiscardPallet(palletNo) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
}
export async function confirmCargoDiscard(discardNo, palletNo, lines) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines }))
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds }))
}
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
export const downloadCargoDiscardPhoto = (discardNo, palletNo, fileId) => overseasDownload(`${discardPhotoPath(discardNo, palletNo)}/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
+2
View File
@@ -4,6 +4,8 @@ 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 const listRelabelPalletMeasurements = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-pallet-measurements`, { relabelNo })
export const measureRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-measure`, payload)
export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
+2
View File
@@ -4,6 +4,7 @@ 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))
export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload))
export const packReturnRelabelCartons = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-cartons/pack`, payload))
export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload))
function asList(value) { return Array.isArray(value) ? value : [] }
@@ -11,6 +12,7 @@ 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 || ''),
returnCartonId: item.returnCartonId == null ? null : String(item.returnCartonId), returnQuantity: Number(item.returnQuantity || 0),
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)
+20
View File
@@ -0,0 +1,20 @@
import { overseasRequest } from './overseas.js'
const BASE = '/pda/v1/last-leg/return-inventory/counting'
export function getCurrentReturnCountTask() {
return overseasRequest('GET', `${BASE}/current`)
}
export function beginReturnCountTask(receiptNo) {
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}/begin`)
}
export function scanReturnCountTasks(code) {
return overseasRequest('GET', `${BASE}/scan/${encodeURIComponent(String(code).trim())}`)
}
export function listReturnCountTasks({ page = 1, size = 10, keyword = '' } = {}) {
return overseasRequest('GET', `${BASE}/tasks?page=${page}&size=${size}&keyword=${encodeURIComponent(keyword.trim())}`)
}
export function scanReturnCountCarton(mark) {
return overseasRequest('GET', `${BASE}/cartons/${encodeURIComponent(String(mark).trim())}`)
}
export function confirmReturnCount(receiptNo, request) {
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}`, request)
}
+61 -3
View File
@@ -17,7 +17,17 @@ function normalizeItems(items) {
}
export function normalizePackingPreview(value = {}) {
return {
value = value && typeof value === 'object' ? value : {}
const normalizeCustomer = (customer = {}) => ({
customerId: customer.customerId == null ? '' : String(customer.customerId),
orders: Array.isArray(customer.orders) ? customer.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
})) : [],
skus: Array.isArray(customer.skus) ? customer.skus.map((item) => ({
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
})
const result = {
customerKey: value.customerId == null ? '' : String(value.customerId),
orders: Array.isArray(value.orders) ? value.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
@@ -26,6 +36,13 @@ export function normalizePackingPreview(value = {}) {
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
}
if (Array.isArray(value.customers)) result.customers = value.customers.map(normalizeCustomer).filter((item) => item.customerId)
else if (result.customerKey && result.orders.length) {
// Older service versions returned the single-customer shape. Keep the
// PDA behavior consistent by exposing it through the grouped contract.
result.customers = [{ customerId: result.customerKey, orders: result.orders, skus: result.skus }]
}
return result
}
export function normalizeReturnLabel(value = {}) {
@@ -37,16 +54,53 @@ export function normalizeReturnLabel(value = {}) {
}
}
export async function findPackingOrders(returnNo = '') {
export async function findPackingOrders(returnNo = '', mode = 'RETURN_NO') {
const query = String(returnNo || '').trim()
try {
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?${mode === 'RETURN_NO' ? 'returnNo' : 'skuCode'}=${encoded(query)}` : ''}`))
} catch (error) {
if (isNotFound(error)) return normalizePackingPreview()
throw error
}
}
export async function listPackingPlans() {
const value = await overseasRequest('GET', `${BASE}/packing/plans`)
return Array.isArray(value) ? value.map(normalizePackingPlan) : []
}
export async function completePackingPlan(planNo, request) {
const value = await overseasRequest('POST', `${BASE}/packing/plans/${encoded(planNo)}/complete`, request)
return { planNo: String(value?.planNo || planNo), labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
}
export function normalizePackingPlan(value = {}) {
value = { ...value, completionCartons: Array.isArray(value.completionCartons) ? value.completionCartons.map(normalizeReturnLabel) : [], transfers: Array.isArray(value.transfers) ? value.transfers : [], completedAt: value.completedAt || null }
return { completionCartons: value.completionCartons, transfers: value.transfers, completedAt: value.completedAt, planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
}
export async function getPackingPlan(planNo) {
return normalizePackingPlan(await overseasRequest('GET', `${BASE}/packing/plans/${encoded(planNo)}`))
}
export async function scanReturnCarton(mark) {
return overseasRequest('GET', `${BASE}/cartons/${encoded(mark)}`)
}
export async function mergeReturnBoxes(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge-boxes`, request)
return { labels: [normalizeReturnLabel(value?.label)] }
}
export async function packReturnSkus(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/pack`, request)
return { labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
}
export async function putawayReturnCarton(mark, request) {
return overseasRequest('POST', `${BASE}/cartons/${encoded(mark)}/putaway`, request)
}
export async function mergeReturnCartons(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request)
return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) }
@@ -55,3 +109,7 @@ export async function mergeReturnCartons(request) {
export async function getReturnCartonLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
}
export async function confirmReturnLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('POST', `${BASE}/cartons/${encoded(cartonMark)}/confirm-label`))
}
+127 -30
View File
@@ -6,20 +6,30 @@
</view>
<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>
<button v-for="binding in currentBindings" :key="binding.mark" class="binding-row" :disabled="busy" @tap="selectRebind(binding)"><text class="binding-mark">{{ binding.mark }}</text><text class="binding-arrow">→</text><text class="binding-pallet">{{ binding.pallet }}</text><text class="binding-action">重新绑定</text></button>
</view>
<view v-for="pallet in boundPalletTotals" :key="pallet.palletCode" class="hint"><text>{{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}</text></view>
<view v-for="pallet in measurements" :key="pallet.palletCode" class="measurement-card">
<text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text>
<text class="hint">{{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}</text>
<text v-if="pallet.volumeCbm != null" class="hint">已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
<view class="measurement-inputs">
<label v-for="field in measurementFields" :key="field.key"><text>{{ field.label }}</text><input v-model="pallet[field.key]" type="digit" :disabled="busy" :placeholder="field.label" /></label>
</view>
<button :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
</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>
<template v-if="!allBindingsComplete && !rebindSelection">
<view v-for="row in operableSourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
<text>托盘号:{{ row.palletCode }} · 入仓码:{{ row.inboundCode }}</text>
<text class="hint">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
<view class="source-identifiers"><view v-if="!unboundSourceMode" class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view><view v-else class="source-identifier"><text class="source-caption">来源箱</text><text class="source-code">{{ (row.cartonMarks || []).join('、') }}</text></view></view>
<text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
</view>
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘</text>
<ScanTargetInput v-if="!isReturnBulk" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="resolve('source')" />
<button v-if="!isReturnBulk" :disabled="busy || !sourceCode.trim()" @tap="resolve('source')">确认来源托盘</button>
<text v-if="source" class="hint">已确认:{{ source.palletCode }}</text>
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘或来源箱</text>
<ScanTargetInput v-if="!isReturnBulk && !unboundSourceMode" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<ScanTargetInput v-if="unboundSourceMode" v-model="sourceCode" name="relabel-source-carton" label="来源箱唛" placeholder="扫描来源箱唛确认" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
<view v-if="selectedSource">
<text class="hint">本次{{ isReturnBulk && wholeSource ? '整托换标数量' : `拆出${isReturnBulk ? '件数' : '箱数'}` }}(可分多个来源托盘,逐批提交)</text>
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
<template v-if="isReturnBulk">
<text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text>
<text v-if="wholeSource" class="hint">贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。</text>
@@ -27,26 +37,41 @@
<text v-else class="hint">退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。</text>
<text class="hint">退货换标无需扫描箱唛。</text>
</template>
<template v-else><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></template>
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="marks = []" /><text>箱</text></view></template>
</view>
<view class="split-progress"><template v-if="!isReturnBulk"><text>本次需换标拆 <strong>{{ requiredSplitPackages }}</strong> 箱</text><text>已拆 <strong>{{ splitPackages }}</strong> 箱</text></template><button class="create-pallet-button" :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
</template>
<template v-if="!allBindingsComplete || rebindSelection">
<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>
<button v-if="canRetainOriginal" :disabled="busy" @tap="quantity = String(source.totalPackages); targetCode = source.palletCode; confirmTargetAndBind()">整托全部贴标,保留原托盘</button>
<button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">{{ rebindSelection ? '确认改绑目标托盘' : '确认绑定目标托盘' }}</button>
<text v-if="labelBlockReason" class="hint">{{ labelBlockReason }}</text>
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
<view v-if="newPallet" class="hint"><text>新托盘号:{{ newPallet.palletCode }},请贴好托盘标签后扫描确认。</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
<view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
</template>
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources } from '@/api/pallet.js'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: 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: '' } },
data() { return { measurements: [], measurementFields: [{ key: 'lengthCm', label: '长 cm' }, { key: 'widthCm', label: '宽 cm' }, { key: 'heightCm', label: '高 cm' }, { key: 'grossWeightKg', label: '毛重 kg' }], palletTotals: {}, rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
mounted() { this.loadSources() },
computed: {
canRetainOriginal() { return !this.isReturnBulk && !this.unboundSourceMode && this.source && this.selectedSource && this.plannedPackages === Number(this.source.totalPackages) },
boundPalletTotals() { const codes = new Set(this.currentBindings.map(binding => binding.pallet)); return Object.values(this.palletTotals).filter(pallet => codes.has(pallet.palletCode) && !this.measurements.some(measured => measured.palletCode === pallet.palletCode)) },
allBindingsComplete() {
const required = (this.task.items || []).filter(item => Number(item.expectedPackages || 0) > 0 && (item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)))
const bound = new Set(this.currentBindings.map(binding => binding.mark))
return required.length > 0 && required.every(item => bound.has(item.cartonMark))
},
requiredSplitPackages() { return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
splitPackages() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) && bound.has(item.cartonMark)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
currentBindings() {
const latest = new Map()
for (const entry of this.task.history || []) {
@@ -60,16 +85,37 @@ export default {
}
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.isReturnBulk ? this.sourceItems() : this.completedItems()).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
eligibleItems() { return (this.isReturnBulk ? this.sourceItems() : this.completedItems()).filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
returnCartonItems() { return (this.task.items || []).filter(item => item.returnCartonId && !this.currentBindings.some(binding => binding.mark === item.cartonMark)) },
operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) },
plannedPackages() { return this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
labelBlockReason() {
const pending = this.sourceItems().filter(item => Number(item.relabelledPackages || 0) < Number(item.expectedPackages || 1))
if (!pending.length) return ''
const missingSku = pending.some(item => item.operationType === 'CARTON_AND_SKU' && Number(item.skuProcessedPackages || 0) < Number(item.expectedPackages || 1))
if (missingSku) return '本托盘还有未完成的 SKU 贴标登记。混合换标须完成箱唛和 SKU 两项,请返回 SKU 页签处理后再确认绑定。'
return '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。'
},
wholeSource() { return this.isReturnBulk && this.selectedSource && (!this.allowDisassemble || this.plannedPackages === Number(this.selectedSource.stockPackages || 0)) },
readyToBind() { return this.source && this.target && this.selectedSource && this.samePallet(this.source, this.selectedSource) && !this.samePallet(this.source, this.target) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
unboundSourceMode() { return !this.isReturnBulk && this.returnCartonItems.length > 0 && (this.selectedSource ? (!this.selectedSource.palletCode || this.selectedSource.palletCode === 'UNBOUND') : !this.operableSourceRows.some(row => row.palletCode && row.palletCode !== 'UNBOUND')) },
readyToBind() { return this.target && this.selectedSource && (this.unboundSourceMode || (this.source && this.samePallet(this.source, this.selectedSource) && (!this.samePallet(this.source, this.target) || this.canRetainOriginal))) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
},
methods: {
async saveMeasurement(pallet) {
const dimensions = Object.fromEntries(this.measurementFields.map(field => [field.key, Number(pallet[field.key])]))
if (!Object.values(dimensions).every(value => Number.isFinite(value) && value > 0)) { this.error = '请填写有效的长、宽、高和毛重'; return }
const payload = { relabelNo: this.task.relabelNo, palletCode: pallet.palletCode, stockVersion: pallet.stockVersion, ...dimensions }
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
await this.run(async () => {
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('relabel', key.signature)
this.message = `托盘 ${pallet.palletCode} 实测已保存`
this.$emit('saved')
})
},
savePalletTotals(result) { for (const pallet of result?.pallets || []) this.palletTotals = { ...this.palletTotals, [pallet.palletCode]: pallet } },
samePallet(left, right) {
if (!left || !right) return false
// 扫码枪可能在托盘号前后附带 CR/LF、零宽字符或全角空格;
@@ -99,7 +145,7 @@ export default {
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) }))
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 () => {
@@ -107,21 +153,27 @@ export default {
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 })
this.savePalletTotals(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}`)
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
})
if (this.error) this.showBindingMessage('改绑失败', this.error)
},
sourceItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark)) },
completedItems() { return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : 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 = '' }) },
completedItems() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : Number(item.relabelledPackages) >= 1 && !bound.has(item.cartonMark)) },
async loadSources() { await this.run(async () => { try { this.sourceRows = await findRelabelSources(this.task.relabelNo) } catch (error) { if (!this.isReturnBulk && this.returnCartonItems.length) this.sourceRows = []; else throw error } if (!this.isReturnBulk && this.returnCartonItems.length && !this.sourceRows.some(row => Number(row.movablePackages) > 0)) this.sourceRows = [{ positionId: this.returnCartonItems[0].returnCartonId, positionVersion: 0, palletCode: 'UNBOUND', stockPackages: this.returnCartonItems.length, movablePackages: this.returnCartonItems.length, cartonMarks: this.returnCartonItems.map(item => item.cartonMark).filter(Boolean) }]; for (const code of new Set(this.currentBindings.map(binding => binding.pallet))) { const pallet = await resolvePallet(code); this.savePalletTotals({ pallets: [pallet] }) } this.measurements = await listRelabelPalletMeasurements(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = ''; if (this.unboundSourceMode && this.operableSourceRows.length === 1) { this.selectedSource = this.operableSourceRows[0]; this.selectedDestination = this.returnCartonItems[0]?.destinationFbaWarehouseCode || ''; this.marks = this.completedItems().map(item => item.cartonMark); this.quantity = String(this.marks.length); this.activate('target') } }) },
async selectSource(row) {
if (this.busy) return
this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []
this.selectedDestination = this.sourceItems()[0]?.destinationFbaWarehouseCode || ''
this.selectedDestination = (this.isReturnBulk ? this.sourceItems() : this.completedItems())[0]?.destinationFbaWarehouseCode || ''
if (this.unboundSourceMode) {
this.marks = this.completedItems().map(item => item.cartonMark)
this.quantity = String(this.marks.length)
this.activate('target')
return
}
if (this.isReturnBulk) {
const items = this.eligibleItems
this.marks = items.map(item => item.cartonMark)
@@ -144,23 +196,57 @@ export default {
fillBatch() { const count = Number(this.quantity); const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0); const invalid = !Number.isSafeInteger(count) || count <= 0 || count > this.selectedSource.movablePackages || (this.isReturnBulk ? count !== packages : count > this.eligibleItems.length); if (invalid) { this.error = this.isReturnBulk ? '拆出件数必须等于本批已完成贴标件数,且不能超过来源托盘可拆数量' : '拆出数量必须为正整数,且不超过来源托盘可拆箱数和已完成换标箱数'; return } this.marks = (this.isReturnBulk ? this.eligibleItems : 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) },
handleScan(value) { if (this.busy) return; this[this.active + 'Code'] = value; return this.active === 'target' ? this.confirmTargetAndBind() : this.confirmSourceScan() },
async confirmBindingChoice(content) {
this.busy = true; this.$emit('busy', true)
try { return await new Promise(resolve => uni.showModal({ title: '是否绑定', content, confirmText: '是', cancelText: '否', success: result => resolve(!!result.confirm), fail: () => resolve(false) })) }
finally { this.busy = false; this.$emit('busy', false) }
},
async confirmSourceScan() {
if (this.busy || !this.sourceCode.trim()) return
const matched = this.unboundSourceMode
? this.operableSourceRows.find(row => (row.cartonMarks || []).some(mark => String(mark).trim().toUpperCase() === this.sourceCode.trim().toUpperCase()))
: this.operableSourceRows.find(row => this.samePallet(row, { palletCode: this.sourceCode }))
this.source = null
if (!matched) { this.error = '该托盘不在本单可操作的来源托盘列表中'; this.showBindingMessage('来源托盘不匹配', this.error); return }
if (this.unboundSourceMode) {
this.selectedSource = matched
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = this.completedItems().map(item => item.cartonMark)
this.quantity = String(this.marks.length)
this.activate('target')
return
}
await this.resolve('source')
if (this.error || !this.source) return
const source = this.source; this.source = null
if (await this.confirmBindingChoice(`是否绑定来源托盘 ${source.palletCode}?`)) { this.source = source; this.activate('target') }
},
showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
async confirmTargetAndBind() {
if (this.busy) return
if (this.rebindSelection) return this.submitRebind()
if (!this.isReturnBulk && this.selectedSource && this.samePallet(this.source, this.selectedSource)) {
if (!this.completedItems().length && this.labelBlockReason) { this.error = this.labelBlockReason; this.showBindingMessage('尚未绑定', this.error); return }
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = []
this.fillBatch()
if (this.error) { this.showBindingMessage('尚未绑定', this.error); return }
}
this.message = ''
let reason = ''
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘。'
else if (!this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。'
else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。'
else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成本托盘的 SKU 贴标登记,再绑定新托盘;整托无需填写拆箱数量。'
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = this.isReturnBulk ? '本托盘可操作件数不足,请刷新来源托盘。' : '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '请选择本批目的仓和对应箱唛,勾选箱数必须等于拆出箱数。'
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
await this.resolve('target')
if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
if (!this.readyToBind) { this.error = '来源和目标托盘不能相同,请核对后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
if (!this.readyToBind) { this.error = '请核对来源箱、目标托盘和换标数量后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?${this.samePallet(this.source, this.target) ? '整托保留原托盘且货物和包装未变可沿用原测量;包装变化请重新补测。' : ''}`)) { this.target = null; return }
await this.bind()
},
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
@@ -195,10 +281,10 @@ export default {
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 payload = { relabelNo: this.task.relabelNo, ...(this.unboundSourceMode ? {} : { source: expectation(this.source), actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) } }), target: expectation(this.target), cartonMarks: [...this.marks], reason: this.unboundSourceMode ? 'PDA 扫描无来源托盘退货箱并绑定目的托盘' : 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
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') })
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
else if (this.error) this.showBindingMessage('绑定失败', this.error)
},
@@ -215,12 +301,23 @@ export default {
</script>
<style scoped>
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
.measurement-card{margin:12px 0;padding:12px;border:1px solid #d7e1ef;border-radius:8px}
.measurement-inputs{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:10px 0}
.measurement-inputs input{padding:8px;border:1px solid #d7e1ef;border-radius:6px}
.pallet-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}
.heading{display:block;font-size:18px;font-weight:700}
.refresh-button{flex:none;margin:0;padding:6px 14px;font-size:13px;line-height:1.5}
.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{display:flex;align-items:center;gap:8px;padding:8px;margin:8px 0;border:1px solid #d7e1ef;border-radius:8px;white-space:nowrap}
.source-identifiers{flex:1;min-width:0}
.source-identifier{display:flex;align-items:baseline;gap:5px;max-width:100%;min-width:0}
.source-caption{flex-shrink:0;color:#718095;font-size:10px}
.source-code{min-width:0;color:#243951;font-size:11px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.source-quantity{flex-shrink:0;color:#52647c;font-size:9px;line-height:16px}
.split-quantity-row{display:flex;align-items:center;gap:8px;margin:10px 0;color:#52647c;font-size:12px}.split-quantity-row input{box-sizing:border-box;width:72px;height:34px;padding:6px 8px;text-align:center;font-size:13px}
.split-progress{display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin:10px 0;color:#52647c;font-size:12px;line-height:20px}.split-progress strong{color:#1f5fca;font-weight:700}.split-progress .create-pallet-button{margin:0 0 0 auto;padding:0 10px;border-radius:6px;background:#22c55e;color:#fff;font-size:10px;font-weight:700;line-height:28px}.split-progress .create-pallet-button[disabled]{background:#bbf7d0;color:#6b987b}.split-progress .create-pallet-button::after{border:none}
.source-row.selected{border-color:#2674d9;background:#eff6ff}
.binding-row{display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;min-width:0;padding:8px 9px;font-size:11px;line-height:20px;text-align:left;white-space:nowrap}.binding-mark{max-width:25%;overflow:hidden;text-overflow:ellipsis}.binding-arrow,.binding-action{flex-shrink:0}.binding-pallet{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.binding-action{color:#1f5fca;font-size:10px}
.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}
+162
View File
@@ -0,0 +1,162 @@
<template>
<view>
<view class="work-card">
<text class="card-title">包裹/箱合箱</text>
<text v-if="plan" class="business-code">{{ plan.planNo }}</text>
<text v-if="plan && plan.remark" class="hint plan-remark">合箱备注:{{ plan.remark }}</text>
<view class="choice-row"><button class="secondary" @tap="choose('NEW')">{{ mode === 'NEW' ? '✓ ' : '' }}新增箱子</button><button v-if="!plan || plan.mode !== 'SKU'" class="secondary" @tap="choose('EXISTING')">{{ mode === 'EXISTING' ? '✓ ' : '' }}使用已有箱子</button></view>
<text class="hint">{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}</text>
<template v-if="mode">
<ScanTargetInput name="source" v-model="sourceCode" label="来源箱唛" placeholder="扫描来源箱唛" :active="active === 'source'" @activate="active = 'source'" @submit="scanSource" />
<button class="secondary" :disabled="busy || !mode || !sourceCode.trim()" @tap="scanSource(sourceCode)">{{ !plan && mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
<view v-if="plan" class="plan-boxes section-gap">
<text class="card-title">计划需扫描箱子</text>
<view v-for="box in plannedSourceRows" :key="box.cartonMark" class="plan-box">
<text class="business-code">{{ box.cartonMark }} · {{ box.participation === 'FULL' ? '整箱' : box.participation === 'PARTIAL' ? '部分' : '计划来源' }}</text>
<text class="meta-line">来源退货单:{{ box.sourceReturnNo || '—' }}</text>
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<text v-for="item in box.originalItems || []" :key="'original-' + item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件 · 计划后剩余 {{ target?.cartonMark === box.cartonMark ? item.quantity : Math.max(0, item.quantity - (box.items || []).filter(row => row.skuCode === item.skuCode).reduce((sum, row) => sum + Number(row.quantity), 0)) }} 件</text>
<text class="hint">{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '已扫描确认' : '待扫描' }}</text>
</view>
</view>
<view v-if="mode === 'EXISTING'" class="confirmed-sources">
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button class="link-button" :disabled="busy" @tap="active = 'source'">更换来源箱</button></view>
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<text class="hint">来源箱已确认</text>
</view>
</view>
</template>
</view>
<view v-if="mode" class="work-card">
<text class="card-title">{{ mode === 'NEW' ? '新增目标箱' : '已有目标箱' }}</text>
<template v-if="mode === 'EXISTING'">
<ScanTargetInput name="target" v-model="targetCode" label="目标箱唛" placeholder="扫描另一只箱作为目标箱" :active="active === 'target'" @activate="active = 'target'" @submit="scanTarget" />
<button class="secondary" :disabled="busy || !sources.length || !targetCode.trim()" @tap="scanTarget(targetCode)">确认目标箱</button>
<view v-if="target" class="item-card section-gap"><text class="business-code">{{ target.cartonMark }}</text><text v-for="item in target.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)</text></view>
</template>
<template v-else><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input class="small-input" type="digit" v-model="dimensions[field.key]" placeholder="请输入" /></label></view><text class="hint">体积:{{ volume }} m³</text></template>
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button v-if="!plan" class="link-button" @tap="remove(box.cartonMark)">移除</button></view>
<button v-if="!plan" class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button>
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · {{ plan ? '计划取出' : '可扣' }} {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="!!plan" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
</view>
<button class="primary" :disabled="!canComplete" @tap="complete">{{ busy ? '处理中…' : '确认合箱' }}</button>
</view>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
emits: ['completed', 'error', 'busy-change'],
data() { return { mode:'', dimensions:{length:'',width:'',height:'',weight:''}, fields:[{key:'length',label:'长 cm'},{key:'width',label:'宽 cm'},{key:'height',label:'高 cm'},{key:'weight',label:'重量 kg'}], sourceCode: '', targetCode: '', sources: [], target: null, deductions: {}, active: 'source', busy: false } },
computed: { plannedSourceRows() { const listed = Array.isArray(this.plan?.sources) ? this.plan.sources : []; const marks = this.plan?.sourceCartonMarks?.length ? this.plan.sourceCartonMarks : listed.map(box => box.cartonMark).filter(Boolean); return marks.map(mark => listed.find(box => box.cartonMark === mark) || { cartonMark: mark, items: [] }) }, volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && (!this.plan || (this.plan.sourceCartonMarks || this.plan.sources.map(box => box.cartonMark)).every(mark => this.sources.some(box => box.cartonMark === mark) || this.target?.cartonMark === mark)) && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && (!!this.plan || this.sources[0]?.cartonMark === this.sourceCode.trim())) } },
async mounted() {
if (!this.plan && this.initialSource) { this.choose('NEW'); this.sources = [this.initialSource]; this.sourceCode = ''; this.active = 'source'; return }
this.choose('NEW')
this.targetCode = warehouseWork.returnPacking.targetCode || ''
const marks = this.plan?.sourceCartonMarks || warehouseWork.returnPacking.sourceCartonMarks || []
if (this.plan) {
// Reserved plan sources are read from the reviewed plan, never from the free-stock scan endpoint.
this.sources = []
this.sourceCode = ''
this.active = 'source'
} else for (const mark of marks) await this.scanSource(mark)
},
methods: {
choose(mode) { if(this.busy || this.mode === mode) return; const current = !this.plan ? (this.sources[0] || this.initialSource) : null; this.mode=mode; this.sources=current ? [current] : []; this.target=null; this.targetCode=''; this.sourceCode=mode === 'EXISTING' && current ? current.cartonMark : ''; this.deductions={}; this.active=mode === 'EXISTING' && current ? 'target' : 'source' },
save() { updateWarehouseWork('returnPacking', { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCode: this.targetCode }) },
remove(mark) { this.sources = this.sources.filter(box => box.cartonMark !== mark); this.save() },
async scanSource(raw) {
if (!this.mode || this.busy) return
const mark = String(raw || this.sourceCode).trim(); if (!mark) return
await this.run(async () => {
if (this.sources.some(box => box.cartonMark === mark)) {
if (this.mode === 'EXISTING') { this.sourceCode = mark; this.active = 'target'; return }
throw new Error('该来源箱已添加')
}
if ((this.plan || this.mode !== 'EXISTING') && this.target?.cartonMark === mark) throw new Error('来源箱不能与目标箱相同')
const planned = this.plan?.sources?.find(box => box.cartonMark === mark)
if(this.plan && !planned) throw new Error('该来源箱不在合箱计划中')
const box = planned ? {...planned,customerId:this.plan.customerId} : await scanReturnCarton(mark)
const customer = this.sources[0]?.customerId || this.target?.customerId
if (customer && customer !== box.customerId) throw new Error('不同客户的箱不能合并')
if (this.mode === 'EXISTING' && !this.plan) {
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
} else { this.sources.push(box); this.sourceCode = '' }
if (this.plan) this.deductions = { ...this.deductions, [mark]: Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)])) }
this.save(); this.active = this.mode === 'NEW' ? 'source' : 'target'
})
},
async scanTarget(raw) {
if (this.busy || !this.sources.length || this.mode !== 'EXISTING') return
const mark = String(raw || this.targetCode).trim(); if (!mark) return
this.targetCode = mark; this.target = null
await this.run(async () => {
if(this.sources.some(box => box.cartonMark === mark)) throw new Error('来源箱和目标箱不能相同,请扫描另一只箱')
const plannedSource = this.plan?.sources?.find(box => box.cartonMark === mark)
if (plannedSource) {
// A reviewed BOX plan may nominate one of its original boxes as the
// target. The remaining planned boxes are the source boxes.
this.target = {...plannedSource,customerId:this.plan.customerId}
this.targetCode = mark
this.deductions = Object.fromEntries(this.sources.map(box => [box.cartonMark, Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)]))]))
this.save()
return
}
if (this.sources.some(box => box.cartonMark === mark)) throw new Error('目标箱不能与来源箱相同')
const box = await scanReturnCarton(mark)
const customer = this.plan?.customerId || this.sources[0]?.customerId
if (customer && customer !== box.customerId) throw new Error('目标箱与来源箱必须属于同一客户')
this.target = box; this.save()
})
},
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
async printSource(mark) {
// #ifdef H5
const tab = window.open('', '_blank')
if (!tab) { this.$emit('error', new Error('请允许打开标签打印窗口')); return }
try { const label = await getReturnCartonLabel(mark); tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print() }
catch (error) { tab.close(); this.$emit('error', error) }
// #endif
// #ifndef H5
this.$emit('error', new Error('当前设备未配置打印服务'))
// #endif
},
deduction(mark, sku) { return this.deductions?.[mark]?.[sku] || '' },
setDeduction(mark, sku, raw) {
if (this.plan) return
const box = this.sources.find(item => item.cartonMark === mark)
const max = Number(box?.items?.find(item => item.skuCode === sku)?.quantity || 0)
const value = Math.min(max, Math.max(0, Math.floor(Number(raw) || 0)))
this.deductions = { ...this.deductions, [mark]: { ...(this.deductions[mark] || {}), [sku]: value } }
},
plannedItems() { return this.sources.flatMap(box => (box.items || []).map(item => ({ skuCode: item.skuCode, quantity: Number(this.deduction(box.cartonMark, item.skuCode)) || 0 }))).filter(item => item.quantity > 0) },
async complete() {
if (!this.canComplete) return
const sourceDeductions=this.sources.flatMap(box => (box.items || []).map(item => ({cartonMark:box.cartonMark,skuCode:item.skuCode,quantity:Number(this.deduction(box.cartonMark,item.skuCode))||0}))).filter(item=>item.quantity>0)
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity
const items=Object.entries(totals).map(([skuCode,quantity])=>({skuCode,quantity}))
const box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark,sourceDeductions,...box }
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
await this.run(async () => {
const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('returnPacking', key.signature); this.$emit('completed', { ...result, targetMode: this.mode })
})
},
async run(work) { this.busy = true; this.$emit('busy-change', true); try { await work() } catch (error) { this.$emit('error', error) } finally { this.busy = false; this.$emit('busy-change', false) } }
}
}
</script>
<style scoped lang="scss">
@import '../pages/warehouse/operations.scss';
.business-code{font-size:13px}.item-card{padding:8px}.secondary,.primary{min-height:36px}.source-quantity{display:flex;align-items:center;justify-content:space-between;gap:8px}.small-input{width:90px;height:30px;padding:0 6px;border:1px solid #cbd8e8;border-radius:6px;text-align:center;font-size:11px}.plan-remark{display:block;color:#315985;font-weight:600}.choice-row{display:flex;gap:8px}.choice-row button{flex:1}.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.dimensions{gap:8px 12px;padding:8px 0}.dimensions label{display:flex;align-items:center;gap:6px;min-width:0}.dimensions label text{flex-shrink:0;width:44px;font-size:11px;color:#617287}.dimensions .small-input{box-sizing:border-box;width:100%;min-width:0;height:30px;background:#f8fbff;font-size:12px}.choice-row button{height:34px;min-height:34px;line-height:32px;margin:4px 0;padding:0 8px;font-size:11px}.plan-box{padding:7px 0;border-bottom:1px solid #e6edf5}.source-quantity .meta-line{flex:1;min-width:0;overflow-wrap:anywhere}.source-quantity .small-input{flex-shrink:0;width:72px}.primary{height:36px;line-height:36px;padding:0 10px}.business-code{overflow-wrap:anywhere}
</style>
@@ -0,0 +1,31 @@
<template>
<view class="label-confirmation">
<text v-if="confirmed">标签已扫码确认,可按该包裹/箱号出库</text>
<template v-else>
<ScanTargetInput v-model="code" label="确认已贴标签" placeholder="贴好后扫描此包裹/箱标签" @submit="confirm" />
<button class="secondary" :disabled="busy || !code.trim()" @tap="confirm(code)">确认贴标</button>
<text v-if="error">{{ error }}</text>
</template>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { confirmReturnLabel } from '@/api/return-packing.js'
export default {
components: { ScanTargetInput },
props: { cartonMark: { type: String, required: true } },
data() { return { code: '', confirmed: false, busy: false, error: '' } },
methods: {
acceptScan(value) { if (String(value).trim() === this.cartonMark) return this.confirm(value) },
async confirm(raw) {
if (this.busy || this.confirmed) return
const mark = String(raw?.detail?.value ?? raw ?? this.code).trim()
if (mark !== this.cartonMark) { this.error = '请扫描当前包裹/箱的标签'; return }
this.busy = true; this.error = ''
try { await confirmReturnLabel(mark); this.confirmed = true }
catch (error) { this.error = error?.message || '贴标确认失败,请重试' }
finally { this.busy = false }
}
}
}
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<view class="section-gap">
<text class="card-title">换标商品独立合箱</text><text class="hint">部分换标须装入独立新箱;来源余量留在原包裹/箱。所有来源登记成功后才可生成新箱。</text>
<view v-for="row in deductions" :key="`${row.cartonMark}:${row.skuCode}`" class="meta-line">{{ row.cartonMark }} · {{ row.skuCode }} · {{ row.quantity }} 件</view>
<template v-if="!facts.length"><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input v-model="dimensions[field.key]" type="digit" placeholder="请输入" /></label></view><button class="primary" :disabled="!ready" @tap="pack">{{ busy ? '生成中…' : '生成独立新箱及标签' }}</button></template>
<view v-for="fact in facts" :key="fact.cartonMark"><text class="business-code">新箱:{{ fact.cartonMark }} · {{ fact.quantity }} 件</text><button class="secondary" @tap="print(fact.label)">查看 / 打印新箱识别标签</button><text class="hint">此标签用于识别仓库包裹/箱;商品目标 SKU 标签请按上方换标明细核对。</text></view>
</view>
</template>
<script>
import { packReturnRelabelCartons } from '@/api/relabel.js'
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'],
data() { return { busy: false, dimensions: { length: '', width: '', height: '', weight: '' }, fields: [{ key: 'length', label: '长 cm' }, { key: 'width', label: '宽 cm' }, { key: 'height', label: '高 cm' }, { key: 'weight', label: '重量 kg' }] } },
computed: {
deductions() { return returnDeductions(this.task) }, facts() { return returnPackFacts(this.task) },
ready() { const items = returnSelections(this.task); return !this.locked && !this.busy && requiresReturnPack(this.task) && !this.facts.length && items.length === this.task.items.length && items.length > 0 && items.every(item => Number(item.relabelledPackages) === Number(item.expectedPackages) && !Number(item.skippedPackages) && !Number(item.exceptionPackages)) && this.deductions.every(row => row.cartonMark && row.skuCode && Number.isSafeInteger(row.quantity) && row.quantity > 0) && this.fields.every(field => Number.isFinite(Number(this.dimensions[field.key])) && Number(this.dimensions[field.key]) > 0) }
},
methods: {
async pack() {
if (!this.ready) return
const payload = { sourceCartonMarks: [...new Set(this.deductions.map(row => row.cartonMark))], targetCartonMark: null, sourceDeductions: this.deductions, ...Object.fromEntries(this.fields.map(field => [field.key, Number(this.dimensions[field.key])])) }
const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
this.busy = true; this.$emit('busy', true)
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { releaseWarehouseKey('relabel', key.signature); this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
},
print(label) {
// #ifdef H5
if (!label?.printableHtml) return this.$emit('error', new Error('服务端未返回可打印的新箱标签'))
const tab = window.open('', '_blank'); if (!tab) return this.$emit('error', new Error('请允许打开标签打印窗口'))
tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print()
// #endif
// #ifndef H5
this.$emit('error', new Error('当前设备未配置打印服务,生成标签不代表打印完成'))
// #endif
}
}
}
</script>
<style scoped>.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin:12px 0}.dimensions input{border:1px solid #cbd8e8;padding:8px}.hint,.meta-line{display:block;margin:8px 0}</style>
+2 -1
View File
@@ -4,7 +4,8 @@ export const MODULES = [
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
{ 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' }
{ 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' },
{ key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
] },
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
+1
View File
@@ -6,6 +6,7 @@
{ "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-counting/return-counting", "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" } },
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
+30 -17
View File
@@ -5,14 +5,14 @@
<view class="portal-content fulfillment-content">
<view class="fulfillment-heading">
<text class="fulfillment-title">排车拣货与发车</text>
<text class="fulfillment-subtitle">客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业</text>
<text class="fulfillment-subtitle">扫描排车单号打开任务,再扫描任务中的托盘码进行拣货</text>
</view>
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view class="fulfillment-card">
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号或托盘码" placeholder="扫描排车单号 / 托盘码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号 / 托盘码 / 来源箱唛" placeholder="扫描排车单号、托盘码或退货箱唛" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配托盘' : '打开排车任务' }}</button>
<text class="scan-help">网络失败不会清空草稿,再次提交会沿用同一幂等键。</text>
<text class="scan-help">网络中断会保留当前作业,恢复后可重试。</text>
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载排车任务…</text></view>
@@ -21,7 +21,7 @@
<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>
<text v-if="error.network" class="offline-note">网络连接失败,未完成作业与幂等键已保留。</text>
<text v-if="error.network" class="offline-note">网络连接失败,当前作业已保留,请恢复网络后重试。</text>
<button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button>
</view>
@@ -47,9 +47,10 @@
</view>
<view v-if="current.items.length" class="item-list">
<view v-for="item in current.items" :key="item.palletNo" class="fulfillment-card item-card" :class="{ selected: selectedItem?.palletNo === item.palletNo }" @tap="selectPallet(item)">
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱</text></view>
<text class="task-meta">{{ routeLabel(item) }}</text>
<text v-for="carton in item.returnCartons" :key="carton.cartonMark" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
</view>
</view>
@@ -57,7 +58,8 @@
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card">
<text class="card-title">本次拣货:{{ selectedItem.palletNo }}</text>
<view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
<text v-if="selectedItem.returnCartons.length" class="task-meta">{{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}</text>
<view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认拣货' }}</button>
</view>
@@ -105,16 +107,17 @@
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages },
selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
selectedRemaining() { return Math.max(0, Math.min(this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0, this.selectedPosition?.availablePackages || 0)) },
selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
departed() { return this.current?.status === 'DEPARTED' },
canPick() { return this.current?.canPick === true && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
canPick() { return this.current?.canPick === true && (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && !this.pickingCompleted },
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
@@ -144,26 +147,36 @@
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
async openTask(dispatchNo) {
const code = String(dispatchNo || '').trim(); if (!code) return
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: previousNo === this.current.dispatchNo ? fulfillment.dispatch.palletCode : '' }); this.scanCode = '' }, true)
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
},
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', packages: 1 }); this.loadTasks() },
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
this.scanCode = code
this.error = null; this.successMessage = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 })
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
if (cartonItem) {
if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
}
const matches = this.current.items.filter((value) => value.palletNo === code && value.scheduledPackages > value.pickedPackages)
if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应货物卡片'), '请选择拣货明细')
const item = matches[0]
if (!item) return this.setError(new Error(this.current.items.some((value) => value.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫码未匹配当前任务的托盘码或来源箱唛'), '扫码未匹配')
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
},
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
async submitPick() {
if (!this.canPick) return
const payload = { dispatchNo: this.current.dispatchNo, palletNo: this.selectedItem.palletNo, positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
await this.run('提交排车拣货', async () => {
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0, idempotencyKey: key.value })
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
})
},
async completePicking() {
+72 -15
View File
@@ -6,7 +6,7 @@
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</text></view>
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view class="fulfillment-card">
<ScanTargetInput name="outbound" :model-value="scanCode" :label="discardCurrent ? '废弃订单托盘码' : '任务号、出库单号、排车单号、托盘码或箱码'" :placeholder="discardCurrent ? '扫描废弃订单对应托盘码' : '扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码'" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
</view>
@@ -27,18 +27,18 @@
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
<view v-if="current.items.length" class="item-list">
<view v-for="item in current.items" :key="item.outboundPalletItemId" class="fulfillment-card item-card">
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
<view class="task-head"><text class="business-code">{{ item.palletNo || '散件出库' }}</text><text>{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱</text></view>
<view v-for="position in current.outboundType === 'RETURN_OUTBOUND' && item.palletNo ? [] : item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
<text class="line-code">{{ lineBusinessCode(position) }}</text><text class="line-meta">{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱</text>
</view>
<text v-if="current.outboundType === 'RETURN_OUTBOUND' && item.palletNo && item.pickedPackages < item.plannedPackages" class="scan-help">整托出库请扫描托盘码 {{ item.palletNo }}</text>
<text v-if="current.outboundType === 'RETURN_OUTBOUND' && item.palletNo && item.pickedPackages < item.plannedPackages" class="scan-help">整托出库请扫描托盘号 {{ item.palletNo }}</text>
<text v-if="!item.positions.length && item.pickedPackages < item.plannedPackages" class="missing-scan-contract">当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。</text>
</view>
</view>
<view v-else class="fulfillment-state"><text class="state-title">任务详情尚无可拣货明细</text><text>请刷新;若持续为空,请联系平台确认出库货物是否已下发。</text></view>
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><text v-if="selectedItem.returnSkuQuantity != null" class="scan-help">本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><text v-else class="gate-note">全部计划货物已扫描核平,服务端将再次校验库存门禁。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
</template>
@@ -46,7 +46,7 @@
<template v-else-if="discardCurrent">
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><button class="secondary-button" :disabled="!discardPalletCode || discardLoading || discardBusy" @tap="resolveDiscardPallet">{{ discardLoading ? '读取中…' : '打开托盘废弃作业' }}</button><text class="scan-help">扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。</text></view>
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">托盘 {{ discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>实际废弃数量</text><input type="number" :value="discardActuals[sku.itemId]" :disabled="discardTask.status === 'COMPLETED'" :placeholder="`请输入 0-${sku.plannedPackages} 箱`" @input="updateDiscardActual(sku.itemId, $event)" /><text>最多 {{ sku.plannedPackages }} 箱</text></view></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">托盘 {{ discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>实际废弃数量</text><input type="number" :value="discardActuals[sku.itemId]" :disabled="discardTask.status === 'COMPLETED'" :placeholder="`请输入 0-${sku.plannedPackages} 箱`" @input="updateDiscardActual(sku.itemId, $event)" /><text>最多 {{ sku.plannedPackages }} 箱</text></view></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前托盘,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
</template>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
@@ -58,7 +58,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
import { listCargoDiscardPhotos, uploadCargoDiscardPhoto, downloadCargoDiscardPhoto, completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession, session } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -66,8 +66,30 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
computed: {
displayItems() {
const items = this.current?.items || []
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
const pallets = new Map()
const rows = []
for (const item of items) {
if (!item.palletNo) { rows.push(item); continue }
let pallet = pallets.get(item.palletNo)
if (!pallet) {
pallet = { ...item, displayKey: `pallet:${item.palletNo}`, plannedPackages: 0, pickedPackages: 0, positions: [] }
pallets.set(item.palletNo, pallet)
rows.push(pallet)
}
pallet.plannedPackages += Number(item.plannedPackages || 0)
pallet.pickedPackages += Number(item.pickedPackages || 0)
pallet.positions.push(...(item.positions || []))
}
return rows
},
hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) },
scanLabel() { return this.discardCurrent ? '废弃订单托盘码' : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' },
scanPlaceholder() { return this.discardCurrent ? '扫描废弃订单对应托盘码' : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
@@ -79,9 +101,9 @@
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
discardOrderCount() { return this.discardOrders.length },
canConfirmDiscard() { return Boolean(!this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
canConfirmDiscard() { return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() },
methods: {
@@ -107,25 +129,59 @@
}, true)
},
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
closeDiscard() { if (this.discardBusy) return; this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
updateDiscardPalletCode(value) { this.discardPalletCode = value },
async resolveDiscardPallet(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading) return
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
const order = this.discardCurrent
this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
this.resetDiscardPhotos(); this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try {
const task = await resolveCargoDiscardPallet(code)
if (this.discardCurrent !== order) return
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
this.discardTask = normalizeCargoDiscardTask(task)
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages]))
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])); await this.restoreDiscardPhotos()
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
finally { if (this.discardCurrent === order) this.discardLoading = false }
},
resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false },
async restoreDiscardPhotos() {
if (!this.discardTask || !this.discardCurrent) return
const order = this.discardCurrent; const task = this.discardTask
this.discardPhotoReady = false
try {
const photos = await listCargoDiscardPhotos(order.discardNo, task.palletNo)
if (this.discardCurrent !== order || this.discardTask !== task) return
this.discardPhotos = photos; this.discardPhotoReady = true
await Promise.all(photos.map(photo => this.loadDiscardPhoto(photo.fileId)))
} catch (error) { if (this.discardCurrent === order && this.discardTask === task) this.setError(error, '废弃照片读取失败') }
},
async loadDiscardPhoto(fileId) {
if (!this.discardCurrent || !this.discardTask) return
const order = this.discardCurrent; const task = this.discardTask
try {
const url = await downloadCargoDiscardPhoto(order.discardNo, task.palletNo, fileId)
if (this.discardCurrent === order && this.discardTask === task) { this.discardPhotoUrls = { ...this.discardPhotoUrls, [fileId]: url }; this.discardPhotoErrors = { ...this.discardPhotoErrors, [fileId]: false } }
} catch (_) { if (this.discardCurrent === order && this.discardTask === task) this.discardPhotoErrors = { ...this.discardPhotoErrors, [fileId]: true } }
},
previewDiscardPhoto(fileId) { const urls = this.discardPhotos.map(photo => this.discardPhotoUrls[photo.fileId]).filter(Boolean); if (this.discardPhotoUrls[fileId]) uni.previewImage({ current: this.discardPhotoUrls[fileId], urls }) },
takeDiscardPhoto() {
if (!this.discardCurrent || !this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardPhotoBusy || this.discardBusy || this.discardLoading || !this.discardPhotoReady || this.discardPhotos.length >= 20) return
const order = this.discardCurrent; const task = this.discardTask; this.discardPhotoBusy = true
uni.chooseImage({ count: 1, sourceType: ['camera'], success: async ({ tempFilePaths }) => {
try {
if (this.discardCurrent !== order || this.discardTask !== task || !tempFilePaths?.[0]) return
const photo = await uploadCargoDiscardPhoto(order.discardNo, task.palletNo, tempFilePaths[0])
if (this.discardCurrent !== order || this.discardTask !== task) return
this.discardPhotos.push(photo); this.discardPhotoUrls = { ...this.discardPhotoUrls, [photo.fileId]: tempFilePaths[0] }; this.error = null
} catch (error) { this.setError(error, '废弃照片上传失败'); await this.restoreDiscardPhotos() }
finally { this.discardPhotoBusy = false }
}, fail: () => { this.discardPhotoBusy = false } })
},
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } },
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId))); this.successMessage = '废弃出库作业及照片已提交,等待仓库确认'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
@@ -167,6 +223,7 @@
<style scoped>
@import '../dispatch/fulfillment.scss';
.discard-photo-section{margin:14px 0;padding-top:12px;border-top:1px solid #d9e3ee}.discard-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0}.discard-photo-tile image{display:block;width:100%;height:120px;border-radius:8px}
.outbound-line { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
.line-code, .line-meta { display: block; }
+98 -57
View File
@@ -2,46 +2,41 @@
<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">{{ stageSubtitle }}</text></view>
<view v-if="showCurrent" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">{{ stage === 'review' ? '换标复核详情' : '换标执行详情' }}</text></view><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<view v-if="!showCurrent" class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<template v-if="!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, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><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">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(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-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><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">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(item)"><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><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>
</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>
<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>
<view class="destination-summary"><text class="meta-line">FBA 目的仓:</text><text v-for="destination in fbaDestinations" :key="destination" class="destination-tag">{{ destination }}</text><text v-if="!fbaDestinations.length" class="meta-line">待维护</text><view class="summary-metrics"><text class="summary-metric">应处理 {{ current.expectedPackages }}</text><text class="summary-metric">已换标 {{ current.relabelledPackages }}</text><text class="summary-metric">跳过 {{ current.skippedPackages }}</text><text class="summary-metric">异常 {{ current.exceptionPackages }}</text></view></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>
<view v-if="availableTabs.length" 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>
<view v-if="availableTabs.length > 1" 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">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
<view v-if="isReturnBulk" class="section-gap"><text class="meta-line">先扫描实际贴标的 SKU,核对数量后登记;无需扫描箱唛。</text><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="扫描退货 SKU" placeholder="扫描或输入待贴标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" :disabled="busy || !returnSkuCode.trim()" @tap="verifyReturnSku(returnSkuCode)">识别 SKU</button><text v-if="verifiedReturnSkuKey" class="success-title">SKU 已识别,请确认实际贴标数量后登记</text></view>
<view v-if="isReturnBulk" class="item-list section-gap"><view v-for="group in returnBulkSkuGroups" :key="group.key" class="item-card" :class="{ done: group.remaining === 0 }">
<view class="row-head"><view><text class="business-code">{{ group.skuCode }}</text><text class="meta-line">FBA 目的仓:{{ group.destinationFbaWarehouseCode || '—' }}</text></view><text class="status-pill">剩余 {{ group.remaining }} 件</text></view>
<view class="metric-row"><view class="metric"><strong>{{ group.expectedPackages }}</strong><text>贴标预报</text></view><view class="metric"><strong>{{ group.relabelledPackages }}</strong><text>已贴</text></view></view>
<view class="source-allocation"><text v-if="isReturnBulk" class="source-title">来源托盘换标数量</text><text v-else class="source-title">来源托盘拆箱数量</text><view v-for="source in group.sources" :key="source.itemId" class="source-row"><text>{{ source.palletCode || '未记录托盘' }}</text><strong>{{ isReturnBulk ? '换标' : '拆箱' }} {{ source.expectedPackages }} 件</strong></view><view class="source-total"><text>合计{{ isReturnBulk ? '换标' : '拆箱' }}</text><strong>{{ group.expectedPackages }} 件</strong></view></view>
<view v-if="group.remaining > 0"><input v-model="bulkQuantities[group.key]" class="bulk-quantity" type="number" :placeholder="`本次贴标数量(默认剩余 ${group.remaining} 件)`" /><button class="primary bulk-total-button" :disabled="busy || verifiedReturnSkuKey !== group.key || returnSkuCode.trim().toUpperCase() !== group.skuCode.toUpperCase()" @tap="completeBulkSku(group)">{{ busyAction === 'return-bulk-sku' ? '提交中…' : `登记本次贴标(默认 ${group.remaining} 件)` }}</button></view>
</view></view>
<view v-if="taskTab === 'carton' && !isReturnBulk" 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="isReturnBulk" class="section-gap"><text class="hint">先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。</text><ScanTargetInput name="returnCarton" v-model="returnCartonCode" label="来源包裹/箱码" placeholder="扫描真实来源包裹/箱" :active="activeTarget === 'return-carton'" @activate="activeTarget = 'return-carton'" @submit="verifyReturnCarton" /><button class="secondary" :disabled="busy" @tap="verifyReturnCarton(returnCartonCode)">确认来源包裹/箱</button><text v-if="verifiedReturnCarton" class="success-title">已确认:{{ verifiedReturnCarton }}</text></view>
<view v-if="isReturnBulk" class="section-gap"><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="商品 SKU" placeholder="扫描本来源待换标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" @tap="verifyReturnSku(returnSkuCode)">核对商品 SKU</button><text v-for="group in returnBulkSkuGroups" :key="group.key" class="meta-line">{{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)</text></view>
<template v-if="isReturnBulk"><view v-for="item in returnBulkSources" :key="item.itemId" class="item-card section-gap"><text class="business-code">{{ item.cartonMark }} · {{ item.skuCode }}</text><text class="meta-line">目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件</text><ScanTargetInput :name="'target-' + item.itemId" v-model="returnTargetLabels[item.itemId]" label="目标商品 SKU 标签" placeholder="扫描目标商品标签" :active="activeTarget === 'target-' + item.itemId" @activate="activeTarget = 'target-' + item.itemId" /><input v-model="bulkQuantities[item.itemId]" type="number" placeholder="必填:本来源本次实际贴标件数" /><button class="secondary" :disabled="busy" @tap="fillAllReturn(item)">本来源全部(需确认)</button><button class="primary" :disabled="busy" @tap="completeBulkSku(item)">登记本来源实际数量</button></view></template>
<view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描箱唛自动确认,手输后按完成" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /></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>
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描旧 SKU 自动确认" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
<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.item.sourcePalletCode || '未提供托盘信息' }}:{{ 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 sku-detail-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">来源箱号:{{ row.item.cartonMark || '未提供' }} · {{ row.sku.quantity }} 件</text><text class="meta-line">{{ row.item.sourcePalletBasis === 'RETURN_CARTON' ? '所在托盘' : '参考来源托盘' }}:{{ row.item.sourcePalletCode === 'UNBOUND' ? '未绑定托盘' : row.item.sourcePalletCode || '未提供托盘信息' }}</text><text v-if="row.item.sourcePalletBasis === 'INVENTORY_REFERENCE' && row.item.sourcePalletCode && row.item.sourcePalletCode !== 'UNBOUND'" class="meta-line">参考托盘来自库存预留,实际箱子位置请现场核对。</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 v-if="taskTab === 'carton'" class="item-list carton-label-list"><view v-for="item in visibleItems" :key="item.itemId" class="carton-label-item" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
<view class="carton-label"><text class="label-caption">仓库箱唛</text><text class="label-code">{{ item.cartonMark || '—' }}</text></view><text class="label-arrow">→</text><view class="carton-label fba-label"><text class="label-caption">FBA 箱唛</text><text class="label-code">{{ item.fbaCartonMark || '—' }}</text></view>
<text v-if="fbaDestinations.length > 1" class="destination-tag">{{ item.destinationFbaWarehouseCode || '待维护' }}</text>
</view></view>
<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>
@@ -50,9 +45,9 @@
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template>
<view class="section-gap">
<!-- Return SKU relabel still requires moving the received carton from the
source pallet to a new pallet carrying the FBA destination. -->
<RelabelPalletWork v-if="taskTab === 'carton' || isReturnBulk" ref="palletWork" :key="current.relabelNo" :task="current" :allow-disassemble="!isReturnBulk" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
<ReturnRelabelPackWork v-if="showReturnPack" :key="current.relabelNo + ':' + (current.reviewedAt || 'INITIAL')" :task="current" :locked="busy" @saved="applyCurrent" @refresh="reloadCurrent" @error="setError" @busy="busy = $event" />
<RelabelPalletWork v-if="requiresPalletBinding && (taskTab === 'carton' || isReturnTask)" ref="palletWork" :key="current.relabelNo" :task="palletTask" :allow-disassemble="true" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
<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">
@@ -63,7 +58,6 @@
</view></view>
<textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" />
<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>
@@ -73,7 +67,6 @@
</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>
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
</view></scroll-view>
</view>
@@ -81,21 +74,39 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ReturnRelabelPackWork from '@/components/ReturnRelabelPackWork.vue'
import { requiresReturnPack, returnPackFacts } from '@/utils/return-relabel.js'
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 { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSkuTotal, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { isRelabelFundsBlocked, relabelErrorMessage } from '@/utils/relabel-error.js'
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, 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: '其他' }] } },
components: { PortalHeader, ScanTargetInput, RelabelPalletWork, ReturnRelabelPackWork },
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnCartonCode: '', verifiedReturnCarton: '', returnTargetLabels: {}, quickTargetLabel: '', quickActualQuantity: '', returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, 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: {
palletTask() {
if (!this.isReturnTask) return this.current
const boxes = new Map()
for (const item of this.current.items || []) {
const previous = boxes.get(item.cartonMark)
const done = Number(item.relabelledPackages) === Number(item.expectedPackages)
boxes.set(item.cartonMark, { ...item, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: done && (!previous || previous.relabelledPackages === 1) ? 1 : 0 })
}
return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] }
},
isReturnTask() { return (this.current?.items || []).some(item => item.returnCartonId) },
showReturnPack() { return this.isReturnTask && (requiresReturnPack(this.current) || returnPackFacts(this.current).length > 0) },
requiresPalletBinding() { return this.isReturnTask ? this.current?.requiresPalletBinding === true || this.current?.deliveryMethod === 'TRUCK' : true },
returnBulkSources() { return (this.current?.items || []).filter(item => item.returnCartonId && item.operationType === 'RETURN_BULK_SKU_LABEL' && item.cartonMark === this.verifiedReturnCarton && item.skuCode.toUpperCase() === this.returnSkuCode.trim().toUpperCase() && this.remainingReturnQuantity(item) > 0) },
fbaDestinations() { return [...new Set((this.current?.items || []).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
returnBulkSkuGroups() {
const groups = new Map()
@@ -109,7 +120,7 @@
group.relabelledPackages += Number(item.relabelledPackages || 0)
group.skippedPackages += Number(item.skippedPackages || 0)
group.exceptionPackages += Number(item.exceptionPackages || 0)
group.sources.push({ itemId: item.itemId, palletCode: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
group.sources.push({ itemId: item.itemId, cartonMark: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
}
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
},
@@ -137,14 +148,15 @@
completionBlockReason() {
if (!this.current) return '请先选择换标任务'
if (!this.allProcessed && !this.remark.trim()) return '仍有未扫描明细,请继续处理;需要异常完工时必须填写异常作业备注'
const requiresPalletBinding = (this.current.items || []).some((item) => ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
if (this.isReturnTask && requiresReturnPack(this.current) && !returnPackFacts(this.current).length) return '部分换标须先完成全部来源登记并生成独立新箱'
const requiresPalletBinding = this.requiresPalletBinding && (this.current.items || []).some((item) => item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].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 [] }
}))
const requiredMarks = new Set((this.current.items || []).filter((item) =>
['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)
(item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
&& Number(item.relabelledPackages || 0) > 0).map((item) => item.cartonMark).filter(Boolean))
if ([...requiredMarks].some((mark) => !marks.has(mark))) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
}
@@ -157,7 +169,7 @@
onLoad(options) { this.stage = STAGES[options.op] || 'receive'; this.entryTaskNo = String(options.task || '').trim() },
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: '' }) } },
'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 = ''; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; 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) }
},
@@ -171,40 +183,60 @@
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) {
const exists = (this.current.items || []).some(item => this.skuDetails(item).some(sku => sku.skuCode === oldSku))
return this.setError(new Error(exists ? '该 SKU 已处理完成,不需要重复换标' : '本单没有该 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 }
if (row.item.returnCartonId && (!row.item.cartonMark || !row.sku.newSkuCode || !Number.isSafeInteger(Number(row.sku.quantity)) || Number(row.sku.quantity) <= 0)) return this.setError(new Error('来源箱号、目标 SKU 或申请件数缺失,请先维护换标明细'))
const payload = { itemId: row.item.itemId, ...(row.item.returnCartonId ? { cartonMark: row.item.cartonMark, newSkuCode: row.sku.newSkuCode, quantity: Number(row.sku.quantity) } : {}), 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'
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; 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 + 箱唛)', RETURN_BULK_SKU_LABEL: '退货贴标(按 SKU 数量)' })[type] || '换标类型待确认' },
remainingReturnQuantity(item) { return Math.max(0, Number(item.expectedPackages || 0) - Number(item.relabelledPackages || 0) - Number(item.skippedPackages || 0) - Number(item.exceptionPackages || 0)) },
verifyReturnSku(raw) {
if (this.busy) return
const code = String(raw?.detail?.value ?? raw ?? this.returnSkuCode).trim()
this.returnSkuCode = code; this.verifiedReturnSkuKey = ''
const matches = this.returnBulkSkuGroups.filter(group => group.remaining > 0 && group.skuCode.toUpperCase() === code.toUpperCase())
if (matches.length !== 1) { this.setError(new Error(matches.length ? '相同 SKU 对应多个目的仓,请核对任务' : '扫描的 SKU 不属于本单待贴标明细,或已全部登记')); return }
this.error = null; this.verifiedReturnSkuKey = matches[0].key; this.activeTarget = 'return-sku'
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton) return this.setError(new Error('请先确认来源包裹/箱'))
const matches = this.returnBulkSources
if (!matches.length) { this.setError(new Error('该 SKU 不属于已扫描来源的待贴标明细,或已全部登记')); return }
this.error = null; this.verifiedReturnSkuKey = code.toUpperCase(); this.activeTarget = 'return-sku'
},
async completeBulkSku(group) {
if (this.busy || this.verifiedReturnSkuKey !== group.key || this.returnSkuCode.trim().toUpperCase() !== group.skuCode.toUpperCase()) return
const entered = String(this.bulkQuantities[group.key] ?? '').trim()
const quantity = entered ? Number(entered) : Number(group.remaining || 0)
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > group.remaining) { this.setError(new Error(`本次贴标数量须为 1 至 ${group.remaining} 件`)); return }
const payload = { skuCode: group.skuCode, quantity, expectedVersion: this.current.version }
verifyReturnCarton(raw) {
if (this.busy) return
const mark = String(raw?.detail?.value ?? raw ?? this.returnCartonCode).trim()
this.returnCartonCode = mark; this.verifiedReturnCarton = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []
if (!(this.current?.items || []).some(item => item.returnCartonId && item.cartonMark === mark)) return this.setError(new Error('该包裹/箱不属于本单真实退货来源'))
this.verifiedReturnCarton = mark; this.activeTarget = this.taskTab === 'carton' ? 'quick-carton' : 'quick-sku'
},
async fillAllReturn(item) {
const no = this.current.relabelNo; const version = this.current.version; const mark = this.verifiedReturnCarton
if (this.busy || item.cartonMark !== mark || this.returnCartonCode.trim() !== mark) return
const confirmed = await new Promise(resolve => uni.showModal({ title: '登记本来源全部', content: mark + ' · ' + item.skuCode + ':确认实际已贴标剩余 ' + this.remainingReturnQuantity(item) + ' 件?', success: result => resolve(result.confirm), fail: () => resolve(false) }))
if (confirmed && this.current.relabelNo === no && this.current.version === version && this.verifiedReturnCarton === mark) this.bulkQuantities[item.itemId] = String(this.remainingReturnQuantity(item))
},
async completeBulkSku(item) {
if (this.busy) return
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton || item.cartonMark !== this.verifiedReturnCarton || this.returnSkuCode.trim().toUpperCase() !== item.skuCode.toUpperCase() || !item.newSkuCode || this.returnTargetLabels[item.itemId]?.trim() !== item.newSkuCode) return this.setError(new Error('请先确认本来源包裹、商品 SKU 和目标商品标签'))
const entered = String(this.bulkQuantities[item.itemId] ?? '').trim(); const quantity = entered ? Number(entered) : NaN
const remaining = this.remainingReturnQuantity(item)
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件'))
const payload = { itemId: item.itemId, cartonMark: item.cartonMark, skuCode: item.skuCode, quantity, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSkuTotal(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; delete this.bulkQuantities[group.key]; this.activeTarget = 'return-sku'; this.successMessage = `${group.skuCode} 已登记贴标 ${quantity} 件` }, true, key)
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
},
async loadPhoto(fileId) {
const no = this.current?.relabelNo
@@ -245,6 +277,7 @@
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('该仓库唛头不属于本单箱唛换标明细'))
// The carton scan identifies the physical return source and records its label stage in one request.
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 () => {
@@ -262,14 +295,14 @@
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 === '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) { if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } 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) },
handleHardwareScan(value) { if (this.activeTarget === 'return-carton') { this.verifyReturnCarton(value); return } if (this.activeTarget.startsWith('target-')) { this.returnTargetLabels[this.activeTarget.slice(7)] = value; return } if (this.activeTarget === 'quick-target') { this.quickTargetLabel = value; return } if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } 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.error || this.stage !== 'execute' || !this.entryTaskNo) return; const target = this.candidates.find((item) => item.relabelNo === this.entryTaskNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); this.entryTaskNo = ''; 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; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; 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.quickSku = ''; this.skuChoices = []; this.activeTarget = this.isReturnBulk ? 'return-sku' : value === 'carton' ? 'quick-carton' : 'quick-sku' },
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; this.returnCartonCode = ''; this.verifiedReturnCarton = ''; this.returnTargetLabels = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; 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.quickSku = ''; this.skuChoices = []; this.activeTarget = value === 'carton' ? 'quick-carton' : this.isReturnBulk ? 'return-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: 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' },
@@ -324,20 +357,28 @@
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()) { 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() },
async completeTask() { if (this.busy || this.completionBlockReason) 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' }) },
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
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)) },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
setError(error, fallback = '换标作业失败') { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
async reloadCurrent() { if (!this.current?.relabelNo) return; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
setError(error, fallback = '换标作业失败') {
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
playErrorSound()
const content = [this.error.message, this.error.code && `错误码:${this.error.code}`, this.error.requestId && `requestId:${this.error.requestId}`, this.error.traceId && `traceId:${this.error.traceId}`].filter(Boolean).join('\n')
uni.showModal({ title: '操作提示', content, showCancel: false, confirmText: '知道了' })
},
statusLabel(value) { return ({ AVAILABLE: '待认领', PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待客户/平台处理', COMPLETED: '已完成' })[value] || value || '待认领' },
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
}
}
</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}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:4px 0 10px;padding:10px 12px;border:1px solid #bed4f6;border-radius:11px;background:#eef5ff}.detail-kicker,.detail-title{display:block}.detail-kicker{color:#6580a4;font-size:9px}.detail-title{margin-top:2px;color:#153d74;font-size:14px;font-weight:800}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.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}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
</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}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.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}.item-card>input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #d7e1ef;border-radius:8px;margin:8px 0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
.candidate-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px}.candidate-customer{color:#52647c;font-size:10px;line-height:16px;overflow-wrap:anywhere}</style>
@@ -0,0 +1,93 @@
<template>
<view v-if="user" class="page-shell portal-page">
<PortalHeader title="退货箱内清点" :user="user" fallback-url="/pages/module/module?key=inbound" />
<view class="work-content counting-content">
<view class="work-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view class="actions"><button v-if="task || scanResult" class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view>
<view v-if="error" class="work-card error-card"><text>{{ error.message }}</text><text v-if="error.code" class="hint">错误码:{{ error.code }}</text><text v-if="error.requestId" class="hint">请求号:{{ error.requestId }}</text><text v-if="error.traceId" class="hint">跟踪号:{{ error.traceId }}</text></view>
<template v-if="!task">
<view v-if="activeTask" class="work-card"><text class="card-title">有未完成的清点任务</text><text class="hint">{{ activeTask.returnNo }} · {{ activeTask.palletCode }}</text><button class="secondary small" :disabled="busy" @tap="choose(activeTask)">继续未完成清点</button></view>
<view v-for="row in tasks" :key="row.receiptNo" class="work-card" @tap="choose(row)"><text class="card-title">{{ row.palletCode }}</text><text class="hint">{{ row.returnNo }}</text><text class="hint">{{ row.boxes.length }} 箱 · {{ row.status === 'COUNTING' ? '清点中' : '待清点' }}</text><text class="entry">{{ row.status === 'COUNTING' ? '继续清点' : '开始清点' }} ›</text></view>
<view v-if="!error && !busy && !tasks.length" class="work-card hint">{{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}</view>
<view v-if="!scanResult && total > 10" class="actions pager"><button class="secondary small" :disabled="busy || page <= 1" @tap="changePage(-1)">上一页</button><text>{{ page }} / {{ Math.max(1, Math.ceil(total / 10)) }}</text><button class="secondary small" :disabled="busy || page * 10 >= total" @tap="changePage(1)">下一页</button></view>
</template>
<template v-else>
<view class="work-card"><text class="card-title">{{ task.palletCode }}</text><text class="hint">{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}</text><text class="hint">{{ task.returnNo }} · {{ task.boxes.length }} 箱</text><view v-for="row in totals" :key="row.skuCode" class="total-row" :class="{ matched: row.actual === row.quantity }"><text>{{ row.skuCode }}</text><text>签收 {{ row.quantity }} / 已填 {{ row.actual }}</text></view></view>
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} cm³</strong></view>
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="busy || task.status !== 'COUNTING'" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="busy || task.status !== 'COUNTING'" placeholder="数量" /><button v-if="task.status === 'COUNTING' && box.items.length > 1" class="link-button" :disabled="busy" @tap="box.items.splice(lineIndex, 1)">删除</button></view>
</view>
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
</template>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, busy: false, error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
computed: {
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
totals() { return (this.task?.expectedItems || []).map(row => ({ ...row, quantity: Number(row.quantity), actual: this.task.boxes.reduce((sum, box) => sum + box.items.filter(line => line.skuCode === row.skuCode).reduce((value, line) => value + (Number(line.quantity) || 0), 0), 0) })) },
ready() { return this.task?.status === 'COUNTING' && this.totals.length > 0 && this.totals.every(row => row.actual === row.quantity) && this.task.boxes.every(box => box.items.length && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0)) && box.items.every(line => this.skuCodes.includes(line.skuCode) && Number.isSafeInteger(Number(line.quantity)) && Number(line.quantity) > 0) && new Set(box.items.map(line => line.skuCode)).size === box.items.length) },
draftKey() { return this.task ? `return-count:${session.apiBase}:${this.user?.id}:${this.task.receiptNo}` : '' }
},
watch: { task: { deep: true, handler() { if (this.task?.status === 'COUNTING') uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature: this.signature }) } } },
onLoad(options) { this.scanCode = String(options?.cartonMark || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.scannerStop = startScanBroadcast({ onScan: value => this.scan(value) }); if (!this.task) { if (this.scanCode) this.scan(this.scanCode); else this.load() } },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
stopScanner() { this.scannerStop?.(); this.scannerStop = null },
async run(work) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.error = { message: error?.message || '清点操作失败', code: error?.code, requestId: error?.requestId, traceId: error?.traceId } } finally { this.busy = false } },
async fetchTasks() { const [active, value] = await Promise.all([getCurrentReturnCountTask(), listReturnCountTasks({ page: this.page })]); this.activeTask = active; this.tasks = value.records || []; this.total = Number(value.total) || 0 },
async load() { await this.run(() => this.fetchTasks()) },
changePage(delta) { this.page += delta; this.load() },
ask(options) { return new Promise((resolve, reject) => uni.showModal({ ...options, success: resolve, fail: reject })) },
async choose(row) {
await this.run(() => this.enterTask(row, true));
},
async enterTask(row, confirmStart = false) {
const active = await getCurrentReturnCountTask(); this.activeTask = active;
if (active && active.receiptNo !== row.receiptNo) {
const answer = await this.ask({ title: '有未完成的清点任务', content: `退货单 ${active.returnNo} 尚未清点完成,一次只能清点一单。`, confirmText: '继续当前单', cancelText: '取消' });
if (answer.confirm) this.open(await beginReturnCountTask(active.receiptNo));
return;
}
if (confirmStart) {
const answer = await this.ask({ title: active ? '继续清点' : '开始清点', content: `是否${active ? '继续' : '开始'}清点退货单 ${row.returnNo}?`, confirmText: active ? '继续' : '开始', cancelText: '取消' });
if (!answer.confirm) return;
}
this.open(await beginReturnCountTask(row.receiptNo)); this.activeTask = this.task; this.selectedMark = this.scanResult;
},
open(row) { this.task = JSON.parse(JSON.stringify(row)); this.requestKey = ''; this.signature = ''; this.selectedMark = ''; const draft = uni.getStorageSync(this.draftKey); if (row.status === 'COUNTING' && draft?.boxes?.length === row.boxes.length && draft.boxes.every(box => row.boxes.some(item => item.cartonMark === box.cartonMark))) { this.task.boxes = draft.boxes; this.requestKey = draft.requestKey || ''; this.signature = draft.signature || '' } },
async scan(value) { const mark = String(value || this.scanCode).trim(); if (!mark) return; await this.run(async () => { const rows = await scanReturnCountTasks(mark); this.tasks = rows; this.total = rows.length; this.scanResult = mark; this.scanCode = ''; this.selectedMark = ''; if (rows.length === 1) await this.enterTask(rows[0]); else this.task = null }) },
addSku(box) { box.items.push({ skuCode: '', quantity: '' }) },
boxVolume(box) { const value = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(value) && value > 0 ? value.toFixed(2) : '—' },
async confirm() { if (!this.ready) return; await this.run(async () => { const completed = { receiptNo: this.task.receiptNo, palletCode: this.task.palletCode }; const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) })); const signature = JSON.stringify(boxes); if (!this.requestKey || signature !== this.signature) { this.requestKey = `return-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.signature = signature } uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature }); const result = await confirmReturnCount(this.task.receiptNo, { idempotencyKey: this.requestKey, boxes }); if (result?.status !== 'CONFIRMED') throw new Error('清点未完成,请刷新核实任务状态'); uni.removeStorageSync(this.draftKey); this.activeTask = null; this.resetList(); uni.showToast({ title: '箱内清点完成', icon: 'success' }); await this.fetchTasks(); await this.offerNextPalletTask(completed) }) },
async offerNextPalletTask(completed) {
if (!completed.palletCode) return;
let rows;
try { rows = await scanReturnCountTasks(completed.palletCode) }
catch (error) { error.message = `当前清点已完成,查询该托盘剩余任务失败:${error.message || '请刷新任务列表'}`; throw error }
const remaining = rows.filter(row => row.palletCode === completed.palletCode && row.receiptNo !== completed.receiptNo && ['PENDING', 'COUNTING'].includes(row.status));
if (!remaining.length) return;
const answer = await this.ask({ title: '该托盘还有待清点项', content: `当前清点已完成,托盘 ${completed.palletCode} 还有 ${remaining.length} 项待清点任务,是否继续?`, confirmText: '继续清点', cancelText: '暂不清点' });
if (!answer.confirm) return;
this.tasks = remaining; this.total = remaining.length; this.scanResult = completed.palletCode;
await this.enterTask(remaining[0]);
},
resetList() { this.task = null; this.selectedMark = ''; this.scanCode = ''; this.scanResult = ''; this.page = 1; this.tasks = []; this.total = 0 },
back() { this.resetList(); this.load() }
}
}
</script>
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0}
.actions{display:flex;align-items:center;gap:8px;margin-top:10px}.small{width:auto;min-height:32px;margin:0;padding:0 12px;font-size:12px;line-height:32px}.search{width:100%;font-size:12px}.pager{justify-content:center;font-size:12px}.entry,.success{color:#135ee8;font-size:12px}.total-row{display:flex;justify-content:space-between;gap:12px;font-size:11px;margin-top:8px;color:#c67716}.total-row.matched{color:#22804a}.box-mark{display:block;color:#65768b;font-size:11px;margin:6px 0;word-break:break-all}.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:11px;word-break:break-all}.quantity{flex:none;width:70px;height:32px;text-align:center;font-size:12px}.box-card.selected{border-color:#135ee8}.row-head .card-title{font-size:13px}
.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin:10px 0 6px}.measure-grid label text{display:block;margin-bottom:4px;color:#65768b;font-size:10px}.measure-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #cbd9e8;border-radius:8px;color:#203750;font-size:13px;text-align:center;background:#fbfdff}.volume-chip{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin:4px 0 8px;color:#728197;font-size:10px}.volume-chip strong{color:#135ee8;font-size:14px}
</style>
+98 -20
View File
@@ -2,18 +2,37 @@
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货合箱" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!result" class="work-card"><text class="card-title">添加退货单</text><ScanTargetInput name="returnNo" v-model="searchCode" label="退货单号" placeholder="扫描或输入完整退货单号" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
<button v-if="!result && (activePlan || mode === 'BOX')" class="link-button" :disabled="busy" @tap="leavePlan">返回扫码 / 计划列表</button>
<ReturnBoxMergeWork v-if="mode === 'BOX' && !result" ref="boxWork" :key="activePlan?.planNo || initialSource?.cartonMark || 'direct'" :plan="activePlan" :initial-source="initialSource" @busy-change="busy = $event" @completed="packingCompleted" @error="setError($event, '箱合并失败')" />
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card direct-merge-card"><text class="card-title">仓库现场直接合箱</text><text class="hint">扫描 SKU 或箱号开始;系统会自动锁定客户和当前仓,跨客户货物不能合箱。</text><ScanTargetInput name="stock" v-model="searchCode" label="SKU 或箱号" placeholder="扫描 SKU 或箱号,查询可合箱库存" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
<view v-if="!result && !activePlan && plans.length" class="work-card"><view class="row-head"><text class="card-title">平台合箱计划</text><text class="meta-line">审核通过后推送</text></view><text class="hint">按计划锁定的来源箱和数量作业,后续合箱规则不变。</text><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button></view>
<view v-if="candidates !== null && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
<view v-if="customerCandidates.length && !selectedCustomer && !result" class="section-gap"><view class="work-card"><text class="card-title">选择客户</text><text class="hint">该 SKU 在多个客户有可用退货库存,请先选择客户</text><view class="task-list"><button v-for="(customer, index) in customerCandidates" :key="index" class="task-card candidate-card" @tap="selectCustomer(customer)"><view class="row-head"><view><text class="business-code">{{ customerLabel(customer, index) }}</text><text class="meta-line">可用 SKU {{ customer.skus.length }} 个 · 退货单 {{ customer.orders.length }} 个</text></view><text class="select-link">选择 ›</text></view></button></view></view></view>
<view v-if="candidates !== null && !customerCandidates.length && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
<template v-if="selectedOrders.length && !result">
<view class="work-card"><text class="card-title">已选退货单</text><view class="selected-tags"><button v-for="item in selectedOrders" :key="item.returnNo" class="selected-tag" @tap="removeOrder(item.returnNo)">{{ item.returnNo }} ×</button></view><text class="hint">不同客户的退货单不能合箱;选择时会自动校验。</text></view>
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">可用 {{ item.availableQuantity }}</text></view><view class="quantity-row"><text>装入数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
<view class="work-card"><text class="card-title">新箱箱规</text><view class="dimension-grid"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="packing[field.key]" :placeholder="field.placeholder" @input="setDimension(field.key, $event.detail.value)" /></label></view></view>
<template v-if="selectedOrders.length && !result && mode === 'SKU'">
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text><text class="hint">{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '已锁定一个客户;可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}</text></view>
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">可用 {{ item.availableQuantity }}</text></view><view class="quantity-row"><text>装入数量</text><input class="number-input" type="number" :disabled="!!activePlan" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
<button class="secondary" :disabled="busy || !mergeQuantity" @tap="distributeBoxes">按单 SKU 一箱分配</button>
<view v-for="(box,index) in targetBoxes" :key="index" class="work-card">
<view class="row-head"><text class="card-title">目标箱 {{ index + 1 }}</text><button class="link-button" @tap="removeBox(index)">删除</button></view>
<view v-for="item in availableSkus" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }}</text><input class="number-input" type="number" :value="box.quantities?.[item.skuCode] || ''" placeholder="装入数量" @input="setBoxQuantity(index,item.skuCode,$event.detail.value)" /></view>
<view class="dimension-grid section-gap"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="box[field.key]" :placeholder="field.placeholder" @input="setBoxField(index,field.key,$event.detail.value)" /></label></view>
<text class="hint">体积 {{ boxVolume(box) }} m³</text>
</view>
<button class="secondary" :disabled="busy" @tap="addBox">新增目标箱(可混 SKU)</button>
<button class="primary" :disabled="!canMerge || busy" @tap="mergeCartons">{{ busy ? '合箱中…' : `确认合箱(${mergeQuantity} 件)` }}</button>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货合箱成功</text><text class="result-carton">{{ result.label.cartonMark || result.cartonMark }}</text><text class="meta-line">来源退货单:{{ result.label.sourceReturnNo || '—' }}</text><text class="meta-line">箱规:{{ sizeText(result.label) }}</text><view class="item-list section-gap"><view v-for="item in result.label.items" :key="item.skuCode" class="item-card"><view class="row-head"><text class="business-code">{{ item.skuCode }}</text><text class="remaining-copy">{{ item.quantity }} 件</text></view><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view></view><button class="secondary" @tap="copyCartonMark">复制新箱号</button><button class="primary" @tap="nextPacking">继续合箱</button></view>
<view v-if="result" class="success-box result-card"><text class="success-title">合箱完成 · {{ result.labels.length }} 箱</text>
<view v-for="label in result.labels" :key="label.cartonMark" class="item-card section-gap">
<text class="business-code">{{ label.cartonMark }}</text><text class="meta-line">来源退货单:{{ label.sourceReturnNo }}</text>
<text v-for="item in label.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<button class="secondary" :disabled="busy" @tap="printLabel(label)">打印新箱唛</button>
<ReturnLabelConfirmation ref="labelConfirmations" :carton-mark="label.cartonMark" />
</view>
<button class="primary" @tap="nextPacking">返回合箱计划列表</button>
</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 v-if="selectedOrders.length" class="secondary" :disabled="busy" @tap="refreshSelected">重新读取可用库存</button></view>
</view></scroll-view>
</view>
@@ -22,40 +41,99 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { findPackingOrders, mergeReturnCartons } from '@/api/return-packing.js'
import ReturnBoxMergeWork from '@/components/ReturnBoxMergeWork.vue'
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, candidates: null, previews: {}, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
components: { PortalHeader, ScanTargetInput, ReturnBoxMergeWork, ReturnLabelConfirmation },
data() { return { mode: 'SKU', initialSource: null, user: session.currentUser, candidates: null, customerCandidates: [], selectedCustomer: null, previews: {}, plans: [], activePlan: null, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
computed: {
packing() { return warehouseWork.returnPacking },
targetBoxes() { return this.packing.targetBoxes || [] },
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
selectedOrders() { return Array.isArray(this.packing.selectedOrders) ? this.packing.selectedOrders : [] },
availableSkus() { const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
availableSkus() { if (this.selectedCustomer) return this.selectedCustomer.skus || []; const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
mergeQuantity() { return Object.values(this.packing.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
validDimensions() { return this.dimensionFields.every((field) => Number(this.packing[field.key]) > 0) },
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && this.validDimensions && this.availableSkus.every((item) => this.quantity(item.skuCode) <= item.availableQuantity) }
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && validPackingAllocation(this.targetBoxes, this.packing.quantities || {}) && this.availableSkus.every((item) => this.quantity(item.skuCode) <= item.availableQuantity) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); if (this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.mode = this.packing.mode || 'SKU'; this.activePlan = this.packing.activePlan || null; this.result = this.packing.result || null; if (this.activePlan) { const quantities = {}; for (const box of this.activePlan.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity); this.selectedCustomer = { skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }; } this.startScanner(); this.loadPlans(); if (!this.activePlan && !this.result && this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (!this.busy) { this.searchCode = value; this.search(value) } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.result) { for (const confirmation of this.$refs.labelConfirmations || []) confirmation.acceptScan(value); return } if (!this.busy && !this.result) { if (this.mode === 'BOX') this.$refs.boxWork?.acceptScan(value); else { this.searchCode = value; this.search(value) } } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async search(raw) { const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim(); if (!value || this.busy) return; this.searchCode = value; await this.run(async () => { const preview = await findPackingOrders(value); this.candidates = preview.orders.filter((item) => !this.selectedOrders.some((selected) => selected.returnNo === item.returnNo)); if (this.candidates.length) playSuccessSound() }, '查询可合箱退货单失败') },
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('请扫描完整退货单号后重试'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; playSuccessSound() }, '添加退货单失败') },
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
async loadPlans() { try { this.plans = await listPackingPlans() } catch (error) { this.setError(error, '加载合箱计划失败') } },
async search(raw) {
const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim()
if (!value || this.busy || this.activePlan || this.result) return
this.searchCode = value
await this.run(async () => {
let carton
try { carton = await scanReturnCarton(value) } catch (error) {
if (!['A0404', '404'].includes(String(error?.code || '').toUpperCase())) throw error
}
if (carton) {
resetWarehouseDomain('returnPacking'); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null; this.previews = {}
this.initialSource = carton; this.mode = 'BOX'; updateWarehouseWork('returnPacking', {mode:'BOX', initialSource:carton}); playSuccessSound(); return
}
throw new Error('请扫描来源包裹/箱标签,部分出库必须确认具体实物来源')
}, '查询 SKU 或箱号失败')
},
selectCustomer(customer) { if (!customer?.customerId || !customer.orders?.length) return; const existingCustomer = this.selectedOrders[0]?.customerKey; if (existingCustomer && existingCustomer !== customer.customerId) { this.setError(new Error('不同客户的退货库存不能合箱'), '不能选择其他客户'); return } this.selectedCustomer = customer; const selected = customer.orders.filter((item) => !this.selectedOrders.some((current) => current.returnNo === item.returnNo)).map((item) => ({ ...item, customerKey: customer.customerId })); const all = this.selectedOrders.length ? [...this.selectedOrders, ...selected] : selected; updateWarehouseWork('returnPacking', { selectedOrders: all, searchCode: '' }); this.customerCandidates = []; this.candidates = null; this.previews = {} },
customerLabel(customer, index) { return `客户 ${index + 1}` },
async startPlan(plan) { if (!plan?.planNo || this.busy || this.activePlan || this.result) return; await this.run(async () => {
const latest = await getPackingPlan(plan.planNo)
if (latest.status !== 'READY') throw new Error('计划已不在待作业状态,请刷新列表')
this.activePlan = latest; this.mode = 'BOX'; this.customerCandidates = []; this.candidates = null
const quantities = {}; for (const box of latest.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity)
this.selectedCustomer = { customerId: latest.customerId, orders: latest.returnNos.map(returnNo => ({ returnNo })), skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }
updateWarehouseWork('returnPacking', { mode: 'BOX', activePlan: latest, selectedOrders: latest.returnNos.map(returnNo => ({ returnNo, customerKey: latest.customerId })), quantities, targetBoxes: defaultPackingBoxes(Object.entries(quantities).map(([skuCode,quantity]) => ({skuCode,quantity}))), searchCode: '' })
}, '读取合箱计划失败') },
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo, 'RETURN_NO'); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('该退货单没有可用库存'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.selectedCustomer = null; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; this.customerCandidates = []; playSuccessSound() }, '添加退货单失败') },
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; if (this.selectedCustomer) this.selectedCustomer = { ...this.selectedCustomer, orders: selected }; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
validQuantities(selected, previews) { const available = new Map(); for (const order of selected) for (const item of previews[order.returnNo]?.skus || []) available.set(item.skuCode, (available.get(item.skuCode) || 0) + Number(item.availableQuantity || 0)); return Object.fromEntries([...available].map(([sku, maximum]) => [sku, Math.min(maximum, this.quantity(sku))])) },
quantity(skuCode) { return Math.max(0, Number(this.packing.quantities?.[skuCode]) || 0) },
setQuantity(item, raw) { const value = Math.min(item.availableQuantity, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnPacking', { quantities: { ...(this.packing.quantities || {}), [item.skuCode]: value } }) },
setDimension(key, raw) { updateWarehouseWork('returnPacking', { [key]: String(raw || '').trim() }) },
async refreshSelected() { if (this.busy || !this.selectedOrders.length) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
async mergeCartons() { if (!this.canMerge || this.busy) return; const payload = { returnNos: this.selectedOrders.map((item) => item.returnNo), items: this.availableSkus.map((item) => ({ skuCode: item.skuCode, quantity: this.quantity(item.skuCode) })).filter((item) => item.quantity > 0), length: Number(this.packing.length), width: Number(this.packing.width), height: Number(this.packing.height), weight: Number(this.packing.weight) }; const key = warehouseIdempotencyKey('returnPacking', 'merge', payload); await this.run(async () => { this.result = await mergeReturnCartons({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnPacking', key.signature); resetWarehouseDomain('returnPacking'); this.previews = {}; this.candidates = null; playSuccessSound() }, '退货合箱失败') },
async refreshSelected() { if (this.busy || !this.selectedOrders.length || this.selectedCustomer) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview || !preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
async mergeCartons() {
if (!this.canMerge || this.busy) return
const payload = { returnNos: this.selectedOrders.map(item => item.returnNo), boxes: this.targetBoxes.map(packingBoxPayload) }
const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo })
await this.run(async () => {
const value = this.activePlan ? await completePackingPlan(this.activePlan.planNo, { boxes: payload.boxes, idempotencyKey: key.value }) : await packReturnSkus({ ...payload, idempotencyKey: key.value })
this.packingCompleted(value); releaseWarehouseKey('returnPacking', key.signature); await this.loadPlans()
}, '退货合箱失败')
},
distributeBoxes() { updateWarehouseWork('returnPacking', { targetBoxes: defaultPackingBoxes(Object.entries(this.packing.quantities || {}).map(([skuCode,quantity]) => ({skuCode,quantity}))) }) },
addBox() { updateWarehouseWork('returnPacking', { targetBoxes: [...this.targetBoxes, { quantities: {}, length:'', width:'', height:'', weight:'' }] }) },
removeBox(index) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.filter((_,i) => i !== index) }) },
setBoxField(index,key,value) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.map((box,i) => i === index ? { ...box, [key]: String(value || '') } : box) }) },
setBoxQuantity(index,sku,value) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.map((box,i) => i === index ? { ...box, quantities: { ...box.quantities, [sku]: Math.max(0,Math.floor(Number(value) || 0)) } } : box) }) },
boxVolume(box) { return ((Number(box.length)||0)*(Number(box.width)||0)*(Number(box.height)||0)/1000000).toFixed(6) },
changeMode(mode) { if (this.activePlan || this.busy) return; this.mode = mode; resetWarehouseDomain('returnPacking'); updateWarehouseWork('returnPacking', { mode }); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null },
leavePlan() { if (this.busy) return; this.returnToPlans() },
packingCompleted(value) { this.busy = false; playSuccessSound(); if (value.targetMode === 'EXISTING') { this.returnToPlans(); return } this.result = { labels: value.labels || (value.label ? [value.label] : []) }; updateWarehouseWork('returnPacking', { result: this.result }) },
async printLabel(label) {
// #ifdef H5
const tab = window.open('', '_blank')
if (!tab) { this.setError(new Error('请允许打开箱唛打印窗口'), '打印失败'); return }
try { const latest = await getReturnCartonLabel(label.cartonMark); if (!latest.printableHtml) throw new Error('箱唛打印内容为空,请重试'); tab.document.write(latest.printableHtml); tab.document.close(); tab.focus(); tab.print() } catch (error) { tab.close(); this.setError(error, '打印失败') }
// #endif
// #ifndef H5
this.setError(new Error('当前设备未配置打印服务'), '打印失败')
// #endif
},
copyCartonMark() { const value = this.result?.label?.cartonMark || this.result?.cartonMark; if (value) uni.setClipboardData({ data: value }) },
nextPacking() { this.result = null; this.error = null; this.searchCode = '' },
nextPacking() { this.returnToPlans() },
returnToPlans() { resetWarehouseDomain('returnPacking'); this.initialSource = null; this.activePlan = null; this.result = null; this.mode = 'SKU'; this.error = null; this.customerCandidates = []; this.selectedCustomer = null; this.candidates = null; this.previews = {}; this.loadPlans() },
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
sizeText(label) { return `${label.length || '—'} × ${label.width || '—'} × ${label.height || '—'} cm · ${label.weight || '—'} kg` }
+102 -30
View File
@@ -14,31 +14,46 @@
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
</view>
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成按箱签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} 箱。请继续本批签收,已确认的箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成{{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '按箱' }}签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} {{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '箱' }}。请继续本批签收,已确认的包裹/箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
<template v-if="current && !result && !receiptBatch">
<view class="work-card"><view class="row-head"><view><text class="business-code">{{ current.returnNo }}</text><text class="meta-line">{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}</text></view><text class="status-pill">{{ statusLabel(current.status) }}</text></view><button class="link-button" @tap="clearCurrent">更换退货单</button></view>
<view class="work-card receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
<view class="work-card">
<text class="card-title">本次实物单元</text>
<text class="hint">按现场收到的实物选择维度,托盘不是必填项。</text>
<view class="unit-tabs"><button v-for="item in unitOptions" :key="item.value" :class="['unit-tab', {active: unitType === item.value}]" @tap="selectUnitType(item.value)">{{ item.label }}</button></view>
<view v-if="unitType === 'PARCEL'"><text class="field-label">包裹码/快递单号</text><ScanTargetInput name="unit" v-model="unitCode" label="包裹码/快递单号" placeholder="扫描快递面单" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /><text class="hint">本批共用一个快递单号;填写包裹数,再逐包裹录入 SKU 数量,各包裹单独生成仓库标签。</text></view>
<template v-else>
<text v-if="unitType === 'PALLET'" class="scan-hint">托盘入库:扫描已有托盘或生成新托盘,沿用原托盘测量流程。</text><text v-else class="scan-hint">箱子可直接散装入库,也可扫描托盘直接打托。</text>
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘(仅作载体)</button></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>长 cm</text><input class="number-input" type="digit" v-model="palletLength" placeholder="长" /></label><label><text>宽 cm</text><input class="number-input" type="digit" v-model="palletWidth" placeholder="宽" /></label><label><text>高 cm</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="高" /></label><label><text>重量 kg</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="重量" /></label><view class="volume-box"><text>体积</text><strong>{{ palletVolume }}</strong><text>cm³</text></view></view>
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘</button></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} cm³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
<view><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
</template>
</view>
<view class="work-card"><view class="row-head"><text class="card-title">{{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text><button v-if="unitType !== 'BOX'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType === 'BOX'"><view class="quantity-row"><text>本次箱数</text><input class="number-input" type="number" :value="boxRows.length" placeholder="箱数" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else class="item-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>此 SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view>
<view class="summary-bar"><view><text class="summary-label">本托合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收并入托' }}</button></view>
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text><text class="hint">签收过程中可随时拍照</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
<view class="work-card">
<view class="row-head receipt-quantity-heading"><text class="card-title">{{ unitType === 'PARCEL' ? '逐包裹实收数量' : unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量' }}</text><button v-if="unitType === 'PALLET'" class="link-button" @tap="fillRemaining">全部填入</button><view v-else class="unit-count-row"><text>{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}</text><input class="number-input" type="number" placeholder="数量" :value="boxRows.length" :disabled="busy" @input="resizeBoxRows($event.detail.value)" /></view></view>
<view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view>
<template v-if="unitType !== 'PALLET'">
<view v-for="(box,index) in boxRows" :key="index" class="item-card physical-unit-card">
<view class="unit-card-head"><text class="business-code">第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</text><button v-if="box.confirmed" class="link-button" :disabled="busy" @tap="editBoxRow(index)">已确认 · 展开修改</button><text v-else class="unit-card-tag">实测信息</text></view>
<template v-if="box.confirmed"><text class="meta-line">{{ box.length }} × {{ box.width }} × {{ box.height }} cm · {{ box.weight }} kg · {{ box.originalPackaging ? '原包装' : '新包装' }}</text><text v-for="item in selectedItems.filter(item => Number(box.items[item.skuCode]) > 0)" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ box.items[item.skuCode] }} 件</text></template>
<template v-else>
<view class="dimension-grid unit-dimension-grid"><label><text>长(cm)</text><input class="number-input" type="digit" v-model="box.length" placeholder="长" :disabled="busy" /></label><label><text>宽(cm)</text><input class="number-input" type="digit" v-model="box.width" placeholder="宽" :disabled="busy" /></label><label><text>高(cm)</text><input class="number-input" type="digit" v-model="box.height" placeholder="高" :disabled="busy" /></label><label><text>重量(kg)</text><input class="number-input" type="digit" v-model="box.weight" placeholder="重量" :disabled="busy" /></label></view>
<view class="unit-volume"><text>体积</text><strong>{{ rowVolume(box) }}</strong><text>cm³</text></view>
<view class="packaging-choice"><text>是否使用原{{ unitType === 'PARCEL' ? '包装' : '箱' }}</text><button :class="['choice-button', {selected: box.originalPackaging === true}]" :disabled="busy" @tap="setOriginalPackaging(index, true)">是</button><button :class="['choice-button', {selected: box.originalPackaging === false}]" :disabled="busy" @tap="setOriginalPackaging(index, false)">否</button></view>
<view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input unit-quantity-input" type="number" :value="box.items[item.skuCode] || ''" :placeholder="'剩余 ' + boxRemaining(index, item)" :disabled="busy" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view>
<button v-if="boxRowValid(index)" class="primary" :disabled="busy" @tap="confirmBoxRow(index)">确认第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</button>
</template>
</view>
</template>
<view v-if="unitType === 'PALLET'" class="item-list receipt-sku-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="sku-quantity-fields"><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view></view>
<view v-if="unitType === 'PALLET'" class="summary-bar"><view><text class="summary-label">{{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : unitType === 'PARCEL' ? '确认包裹签收' : '确认签收并入托' }}</button></view>
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text><text class="hint">{{ unitType !== 'PALLET' ? '支持多张照片,上传后点击确认签收入库' : '签收过程中可随时拍照' }}</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto()">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
<view v-if="unitType !== 'PALLET'" class="summary-bar"><view><text class="summary-label">本次合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="busy || !canConfirm || !allBoxRowsConfirmed || !photoFileIds.length" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收入库' }}</button></view>
<button v-if="current.status === 'PARTIALLY_RECEIVED'" class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货手动完结</button>
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" @tap="nextReceipt">继续签收</button></view>
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></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 v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
</view></scroll-view>
@@ -56,7 +71,7 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {} }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
@@ -64,11 +79,12 @@
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
palletCode: { get() { return warehouseWork.returnReceipt.palletCode }, set(value) { updateWarehouseWork('returnReceipt', { palletCode: value }) } },
selectedItems() { return this.current?.expectedItems?.filter(item => !this.selectedSku || item.skuCode === this.selectedSku) || [] },
selectedItems() { return this.current?.expectedItems || [] },
allBoxRowsConfirmed() { return this.unitType !== 'PALLET' && this.boxRows.length > 0 && this.boxRows.every((box, index) => box.confirmed && this.boxRowValid(index)) },
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
activeSkuCode() { return this.selectedItems.length === 1 ? this.selectedItems[0].skuCode : '' },
receiptQuantity() { return this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType === 'BOX') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0)) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
activeSkuCode() { return this.selectedItems.find(item => item.skuCode === this.selectedSku)?.skuCode || this.selectedItems[0]?.skuCode || '' },
receiptQuantity() { return this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0) && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0 && typeof box.originalPackaging === 'boolean'))) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && this.palletBoxCount > 0) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
@@ -76,13 +92,29 @@
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; this.palletCode = `${this.unitType}:${code}`; this.activeTarget = 'tracking'; playSuccessSound() },
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { if (this.busy) return; const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }) },
setOriginalPackaging(index, value) { if (this.busy) return; this.boxRows[index].originalPackaging = value; this.boxRows[index].confirmed = false },
rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? v.toFixed(2) : '—' },
setBoxQuantity(index, skuCode, raw) { if (this.busy) return; this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: raw }; this.boxRows[index].confirmed = false },
boxRemaining(index, item) { const allocated = this.boxRows.reduce((sum, box, rowIndex) => sum + (rowIndex === index ? 0 : Math.max(0, Number(box.items[item.skuCode]) || 0)), 0); return Math.max(0, this.remaining(item) - allocated) },
boxRowValid(index) {
const box = this.boxRows[index]
if (!box || !['length', 'width', 'height', 'weight'].every(field => Number.isFinite(Number(box[field])) && Number(box[field]) > 0) || typeof box.originalPackaging !== 'boolean') return false
return this.selectedItems.some(item => Number(box.items[item.skuCode]) > 0) && this.selectedItems.every(item => { const quantity = Number(box.items[item.skuCode] || 0); return Number.isSafeInteger(quantity) && quantity >= 0 && quantity <= this.boxRemaining(index, item) })
},
editBoxRow(index) { if (this.busy) return; this.boxRows[index].confirmed = false; this.error = null },
confirmBoxRow(index) {
if (this.busy) return
if (!this.boxRowValid(index)) return this.setError(new Error('请填写有效箱规、重量和正整数实收数量,数量不能超过扣除其他箱后的剩余量'), '本箱确认失败')
this.boxRows[index].confirmed = true; this.error = null
if (this.allBoxRowsConfirmed) this.choosePhoto(true)
},
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
handleScan(value) { if (this.result) return; if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
@@ -100,16 +132,16 @@
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
await this.run(async () => {
const rows = this.unitType === 'BOX' ? this.boxRows : [null]
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
const entries = rows.map((box, index) => {
const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' || this.unitType === 'BOX' ? undefined : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : Number(box.length), width: this.unitType === 'PALLET' ? Number(this.palletWidth) : Number(box.width), height: this.unitType === 'PALLET' ? Number(this.palletHeight) : Number(box.height), weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : Number(box.weight), originalPackaging: this.unitType === 'PALLET' ? undefined : box.originalPackaging, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null }
})
if (this.unitType === 'BOX') {
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, entries })) })
if (this.unitType !== 'PALLET') {
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
await this.resumeReceiptBatch()
} else {
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
@@ -135,7 +167,7 @@
},
finishReceipt(results) {
const last = results[results.length - 1]
this.result = { ...last, labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.flatMap(item => item.labels?.length ? item.labels : item.printableHtml && item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml }] : []) }
resetWarehouseDomain('returnReceipt')
this.current = null
this.candidates = null
@@ -143,12 +175,47 @@
playSuccessSound()
},
manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) },
choosePhoto() { if (!this.current || this.busy || this.photoFileIds.length >= 20) return; const no = this.current.returnNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { await this.run(async () => { for (const [index,path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId); this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path } playSuccessSound() }, '退货打托照片上传失败') } }) },
choosePhoto(cameraOnly = false) {
if (!this.current || this.busy) return
if (this.photoFileIds.length >= 20) return
const no = this.current.returnNo; const draft = JSON.stringify(this.boxRows); const type = this.unitType
uni.chooseImage({ count: cameraOnly ? 1 : Math.min(9, 20 - this.photoFileIds.length), sourceType: cameraOnly ? ['camera'] : ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
if (this.current?.returnNo !== no || this.unitType !== type || JSON.stringify(this.boxRows) !== draft) return
await this.run(async () => {
for (const [index,path] of (tempFilePaths || []).entries()) {
if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId)
this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path
}
playSuccessSound()
}, '退货打托照片上传失败')
} })
},
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
nextReceipt() { this.result = null; this.clearCurrent() },
continueScanning() {
if (this.busy || this.receiptBatch) return
this.clearCurrent()
this.result = null
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
this.palletCheck = null; this.newPalletHtml = ''
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
this.photoFileIds = []; this.photoNames = []; this.photoUrls = {}
},
async nextReceipt() {
if (this.busy) return
const returnNo = this.result?.continuationReturnNo
if (!returnNo) { this.result = null; this.clearCurrent(); return }
await this.run(async () => {
await this.openOrderValue(returnNo, 'SKU')
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.newPalletHtml = ''
this.result = null
}, '读取剩余待签收货物失败')
},
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '签收中', COMPLETED: '已完成' })[value] || value || '状态未知' }
@@ -158,9 +225,14 @@
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
.dimension-grid label .dimension-label{display:flex;align-items:center;justify-content:space-between;gap:3px;margin-bottom:3px}.dimension-grid label .dimension-label>text{color:#65768b;font-size:10px;line-height:14px;font-weight:400;white-space:nowrap}.dimension-grid label .dimension-label>.volume-text{margin-left:auto;color:#135ee8;font-size:10px}
.receipt-order-card{padding:10px 14px!important}.receipt-order-card .row-head{align-items:center;margin-bottom:5px}.receipt-order-card .card-title{margin:0;font-size:12px}.receipt-order-card .link-button{margin:0;padding:0;font-size:11px;line-height:24px}.receipt-order-line{display:flex;align-items:center;gap:8px}.receipt-order-line .business-code{flex:1;min-width:0;font-size:12px;line-height:18px;white-space:nowrap;overflow-x:auto;word-break:normal}.receipt-order-line .status-pill{flex-shrink:0;font-size:10px;padding:3px 6px}
.receipt-sku-list .item-card{padding:10px 12px}.receipt-sku-list .row-head{align-items:flex-start;gap:8px}.receipt-sku-list .row-head>view{flex:1;min-width:0}.receipt-sku-list .business-code{font-size:12px;line-height:17px;word-break:break-all}.receipt-sku-list .meta-line{font-size:10px;line-height:15px;margin-top:3px}.receipt-sku-list .remaining-copy{flex-shrink:0;white-space:nowrap;font-size:10px}.sku-quantity-fields{display:flex;gap:14px;margin-top:8px}.sku-quantity-fields .quantity-row{flex:1;min-width:0;gap:6px;margin:0;justify-content:flex-start}.sku-quantity-fields .quantity-row>text{white-space:nowrap;font-size:10px}.sku-quantity-fields .quantity-row .number-input{box-sizing:border-box;width:64px;height:30px;min-height:30px;padding:0 5px;border-radius:6px;font-size:13px}
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#e53935;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:130px;background:#f1f4f8}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-tile .link-button{margin:0 8px 6px}
.unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin-top:10px}.dimension-grid label>text{display:block;margin-bottom:3px;color:#65768b;font-size:10px}.dimension-grid .number-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #d7e1ef;border-radius:7px;color:#24364b;font-size:13px;text-align:center;background:#fff}.dimension-grid .number-input:focus{border-color:#2167e8;box-shadow:0 0 0 2px rgba(33,103,232,.12)}.volume-box{display:flex;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 6px;border:1px solid #b9d3f4;border-radius:7px;color:#65768b;font-size:10px;background:#f4f8ff}.volume-box strong{color:#135ee8;font-size:13px}
.work-card>.row-head .business-code{font-size:16px;line-height:20px}.work-card>.row-head .meta-line{font-size:10px;line-height:15px}.work-card>.row-head .status-pill{padding:4px 7px;font-size:10px}
.physical-unit-card{padding:12px!important;border:1px solid #d8e5f5!important;background:linear-gradient(145deg,#fff 0%,#f7faff 100%);box-shadow:0 4px 12px rgba(30,78,130,.08)}.unit-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.unit-card-tag{padding:3px 7px;border-radius:10px;background:#eaf2ff;color:#135ee8;font-size:10px;font-weight:700}.unit-dimension-grid{margin-top:0}.unit-volume{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin-top:7px;color:#728197;font-size:10px}.unit-volume strong{color:#135ee8;font-size:15px}.packaging-choice{display:flex;align-items:center;gap:7px;margin-top:10px;padding-top:9px;border-top:1px solid #e6edf6;color:#52667e;font-size:11px}.packaging-choice>text{flex:1}.choice-button{width:42px!important;min-width:42px!important;height:28px!important;min-height:28px!important;margin:0!important;padding:0!important;border:1px solid #d1ddeb!important;border-radius:7px!important;background:#fff!important;color:#65768b!important;font-size:11px!important;line-height:28px!important}.choice-button.selected{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.receipt-quantity-heading{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;margin-bottom:10px}.receipt-quantity-heading .card-title{margin-bottom:0;white-space:nowrap}.unit-count-row{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0;white-space:nowrap;color:#65768b;font-size:10px}.unit-count-row .number-input{box-sizing:border-box;width:48px;min-width:48px;height:30px;min-height:30px;margin:0;padding:0 4px;border-radius:6px;text-align:center;font-size:13px}.quantity-row .unit-quantity-input{width:88px;flex-shrink:0;font-size:12px}.physical-unit-card .quantity-row>text{min-width:0;overflow-wrap:anywhere}.unit-card-head .link-button{margin:0;padding:0;font-size:10px}
</style>
@@ -305,6 +305,7 @@ export default {
.take-photo-button[disabled]{color:#9aa7b5;background:#e7ecf2;opacity:1}
.print-card{box-sizing:border-box;margin-bottom:11px;padding:14px;border:1px solid #b9d3f4;border-radius:13px;background:#f8fbff}.print-row button{width:auto;margin:0;padding:0 10px;color:#135ee8;border:1px solid #9fc0ef;border-radius:8px;background:#fff}.print-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid #dce8f5;color:#728196;font-size:9px}.print-code{display:block;color:#17314f;font-size:13px;font-weight:850}
.generate-button{width:100%;margin-top:12px;color:#fff;background:#135ee8}.generated-card{display:flex;align-items:center;justify-content:space-between;margin-top:12px;padding:10px;border-radius:9px;background:#edf5ff}.generated-card button,.bind-panel>button{width:auto;margin:0;color:#135ee8;background:#fff}.bind-panel{margin-top:10px}.bound-state{margin-top:10px;padding:9px;border-radius:8px;color:#08705f;font-weight:800;background:#dff5ed}
.generated-card,.print-row{gap:10px}.generated-card>view,.print-row>view{flex:1;min-width:0;overflow-wrap:anywhere}.generated-card button,.print-row button{flex-shrink:0;white-space:nowrap;padding:0 10px}
.operation-detail-heading{display:flex;align-items:flex-start;gap:10px;margin:0 2px 12px;padding:2px 0}.back-to-list{width:auto;height:30px;margin:0;padding:0 9px;border:1px solid #c8d8ec;border-radius:8px;color:#135ee8;font-size:10px;line-height:30px;background:#f8fbff}.operation-detail-heading>view{min-width:0}.operation-detail-title{display:block;color:#243951;font-size:15px;font-weight:850}.operation-detail-subtitle{display:block;margin-top:3px;color:#8794a4;font-size:9px;line-height:14px}
.collapse-button{width:30px;height:28px;margin:0;padding:0;color:#135ee8;font-size:16px;line-height:28px;background:#edf5ff}
.receipt-comparison{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-bottom:9px}.receipt-comparison view{padding:9px;border-radius:9px;text-align:center;background:#fff}.receipt-comparison strong,.receipt-comparison text{display:block}.receipt-comparison strong{color:#17314f;font-size:15px}.receipt-comparison text{margin-top:2px;color:#8390a1;font-size:8px}.receipt-comparison .difference{border:1px solid #e3a044;background:#fff4df}.receipt-comparison .difference strong,.receipt-comparison .difference text{color:#a35b0c;font-weight:800}
+1 -1
View File
@@ -4,7 +4,7 @@ const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
const blankState = () => ({
scopeKey: '',
dispatch: { currentNo: '', palletCode: '', packages: 1, pendingKeys: {} },
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
})
+1 -1
View File
@@ -7,7 +7,7 @@ const blankState = () => ({
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
returnReceipt: { pendingBatch: null, entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {}, pendingKeys: {} },
returnPackingPlan: { pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
+3 -1
View File
@@ -7,7 +7,9 @@ export function toDispatchError(error, fallback = '排车作业失败') {
const originalMessage = String(error?.message || '').trim()
let message = originalMessage || fallback
if (FORBIDDEN_CODES.has(code)) {
if (error?.network) {
message = '网络连接失败,请检查网络后重试,当前作业已保留'
} else if (FORBIDDEN_CODES.has(code)) {
message = '当前账号没有海外仓 PDA 作业权限,请联系管理员授权'
} else if (CONFLICT_CODES.has(code) && originalMessage === VEHICLE_PLATE_REQUIRED) {
message = '请先在平台维护车牌信息后再拣货'
+8
View File
@@ -0,0 +1,8 @@
export function isRelabelFundsBlocked(error) {
return /Relabel funds must be frozen before processing|换标资金尚未冻结/.test(String(error?.message || ''))
}
export function relabelErrorMessage(error, fallback = '换标作业失败') {
if (isRelabelFundsBlocked(error)) return '换标资金尚未冻结,暂时不能认领。请后台检查客户余额或授信及资金冻结结果,处理后再认领。'
return error?.message || fallback
}
+26
View File
@@ -0,0 +1,26 @@
export function defaultPackingBoxes(items) {
return items.filter(item => Number(item.quantity) > 0).map(item => ({
quantities: { [item.skuCode]: Number(item.quantity) }, length: '', width: '', height: '', weight: ''
}))
}
export function packingBoxPayload(box) {
return { items: Object.entries(box.quantities || {}).filter(([, quantity]) => Number(quantity) > 0)
.map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) })),
length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight) }
}
export function validPackingAllocation(boxes, expected) {
if (!boxes.length) return false
const totals = {}
for (const box of boxes) {
const payload = packingBoxPayload(box)
if (!payload.items.length || !['length', 'width', 'height', 'weight'].every(key => Number.isFinite(payload[key]) && payload[key] > 0)) return false
for (const item of payload.items) {
if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0 || !Object.hasOwn(expected, item.skuCode)) return false
totals[item.skuCode] = (totals[item.skuCode] || 0) + item.quantity
}
}
return Object.entries(expected).filter(([, quantity]) => Number(quantity) > 0).every(([sku, quantity]) => totals[sku] === Number(quantity))
&& Object.keys(totals).length === Object.values(expected).filter(quantity => Number(quantity) > 0).length
}
+25
View File
@@ -0,0 +1,25 @@
export function returnSelections(task) {
return (task?.items || []).filter(item => item.returnCartonId && ['SKU_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
}
export function returnDeductions(task) {
const rows = new Map()
for (const item of returnSelections(task)) {
const skus = item.operationType === 'RETURN_BULK_SKU_LABEL' ? [{ skuCode: item.skuCode, quantity: item.expectedPackages }] : item.skuItems || []
for (const sku of skus) {
const key = `${item.cartonMark}::${sku.skuCode}`
const row = rows.get(key) || { cartonMark: item.cartonMark, skuCode: sku.skuCode, quantity: 0 }
row.quantity += Number(sku.quantity || 0); rows.set(key, row)
}
}
return [...rows.values()]
}
export function requiresReturnPack(task) {
const totals = new Map()
for (const row of returnDeductions(task)) totals.set(row.cartonMark, (totals.get(row.cartonMark) || 0) + row.quantity)
return returnSelections(task).some(item => !Number(item.returnQuantity) || totals.get(item.cartonMark) < Number(item.returnQuantity))
}
export function returnPackFacts(task) {
return (task?.history || []).filter(row => row.action === 'RETURN_PACK').flatMap(row => {
try { const detail = typeof row.detail === 'string' ? JSON.parse(row.detail) : row.detail; return detail?.round === (task.reviewedAt || 'INITIAL') ? [detail] : [] } catch { return [] }
})
}
+81 -3
View File
@@ -11,8 +11,47 @@ import {
import { session } from '../src/stores/session.js'
import { toDispatchError } from '../src/utils/dispatch-error.js'
test('dispatch network failures show recovery guidance and retain diagnostics', () => {
const error = toDispatchError({ message: 'request:fail', network: true, code: 'NETWORK_ERROR', requestId: 'network-request', traceId: 'network-trace' })
assert.equal(error.message, '网络连接失败,请检查网络后重试,当前作业已保留')
assert.equal(error.network, true)
assert.equal(error.code, 'NETWORK_ERROR')
assert.equal(error.requestId, 'network-request')
assert.equal(error.traceId, 'network-trace')
assert.equal(toDispatchError({ message: '业务校验失败' }).message, '业务校验失败')
})
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('dispatch merge retains return carton picking and review gates together', async () => {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const definition = new Function('PortalHeader', 'ScanTargetInput', 'DispatchHandoverStatus',
source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))({}, {}, {})
const state = { current: { canPick: true }, selectedItem: { returnCartons: [{}] },
selectedReturnCarton: { cartonMark: 'RETURN-BOX' }, selectedPosition: null,
packages: 1, selectedRemaining: 1, pickingCompleted: false, plannedPackages: 1, remainingPackages: 0 }
assert.equal(definition.computed.canPick.call(state), true)
state.selectedReturnCarton = null
assert.equal(definition.computed.canPick.call(state), false)
state.selectedItem.returnCartons = []
state.selectedPosition = { id: 'position' }
assert.equal(definition.computed.canPick.call(state), true)
for (const canPick of [false, undefined, 'true']) {
state.current.canPick = canPick
assert.equal(definition.computed.canPick.call(state), false)
assert.equal(definition.computed.canComplete.call(state), false)
}
const calls = []
const deps = { pickDispatchItem: async (...args) => calls.push(args), getDispatchTask: async () => state.current,
fulfillmentIdempotencyKey: () => ({ value: 'retry-key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, playSuccessSound() {}, PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {} }
const page = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
await page.methods.submitPick.call({ canPick: true, current: { dispatchNo: 'DS-RETURN' },
selectedItem: { dispatchItemId: 'item' }, selectedReturnCarton: { cartonMark: 'RETURN-BOX', productQuantity: 5 },
run: async (_, work) => work() })
assert.deepEqual(calls, [['DS-RETURN', 'item', { positionId: null, cartonMark: 'RETURN-BOX', packages: 1, productQuantity: 5, idempotencyKey: 'retry-key' }]])
})
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(task.tripRecordVersion, '0')
@@ -37,7 +76,7 @@ test('outbound partial pick submits the selected carton and distinguishes shared
assert.deepEqual(outboundPickPayload({ outboundPalletItemId: '13' }, { inventoryPositionId: '23' }, 2).cartonMarks, [])
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /listOutboundTasks\('PENDING'\)/)
assert.match(page, /:key="item.outboundPalletItemId"/)
assert.match(page, /:key="item.displayKey \|\| item.outboundPalletItemId"/)
assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode\)/)
assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/)
assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/)
@@ -49,7 +88,7 @@ test('return outbound preserves its task type and only permits pallet scans for
assert.equal(task.outboundType, 'RETURN_OUTBOUND')
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /this\.current\?\.outboundType === 'RETURN_OUTBOUND' && item\.palletNo/)
assert.match(page, /整托出库请扫描托盘码/)
assert.match(page, /整托出库请扫描托盘号/)
const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
@@ -190,7 +229,7 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.match(page, /正在加载/)
assert.match(page, /暂无/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(page, /error\.network[\s\S]*幂等键/)
assert.match(page, /error\.network[\s\S]*保留/)
assert.match(page, /fulfillmentIdempotencyKey/)
assert.match(page, /releaseFulfillmentKey/)
assert.match(page, /if \(!this\.can/)
@@ -268,3 +307,42 @@ test('dispatch review gate preserves the server decision without treating a stri
assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
})
async function dispatchScanHarness(items) {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const draft = { palletCode: 'OLD', dispatchItemId: 'old', cartonCode: '' }; let sounds = 0
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {},
updateFulfillmentDraft: (_, value) => Object.assign(draft, value), playSuccessSound: () => { sounds++ },
console: { info() {} } }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const state = { current: { dispatchNo: 'DS-TEST', items }, error: { message: 'previous error' }, successMessage: '',
setError(error) { this.error = { message: error.message } } }
state.selectPallet = definition.methods.selectPallet.bind(state)
return { state, draft, scan: definition.methods.handleScan.bind(state), sounds: () => sounds }
}
test('dispatch wrong scan clears prior selection and a valid pallet clears the old error', async () => {
const h = await dispatchScanHarness([{ palletNo: 'PALLET-A', dispatchItemId: 'a', scheduledPackages: 1, pickedPackages: 0, positions: [{ availablePackages: 1 }] }])
h.scan('UNKNOWN'); assert.equal(h.draft.dispatchItemId, ''); assert.ok(h.state.error)
h.scan('PALLET-A'); assert.equal(h.state.error, null); assert.equal(h.draft.dispatchItemId, 'a'); assert.equal(h.sounds(), 1)
})
test('dispatch cannot use a shared destination code to choose a physical pallet', async () => {
const h = await dispatchScanHarness(['A', 'B'].map(x => ({ palletNo: `PALLET-${x}`, fbaWarehouseCode: 'ONT8', dispatchItemId: x, scheduledPackages: 1, pickedPackages: 0 })))
h.scan('ONT8'); assert.equal(h.draft.dispatchItemId, ''); assert.ok(h.state.error); assert.equal(h.sounds(), 0)
})
test('dispatch completed or ambiguous pallet scan never silently selects a row', async () => {
const done = await dispatchScanHarness([{ palletNo: 'PALLET-A', dispatchItemId: 'a', scheduledPackages: 1, pickedPackages: 1 }])
done.scan('PALLET-A'); assert.match(done.state.error.message, /已完成拣货/); assert.equal(done.draft.dispatchItemId, ''); assert.equal(done.sounds(), 0)
const shared = await dispatchScanHarness(['A','B'].map(x => ({ palletNo: 'PALLET-SHARED', dispatchItemId: x, scheduledPackages: 1, pickedPackages: 0 })))
shared.scan('PALLET-SHARED'); assert.match(shared.state.error.message, /多条待拣明细/); assert.equal(shared.draft.dispatchItemId, ''); assert.equal(shared.sounds(), 0)
})
test('single SKU piece plan is preserved for packed carton and loose SKU scans', () => {
const item={outboundPalletItemId:'12',returnSkuQuantity:'2'};
const pos={cartonMark:'RC-1',inventoryPositionId:null};
assert.deepEqual(outboundPickPayload(item,pos,1,'RC-1'),{outboundPalletItemId:'12',inventoryPositionId:null,packages:1,productQuantity:2,cartonMarks:['RC-1']});
assert.deepEqual(outboundPickPayload({...item,skuCode:'SKU-A'},pos,1,'SKU-A'),{outboundPalletItemId:'12',inventoryPositionId:null,packages:1,productQuantity:2,cartonMarks:[],skuCode:'SKU-A'});
assert.equal('productQuantity' in outboundPickPayload({outboundPalletItemId:'12'},pos,1),false);
});
+51 -3
View File
@@ -9,15 +9,63 @@ function page(overrides = {}) {
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = definition.data()
for (const [name, fn] of Object.entries(definition.methods)) vm[name] = fn.bind(vm)
for (const [name, fn] of Object.entries(definition.computed)) Object.defineProperty(vm, name, { get: () => fn.call(vm) })
return { vm, calls }
return { vm, calls, definition }
}
test('discard requires restored photos and retains task while native camera returns', async () => {
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 1 }
assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true)
vm.discardPhotoBusy = true; assert.equal(vm.canConfirmDiscard, false)
let loads = 0; vm.startScanner = () => {}; vm.loadTasks = () => { loads++ }
definition.onShow.call(vm)
assert.equal(loads, 0); assert.equal(vm.discardTask.palletNo, 'PALLET')
})
test('discard list failure blocks confirmation and stale downloads cannot replace a new pallet', async () => {
const pending = deferred()
const { vm } = page({ listCargoDiscardPhotos: async () => { throw new Error('offline photos') }, downloadCargoDiscardPhoto: () => pending.promise })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'A', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.restoreDiscardPhotos(); assert.equal(vm.canConfirmDiscard, false)
const first = vm.loadDiscardPhoto('101'); vm.discardTask = { palletNo: 'B' }; vm.resetDiscardPhotos()
pending.resolve('old-photo-url'); await first; assert.deepEqual(vm.discardPhotoUrls, {})
})
test('return pallet rows sum carton counts without changing raw pick lines', () => {
const { vm } = page()
const cartons = Array.from({ length: 5 }, (_, index) => ({ outboundPalletItemId: index + 1, palletNo: 'PALLET', plannedPackages: 1, pickedPackages: index < 3 ? 1 : 0, positions: [] }))
const loose = { outboundPalletItemId: 6, palletNo: '', plannedPackages: 2, pickedPackages: 0, positions: [] }
vm.current = { outboundType: 'RETURN_OUTBOUND', items: [...cartons, loose, { outboundPalletItemId: 7, palletNo: 'OTHER', plannedPackages: 2, pickedPackages: 1, positions: [] }] }
const before = JSON.stringify(vm.current.items)
assert.equal(vm.displayItems.length, 3)
assert.equal(vm.displayItems[0].plannedPackages, 5)
assert.equal(vm.displayItems[0].pickedPackages, 3)
assert.equal(vm.displayItems[1], loose)
assert.equal(vm.displayItems[2].palletNo, 'OTHER')
assert.equal(JSON.stringify(vm.current.items), before)
assert.match(vm.scanLabel, /扫描托盘号/)
assert.match(vm.scanPlaceholder, /扫描托盘号/)
cartons.forEach(item => { item.pickedPackages = 1 })
assert.equal(vm.displayItems[0].pickedPackages, 5)
})
test('other outbound types preserve individual selectable lines', () => {
const { vm } = page()
const items = [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }, { palletNo: 'PALLET', plannedPackages: 2, pickedPackages: 0, positions: [] }]
vm.current = { outboundType: 'NORMAL', items }
assert.equal(vm.displayItems, items)
assert.equal(vm.hasReturnPallet, false)
})
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
for (const status of ['COMPLETED', 'CANCELLED']) {
const { vm, calls } = page()
@@ -61,7 +109,7 @@ test('discard confirmation is guarded against double submission', async () => {
const pending = deferred(); let submits = 0
const { vm } = page({ confirmCargoDiscard: async () => { submits++; await pending.promise }, listCargoDiscardTasks: async () => [] })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]; const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
})
+2 -2
View File
@@ -176,8 +176,8 @@ test('return packing is a separate same-customer inventory operation', async ()
])
assert.match(pages, /pages\/return-packing\/return-packing/)
assert.match(workbench, /key: 'returnPacking'[^\n]*available: true[^\n]*route: '\/pages\/return-packing\/return-packing'/)
for (const action of ['findPackingOrders', 'mergeReturnCartons']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/merge', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
for (const action of ['findPackingOrders', 'packReturnSkus']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/pack', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
assert.match(page, /startScanBroadcast/)
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
assert.match(page, /idempotencyKey: key\.value/)
+2 -2
View File
@@ -7,7 +7,7 @@ const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*impo
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 component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', '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' }) }
@@ -34,7 +34,7 @@ test('cancel stays on list and preserves existing task selection', async () => {
test('new relabel task asks whether to start before it can be claimed', async () => {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false }
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
+2 -1
View File
@@ -6,7 +6,8 @@ const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.met
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
assert.match(page, /class="destination-summary"[^\n]+FBA 目的仓:[^\n]+v-for="destination in fbaDestinations"/)
assert.match(page, /v-if="fbaDestinations.length > 1"[^\n]+item\.destinationFbaWarehouseCode/)
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
})
+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { isRelabelFundsBlocked, relabelErrorMessage } from '../src/utils/relabel-error.js'
test('wallet block receives actionable Chinese guidance without version-conflict recovery', () => {
const error = { code: 'A0409', message: 'Relabel funds must be frozen before processing' }
assert.equal(isRelabelFundsBlocked(error), true)
assert.match(relabelErrorMessage(error), /后台检查客户余额或授信/)
assert.doesNotMatch(relabelErrorMessage(error), /刷新任务版本/)
assert.equal(isRelabelFundsBlocked({ code: 'A0409', message: 'Task version changed' }), false)
assert.equal(relabelErrorMessage({ message: 'Task version changed' }), 'Task version changed')
})
+35
View File
@@ -0,0 +1,35 @@
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')
function setup() {
const calls = []
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
() => ({ value: 'stable-key', signature: 'signature' }), () => {})
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
return { component, value, calls }
}
test('pallet measurement submits actual dimensions, weight and current stock version and refreshes results', async () => {
const { value, calls } = setup()
await value.saveMeasurement({ palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: '100', widthCm: '50', heightCm: '50', grossWeightKg: '40' })
assert.deepEqual(calls, [{ relabelNo: 'RL-1', palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: 100, widthCm: 50, heightCm: 50, grossWeightKg: 40, idempotencyKey: 'stable-key' }])
assert.equal(value.measurements[0].volumeCbm, 0.25)
assert.equal(value.busy, false)
await value.saveMeasurement({ palletCode: 'NEW', lengthCm: 0, widthCm: 50, heightCm: 50, grossWeightKg: 40 })
assert.equal(calls.length, 1)
assert.match(value.error, /有效/)
})
test('only whole physical pallet quantities offer original pallet reuse', () => {
const { component } = setup()
const canRetain = component.computed.canRetainOriginal
const ctx = { source: { totalPackages: 10 }, selectedSource: {}, plannedPackages: 10, isReturnBulk: false, unboundSourceMode: false }
assert.equal(canRetain.call(ctx), true)
ctx.plannedPackages = 4
assert.equal(canRetain.call(ctx), false)
ctx.plannedPackages = 10;ctx.isReturnBulk = true
assert.equal(canRetain.call(ctx), false)
})
+70 -2
View File
@@ -14,6 +14,7 @@ function context() {
] } }
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.confirmBindingChoice = async () => true
value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
return value
}
@@ -58,7 +59,8 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
assert.match(source, /仍归属/)
assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
test('source pallet list is concise and hides rows with no movable packages', () => {
@@ -70,7 +72,8 @@ test('source pallet list is concise and hides rows with no movable packages', ()
assert.deepEqual(value.operableSourceRows.map(row => row.palletCode), ['PALLET-1'])
assert.match(source, />托盘</)
assert.match(source, />刷新</)
assert.match(source, /托盘号:\{\{ row\.palletCode \}\} · 入仓码:\{\{ row\.inboundCode \}\}/)
assert.match(source, /class="source-caption">托盘号<\/text><text class="source-code">\{\{ row\.palletCode \}\}/)
assert.doesNotMatch(template, /入仓码|row\.inboundCode/)
assert.match(source, /可拆\{\{ isReturnBulk \? '件数' : '箱数' \}\}:\{\{ row\.movablePackages \}\}/)
assert.doesNotMatch(template, /刷新来源托盘/)
assert.doesNotMatch(template, /按原入仓单拆托/)
@@ -104,10 +107,75 @@ test('target confirmation submits binding directly, with no second submit button
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
assert.match(source, /@submit="confirmTargetAndBind"/)
assert.match(source, /@tap="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
test('mixed carton scan alone explains missing SKU and does not bind; completing SKU allows binding', async () => {
const value = context()
value.task.items = [{ cartonMark: 'A', operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 0, cartonProcessedPackages: 1, skuProcessedPackages: 0, destinationFbaWarehouseCode: 'ONT8' }]
value.source = { palletCode: 'SOURCE' }; value.targetCode = 'TARGET'; value.quantity = '1'
let message = ''; let binds = 0
value.showBindingMessage = (title, content) => { message = content }
value.resolve = async () => { value.target = { palletCode: 'TARGET' } }
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.match(message, /SKU 贴标登记/)
assert.equal(binds, 0)
assert.equal(value.target, null)
value.task.items[0].skuProcessedPackages = 1
value.task.items[0].relabelledPackages = 1
assert.equal(value.labelBlockReason, '')
await value.confirmTargetAndBind()
assert.equal(binds, 1)
})
test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
const value = context()
value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
value.sourceCode = 'SOURCE'
let prompts = 0
value.resolve = async () => { value.source = { palletCode: 'SOURCE' } }
value.confirmBindingChoice = async () => { prompts++; return false }
await value.confirmSourceScan()
assert.equal(value.source, null)
value.confirmBindingChoice = async () => { prompts++; return true }
await value.confirmSourceScan()
assert.equal(value.source.palletCode, 'SOURCE')
value.sourceCode = 'OTHER'
value.showBindingMessage = () => {}
value.resolve = async () => assert.fail('unknown pallet must not resolve')
await value.confirmSourceScan()
assert.equal(value.source, null)
assert.equal(prompts, 2)
})
test('declining target confirmation does not move stock', async () => {
const value = context()
value.quantity = '1'; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
value.confirmBindingChoice = async () => false
value.bind = async () => assert.fail('cancelled target must not bind')
await value.confirmTargetAndBind()
assert.equal(value.target, null)
})
test('simplified split automatically fills a batch and counts saved bindings without repeats', async () => {
const value = context()
value.task.items = ['A', 'B', 'C'].map(cartonMark => ({ cartonMark, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }))
value.task.history = [{ action: 'PALLET_BIND', detail: JSON.stringify({ cartonMarks: ['A'], targetPallets: ['OLD-TARGET'] }) }]
assert.equal(value.requiredSplitPackages, 3)
assert.equal(value.splitPackages, 1)
value.quantity = '1'; value.marks = []; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
value.bind = async () => { assert.deepEqual(value.marks, ['B']); assert.equal(value.readyToBind, true) }
await value.confirmTargetAndBind()
assert.doesNotMatch(template, /按箱数填入本批换标箱|选择本批新目的仓|本批确认|@tap="toggleMark/)
value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
assert.equal(value.splitPackages, 0)
})
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 = ''
+30 -1
View File
@@ -3,7 +3,7 @@ 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)({}, {}, {})
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 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)
@@ -24,6 +24,35 @@ test('normal SKU input is always visible in SKU tab and never requests the new l
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
test('return SKU follows ordinary single scan and requires explicit choice when the same SKU spans boxes', async () => {
const calls = []
let value
const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
{}, {}, {}, {}, async (no, payload) => { calls.push(payload); return value.current },
() => ({ value: 'KEY', signature: 'SIG' }), () => {}, { showModal: options => options.complete() }
)
value = context()
Object.assign(value, { busy: false, quickSku: '', skuChoices: [], verifiedReturnCarton: '', returnCartonCode: '',
async run(action, work) { await work() }, applyCurrent(task) { this.current = task },
setError(error) { throw error }
})
for (const name of ['submitSku', 'commitSku']) value[name] = subject.methods[name].bind(value)
value.current.relabelNo = 'RL'; value.current.version = 3
value.current.items = [{ itemId: '1', returnCartonId: 'R1', cartonMark: 'BOX-A', operationType: 'SKU_LABEL', expectedPackages: 1, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 20 }] }]
await value.submitSku('OLD')
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', newSkuCode: 'NEW', quantity: 20, skuIndex: 0, oldSku: 'OLD', expectedVersion: 3, idempotencyKey: 'KEY' }])
calls.length = 0
value.current.items.push({ ...value.current.items[0], itemId: '2', returnCartonId: 'R2', cartonMark: 'BOX-B' })
await value.submitSku('OLD')
assert.equal(calls.length, 0)
assert.equal(value.skuChoices.length, 2)
await value.commitSku(value.skuChoices[1])
assert.equal(calls.length, 1)
assert.equal(calls[0].itemId, '2')
assert.equal(calls[0].cartonMark, 'BOX-B')
assert.doesNotMatch(source.split('<script>')[0], /name="quickTarget"|placeholder="必填:本来源该 SKU 实际件数"/)
})
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)
+68 -43
View File
@@ -1,49 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const api = await readFile(new URL('../src/api/relabel.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const palletWork = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
test('PDA exposes the approved return bulk SKU completion endpoint', () => {
assert.match(api, /completeReturnRelabelSkuTotal/)
assert.match(api, /\/return-skus\/complete-total/)
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const helperSource = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const helpers = await import('data:text/javascript;base64,' + Buffer.from(helperSource).toString('base64'))
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup(confirm = false) {
const calls = [], errors = []
const uni = { showModal(options) { options.success({ confirm }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', 'completeReturnRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)({}, {}, {}, {}, helpers.requiresReturnPack, helpers.returnPackFacts, async (no, payload) => { calls.push(payload); return value.current }, () => ({ value: 'key', signature: 'signature' }), () => {}, uni)
const item = (id, mark) => ({ itemId: id, returnCartonId: id, cartonMark: mark, returnQuantity: 10, operationType: 'RETURN_BULK_SKU_LABEL', skuCode: 'SKU', newSkuCode: 'TARGET', expectedPackages: 4, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0 })
const value = { current: { relabelNo: 'RL', version: 1, items: [item('1', 'BOX-A'), item('2', 'BOX-B')], history: [] }, busy: false, allProcessed: true, remark: '', photoFileIds: ['photo'], returnCartonCode: '', verifiedReturnCarton: '', returnSkuCode: '', bulkQuantities: {}, returnTargetLabels: {}, setError(error) { errors.push(error.message) }, applyCurrent(result) { this.current = result }, async run(action, work) { await work() } }
for (const [name, fn] of Object.entries(component.methods)) if (!value[name]) value[name] = fn.bind(value)
for (const name of ['isReturnTask', 'isReturnBulk', 'returnBulkSources', 'returnBulkSkuGroups', 'requiresPalletBinding', 'palletTask', 'completionBlockReason']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
return { value, calls, errors }
}
test('SKU alone cannot register or allocate unscanned sources', async () => {
const { value, calls, errors } = setup()
value.verifyReturnSku('SKU'); assert.match(errors.pop(), /先确认来源/)
value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
assert.deepEqual(value.returnBulkSources.map(item => item.cartonMark), ['BOX-A'])
value.returnTargetLabels['2'] = 'TARGET'; value.bulkQuantities['2'] = '4'
await value.completeBulkSku(value.current.items[1]); assert.equal(calls.length, 0)
})
test('quantity is explicit, integral and source bounded; wrong target cannot write', async () => {
const { value, calls } = setup(); value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
const item = value.current.items[0]; value.returnTargetLabels['1'] = 'TARGET'
for (const quantity of ['', '1.5', '5', '0']) { value.bulkQuantities['1'] = quantity; await value.completeBulkSku(item) }
assert.equal(calls.length, 0)
value.bulkQuantities['1'] = '2'; value.returnTargetLabels['1'] = 'WRONG'; await value.completeBulkSku(item); assert.equal(calls.length, 0)
value.returnTargetLabels['1'] = 'TARGET'; await value.completeBulkSku(item)
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', skuCode: 'SKU', quantity: 2, expectedVersion: 1, idempotencyKey: 'key' }])
})
test('all action needs confirmation and rescanning clears quantity and target', async () => {
for (const confirm of [false, true]) { const { value } = setup(confirm); value.verifyReturnCarton('BOX-A'); await value.fillAllReturn(value.current.items[0]); assert.equal(value.bulkQuantities['1'], confirm ? '4' : undefined); value.verifyReturnCarton('BOX-B'); assert.deepEqual(value.bulkQuantities, {}); assert.deepEqual(value.returnTargetLabels, {}) }
})
test('normal return SKU rejects incomplete approved target before server scan', async () => {
const { value, errors } = setup(); const item = { ...value.current.items[0], operationType: 'SKU_LABEL', expectedPackages: 1, skuItems: [{ skuCode: 'SKU', newSkuCode: 'TARGET', quantity: 3 }] }
value.current.items = [item]; value.pendingSkuRows = [{ item, sku: item.skuItems[0], index: 0 }]
item.skuItems[0].newSkuCode = ''; value.quickSku = 'SKU'; await value.commitSku(value.pendingSkuRows[0]); assert.match(errors.pop(), /申请件数缺失/)
})
test('packing uses SKU pieces instead of box count and preserves both sources', () => {
const task = { items: [{ returnCartonId: '1', cartonMark: 'A', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 8, skuItems: [{ skuCode: 'SKU', quantity: 3 }] }, { returnCartonId: '2', cartonMark: 'B', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 2, skuItems: [{ skuCode: 'SKU', quantity: 2 }] }] }
assert.deepEqual(helpers.returnDeductions(task), [{ cartonMark: 'A', skuCode: 'SKU', quantity: 3 }, { cartonMark: 'B', skuCode: 'SKU', quantity: 2 }]); assert.equal(helpers.requiresReturnPack(task), true)
task.items[0].returnQuantity = 3; assert.equal(helpers.requiresReturnPack(task), false)
})
test('only truck requires return pallet binding and counts physical new boxes', () => {
const { value } = setup(); for (const method of [null, 'EXPRESS', 'EXPRESS_PICKUP', 'TRUCK']) { value.current.deliveryMethod = method; assert.equal(value.requiresPalletBinding, method === 'TRUCK') }
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.relabelledPackages = item.expectedPackages })
assert.equal(value.palletTask.items.length, 1); assert.equal(value.palletTask.items[0].expectedPackages, 1)
})
test('pack recovery ignores previous rework round', () => {
const task = { reviewedAt: 'ROUND2', history: [{ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'OLD' }) }, { action: 'RETURN_PACK', detail: JSON.stringify({ round: 'ROUND2', cartonMark: 'NEW' }) }] }
assert.deepEqual(helpers.returnPackFacts(task).map(row => row.cartonMark), ['NEW'])
})
test('return relabel pallet binding validates pieces while binding physical carton marks', () => {
assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/)
assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/)
assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/)
assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/)
})
test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => {
assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/)
assert.match(page, /退货贴标 SKU 明细/)
assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/)
assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/)
assert.match(page, /verifiedReturnSkuKey !== group\.key/)
assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/)
assert.match(page, /returnBulkSkuGroups/)
assert.match(page, /来源托盘拆箱数量/)
assert.match(page, /登记本次贴标.*group\.remaining/)
assert.match(page, /completeReturnRelabelSkuTotal/)
assert.match(page, /taskTab === 'carton' && !isReturnBulk/)
})
test('new relabel pallet is immediately copied into the target scan field', () => {
assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/)
assert.match(palletWork, /退货换标无需扫描箱唛/)
})
test('return relabel distinguishes a whole pallet from a partial unpack', () => {
assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/)
assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/)
assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/)
assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/)
assert.match(palletWork, /贴标数量等于本托盘全部/)
assert.match(palletWork, /贴标数量少于本托盘库存/)
assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/)
assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/)
test('partial return completion requires current-round independent box then truck binding and photo', () => {
const { value } = setup()
value.current.items.forEach(item => { item.relabelledPackages = item.expectedPackages })
value.current.relabelledPackages = 8
value.current.deliveryMethod = 'EXPRESS'
assert.match(value.completionBlockReason, /独立新箱/)
value.current.history.push({ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'NEW' }) })
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.returnQuantity = 8 })
assert.equal(value.completionBlockReason, '')
value.current.deliveryMethod = 'TRUCK'
assert.match(value.completionBlockReason, /组托/)
value.current.history.push({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['NEW'] }) })
assert.equal(value.completionBlockReason, '')
value.photoFileIds = []
assert.match(value.completionBlockReason, /照片/)
})
@@ -0,0 +1,54 @@
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')
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
const body = page.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
test('return carton and normal SKU use single scans; explicit source scan is reserved for bulk quantities', () => {
assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
assert.doesNotMatch(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
assert.match(page, /v-if="isReturnBulk" class="section-gap"><text class="hint">先扫描来源/)
assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
})
test('whole carton and full SKU relabel hide new box dimensions; partial SKU keeps isolation form', () => {
const value = { isReturnTask: true, current: { items: [{ returnCartonId: '1', cartonMark: 'C1', operationType: 'CARTON_LABEL', returnQuantity: 15 }], history: [] } }
assert.equal(component.computed.showReturnPack.call(value), false)
value.current.items[0].operationType = 'SKU_LABEL'
value.current.items[0].skuItems = [{ skuCode: 'A', quantity: 15 }]
assert.equal(component.computed.showReturnPack.call(value), false)
value.current.items[0].skuItems[0].quantity = 5
assert.equal(component.computed.showReturnPack.call(value), true)
assert.match(page, /ReturnRelabelPackWork v-if="showReturnPack"/)
})
test('one return carton scan saves label stage without a prior source scan; unrelated carton is rejected', async () => {
const calls = []
const item = { itemId: 'I1', cartonMark: 'C1', operationType: 'CARTON_LABEL', fbaCartonMark: 'FBA1' }
const result = { relabelNo: 'RL1', version: 1, items: [{ ...item, relabelledPackages: 1 }] }
const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelCarton', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
{}, {}, {}, {},
async (no, payload) => { calls.push({ no, payload }); return result },
() => ({ value: 'KEY', signature: 'SIG' }), () => {},
{ showModal: options => options.complete() }
)
const value = {
busy: false, current: { relabelNo: 'RL1', version: 0, items: [item] }, verifiedReturnCarton: '',
run: async (action, work) => work(), applyCurrent(task) { this.current = task },
setError(error) { this.error = error.message }
}
await subject.methods.submitCarton.call(value, ' C1\n')
assert.deepEqual(calls, [{ no: 'RL1', payload: { cartonMark: 'C1', expectedVersion: 0, idempotencyKey: 'KEY' } }])
assert.equal(value.current.version, 1)
assert.equal(value.lastCarton.fbaCartonMark, 'FBA1')
await subject.methods.submitCarton.call(value, 'OTHER')
assert.equal(calls.length, 1)
assert.match(value.error, /不属于本单/)
})
test('mixed return carton keeps one physical package while SKU deductions use pieces', () => {
const task={items:[{returnCartonId:'1',cartonMark:'P1',operationType:'CARTON_AND_SKU',returnQuantity:5,skuItems:[{skuCode:'S',quantity:5}]}]}
assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])
})
+31
View File
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
const source = await readFile(new URL('../src/components/ReturnRelabelPackWork.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = [], events = []
const component = new Function('requiresReturnPack','returnDeductions','returnPackFacts','returnSelections','packReturnRelabelCartons','warehouseIdempotencyKey','releaseWarehouseKey',body)(requiresReturnPack,returnDeductions,returnPackFacts,returnSelections,async(no,payload)=>{calls.push(payload);return {relabelNo:no}},()=>({value:'stable',signature:'s'}),()=>{})
const item = (mark,done) => ({returnCartonId:mark,cartonMark:mark,operationType:'SKU_LABEL',expectedPackages:1,relabelledPackages:done,skippedPackages:0,exceptionPackages:0,skuItems:[{skuCode:'SKU',quantity:3}]})
const value = {...component.data(),task:{relabelNo:'RL',items:[item('A',1),item('B',0)],history:[]},$emit:(...args)=>events.push(args)}
for(const[name,fn]of Object.entries(component.methods))value[name]=fn.bind(value)
for(const[name,fn]of Object.entries(component.computed))Object.defineProperty(value,name,{get:fn.bind(value)})
return {value,calls,events}
}
test('packing cannot run with an unfinished source, missing measurement, skipped or exception fact',async()=>{
const {value,calls}=setup(); await value.pack();assert.equal(calls.length,0)
value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
assert.equal(value.ready,false);value.task.items[1].relabelledPackages=1;assert.equal(value.ready,true)
value.task.items[0].skippedPackages=1;assert.equal(value.ready,false);value.task.items[0].skippedPackages=0
value.task.items[0].exceptionPackages=1;assert.equal(value.ready,false)
})
test('pack sends every source with exact SKU piece deductions and generated target; saved history prevents repeat',async()=>{
const {value,calls,events}=setup();value.task.items[1].relabelledPackages=1;value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
await value.pack()
assert.deepEqual(calls,[{sourceCartonMarks:['A','B'],targetCartonMark:null,sourceDeductions:[{cartonMark:'A',skuCode:'SKU',quantity:3},{cartonMark:'B',skuCode:'SKU',quantity:3}],length:20,width:30,height:40,weight:2,idempotencyKey:'stable'}])
assert.ok(events.some(row=>row[0]==='saved'))
value.task.history=[{action:'RETURN_PACK',detail:JSON.stringify({round:'INITIAL',cartonMark:'NEW',quantity:6,label:{printableHtml:'<html/>'}})}]
assert.equal(value.ready,false);assert.equal(value.facts[0].cartonMark,'NEW')
})
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/components/ReturnBoxMergeWork.vue'
const source = readFileSync(new URL('../' + filename, import.meta.url), 'utf8')
const { descriptor } = parse(source)
test('box merge component compiles both its script and template', () => {
compileScript(descriptor, { id: 'box-merge' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
})
function instance() {
const boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark) })
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
work.save = () => {}
return {work,component}
}
test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
const {work,component}=instance(); work.choose('NEW')
assert.equal(work.sources.length,0)
assert.equal(work.active,'source')
await work.acceptScan('A'); await work.acceptScan('B')
work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
work.dimensions={length:10,width:20,height:30,weight:1}
assert.equal(work.sources.length,2)
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),7)
assert.equal(component.computed.canComplete.call(work),true)
})
test('planned source carton marks are visible before scanning', () => {
const {work, component} = instance()
work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
assert.deepEqual(component.computed.plannedSourceRows.call(work).map(box => box.cartonMark), ['A', 'B'])
})
test('existing box uses another box as target and never removes target inventory', async () => {
const {work,component}=instance(); work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
assert.equal(work.target.cartonMark,'B'); assert.equal(work.sources.length,1)
work.setDeduction('A','SKU1',1)
assert.equal(work.plannedItems().length,2)
assert.equal(work.target.items[0].quantity,2)
assert.equal(component.computed.canComplete.call(work),true)
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
assert.equal(work.plannedItems().length,0)
})
test('existing source can be replaced before merging, echoing the mark and clearing old deductions and target', async () => {
const {work,component}=instance()
work.plan=null
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
work.setDeduction('A','SKU1',1)
assert.equal(work.sourceCode,'A')
work.sourceCode='B'
assert.equal(component.computed.canComplete.call(work),false)
await work.scanSource('B')
assert.equal(work.sources.length,1)
assert.equal(work.sources[0].cartonMark,'B')
assert.equal(work.sourceCode,'B')
assert.equal(work.target,null)
assert.equal(work.targetCode,'')
assert.equal(work.plannedItems().length,0)
await work.scanTarget('A'); work.setDeduction('B','SKU1',1)
assert.equal(component.computed.canComplete.call(work),true)
})
test('BOX plan requires every source and accumulates multiple scanned sources', async () => {
const {work,component}=instance()
work.plan.mode='BOX'
work.plan.sources.push({cartonMark:'C',items:[{skuCode:'SKU1',quantity:4}]})
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
assert.equal(component.computed.canComplete.call(work),false)
await work.scanSource('C')
assert.equal(work.sources.length,2)
assert.equal(component.computed.canComplete.call(work),true)
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),9)
})
test('a directly scanned box survives switching between existing and new targets', async () => {
const {work}=instance()
const box=work.plan.sources[0];work.plan=null;work.initialSource=box
work.choose('EXISTING')
assert.equal(work.sources[0].cartonMark,'A');assert.equal(work.sourceCode,'A')
work.choose('NEW')
assert.equal(work.sources[0].items.length,2)
work.choose('EXISTING')
assert.equal(work.sourceCode,'A');assert.equal(work.active,'target')
})
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { listReturnCountTasks, scanReturnCountCarton, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '../src/api/return-counting.js'
import { session } from '../src/stores/session.js'
test('counting preserves box contents and retry key without accepting client warehouse scope', async () => {
session.apiBase = '/api'; session.token = 'counting-test'
const calls = []
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: { status: 'PENDING' } }, header: {} }) } }
await listReturnCountTasks({ page: 2, keyword: ' RC#2-1 ' })
await scanReturnCountCarton('RC#2-1')
const request = { idempotencyKey: 'count-retry', boxes: [{ cartonMark: 'RC#2-1', items: [{ skuCode: 'SKU-1', quantity: 3 }] }, { cartonMark: 'RC#2-2', items: [{ skuCode: 'SKU-1', quantity: 7 }] }] }
await confirmReturnCount('RR/1', request)
assert.deepEqual(calls.map(row => [row.method, row.url]), [
['GET', '/api/pda/v1/last-leg/return-inventory/counting/tasks?page=2&size=10&keyword=RC%232-1'],
['GET', '/api/pda/v1/last-leg/return-inventory/counting/cartons/RC%232-1'],
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1']
])
assert.deepEqual(calls[2].data, request)
assert.ok(calls.every(row => row.header.Authorization === 'Bearer counting-test'))
})
test('unified scan supports box and pallet codes and preserves the no-count empty result', async () => {
session.apiBase = '/api'; session.token = 'counting-test'
const urls = []
globalThis.uni = { request(options) { urls.push(options.url); options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
assert.deepEqual(await scanReturnCountTasks(' RC#2-1 '), [])
assert.deepEqual(await scanReturnCountTasks('PLT/1'), [])
assert.deepEqual(urls, ['/api/pda/v1/last-leg/return-inventory/counting/scan/RC%232-1', '/api/pda/v1/last-leg/return-inventory/counting/scan/PLT%2F1'])
})
test('current task preserves null and begin uses the operator-scoped claim route', async () => {
session.apiBase = '/api'; session.token = 'counting-test'
const calls = []
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: options.method === 'GET' ? null : { receiptNo: 'RR/1', status: 'COUNTING' } }, header: {} }) } }
assert.equal(await getCurrentReturnCountTask(), null)
assert.equal((await beginReturnCountTask('RR/1')).status, 'COUNTING')
assert.deepEqual(calls.map(row => [row.method, row.url]), [
['GET', '/api/pda/v1/last-leg/return-inventory/counting/current'],
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1/begin']
])
assert.equal(calls[1].data, undefined)
})
+187
View File
@@ -0,0 +1,187 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
const scope = { PortalHeader: {}, ScanTargetInput: {} }
runInNewContext(script, scope)
function state(a = '2', b = '2', c = '2') {
const value = {
scanCode: '', selectedMark: '',
task: { status: 'COUNTING', expectedItems: [{ skuCode: 'A', quantity: '4' }, { skuCode: 'B', quantity: '2' }], boxes: [
{ cartonMark: 'RC#2-1', items: [{ skuCode: 'A', quantity: a }] },
{ cartonMark: 'RC#2-2', items: [{ skuCode: 'A', quantity: b }] },
{ cartonMark: 'RC#1-1', items: [{ skuCode: 'B', quantity: c }] }
] }
}
value.skuCodes = scope.page.computed.skuCodes.call(value)
value.totals = scope.page.computed.totals.call(value)
return value
}
test('list entry can confirm without scanning when string wire quantities reconcile', () => {
const value = state()
assert.equal(value.totals[0].quantity, 4)
assert.equal(value.totals[1].quantity, 2)
assert.equal(value.totals.every(row => row.actual === row.quantity), true)
assert.equal(scope.page.computed.ready.call(value), true)
})
test('counting still rejects mismatched totals and invalid box quantities', () => {
for (const value of [state('1', '2', '2'), state('0', '4', '2'), state('1.5', '2.5', '2')]) {
assert.equal(scope.page.computed.ready.call(value), false)
}
const duplicate = state()
duplicate.task.boxes[0].items = [{ skuCode: 'A', quantity: 1 }, { skuCode: 'A', quantity: 1 }]
assert.equal(scope.page.computed.ready.call(duplicate), false)
})
function scanState() {
const value = { task: state().task, tasks: [], error: null, scanCode: '', scanResult: '', selectedMark: '', busy: false,
open(row) { this.task = row }, $nextTick(callback) { callback() } }
value.run = scope.page.methods.run.bind(value)
value.enterTask = scope.page.methods.enterTask.bind(value)
value.offerNextPalletTask = scope.page.methods.offerNextPalletTask.bind(value)
return value
}
test('box and single-batch pallet scans claim and open counting directly without start dialog', async () => {
for (const mark of ['RC#2-1', 'PLT-1']) {
const value = scanState()
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-SCAN', returnNo: 'RT-SCAN' }]
scope.getCurrentReturnCountTask = async () => null
let claimed
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; assert.equal(value.busy, true); return { receiptNo, status: 'COUNTING' } }
value.ask = async () => { assert.fail('matching scan should enter directly') }
await scope.page.methods.scan.call(value, mark)
assert.equal(claimed, 'RR-SCAN')
assert.equal(value.task.status, 'COUNTING')
assert.equal(value.selectedMark, mark)
assert.equal(value.activeTask, value.task)
}
})
test('completed pallet offers remaining batches and starts only after confirmation', async () => {
for (const confirm of [false, true]) {
const value = scanState(); value.task = null
const next = { receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'PENDING' }
scope.scanReturnCountTasks = async code => { assert.equal(code, 'PLT-1'); return [next, { ...next, receiptNo: 'RR-DONE', status: 'CONFIRMED' }, { ...next, palletCode: 'PLT-2' }] }
scope.getCurrentReturnCountTask = async () => null
let claimed = 0
scope.beginReturnCountTask = async receiptNo => { claimed++; assert.equal(receiptNo, 'RR-NEXT'); return { ...next, status: 'COUNTING' } }
value.ask = async options => { assert.match(options.content, /还有 1 项/); assert.equal(options.cancelText, '暂不清点'); return { confirm } }
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
assert.equal(claimed, confirm ? 1 : 0)
assert.equal(value.task?.status || null, confirm ? 'COUNTING' : null)
}
})
test('no remaining pallet task skips prompt and claim conflicts remain visible', async () => {
const value = scanState(); value.task = null
value.ask = async () => assert.fail('no remaining task should not prompt')
scope.scanReturnCountTasks = async () => []
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'COUNTING' }]
value.ask = async () => ({ confirm: true })
scope.getCurrentReturnCountTask = async () => null
scope.beginReturnCountTask = async () => { throw Object.assign(new Error('该任务正在由其他人员清点'), { code: 'A0409' }) }
await value.run(() => value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' }))
assert.equal(value.error.code, 'A0409')
assert.equal(value.task, null)
})
test('direct scan cannot claim a different task while the operator has unfinished work', async () => {
const value = scanState()
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEW', returnNo: 'RT-NEW' }]
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
scope.beginReturnCountTask = async () => { assert.fail('must not claim a second task') }
let dialog
value.ask = async options => { dialog = options; return { confirm: false } }
await scope.page.methods.scan.call(value, 'PLT-NEW')
assert.equal(dialog.title, '有未完成的清点任务')
})
test('a pallet scan shows every pending batch and no pending results clear stale details', async () => {
const value = scanState()
const rows = [{ receiptNo: 'RR-1', boxes: [] }, { receiptNo: 'RR-2', boxes: [] }]
scope.scanReturnCountTasks = async () => rows
await scope.page.methods.scan.call(value, 'PLT-1')
assert.equal(value.task, null)
assert.equal(value.tasks.length, 2)
assert.equal(value.scanResult, 'PLT-1')
scope.scanReturnCountTasks = async () => []
await scope.page.methods.scan.call(value, 'RC-DONE')
assert.equal(value.task, null)
assert.equal(value.tasks.length, 0)
assert.equal(value.scanResult, 'RC-DONE')
assert.equal(value.error, null)
})
test('scanner failures remain visible rather than claiming no count is required', async () => {
const value = scanState()
scope.scanReturnCountTasks = async () => { throw Object.assign(new Error('服务暂不可用'), { code: 'B0500' }) }
await scope.page.methods.scan.call(value, 'PLT-1')
assert.equal(value.error.code, 'B0500')
assert.equal(value.scanResult, '')
})
test('declining start never claims a task and another active task blocks a new claim', async () => {
const value = scanState(); value.activeTask = null
let begins = 0
scope.getCurrentReturnCountTask = async () => null
scope.beginReturnCountTask = async () => { begins++; return { status: 'COUNTING' } }
value.ask = async () => ({ confirm: false })
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
assert.equal(begins, 0)
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
let dialog
value.ask = async options => { dialog = options; return { confirm: false } }
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
assert.equal(dialog.title, '有未完成的清点任务')
assert.equal(begins, 0)
})
test('affirming start claims the selected task and switches it to working state', async () => {
const value = scanState(); value.scanResult = 'RC-1'
scope.getCurrentReturnCountTask = async () => null
let claimed
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; return { receiptNo, status: 'COUNTING' } }
value.ask = async () => ({ confirm: true })
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
assert.equal(claimed, 'RR-NEW')
assert.equal(value.task.status, 'COUNTING')
const unclaimed = state(); unclaimed.task.status = 'PENDING'
assert.equal(scope.page.computed.ready.call(unclaimed), false)
})
test('completed counting returns to page one and refreshes remaining tasks while busy', async () => {
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'; value.page = 3; value.scanResult = 'PLT-1'
value.resetList = scope.page.methods.resetList.bind(value)
value.fetchTasks = scope.page.methods.fetchTasks.bind(value)
let removed = false, refreshed = false
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true }, showToast() {} }
scope.confirmReturnCount = async () => ({ status: 'CONFIRMED' })
scope.getCurrentReturnCountTask = async () => null
scope.listReturnCountTasks = async ({ page }) => { assert.equal(page, 1); assert.equal(value.busy, true); refreshed = true; return { records: [{ receiptNo: 'RR-NEXT' }], total: '1' } }
await scope.page.methods.confirm.call(value)
assert.equal(value.task, null)
assert.equal(value.scanResult, '')
assert.equal(value.activeTask, null)
assert.equal(value.tasks[0].receiptNo, 'RR-NEXT')
assert.equal(removed && refreshed, true)
})
test('failed confirmation retains the current task and draft for retry', async () => {
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'
const original = value.task
let removed = false
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true } }
scope.confirmReturnCount = async () => { throw new Error('数量汇总不一致') }
await scope.page.methods.confirm.call(value)
assert.equal(value.task, original)
assert.equal(removed, false)
assert.equal(value.error.message, '数量汇总不一致')
})
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/components/ReturnLabelConfirmation.vue'
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
test('physical label confirmation compiles and requires its own barcode before calling the service', async () => {
compileScript(descriptor, { id: 'return-label' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'return-label' }).errors, [])
const calls = []
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), {
component: null, ScanTargetInput: {}, confirmReturnLabel: async mark => calls.push(mark)
})
const label = { ...component.data(), cartonMark: 'RP-1' }
for (const [name, method] of Object.entries(component.methods)) label[name] = method.bind(label)
await label.confirm('RP-OTHER'); assert.equal(calls.length, 0); assert.equal(label.confirmed, false)
await label.acceptScan('RP-1'); assert.deepEqual(calls, ['RP-1']); assert.equal(label.confirmed, true)
await label.confirm('RP-1'); assert.equal(calls.length, 1)
})
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '../src/utils/return-packing-allocation.js'
const measured = box => ({ ...box, length: '20', width: '30', height: '40', weight: '5' })
test('default one box per SKU preserves all planned quantities', () => {
const boxes = defaultPackingBoxes([{ skuCode: 'A', quantity: 7 }, { skuCode: 'B', quantity: 3 }, { skuCode: 'C', quantity: 0 }]).map(measured)
assert.equal(boxes.length, 2)
assert.equal(validPackingAllocation(boxes, { A: 7, B: 3 }), true)
assert.deepEqual(packingBoxPayload(boxes[0]).items, [{ skuCode: 'A', quantity: 7 }])
})
test('split and mixed boxes must equal the complete locked plan', () => {
const boxes = [measured({ quantities: { A: 2, B: 3 } }), measured({ quantities: { A: 5 } })]
assert.equal(validPackingAllocation(boxes, { A: 7, B: 3 }), true)
assert.equal(validPackingAllocation(boxes, { A: 8, B: 3 }), false)
assert.equal(validPackingAllocation(boxes, { A: 6, B: 3 }), false)
assert.equal(validPackingAllocation(boxes, { A: 7 }), false)
})
test('empty boxes, decimal quantities and invalid measurements block completion', () => {
assert.equal(validPackingAllocation([], { A: 1 }), false)
assert.equal(validPackingAllocation([measured({ quantities: {} })], {}), false)
assert.equal(validPackingAllocation([measured({ quantities: { A: 1.5 } })], { A: 1.5 }), false)
assert.equal(validPackingAllocation([{ ...measured({ quantities: { A: 1 } }), weight: '0' }], { A: 1 }), false)
})
+5
View File
@@ -11,6 +11,11 @@ test('packing preview keeps customer scope internal and exposes stable order ref
assert.equal(value.skus[0].availableQuantity, 8)
})
test('legacy single-customer preview is normalized to all customer orders', () => {
const value = normalizePackingPreview({ customerId: '100', orders: [{ returnNo: 'RT-1' }, { returnNo: 'RT-2' }], skus: [{ skuCode: 'SKU-1', availableQuantity: 8 }] })
assert.deepEqual(value.customers[0].orders.map((item) => item.returnNo), ['RT-1', 'RT-2'])
})
test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => {
const calls = []
session.apiBase = '/api'; session.token = 'packing-token'
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/pages/return-packing/return-packing.vue'
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
function instance(api = {}) {
let saved = {}, reset = false
const component = vm.runInNewContext(descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'component ='), {
component: null, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ReturnBoxMergeWork: {}, session: {currentUser:{}}, playSuccessSound() {},
updateWarehouseWork(domain, patch) { saved = {...saved,...patch} }, resetWarehouseDomain() { reset = true }, ...api
})
const page = {...component.data(), activePlan:{planNo:'P1'}, busy:true}
for (const [key, method] of Object.entries(component.methods)) page[key] = method.bind(page)
page.loadPlans = () => { page.plans = [{planNo:'P2'}] }
return {page, saved:()=>saved, reset:()=>reset}
}
test('packing completion template compiles without a putaway prerequisite', () => {
compileScript(descriptor, {id:'packing'})
assert.deepEqual(compileTemplate({source:descriptor.template.content,filename,id:'packing'}).errors,[])
assert.equal(descriptor.template.content.includes('确认上架'),false)
})
test('unified scan resolves an actual carton and passes its SKU contents into box work', async () => {
let skuCalls=0
const carton={cartonMark:'BOX1',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}
const {page,saved}=instance({scanReturnCarton:async()=>carton,findPackingOrders:async()=>{skuCalls++;return {}}})
page.activePlan=null;page.busy=false;page.selectedOrders=[]
await page.search('BOX1')
assert.equal(page.mode,'BOX');assert.equal(page.initialSource,carton)
assert.equal(saved().initialSource.items[0].quantity,2);assert.equal(skuCalls,0)
})
test('unknown SKU cannot silently choose parcel sources; occupied sources retain the conflict', async () => {
let skuCalls=0, queryError={code:'A0404'}
const {page}=instance({scanReturnCarton:async()=>{throw queryError},findPackingOrders:async()=>{skuCalls++;return {}}})
page.activePlan=null;page.busy=false;page.selectedOrders=[];page.searchCode=''
page.setError=error=>{page.error=error}
await page.search('SKU1')
assert.match(page.error.message,/扫描来源包裹/)
assert.equal(skuCalls,0)
queryError={code:'A0409',message:'箱库存被占用'}
await page.search('BOX1')
assert.equal(page.error.message,'箱库存被占用');assert.equal(skuCalls,0)
})
test('existing target immediately clears work and returns to refreshed plans even during completion', () => {
const {page,reset}=instance()
page.packingCompleted({targetMode:'EXISTING',labels:[{cartonMark:'B'}]})
assert.equal(page.result,null)
assert.equal(page.activePlan,null)
assert.equal(reset(),true)
assert.equal(page.plans[0].planNo,'P2')
})
test('new target retains labels for printing and can return without putaway', () => {
const {page,saved,reset}=instance()
page.packingCompleted({targetMode:'NEW',labels:[{cartonMark:'NEW#1-1'}]})
assert.equal(page.busy,false)
assert.equal(saved().result.labels[0].cartonMark,'NEW#1-1')
assert.equal(reset(),false)
page.busy=false; page.nextPacking()
assert.equal(page.result,null)
assert.equal(reset(),true)
})
test('completed packing can return to plans even when the page busy flag is stale', () => {
const {page,reset}=instance()
page.result={labels:[{cartonMark:'NEW#1-1'}]}
page.nextPacking()
assert.equal(page.result,null)
assert.equal(reset(),true)
assert.equal(page.plans[0].planNo,'P2')
assert.doesNotMatch(descriptor.template.content, /<button[^>]*:disabled[^>]*@tap="nextPacking"/)
})
+258 -4
View File
@@ -3,10 +3,118 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {},
const physicalRow = items => ({ items, length: 10, width: 10, height: 10, weight: 1, originalPackaging: true, confirmed: true })
test('box and parcel confirmation opens the camera only after every valid row is confirmed', () => {
for (const type of ['BOX', 'PARCEL']) {
const cameras = []
const page = setup(async () => ({}), new Map(), 'operator:warehouse', { uni: { chooseImage: options => cameras.push(options) } })
page.unitType = type
page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
page.boxRows.forEach(row => { row.confirmed = false })
page.confirmBoxRow(0)
assert.equal(page.boxRows[0].confirmed, true)
assert.equal(cameras.length, 0)
page.confirmBoxRow(1)
assert.equal(cameras.length, 1)
assert.deepEqual(Array.from(cameras[0].sourceType), ['camera'])
page.editBoxRow(0)
assert.equal(page.allBoxRowsConfirmed, false)
assert.equal(page.boxRows[0].confirmed, false)
}
})
test('remaining quantity accounts for other drafts and rejects fractional negative or excessive input', () => {
const page = setup(async () => ({}))
page.unitType = 'BOX'
page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
assert.equal(page.boxRemaining(0, page.selectedItems[0]), 7)
for (const quantity of ['7.5', '-1', '8']) {
page.setBoxQuantity(0, 'SKU-TEST', quantity)
assert.equal(page.boxRows[0].items['SKU-TEST'], quantity)
assert.equal(page.boxRowValid(0), false)
page.confirmBoxRow(0)
assert.equal(page.boxRows[0].confirmed, false)
}
page.setBoxQuantity(0, 'SKU-TEST', '7')
assert.equal(page.boxRowValid(0), true)
page.resizeBoxRows(3)
assert.equal(page.boxRows[2].originalPackaging, true)
assert.equal(page.boxRows[2].confirmed, false)
})
test('final photo upload waits for explicit confirmation and failed uploads cannot submit', async () => {
for (const failUpload of [false, true]) {
let camera, requests = []
const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => { if (failUpload) throw new Error('upload failed'); return { fileId: 'PHOTO-1' } }
})
page.unitType = 'BOX'; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.boxRows[0].confirmed = false
page.confirmBoxRow(0)
assert.equal(requests.length, 0)
await camera.success({ tempFilePaths: ['photo.jpg'], tempFiles: [{ size: 100 }] })
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, failUpload ? 0 : 1)
if (failUpload) assert.match(page.error.message, /照片/)
}
})
test('parcel photos accumulate without submitting until explicit receipt confirmation', async () => {
let camera, uploads = 0
const requests = []
const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => ({ fileId: `PHOTO-${++uploads}` })
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.boxRows[0].confirmed = false
await page.confirmReceipt()
assert.equal(requests.length, 0)
page.confirmBoxRow(0)
await camera.success({ tempFilePaths: ['first.jpg'] })
assert.equal(requests.length, 0)
assert.ok(page.current)
page.choosePhoto()
assert.equal(camera.count, 9)
await camera.success({ tempFilePaths: ['second.jpg', 'third.jpg'] })
assert.equal(page.photoFileIds.length, 3)
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, 1)
assert.equal(page.current, null)
})
test('parcel signing requires a photo even for a partial receipt', async () => {
let requests = 0
const page = setup(async () => { requests++; return {} })
page.unitType = 'PARCEL'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(requests, 0)
assert.match(page.error.message, /照片/)
})
test('editing the draft before the camera returns prevents late photo submission', async () => {
let camera, uploads = 0, requests = 0
const page = setup(async () => { requests++; return {} }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => { uploads++; return { fileId: 'PHOTO-1' } }
})
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
page.choosePhoto(true)
page.editBoxRow(0)
await camera.success({ tempFilePaths: ['late.jpg'] })
assert.equal(uploads, 0)
assert.equal(requests, 0)
})
function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime = {}) {
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } })
...runtime,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key), ...runtime.uni } })
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
vm.runInContext(store.replace(/^import .*$/mg, '').replace(/export /g, ''), context)
const page = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
@@ -16,13 +124,31 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
const instance = Object.assign({}, component.methods, {
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
selectedItems: [{ skuCode: 'SKU-TEST' }], photoFileIds: [], trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
selectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photoFileIds: [], photoNames: [], photoUrls: {}, trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
return instance
}
test('SKU lookup displays all pending order lines while retaining scanned SKU for pallet generation', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const lines = [{ skuCode: 'SKU-A', expectedQuantity: 6 }, { skuCode: 'SKU-B', expectedQuantity: 4 }]
const instance = { current: { expectedItems: lines }, selectedSku: 'SKU-A' }
instance.selectedItems = component.computed.selectedItems.call(instance)
assert.equal(instance.selectedItems.length, 2)
assert.equal(instance.selectedItems[1].skuCode, 'SKU-B')
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
instance.selectedSku = ''
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
instance.selectedSku = 'SKU-FROM-PREVIOUS-ORDER'
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
})
test('PALLET page submits the required palletCode through the actual confirmation method', async () => {
const calls = []
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); return { palletCode: payload.palletCode } })
@@ -41,6 +167,105 @@ test('PALLET page submits the required palletCode through the actual confirmatio
assert.equal(page.current, null)
})
test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), true)
state.boxRows[0].items.A = 4
assert.equal(component.computed.canConfirm.call(state), false)
const calls = []
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ''; page.trackingNo = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].trackingNo, undefined)
assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})
test('parcel waybill is submitted as trackingNo and leaves unitCode for the server', async () => {
const calls = []
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(calls[0].trackingNo, 'sf561626')
assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})
test('batch waybill scans stay on the single parcel waybill input', () => {
const page = setup(async () => ({}))
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.activeTarget = 'unit'; page.palletCode = ''
page.handleScan('WAYBILL-ONE')
assert.equal(page.unitCode, 'WAYBILL-ONE'); assert.equal(page.palletCode, '')
assert.equal(page.activeTarget, 'unit')
page.handleScan('WAYBILL-CORRECTED')
assert.equal(page.unitCode, 'WAYBILL-CORRECTED')
})
test('parcel rows must be nonempty and summed quantities cannot exceed the order remainder', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 4, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 }), physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), false)
state.remaining = () => 5
assert.equal(component.computed.canConfirm.call(state), true)
state.boxRows[1].items = {}
assert.equal(component.computed.canConfirm.call(state), false)
})
test('one submission signs multiple mixed-SKU parcels sharing one waybill and independent labels', async () => {
const calls = []
const page = setup(async request => {
calls.push(JSON.parse(JSON.stringify(request)))
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${calls.length}`, printableHtml: 'label' }], fullyMatched: false }
})
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ' WAYBILL-BATCH '; page.trackingNo = 'stale'; page.selectedItems = [{ skuCode: 'SKU-TEST', expectedQuantity: 10 }, { skuCode: 'SKU-B', expectedQuantity: 10 }]
page.boxRows = [physicalRow({ 'SKU-TEST': 1, 'SKU-B': 2 }), physicalRow({ 'SKU-TEST': 3 })]
await page.confirmReceipt()
assert.equal(calls.length, 2)
assert.deepEqual(calls[0].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 1 }, { skuCode: 'SKU-B', receivedQuantity: 2 }])
assert.deepEqual(calls[1].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 3 }])
assert.equal(calls[0].trackingNo, 'WAYBILL-BATCH'); assert.equal(calls[1].trackingNo, 'WAYBILL-BATCH')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
})
test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
const saved = new Map(), calls = []
let failSecond = true
const confirm = async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.items[0].receivedQuantity === 2 && failSecond) throw new Error('response lost')
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${request.items[0].receivedQuantity}`, printableHtml: 'label' }], fullyMatched: false }
}
const page = setup(confirm, saved)
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
failSecond = false
const restored = setup(confirm, saved)
await restored.confirmReceipt()
assert.equal(calls.length, 3)
assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
assert.deepEqual(calls[1], calls[2])
assert.equal(restored.result.labels.length, 2)
})
test('partial receipt continues the same order with fresh pallet measurements', async () => {
const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }])
assert.equal(page.result.continuationReturnNo, 'RT-TEST')
let reopened
page.openOrderValue = async returnNo => { reopened = returnNo; page.current = { returnNo } }
await page.nextReceipt()
assert.equal(reopened, 'RT-TEST')
assert.equal(page.result, null)
assert.equal(page.palletLength, '')
assert.equal(page.palletWeight, '')
})
test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
const calls = []
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
@@ -52,3 +277,32 @@ test('PALLET response failure keeps the draft and same idempotency key for retry
assert.equal(calls.length, 2)
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
})
test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }])
page.lookupCode = 'PREVIOUS-SKU'
page.selectedSku = 'PREVIOUS-SKU'
page.palletCheck = { palletCode: 'PLT-TEST' }
page.photoFileIds = ['previous-photo']
page.busy = true
page.continueScanning()
assert.ok(page.result)
page.busy = false
page.continueScanning()
assert.equal(page.result, null)
assert.equal(page.current, null)
assert.equal(page.candidates, null)
assert.equal(page.activeTarget, 'lookup')
assert.equal(page.entryMode, 'SKU')
assert.equal(page.lookupCode, '')
assert.equal(page.selectedSku, '')
assert.equal(page.palletCheck, null)
assert.equal(page.palletLength, '')
assert.equal(page.photoFileIds.length, 0)
let scanned
page.lookup = value => { scanned = value }
page.handleScan('NEXT-SKU')
assert.equal(scanned, 'NEXT-SKU')
assert.equal(page.lookupCode, 'NEXT-SKU')
})
+2 -2
View File
@@ -91,7 +91,7 @@ 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 and return bulk also mounts pallet work', async () => {
test('mixed relabel keeps SKU work open and return transport gates 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)
@@ -99,7 +99,7 @@ test('mixed relabel keeps SKU work open and return bulk also mounts pallet work'
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' \|\| isReturnBulk"/)
assert.match(page, /<RelabelPalletWork v-if="requiresPalletBinding && \(taskTab === 'carton' \|\| isReturnTask\)"/)
})
test('relabel list confirms before starting a new task and completion returns to the list', async () => {
+2
View File
@@ -10,6 +10,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
{ module: 'inbound', name: '退货入库' },
{ module: 'inbound', name: '退货合箱' },
{ module: 'inbound', name: '退货箱内清点' },
{ module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '商品移库' },
@@ -26,6 +27,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/return-counting/return-counting')
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')